From d3833c69cb51cd93f1cdff6f90e43b4915ff5794 Mon Sep 17 00:00:00 2001 From: cadlaxa Date: Wed, 29 Apr 2026 19:25:48 +0800 Subject: [PATCH 01/24] Add YAML Dictionary Editor --- OpenUtau.Core/Util/Preferences.cs | 1 + .../Controls/DictionaryEditorControl.axaml | 118 +++++ .../Controls/DictionaryEditorControl.axaml.cs | 167 +++++++ OpenUtau/Controls/PianoRoll.axaml | 25 + OpenUtau/Controls/PianoRoll.axaml.cs | 7 + OpenUtau/Strings/Strings.axaml | 41 ++ .../ViewModels/DictionaryEditorViewModel.cs | 430 ++++++++++++++++++ OpenUtau/ViewModels/NotesViewModel.cs | 8 +- 8 files changed, 796 insertions(+), 1 deletion(-) create mode 100644 OpenUtau/Controls/DictionaryEditorControl.axaml create mode 100644 OpenUtau/Controls/DictionaryEditorControl.axaml.cs create mode 100644 OpenUtau/ViewModels/DictionaryEditorViewModel.cs diff --git a/OpenUtau.Core/Util/Preferences.cs b/OpenUtau.Core/Util/Preferences.cs index f9b8d4e4e..478433246 100644 --- a/OpenUtau.Core/Util/Preferences.cs +++ b/OpenUtau.Core/Util/Preferences.cs @@ -189,6 +189,7 @@ public class SerializablePreferences { public bool ShowPhoneme = true; public bool ShowExpressions = true; public bool ShowNoteParams = true; + public bool ShowDictionaryEditor = false; public Dictionary DefaultResamplers = new Dictionary(); public Dictionary DefaultWavtools = new Dictionary(); public string LyricHelper = string.Empty; diff --git a/OpenUtau/Controls/DictionaryEditorControl.axaml b/OpenUtau/Controls/DictionaryEditorControl.axaml new file mode 100644 index 000000000..aec013f3b --- /dev/null +++ b/OpenUtau/Controls/DictionaryEditorControl.axaml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -726,6 +743,14 @@ + + + + diff --git a/OpenUtau/Controls/PianoRoll.axaml.cs b/OpenUtau/Controls/PianoRoll.axaml.cs index ec4a93c4f..035b5a4c6 100644 --- a/OpenUtau/Controls/PianoRoll.axaml.cs +++ b/OpenUtau/Controls/PianoRoll.axaml.cs @@ -1540,6 +1540,13 @@ bool OnKeyExtendedHandler(KeyEventArgs args) { return true; } break; + case Key.Oem2: + case Key.Divide: + if (isNone) { + notesVm.ShowDictionaryEditor = !notesVm.ShowDictionaryEditor; + return true; + } + break; #endregion #region navigate keys // NAVIGATE/EDIT/SELECT HANDLERS diff --git a/OpenUtau/Strings/Strings.axaml b/OpenUtau/Strings/Strings.axaml index d6787a479..4a667f66b 100644 --- a/OpenUtau/Strings/Strings.axaml +++ b/OpenUtau/Strings/Strings.axaml @@ -497,6 +497,7 @@ Warning: this option removes custom presets. Snap Division View Final Pitch to Render (R) View Note Parameters (\) + Toggle Dictionary Editor (/) View Phonemes (O) View Pitch Bend (I) Toggle Snap (P) @@ -825,4 +826,44 @@ General Theme Editor Save Cancel + + Dictionary Setup + File + None + Delete File + New File + Refresh Folder + Create New YAML File + Filename (e.g., my_dictionary.yaml) + Create File + Creates a new blank YAML file + Delete File? + Are you sure you want to permanently delete: + Cancel + Delete + Object + Delete Object + Adds new Object + cols + Manage Columns + Create New Object + Object Name (e.g., replacements) + Columns (e.g., from, to) + Create Object + Generates the new object category + Manage Columns + Target Column Name (e.g., where) + Add + Adds the column to the grid + Remove + Removes the column and its data + Open YAML + Open in Text Editor + Save YAML + Saves the YAML file + Editor + Delete Entry + Deletes the selected row + Add Entry + Adds a new blank row diff --git a/OpenUtau/ViewModels/DictionaryEditorViewModel.cs b/OpenUtau/ViewModels/DictionaryEditorViewModel.cs new file mode 100644 index 000000000..3530529ae --- /dev/null +++ b/OpenUtau/ViewModels/DictionaryEditorViewModel.cs @@ -0,0 +1,430 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using ReactiveUI; +using ReactiveUI.Fody.Helpers; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.EventEmitters; + +namespace OpenUtau.App.ViewModels { + public class DynamicYamlRow : ReactiveObject { + private readonly Dictionary _data = new(); + + public string this[string key] { + get => _data.ContainsKey(key) ? _data[key] : string.Empty; + set { + _data[key] = value; + this.RaisePropertyChanged("Item"); + } + } + public Dictionary GetData() => _data; + } + + public class YamlCategory : ReactiveObject { + public string Name { get; set; } = string.Empty; + public List Columns { get; set; } = new(); + public HashSet ListColumns { get; set; } = new(); + public bool IsDictionaryFormat { get; set; } = false; + public ObservableCollection Rows { get; } = new(); + } + + public class DictionaryEditorViewModel : ViewModelBase { + private string _currentDirectory = string.Empty; + private Dictionary _filePaths = new(); + public ObservableCollection AvailableFiles { get; } = new(); + [Reactive] public string SelectedFile { get; set; } = string.Empty; + + // Dynamic categories for new tables + public ObservableCollection Categories { get; } = new(); + [Reactive] public YamlCategory? SelectedCategory { get; set; } + public event Action? ColumnsChanged; + [Reactive] public DynamicYamlRow? SelectedRow { get; set; } + [Reactive] public bool IsCreatingNewCategory { get; set; } = false; + [Reactive] public string NewCategoryName { get; set; } = string.Empty; + [Reactive] public string NewCategoryColumns { get; set; } = string.Empty; + [Reactive] public bool IsManagingColumns { get; set; } = false; + [Reactive] public string ManageColumnName { get; set; } = string.Empty; + [Reactive] public bool IsConfirmingDelete { get; set; } = false; + [Reactive] public bool IsCreatingNewFile { get; set; } = false; + [Reactive] public string NewFileName { get; set; } = string.Empty; + + public DictionaryEditorViewModel() { + this.WhenAnyValue(x => x.SelectedFile) + .Subscribe(file => { + if (!string.IsNullOrEmpty(file) && !string.IsNullOrEmpty(_currentDirectory)) { + if (_filePaths.TryGetValue(file, out string? relativePath) && relativePath != null) { + LoadYaml(Path.Combine(_currentDirectory, relativePath)); + } + } + }); + } + + public void ToggleNewFilePanel() { + IsCreatingNewFile = !IsCreatingNewFile; + IsCreatingNewCategory = false; + IsManagingColumns = false; + IsConfirmingDelete = false; + NewFileName = string.Empty; + } + + public void ToggleConfirmDeletePanel() { + if (string.IsNullOrEmpty(SelectedFile)) return; + IsConfirmingDelete = !IsConfirmingDelete; + IsCreatingNewFile = false; + IsCreatingNewCategory = false; + IsManagingColumns = false; + } + + public void ToggleNewCategoryPanel() { + IsCreatingNewCategory = !IsCreatingNewCategory; + IsCreatingNewFile = false; + IsManagingColumns = false; + IsConfirmingDelete = false; + NewCategoryName = string.Empty; + NewCategoryColumns = string.Empty; + } + + public void ToggleManageColumnsPanel() { + IsManagingColumns = !IsManagingColumns; + IsCreatingNewFile = false; + IsCreatingNewCategory = false; + IsConfirmingDelete = false; + ManageColumnName = string.Empty; + } + + // File Actions + public void ConfirmNewFile() { + if (string.IsNullOrWhiteSpace(NewFileName) || string.IsNullOrEmpty(_currentDirectory)) return; + + string fileName = NewFileName.Trim(); + if (!fileName.EndsWith(".yaml") && !fileName.EndsWith(".yml")) { + fileName += ".yaml"; + } + string filePath = Path.Combine(_currentDirectory, fileName); + if (!File.Exists(filePath)) { + File.WriteAllText(filePath, "Metadata:\n Created: True\n"); + AvailableFiles.Add(fileName); + } + SelectedFile = fileName; + ToggleNewFilePanel(); + } + + public void DeleteSelectedFile() { + if (string.IsNullOrEmpty(SelectedFile) || string.IsNullOrEmpty(_currentDirectory)) return; + + if (_filePaths.TryGetValue(SelectedFile, out string? relativePath) && relativePath != null) { + string filePath = Path.Combine(_currentDirectory, relativePath); + if (File.Exists(filePath)) { + File.Delete(filePath); + } + } + + AvailableFiles.Remove(SelectedFile); + if (AvailableFiles.Count > 0) SelectedFile = AvailableFiles[0]; + else ClearContext(); + } + + public void ConfirmDeleteFile() { + if (string.IsNullOrEmpty(SelectedFile) || string.IsNullOrEmpty(_currentDirectory)) return; + + if (_filePaths.TryGetValue(SelectedFile, out string? relativePath) && relativePath != null) { + string filePath = Path.Combine(_currentDirectory, relativePath); + if (File.Exists(filePath)) { + File.Delete(filePath); + } + } + + AvailableFiles.Remove(SelectedFile); + + if (AvailableFiles.Count > 0) SelectedFile = AvailableFiles[0]; + else ClearContext(); + + IsConfirmingDelete = false; + } + + // Object Actions + public void ConfirmNewCategory() { + if (string.IsNullOrWhiteSpace(NewCategoryName) || string.IsNullOrWhiteSpace(NewCategoryColumns)) return; + + var columns = NewCategoryColumns.Split(',').Select(c => c.Trim()).Where(c => !string.IsNullOrEmpty(c)).ToList(); + if (columns.Count == 0) return; + + var newCat = new YamlCategory { Name = NewCategoryName.Trim(), Columns = columns }; + Categories.Add(newCat); + SelectedCategory = newCat; + ToggleNewCategoryPanel(); + } + + public void DeleteSelectedCategory() { + if (SelectedCategory != null) { + Categories.Remove(SelectedCategory); + SelectedCategory = Categories.FirstOrDefault(); + } + } + + // Column Actions + public void AddNewColumn() { + if (SelectedCategory == null || string.IsNullOrWhiteSpace(ManageColumnName)) return; + string col = ManageColumnName.Trim(); + if (!SelectedCategory.Columns.Contains(col)) { + SelectedCategory.Columns.Add(col); + ColumnsChanged?.Invoke(); + } + ManageColumnName = string.Empty; + } + + public void RemoveColumn() { + if (SelectedCategory == null || string.IsNullOrWhiteSpace(ManageColumnName)) return; + string col = ManageColumnName.Trim(); + if (SelectedCategory.Columns.Contains(col)) { + SelectedCategory.Columns.Remove(col); + ColumnsChanged?.Invoke(); + } + ManageColumnName = string.Empty; + } + + // Row Actions + public void AddNewRow() { + if (SelectedCategory == null) return; + var newRow = new DynamicYamlRow(); + + // If a row is clicked, insert the new one directly below it + if (SelectedRow != null) { + int index = SelectedCategory.Rows.IndexOf(SelectedRow); + if (index >= 0) { + SelectedCategory.Rows.Insert(index + 1, newRow); + SelectedRow = newRow; + return; + } + } + // Otherwise, just drop it at the bottom + SelectedCategory.Rows.Add(newRow); + SelectedRow = newRow; + } + + public void DeleteSelectedRow() { + if (SelectedCategory != null && SelectedRow != null) { + SelectedCategory.Rows.Remove(SelectedRow); + } + } + + public void SetSingerContext(string dir, Dictionary fileMap) { + _currentDirectory = dir; + _filePaths = fileMap; + + AvailableFiles.Clear(); + foreach (var name in fileMap.Keys) { + AvailableFiles.Add(name); + } + + if (AvailableFiles.Count > 0) { + SelectedFile = AvailableFiles[0]; + } + } + + public void ClearContext() { + _currentDirectory = string.Empty; + AvailableFiles.Clear(); + Categories.Clear(); + } + + public void LoadYaml(string filePath) { + Categories.Clear(); + if (!File.Exists(filePath)) return; + + try { + var deserializer = new DeserializerBuilder().Build(); + var yamlContent = File.ReadAllText(filePath); + + var rawData = deserializer.Deserialize>(yamlContent); + if (rawData == null) return; + + YamlCategory? metaCategory = null; + + foreach (var kvp in rawData) { + string rootKey = kvp.Key; + object rootValue = kvp.Value; + + if (rootValue is List rows) { + var category = new YamlCategory { Name = rootKey }; + var allColumns = new HashSet(); + + foreach (var rowObj in rows) { + if (rowObj is Dictionary rowDict) + foreach (var key in rowDict.Keys) + if (key != null) allColumns.Add(key.ToString() ?? ""); + } + category.Columns = allColumns.ToList(); + + if (category.Columns.Count > 0) { + foreach (var rowObj in rows) { + if (rowObj is Dictionary rowDict) { + var row = new DynamicYamlRow(); + foreach (var col in category.Columns) { + var keyMatch = rowDict.Keys.FirstOrDefault(k => k?.ToString() == col); + if (keyMatch != null) { + var val = rowDict[keyMatch]; + if (val is List list) { + // Re-wrap list items in quotes if they contain spaces or commas + var formattedList = list.Select(x => { + string s = x?.ToString() ?? ""; + return (s.Contains(" ") || s.Contains(",")) ? $"\"{s}\"" : s; + }); + row[col] = string.Join(" ", formattedList); + category.ListColumns.Add(col); + } else { + row[col] = val?.ToString() ?? ""; + } + } + } + category.Rows.Add(row); + } + } + } + Categories.Add(category); + } else if (rootValue is Dictionary dictData) { + var category = new YamlCategory { + Name = rootKey, + Columns = new List { "Key", "Value" }, + IsDictionaryFormat = true + }; + + foreach (var innerKvp in dictData) { + var row = new DynamicYamlRow(); + row["Key"] = innerKvp.Key?.ToString() ?? ""; + + if (innerKvp.Value is List list) { + var formattedList = list.Select(x => { + string s = x?.ToString() ?? ""; + return (s.Contains(" ") || s.Contains(",")) ? $"\"{s}\"" : s; + }); + row["Value"] = string.Join(" ", formattedList); + category.ListColumns.Add("Value"); + } else { + row["Value"] = innerKvp.Value?.ToString() ?? ""; + } + category.Rows.Add(row); + } + Categories.Add(category); + } else { + if (metaCategory == null) { + metaCategory = new YamlCategory { Name = "Metadata", Columns = new List { "Key", "Value" } }; + Categories.Insert(0, metaCategory); + } + var row = new DynamicYamlRow(); + row["Key"] = rootKey; + row["Value"] = rootValue?.ToString() ?? ""; + metaCategory.Rows.Add(row); + } + } + + if (Categories.Count > 0) SelectedCategory = Categories[0]; + } catch (Exception ex) { + Serilog.Log.Error(ex, $"Failed to parse YAML: {filePath}"); + Categories.Clear(); + } + } + + public void SaveYaml() { + if (string.IsNullOrEmpty(SelectedFile) || string.IsNullOrEmpty(_currentDirectory)) return; + + var dictToSave = new Dictionary(); + + foreach (var cat in Categories) { + if (cat.Name == "Metadata") { + foreach (var row in cat.Rows) { + string key = row["Key"]; + string val = row["Value"]; + if (!string.IsNullOrWhiteSpace(key) && !string.IsNullOrWhiteSpace(val)) { + if (double.TryParse(val, out double numVal)) dictToSave[key] = numVal; + else dictToSave[key] = val; + } + } + } else if (cat.IsDictionaryFormat) { + var dictNode = new Dictionary(); + foreach (var row in cat.Rows) { + string key = row["Key"]; + string val = row["Value"]; + + if (string.IsNullOrWhiteSpace(key)) continue; + + if (cat.ListColumns.Contains("Value") && !string.IsNullOrWhiteSpace(val)) { + var matches = System.Text.RegularExpressions.Regex.Matches(val, @"\""[^\""]*\""|[^ ,]+"); + dictNode[key] = matches.Cast() + .Select(m => m.Value.Trim('"')) + .ToList(); + } else { + dictNode[key] = val; + } + } + dictToSave[cat.Name] = dictNode; + } else { + var rowList = new List>(); + foreach (var row in cat.Rows) { + var newRow = new Dictionary(); + foreach (var col in cat.Columns) { + string val = row[col]; + if (string.IsNullOrWhiteSpace(val)) continue; + + if (cat.ListColumns.Contains(col)) { + var matches = System.Text.RegularExpressions.Regex.Matches(val, @"\""[^\""]*\""|[^ ,]+"); + newRow[col] = matches.Cast() + .Select(m => m.Value.Trim('"')) + .ToList(); + } else { + newRow[col] = val; + } + } + if (newRow.Count > 0) rowList.Add(newRow); + } + dictToSave[cat.Name] = rowList; + } + } + + var serializer = new SerializerBuilder() + .DisableAliases() + .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull) + .WithIndentedSequences() + .WithEventEmitter(next => new BracketStyleEmitter(next)) + .Build(); + + if (_filePaths.TryGetValue(SelectedFile, out string? relativePath) && relativePath != null) { + File.WriteAllText(Path.Combine(_currentDirectory, relativePath), serializer.Serialize(dictToSave)); + } + } + } + + public class BracketStyleEmitter : ChainedEventEmitter { + private int _depth = 0; + + public BracketStyleEmitter(IEventEmitter nextEmitter) : base(nextEmitter) { } + + public override void Emit(MappingStartEventInfo eventInfo, IEmitter emitter) { + _depth++; + // Depth 1 is the Root, Depth 2 is the Category List. + // Depth 3+ is the inner row data, which we want wrapped in inline brackets { } + eventInfo.Style = _depth >= 3 ? MappingStyle.Flow : MappingStyle.Block; + + base.Emit(eventInfo, emitter); + } + + public override void Emit(MappingEndEventInfo eventInfo, IEmitter emitter) { + _depth--; + base.Emit(eventInfo, emitter); + } + public override void Emit(SequenceStartEventInfo eventInfo, IEmitter emitter) { + _depth++; + // Depth 3+ is a list inside a row (like phonemes), which we want wrapped in brackets [ ] + eventInfo.Style = _depth >= 3 ? SequenceStyle.Flow : SequenceStyle.Block; + base.Emit(eventInfo, emitter); + } + public override void Emit(SequenceEndEventInfo eventInfo, IEmitter emitter) { + _depth--; + base.Emit(eventInfo, emitter); + } + } +} \ No newline at end of file diff --git a/OpenUtau/ViewModels/NotesViewModel.cs b/OpenUtau/ViewModels/NotesViewModel.cs index f81023e08..88329ea07 100644 --- a/OpenUtau/ViewModels/NotesViewModel.cs +++ b/OpenUtau/ViewModels/NotesViewModel.cs @@ -70,6 +70,7 @@ public class NotesViewModel : ViewModelBase, ICmdSubscriber { [Reactive] public bool ShowWaveform { get; set; } [Reactive] public bool ShowPhoneme { get; set; } [Reactive] public bool ShowNoteParams { get; set; } + [Reactive] public bool ShowDictionaryEditor { get; set; } [Reactive] public bool ShowExpressions { get; set; } [Reactive] public bool IsSnapOn { get; set; } [Reactive] public string SnapDivText { get; set; } @@ -289,7 +290,12 @@ public NotesViewModel() { Preferences.Default.ShowNoteParams = showNoteParams; Preferences.Save(); }); - + ShowDictionaryEditor = Preferences.Default.ShowDictionaryEditor; + this.WhenAnyValue(x => x.ShowDictionaryEditor) + .Subscribe(show => { + Preferences.Default.ShowDictionaryEditor = show; + Preferences.Save(); + }); TickWidth = ViewConstants.PianoRollTickWidthDefault; TrackHeight = ViewConstants.NoteHeightDefault; TrackOffset = 4 * 12 + 6; From 63702367022ccafe1b5f3ca2b6deb1dbb665ad87 Mon Sep 17 00:00:00 2001 From: cadlaxa Date: Wed, 29 Apr 2026 20:23:13 +0800 Subject: [PATCH 02/24] fix open yaml function for sub folders --- .../Controls/DictionaryEditorControl.axaml.cs | 19 ++++++------------- .../ViewModels/DictionaryEditorViewModel.cs | 17 ++++++++++------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/OpenUtau/Controls/DictionaryEditorControl.axaml.cs b/OpenUtau/Controls/DictionaryEditorControl.axaml.cs index 61007e7fd..162473a48 100644 --- a/OpenUtau/Controls/DictionaryEditorControl.axaml.cs +++ b/OpenUtau/Controls/DictionaryEditorControl.axaml.cs @@ -23,7 +23,6 @@ public UVoicePart? Part { get => GetValue(PartProperty); set => SetValue(PartProperty, value); } - public DictionaryEditorControl() { InitializeComponent(); @@ -39,7 +38,6 @@ public DictionaryEditorControl() { this.Loaded += (s, e) => LoadDictionaryForPart(Part); } - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); if (change.Property == PartProperty) { @@ -47,7 +45,6 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang LoadDictionaryForPart((UVoicePart?)change.NewValue); } } - private void RebuildGridColumns(YamlCategory? category) { var grid = this.FindControl("EditorGrid"); if (grid == null) return; @@ -69,26 +66,22 @@ private void RebuildGridColumns(YamlCategory? category) { } grid.ItemsSource = currentData; } - private void OnRefreshClicked(object? sender, RoutedEventArgs e) { Log.Information("DictionaryEditor: Refresh button clicked."); LoadDictionaryForPart(Part); } private void OnOpenFileClicked(object? sender, RoutedEventArgs e) { - if (string.IsNullOrEmpty(ViewModel.SelectedFile)) return; - - var project = DocManager.Inst.Project; - if (project == null || Part == null || Part.trackNo >= project.tracks.Count) return; + string filePath = ViewModel.GetSelectedFileFullPath(); - var singer = project.tracks[Part.trackNo].Singer; - if (singer != null && !string.IsNullOrEmpty(singer.Location)) { - var filePath = Path.Combine(singer.Location, ViewModel.SelectedFile); - if (File.Exists(filePath)) { - Process.Start(new ProcessStartInfo { + if (!string.IsNullOrEmpty(filePath) && File.Exists(filePath)) { + try { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = filePath, UseShellExecute = true }); + } catch (Exception ex) { + Serilog.Log.Error(ex, $"DictionaryEditor: Failed to open file in external editor: {filePath}"); } } } diff --git a/OpenUtau/ViewModels/DictionaryEditorViewModel.cs b/OpenUtau/ViewModels/DictionaryEditorViewModel.cs index 3530529ae..553985f38 100644 --- a/OpenUtau/ViewModels/DictionaryEditorViewModel.cs +++ b/OpenUtau/ViewModels/DictionaryEditorViewModel.cs @@ -70,7 +70,6 @@ public void ToggleNewFilePanel() { IsConfirmingDelete = false; NewFileName = string.Empty; } - public void ToggleConfirmDeletePanel() { if (string.IsNullOrEmpty(SelectedFile)) return; IsConfirmingDelete = !IsConfirmingDelete; @@ -78,7 +77,6 @@ public void ToggleConfirmDeletePanel() { IsCreatingNewCategory = false; IsManagingColumns = false; } - public void ToggleNewCategoryPanel() { IsCreatingNewCategory = !IsCreatingNewCategory; IsCreatingNewFile = false; @@ -87,7 +85,6 @@ public void ToggleNewCategoryPanel() { NewCategoryName = string.Empty; NewCategoryColumns = string.Empty; } - public void ToggleManageColumnsPanel() { IsManagingColumns = !IsManagingColumns; IsCreatingNewFile = false; @@ -97,6 +94,16 @@ public void ToggleManageColumnsPanel() { } // File Actions + public string GetSelectedFileFullPath() { + if (string.IsNullOrEmpty(SelectedFile) || string.IsNullOrEmpty(_currentDirectory)) { + return string.Empty; + } + if (_filePaths.TryGetValue(SelectedFile, out string? relativePath) && relativePath != null) { + return Path.Combine(_currentDirectory, relativePath); + } + + return string.Empty; + } public void ConfirmNewFile() { if (string.IsNullOrWhiteSpace(NewFileName) || string.IsNullOrEmpty(_currentDirectory)) return; @@ -112,7 +119,6 @@ public void ConfirmNewFile() { SelectedFile = fileName; ToggleNewFilePanel(); } - public void DeleteSelectedFile() { if (string.IsNullOrEmpty(SelectedFile) || string.IsNullOrEmpty(_currentDirectory)) return; @@ -127,7 +133,6 @@ public void DeleteSelectedFile() { if (AvailableFiles.Count > 0) SelectedFile = AvailableFiles[0]; else ClearContext(); } - public void ConfirmDeleteFile() { if (string.IsNullOrEmpty(SelectedFile) || string.IsNullOrEmpty(_currentDirectory)) return; @@ -225,13 +230,11 @@ public void SetSingerContext(string dir, Dictionary fileMap) { SelectedFile = AvailableFiles[0]; } } - public void ClearContext() { _currentDirectory = string.Empty; AvailableFiles.Clear(); Categories.Clear(); } - public void LoadYaml(string filePath) { Categories.Clear(); if (!File.Exists(filePath)) return; From 7d9849585b25430fd3deb89501f826d74c675214 Mon Sep 17 00:00:00 2001 From: cadlaxa Date: Thu, 30 Apr 2026 12:11:56 +0800 Subject: [PATCH 03/24] Add Regex Find Replace and view entry index number --- .../Controls/DictionaryEditorControl.axaml | 79 ++++++++++++++--- .../Controls/DictionaryEditorControl.axaml.cs | 31 +++++++ OpenUtau/Strings/Strings.axaml | 15 +++- .../ViewModels/DictionaryEditorViewModel.cs | 85 ++++++++++++++++++- 4 files changed, 192 insertions(+), 18 deletions(-) diff --git a/OpenUtau/Controls/DictionaryEditorControl.axaml b/OpenUtau/Controls/DictionaryEditorControl.axaml index aec013f3b..e2ca165ba 100644 --- a/OpenUtau/Controls/DictionaryEditorControl.axaml +++ b/OpenUtau/Controls/DictionaryEditorControl.axaml @@ -4,7 +4,7 @@ x:Name="DictControl" Width="400"> - + @@ -19,11 +19,18 @@ - + - + + + + + - + + diff --git a/OpenUtau/Controls/DictionaryEditorControl.axaml.cs b/OpenUtau/Controls/DictionaryEditorControl.axaml.cs index 8a321ead2..01489df92 100644 --- a/OpenUtau/Controls/DictionaryEditorControl.axaml.cs +++ b/OpenUtau/Controls/DictionaryEditorControl.axaml.cs @@ -159,8 +159,13 @@ private void LoadDictionaryForPart(UVoicePart? part) { var excludedFiles = new HashSet { "character.yaml", "dsconfig.yaml", "vocoder.yaml" }; var validFiles = allFiles - .Where(f => (f.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase)) - && !excludedFiles.Contains(Path.GetFileName(f).ToLower())) + .Where(f => { + string fileName = Path.GetFileName(f).ToLower(); + bool isValidYaml = fileName.EndsWith(".yaml") && !excludedFiles.Contains(fileName); + bool isPresamp = fileName == "presamp.ini"; + + return isValidYaml || isPresamp; + }) .ToList(); // Group by filename to find duplicates @@ -177,7 +182,6 @@ private void LoadDictionaryForPart(UVoicePart? part) { displayNames.Add(fileName); fileMap[fileName] = relativePath; } else { - // Duplicate file names exist, so we append the folder name foreach (var filePath in group) { var fileName = Path.GetFileName(filePath); var folderName = Path.GetFileName(Path.GetDirectoryName(filePath)); @@ -197,7 +201,7 @@ private void LoadDictionaryForPart(UVoicePart? part) { } } - Log.Information($"DictionaryEditor: Found {displayNames.Count} valid YAML dictionaries."); + Log.Information($"DictionaryEditor: Found {displayNames.Count} valid dictionary/presamp files."); ViewModel.SetSingerContext(singer.Location, fileMap); } } diff --git a/OpenUtau/Strings/Strings.axaml b/OpenUtau/Strings/Strings.axaml index 4acf0a922..9954698ef 100644 --- a/OpenUtau/Strings/Strings.axaml +++ b/OpenUtau/Strings/Strings.axaml @@ -861,6 +861,10 @@ General Open in Text Editor Save YAML Saves the YAML file + Save Presamp + Save changes to presamp.ini + Open Presamp + Browse for an existing presamp.ini file Editor Delete Entry Deletes the selected row diff --git a/OpenUtau/ViewModels/DictionaryEditorViewModel.cs b/OpenUtau/ViewModels/DictionaryEditorViewModel.cs index 9ff517a49..a84830993 100644 --- a/OpenUtau/ViewModels/DictionaryEditorViewModel.cs +++ b/OpenUtau/ViewModels/DictionaryEditorViewModel.cs @@ -34,9 +34,11 @@ public class YamlCategory : ReactiveObject { public class DictionaryEditorViewModel : ViewModelBase { private string _currentDirectory = string.Empty; + private System.Text.Encoding _currentPresampEncoding = System.Text.Encoding.UTF8; private Dictionary _filePaths = new(); public ObservableCollection AvailableFiles { get; } = new(); [Reactive] public string SelectedFile { get; set; } = string.Empty; + public string CurrentFileType => !string.IsNullOrEmpty(SelectedFile) && SelectedFile.EndsWith(".ini", StringComparison.OrdinalIgnoreCase) ? "ini" : "yaml"; // Dynamic categories for new tables public ObservableCollection Categories { get; } = new(); @@ -107,10 +109,9 @@ public void PasteRow() { public DictionaryEditorViewModel() { this.WhenAnyValue(x => x.SelectedFile) .Subscribe(file => { + this.RaisePropertyChanged(nameof(CurrentFileType)); if (!string.IsNullOrEmpty(file) && !string.IsNullOrEmpty(_currentDirectory)) { - if (_filePaths.TryGetValue(file, out string? relativePath) && relativePath != null) { - LoadYaml(Path.Combine(_currentDirectory, relativePath)); - } + LoadSelectedFile(); } }); } @@ -386,6 +387,131 @@ public void ClearContext() { AvailableFiles.Clear(); Categories.Clear(); } + public void LoadPresamp(string filePath) { + Categories.Clear(); + if (!File.Exists(filePath)) return; + + System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); + + byte[] rawBytes = File.ReadAllBytes(filePath); + var strictUtf8 = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + try { + strictUtf8.GetString(rawBytes); + _currentPresampEncoding = new System.Text.UTF8Encoding(true); + } + catch (System.Text.DecoderFallbackException) { + _currentPresampEncoding = System.Text.Encoding.GetEncoding("shift_jis"); + } + string[] lines = File.ReadAllLines(filePath, _currentPresampEncoding); + YamlCategory? currentCategory = null; + + foreach (var rawLine in lines) { + string line = rawLine.Trim(); + if (string.IsNullOrEmpty(line)) continue; + + if (line.StartsWith("[") && line.EndsWith("]")) { + string sectionName = line.Substring(1, line.Length - 2); + currentCategory = new YamlCategory { Name = sectionName }; + Categories.Add(currentCategory); + + if (sectionName == "VOWEL") { + currentCategory.Columns = new List { "ID", "Base", "Phonemes", "Vol" }; + } else if (sectionName == "CONSONANT") { + currentCategory.Columns = new List { "ID", "Phonemes", "Crossfade" }; + } else if (sectionName == "REPLACE" || sectionName == "ALIAS") { + currentCategory.Columns = new List { "Key", "Value" }; + } else { + currentCategory.Columns = new List { "Value" }; + } + continue; + } + + if (currentCategory == null) continue; + var newRow = new DynamicYamlRow(); + + if (currentCategory.Name == "VOWEL") { + var parts = line.Split('='); + newRow["ID"] = parts.Length > 0 ? parts[0] : ""; + newRow["Base"] = parts.Length > 1 ? parts[1] : ""; + newRow["Phonemes"] = parts.Length > 2 ? parts[2] : ""; + newRow["Vol"] = parts.Length > 3 ? parts[3] : ""; + } + else if (currentCategory.Name == "CONSONANT") { + var parts = line.Split('='); + newRow["ID"] = parts.Length > 0 ? parts[0] : ""; + newRow["Phonemes"] = parts.Length > 1 ? parts[1] : ""; + newRow["Crossfade"] = parts.Length > 2 ? parts[2] : ""; + } + else if (currentCategory.Name == "REPLACE" || currentCategory.Name == "ALIAS") { + var parts = line.Split(new[] { '=' }, 2); + newRow["Key"] = parts.Length > 0 ? parts[0] : ""; + newRow["Value"] = parts.Length > 1 ? parts[1] : ""; + } + else { + // Single value lists like [PRIORITY], [APPEND], [PITCH] + newRow["Value"] = line; + } + + currentCategory.Rows.Add(newRow); + } + + if (Categories.Count > 0) SelectedCategory = Categories[0]; + + // FIX 2: Added the missing semicolon! + ColumnsChanged?.Invoke(); + } + public void SavePresamp(string filePath) { + var lines = new List(); + + foreach (var cat in Categories) { + lines.Add($"[{cat.Name}]"); + + foreach (var row in cat.Rows) { + if (cat.Name == "VOWEL") { + lines.Add($"{row["ID"]}={row["Base"]}={row["Phonemes"]}={row["Vol"]}"); + } + else if (cat.Name == "CONSONANT") { + lines.Add($"{row["ID"]}={row["Phonemes"]}={row["Crossfade"]}"); + } + else if (cat.Name == "REPLACE" || cat.Name == "ALIAS") { + lines.Add($"{row["Key"]}={row["Value"]}"); + } + else { + string val = row["Value"] ?? ""; + if (!string.IsNullOrEmpty(val)) { + lines.Add(val); + } + } + } + } + + File.WriteAllLines(filePath, lines, _currentPresampEncoding); + } + public void LoadSelectedFile() { + if (string.IsNullOrEmpty(SelectedFile) || string.IsNullOrEmpty(_currentDirectory)) return; + if (_filePaths.TryGetValue(SelectedFile, out string? relativePath) && relativePath != null) { + string targetPath = Path.Combine(_currentDirectory, relativePath); + if (SelectedFile.EndsWith(".ini", StringComparison.OrdinalIgnoreCase)) { + LoadPresamp(targetPath); + } else { + LoadYaml(targetPath); + } + } + } + + public void SaveCurrentFile() { + if (string.IsNullOrEmpty(SelectedFile) || string.IsNullOrEmpty(_currentDirectory)) return; + + if (_filePaths.TryGetValue(SelectedFile, out string? relativePath) && relativePath != null) { + string targetPath = Path.Combine(_currentDirectory, relativePath); + if (SelectedFile.EndsWith(".ini", StringComparison.OrdinalIgnoreCase)) { + SavePresamp(targetPath); + } else { + SaveYaml(); + } + } + } public void LoadYaml(string filePath) { Categories.Clear(); if (!File.Exists(filePath)) return; From c571d3626bbb1ee7308718fedc131594abe91420 Mon Sep 17 00:00:00 2001 From: cadlaxa Date: Fri, 1 May 2026 19:24:38 +0800 Subject: [PATCH 07/24] minor presamp fix --- OpenUtau/ViewModels/DictionaryEditorViewModel.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/OpenUtau/ViewModels/DictionaryEditorViewModel.cs b/OpenUtau/ViewModels/DictionaryEditorViewModel.cs index a84830993..6ef93557b 100644 --- a/OpenUtau/ViewModels/DictionaryEditorViewModel.cs +++ b/OpenUtau/ViewModels/DictionaryEditorViewModel.cs @@ -478,9 +478,13 @@ public void SavePresamp(string filePath) { lines.Add($"{row["Key"]}={row["Value"]}"); } else { - string val = row["Value"] ?? ""; - if (!string.IsNullOrEmpty(val)) { - lines.Add(val); + if (cat.Columns.Count > 0) { + string firstCol = cat.Columns[0]; + string val = row[firstCol] ?? ""; + + if (!string.IsNullOrEmpty(val)) { + lines.Add(val); + } } } } From 1d9fd438f90fc7f08b38331a36dccca1c3050a89 Mon Sep 17 00:00:00 2001 From: cadlaxa Date: Sat, 2 May 2026 14:33:52 +0800 Subject: [PATCH 08/24] Fix buttons --- OpenUtau/Controls/PianoRoll.axaml.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/OpenUtau/Controls/PianoRoll.axaml.cs b/OpenUtau/Controls/PianoRoll.axaml.cs index 33bc68f53..0d8782c4f 100644 --- a/OpenUtau/Controls/PianoRoll.axaml.cs +++ b/OpenUtau/Controls/PianoRoll.axaml.cs @@ -1541,6 +1541,9 @@ bool OnKeyExtendedHandler(KeyEventArgs args) { case Key.OemPipe: if (isNone) { notesVm.ShowNoteParams = !notesVm.ShowNoteParams; + if (notesVm.ShowNoteParams) { + notesVm.ShowDictionaryEditor = false; + } return true; } break; @@ -1548,6 +1551,10 @@ bool OnKeyExtendedHandler(KeyEventArgs args) { case Key.Divide: if (isNone) { notesVm.ShowDictionaryEditor = !notesVm.ShowDictionaryEditor; + if (notesVm.ShowDictionaryEditor) { + notesVm.ShowNoteParams = false; + } + return true; } break; From b8b6c6deb491f62f0bfcd38c54407ee38e4a171d Mon Sep 17 00:00:00 2001 From: cadlaxa Date: Sat, 2 May 2026 14:47:54 +0800 Subject: [PATCH 09/24] same mechanics for mouse toggle --- OpenUtau/ViewModels/NotesViewModel.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/OpenUtau/ViewModels/NotesViewModel.cs b/OpenUtau/ViewModels/NotesViewModel.cs index 88329ea07..1102075aa 100644 --- a/OpenUtau/ViewModels/NotesViewModel.cs +++ b/OpenUtau/ViewModels/NotesViewModel.cs @@ -289,12 +289,21 @@ public NotesViewModel() { .Subscribe(showNoteParams => { Preferences.Default.ShowNoteParams = showNoteParams; Preferences.Save(); + + if (showNoteParams) { + ShowDictionaryEditor = false; + } }); + ShowDictionaryEditor = Preferences.Default.ShowDictionaryEditor; this.WhenAnyValue(x => x.ShowDictionaryEditor) .Subscribe(show => { Preferences.Default.ShowDictionaryEditor = show; Preferences.Save(); + + if (show) { + ShowNoteParams = false; + } }); TickWidth = ViewConstants.PianoRollTickWidthDefault; TrackHeight = ViewConstants.NoteHeightDefault; From dc11efb5864ef12cccb652ca3a9b518d9d7f4e73 Mon Sep 17 00:00:00 2001 From: cadlaxa Date: Sat, 30 May 2026 10:24:52 +0800 Subject: [PATCH 10/24] Fix presamp parser --- .../ViewModels/DictionaryEditorViewModel.cs | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/OpenUtau/ViewModels/DictionaryEditorViewModel.cs b/OpenUtau/ViewModels/DictionaryEditorViewModel.cs index 6ef93557b..5d96b0898 100644 --- a/OpenUtau/ViewModels/DictionaryEditorViewModel.cs +++ b/OpenUtau/ViewModels/DictionaryEditorViewModel.cs @@ -407,11 +407,12 @@ public void LoadPresamp(string filePath) { YamlCategory? currentCategory = null; foreach (var rawLine in lines) { - string line = rawLine.Trim(); - if (string.IsNullOrEmpty(line)) continue; + string lineToProcess = rawLine.TrimEnd('\r', '\n'); + if (string.IsNullOrEmpty(lineToProcess)) continue; + string headerCheck = lineToProcess.Trim(); - if (line.StartsWith("[") && line.EndsWith("]")) { - string sectionName = line.Substring(1, line.Length - 2); + if (headerCheck.StartsWith("[") && headerCheck.EndsWith("]")) { + string sectionName = headerCheck.Substring(1, headerCheck.Length - 2); currentCategory = new YamlCategory { Name = sectionName }; Categories.Add(currentCategory); @@ -431,34 +432,31 @@ public void LoadPresamp(string filePath) { var newRow = new DynamicYamlRow(); if (currentCategory.Name == "VOWEL") { - var parts = line.Split('='); + var parts = lineToProcess.Split('='); newRow["ID"] = parts.Length > 0 ? parts[0] : ""; newRow["Base"] = parts.Length > 1 ? parts[1] : ""; newRow["Phonemes"] = parts.Length > 2 ? parts[2] : ""; newRow["Vol"] = parts.Length > 3 ? parts[3] : ""; } else if (currentCategory.Name == "CONSONANT") { - var parts = line.Split('='); + var parts = lineToProcess.Split('='); newRow["ID"] = parts.Length > 0 ? parts[0] : ""; newRow["Phonemes"] = parts.Length > 1 ? parts[1] : ""; newRow["Crossfade"] = parts.Length > 2 ? parts[2] : ""; } else if (currentCategory.Name == "REPLACE" || currentCategory.Name == "ALIAS") { - var parts = line.Split(new[] { '=' }, 2); - newRow["Key"] = parts.Length > 0 ? parts[0] : ""; + var parts = lineToProcess.Split(new[] { '=' }, 2); + newRow["Key"] = parts.Length > 0 ? parts[0].TrimEnd() : ""; newRow["Value"] = parts.Length > 1 ? parts[1] : ""; } else { - // Single value lists like [PRIORITY], [APPEND], [PITCH] - newRow["Value"] = line; + newRow["Value"] = lineToProcess; } currentCategory.Rows.Add(newRow); } if (Categories.Count > 0) SelectedCategory = Categories[0]; - - // FIX 2: Added the missing semicolon! ColumnsChanged?.Invoke(); } public void SavePresamp(string filePath) { From 4efbdf39e621c94a6daedec7d60058814676e5b2 Mon Sep 17 00:00:00 2001 From: cadlaxa Date: Sat, 30 May 2026 17:26:07 +0800 Subject: [PATCH 11/24] Code refactor (preserve quotes and comments and add comment entry) --- .../Controls/DictionaryEditorControl.axaml | 55 +- .../Controls/DictionaryEditorControl.axaml.cs | 90 ++- OpenUtau/Strings/Strings.axaml | 1 + .../ViewModels/DictionaryEditorViewModel.cs | 515 ++++++++++++------ 4 files changed, 461 insertions(+), 200 deletions(-) diff --git a/OpenUtau/Controls/DictionaryEditorControl.axaml b/OpenUtau/Controls/DictionaryEditorControl.axaml index 4ebd4a14f..7824d7de0 100644 --- a/OpenUtau/Controls/DictionaryEditorControl.axaml +++ b/OpenUtau/Controls/DictionaryEditorControl.axaml @@ -1,5 +1,6 @@ @@ -52,7 +53,7 @@ @@ -75,16 +76,14 @@ - + -