diff --git a/RepositoryTuneLab checkout effect b/RepositoryTuneLab checkout effect new file mode 100644 index 00000000..444e6994 --- /dev/null +++ b/RepositoryTuneLab checkout effect @@ -0,0 +1,20 @@ +f2c6b51 (HEAD -> feature/agent, origin/release/2.0.0, origin/feature/agent, release/2.0.0) Merge branch 'feature/agent' into release/2.0.0 +23ce42a refactor: 收紧 agent 工具描述——pitch line 与 vibrato 加性叠加、勿断开音高线 +2bbf396 feat: agent 对话分步可见 + 流式实时 Markdown + 取消回复气泡 +f8e55db feat: agent 加 add_vibrato 工具(创建真正的 Vibrato 对象) +8a72108 Revert "refactor: effect 收口改订 effect.Modified,退掉 SynthesisBatch band-aid" +f7823b9 fix: OpenAI 兼容流式 tool_call 被后续空 id/name 帧覆盖而丢失 +c343b21 feat: agent 工具集三层扩充(只读 / 业务写 / 批量 DSL) +1b4b209 fix: Agent 报错/停止文案可复制——提示行改用 SelectableTextBlock +e64f983 feat: Agent 换模型保上下文 + 连接模型提示 + 标题加固 + 报错保留已输出 +0644c43 refactor: effect 收口改订 effect.Modified,退掉 SynthesisBatch band-aid +2d5b708 refactor: ITiming 死契约从 TuneLab.SDK 下沉到宿主 TuneLab.Data.Timing +08e3a84 docs: 订正 plugin-development 的 voice/effect 章节到当前 SDK 模型 +74cda39 feat: effect 收敛阶段三——回显富类型换形 + effect 回显端到端 +6962336 fix: Agent 短回复时 token 数与 Copy 挤在一起——token 文本加右边距 +3576611 refactor: Agent 设置页 provider 选择复用属性面板样式 + 改名 Model Provider +909ced3 feat: Agent 撞工具回合上限时强制收尾——再请求一次不带工具让模型总结 +65cbdad fix: DataObject merge 通知上行——中间态不漏成祖先 settled、收口沿父链补发 +9a7b359 feat: effect 收敛阶段二——厚 IEffectProcessor/每段一个 + 反应式 EffectGraph +e4e922d feat: Agent 会话菜单 UI 完善——✕ 对齐 + 全名 tooltip + 原生观感 +f4e9907 feat: 原子写文件工具 SaveFile(仿 QSaveFile)+ 应用到 agent 写入点 diff --git a/TuneLab.SDK/ControllerConfigs/ButtonConfig.cs b/TuneLab.SDK/ControllerConfigs/ButtonConfig.cs new file mode 100644 index 00000000..57e2ab57 --- /dev/null +++ b/TuneLab.SDK/ControllerConfigs/ButtonConfig.cs @@ -0,0 +1,21 @@ +using System; + +namespace TuneLab.SDK; + +// 按钮控件配置:在属性面板中渲染一个可点击的按钮。 +// 与值控件不同,ButtonConfig 不绑定数据,而是通过 Action 回调触发行为。 +// 由宿主侧 PropertyObjectController 的 ButtonCreator 渲染为 GUI Components.Button。 +public sealed class ButtonConfig : IControllerConfig +{ + /// 按钮显示文本(经 L.Tr 翻译)。 + public required string DisplayText { get; init; } + + /// 点击时的回调(在 UI 线程触发)。 + public required Action? Action { get; init; } + + /// 按钮激活状态(true=可点击;false=灰显禁用)。 + public bool IsEnabled { get; init; } = true; + + /// 按钮提示文字(鼠标悬停时显示)。 + public string? Tooltip { get; init; } +} diff --git a/TuneLab.SDK/ControllerConfigs/PhonemeEditorConfig.cs b/TuneLab.SDK/ControllerConfigs/PhonemeEditorConfig.cs new file mode 100644 index 00000000..289a4fb8 --- /dev/null +++ b/TuneLab.SDK/ControllerConfigs/PhonemeEditorConfig.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace TuneLab.SDK; + +/// 一个音素条目(含语言前缀与符号)。 +public sealed class PhonemeEntry +{ + /// 完整符号,如 "ja/b" 或 "zh/a"。 + public string Symbol { get; set; } = string.Empty; + /// 是否是元音 + public bool IsVowel { get; set; } + /// 是否是滑音 + public bool IsGlide { get; set; } +} + +/// +/// 音素编辑器控件配置:在属性面板中渲染一个可编辑的音素列表(辅音/元音分组)。 +/// 数据通过 dataKey 绑定到 PropertyObject 的字符串字段(JSON 格式)。 +/// +public sealed class PhonemeEditorConfig : IControllerConfig +{ + public required string DisplayText { get; init; } + + /// 在 PropertyObject 中存储音素 JSON 的 key。 + public string DataKey { get; init; } = "_phonemes"; + + /// 当前音素列表(只读快照,用于 UI 渲染)。 + public IReadOnlyList Phonemes { get; init; } = Array.Empty(); + + /// 可用语言的列表(前缀选项)。 + public IReadOnlyList AvailableLanguages { get; init; } = Array.Empty(); + + /// 语言属性在 PropertyObject 中的 key。 + public string LanguageDataKey { get; init; } = "language"; + + /// 添加辅音时的回调(参数:更新后的 JSON)。 + public Action? OnChanged { get; init; } + + /// 辅音是否能删除(至少保留一个)。 + public bool CanDeleteConsonant { get; init; } = true; + + /// 元音是否能删除(至少保留一个)。 + public bool CanDeleteVowel { get; init; } = true; +} diff --git a/TuneLab.SDK/Voice/IPartPropertyContext.cs b/TuneLab.SDK/Voice/IPartPropertyContext.cs index fe083b33..cf7174d5 100644 --- a/TuneLab.SDK/Voice/IPartPropertyContext.cs +++ b/TuneLab.SDK/Voice/IPartPropertyContext.cs @@ -24,4 +24,7 @@ public interface IPartPropertyContext public interface INotePropertyContext : IPartPropertyContext { PropertyObject NoteProperties { get; } + // 当前选中音符的时间范围(用于区段式操作,NaN 表示无选中) + double SelectionStartTime { get; } + double SelectionEndTime { get; } } diff --git a/TuneLab.SDK/Voice/SynthesizedParameter.cs b/TuneLab.SDK/Voice/SynthesizedParameter.cs index b8948fae..c93bade2 100644 --- a/TuneLab.SDK/Voice/SynthesizedParameter.cs +++ b/TuneLab.SDK/Voice/SynthesizedParameter.cs @@ -12,4 +12,9 @@ public sealed class SynthesizedParameter // 各连续段,按时间升序、互不重叠;段内 Points 为 (全局秒, 值) 折线。 // 空集合 = 整条无值;段间间隙 = NaN 区(绘制断开)。 public required IReadOnlyList> Segments { get; init; } + + // 每段的透明度渐变控制点(可选)。每段对应一组 Point,每个 Point 的 X=相对位置(0~1), Y=透明度(0~1)。 + // 渲染器据此创建 LinearGradientBrush 的多 GradientStop,实现像素级平滑渐变。 + // null = 整段使用默认透明度 0.25。 + public IReadOnlyList>? SegmentOpacityStops { get; init; } } diff --git a/TuneLab/Data/Synthesis/VoiceSynthesisPipeline.cs b/TuneLab/Data/Synthesis/VoiceSynthesisPipeline.cs index da75a6d8..0493e309 100644 --- a/TuneLab/Data/Synthesis/VoiceSynthesisPipeline.cs +++ b/TuneLab/Data/Synthesis/VoiceSynthesisPipeline.cs @@ -196,7 +196,7 @@ void WriteBackPhonemes() } foreach (var note in mPart.Notes) { - note.SynthesizedPhonemes = map.TryGetValue(note, out var list) ? list.ToArray() : null; + note.SynthesizedPhonemes = map.TryGetValue(note, out var list) ? list.ToArray() : []; } } catch (Exception ex) diff --git a/TuneLab/GUI/Assets.cs b/TuneLab/GUI/Assets.cs index 03f59d69..b4ba1ac5 100644 --- a/TuneLab/GUI/Assets.cs +++ b/TuneLab/GUI/Assets.cs @@ -57,6 +57,8 @@ internal static class Assets public static SvgIcon General = new("\n\n"); public static SvgIcon Appearance = new("\n\n\n\n\n\n"); public static SvgIcon Editing = new("\n\n\n\n"); + public static SvgIcon PhonemeAdd = new("\n\n"); + public static SvgIcon PhonemeDelete = new("\n\n"); public static SvgIcon None = new(""); public static FontFamily NotoMono = new FontFamily("NotoMono"); diff --git a/TuneLab/GUI/Controllers/PropertyObjectController.cs b/TuneLab/GUI/Controllers/PropertyObjectController.cs index b20aaa5d..5ca6e45e 100644 --- a/TuneLab/GUI/Controllers/PropertyObjectController.cs +++ b/TuneLab/GUI/Controllers/PropertyObjectController.cs @@ -449,6 +449,278 @@ public override void Dispose() readonly Components.CheckBox mController; } + // 音素编辑器控件 + class PhonemeEditorCreator : Creator + { + public PhonemeEditorCreator(PropertyObjectController parent, string key, PhonemeEditorConfig config) : base(parent) + { + mKey = key; + mPanel = ObjectPoolManager.Get(); + mPanel.Margin = new(0, 8); + mDataProp = Parent.DataObject.StringField(config.DataKey, "[]"); + + // 监听语言属性变化:当语言被用户(而非音素编辑器)修改时,清除自定义音素以强制 G2P 重生 + if (!string.IsNullOrEmpty(config.LanguageDataKey)) + { + mLangField = Parent.DataObject.ValueField(config.LanguageDataKey, PropertyValue.Create(string.Empty)); + mLangField.Modified.Subscribe(OnLanguageFieldChanged, s); + } + + Apply(config); + } + + // 语言属性被外部(语言下拉)修改时清除自定义音素 + void OnLanguageFieldChanged() + { + if (mSettingLanguage) return; // 音素编辑器自身提交导致的语言变更,不理 + if (mLangField == null) return; + var curVal = mLangField.Value.ToString(out var lang) ? lang : string.Empty; + if (string.IsNullOrEmpty(curVal) || curVal == "multi") + return; // 空或 multi 不清除 + // 用户主动选择了具体语言 → 清除自定义音素,强制 G2P 重生 + mDataProp.Set("[]"); + } + + static string Serialize(IReadOnlyList phonemes) + { + var sb = new System.Text.StringBuilder(); + sb.Append('['); + for (int i = 0; i < phonemes.Count; i++) + { + if (i > 0) sb.Append(','); + sb.Append("{\"s\":\""); + sb.Append(phonemes[i].Symbol.Replace("\\", "\\\\").Replace("\"", "\\\"")); + sb.Append("\",\"v\":"); + sb.Append(phonemes[i].IsVowel ? "true" : "false"); + sb.Append('}'); + } + sb.Append(']'); + return sb.ToString(); + } + + void Commit(IReadOnlyList phonemes) + { + var json = Serialize(phonemes); + mDataProp.Set(json); + // 从音素符号中检测语言,回写语言属性 + DetectAndSetLanguage(phonemes); + if (mConfig.OnChanged != null) + mConfig.OnChanged(json); + } + + // 从音素符号中检测语言,回写语言属性。设 mSettingLanguage 标志以阻止 OnLanguageFieldChanged 清除音素。 + void DetectAndSetLanguage(IReadOnlyList phonemes) + { + string? detectedLang = null; + bool mixed = false; + foreach (var ph in phonemes) + { + int idx = ph.Symbol.IndexOf('/'); + if (idx < 0) continue; + string lang = ph.Symbol.Substring(0, idx); + if (detectedLang == null) + detectedLang = lang; + else if (detectedLang != lang) + { + mixed = true; + break; + } + } + if (detectedLang == null) return; + + string langKey = mConfig.LanguageDataKey; + string newLang = mixed ? "multi" : detectedLang; + var current = Parent.DataObject.GetValue(langKey, PropertyValue.Create(string.Empty)); + if (current.ToString(out var curStr) && curStr == newLang) + return; + mSettingLanguage = true; + try { Parent.DataObject.SetValue(langKey, PropertyValue.Create(newLang)); } + finally { mSettingLanguage = false; } + } + + // 字段 + bool mSettingLanguage; + IDataProperty? mLangField; + + void Apply(PhonemeEditorConfig config) + { + mPanel.Children.Clear(); + mConfig = config; + + var title = CreateTitle(config.DisplayText ?? mKey, 26); + mPanel.Children.Add(title); + + RenderGroup(" Consonant", config.Phonemes.Where(p => !p.IsVowel).ToList(), isVowel: false); + RenderGroup(" Vowel", config.Phonemes.Where(p => p.IsVowel).ToList(), isVowel: true); + } + + void RenderGroup(string label, List entries, bool isVowel) + { + var lbl = new Label { Content = label, FontSize = 11, Foreground = Style.LIGHT_WHITE.ToBrush(), Margin = new(24, 4, 0, 2) }; + mPanel.Children.Add(lbl); + + foreach (var phoneme in entries) + { + var row = CreatePhonemeRow(phoneme, isVowel); + mPanel.Children.Add(row); + } + } + + // 布局:[lang↓(70px左)] [音素(弹性拉伸)] [+(22px)] [-(22px最右)] + // 注意 DockPanel LastChildFill=true,最后一个添加的 child 填充剩余空间 + DockPanel CreatePhonemeRow(PhonemeEntry phoneme, bool isVowel) + { + int rowH = 24; + var dock = new DockPanel { Height = rowH + 4, Margin = new(24, 2, 24, 2) }; + int phIdx = mConfig.Phonemes.IndexOf(phoneme); + + // 1. 左端:语言前缀下拉(70px,无前缀时显示「??」) + string langPrefix = phoneme.Symbol.Contains('/') ? phoneme.Symbol.Split('/')[0] : "??"; + var langCombo = new DropDown { Height = rowH, Width = 70, FontSize = 11, MinHeight = rowH, MaxHeight = rowH, Padding = new(6, 0) }; + langCombo.VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center; + foreach (var l in mConfig.AvailableLanguages) langCombo.Items.Add(l); + langCombo.SelectedItem = langPrefix; + langCombo.PlaceholderText = "??"; + int capIdx2 = phIdx; + langCombo.SelectionChanged += (_, _) => + { + if (capIdx2 < mConfig.Phonemes.Count && langCombo.SelectedItem is string newLang) + { + var list = mConfig.Phonemes.ToList(); + if (capIdx2 < list.Count) + { + var entry = list[capIdx2]; + string p = entry.Symbol.Contains('/') ? entry.Symbol.Split('/')[1] : entry.Symbol; + list[capIdx2] = new PhonemeEntry { Symbol = $"{newLang}/{p}", IsVowel = isVowel }; + Commit(list); + } + } + }; + DockPanel.SetDock(langCombo, Dock.Left); + dock.Children.Add(langCombo); + + // 2. 右端:删除按钮(-)始终显示,仅一个时半透明 + bool canDelete = isVowel ? mConfig.CanDeleteVowel : mConfig.CanDeleteConsonant; + var delBtn = new TuneLab.GUI.Components.Button { Width = 22, Height = rowH, Opacity = canDelete ? 1.0 : 0.5 }; + int cap = phIdx; + delBtn.Clicked += () => + { + if (!canDelete) return; + var list = mConfig.Phonemes.ToList(); + if (cap < list.Count) list.RemoveAt(cap); + Commit(list); + }; + delBtn.AddContent(new() { Item = new IconItem() { Icon = Assets.PhonemeDelete }, ColorSet = new() { Color = Style.WHITE } }); + delBtn.VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center; + DockPanel.SetDock(delBtn, Dock.Right); + dock.Children.Add(delBtn); + + // 3. 右2:添加按钮(+) + var addBtn = new TuneLab.GUI.Components.Button { Width = 22, Height = rowH }; + int capAdd = phIdx; + addBtn.Clicked += () => + { + var list = mConfig.Phonemes.ToList(); + var newEntry = new PhonemeEntry { Symbol = mConfig.AvailableLanguages.FirstOrDefault() ?? "??", IsVowel = isVowel }; + list.Insert(capAdd + 1, newEntry); + Commit(list); + }; + addBtn.AddContent(new() { Item = new IconItem() { Icon = Assets.PhonemeAdd }, ColorSet = new() { Color = Style.WHITE } }); + addBtn.VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center; + DockPanel.SetDock(addBtn, Dock.Right); + dock.Children.Add(addBtn); + + // 4. 最后:音素文本输入(弹性拉伸填充剩余空间,高度匹配下拉栏) + string plain = phoneme.Symbol.Contains('/') ? phoneme.Symbol.Split('/')[1] : phoneme.Symbol; + var textInput = new TextInput { Height = rowH, MinHeight = rowH, MaxHeight = rowH, FontSize = 12, Text = plain, Padding = new(6, 0) }; + textInput.VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center; + textInput.Margin = new(4, 0, 4, 0); + int capIdx = phIdx; + textInput.ValueCommitted.Subscribe(() => + { + var list = mConfig.Phonemes.ToList(); + if (capIdx < list.Count) + { + var entry = list[capIdx]; + string lang = entry.Symbol.Contains('/') ? entry.Symbol.Split('/')[0] : ""; + list[capIdx] = new PhonemeEntry { Symbol = string.IsNullOrEmpty(lang) ? textInput.Text : $"{lang}/{textInput.Text}", IsVowel = isVowel }; + Commit(list); + } + }, s); + // 作为最后一个 child,自动填充 + dock.Children.Add(textInput); + + return dock; + } + + public override Type ConfigType => typeof(PhonemeEditorConfig); + public override IEnumerable Views => [mPanel]; + + public override void Update(IControllerConfig config) => Apply((PhonemeEditorConfig)config); + + public override void Dispose() + { + base.Dispose(); + mPanel.Children.Clear(); + ObjectPoolManager.Return(mPanel); + } + + readonly string mKey; + readonly StackPanel mPanel; + PhonemeEditorConfig mConfig = null!; + readonly IDataProperty mDataProp; + } + + // 按钮控件:渲染为可点击的按钮,触发 ButtonConfig.Action(在 UI 线程)。不绑定数据。 + class ButtonCreator : Creator + { + public ButtonCreator(PropertyObjectController parent, string key, ButtonConfig config) : base(parent) + { + var text = config.DisplayText ?? key; + + mButton = ObjectPoolManager.Get(); + mButton.Height = 32; + mButton.Margin = new(24, 8, 24, 8); + mButton.AddContent(new() + { + Item = new BorderItem() { CornerRadius = 4 }, + ColorSet = new() { Color = Style.BUTTON_NORMAL, HoveredColor = Style.BUTTON_NORMAL_HOVER, PressedColor = Style.INTERFACE }, + }); + mButton.AddContent(new() + { + Item = new TextItem() { Text = text, FontSize = 13 }, + ColorSet = new() { Color = Style.WHITE }, + }); + + mAction = config.Action; + mButton.Clicked += OnClicked; + } + + void OnClicked() + { + mAction?.Invoke(); + } + + public override Type ConfigType => typeof(ButtonConfig); + public override IEnumerable Views => [mButton]; + + public override void Update(IControllerConfig config) + { + var btn = (ButtonConfig)config; + mAction = btn.Action; + } + + public override void Dispose() + { + base.Dispose(); + mButton.Clicked -= OnClicked; + ObjectPoolManager.Return(mButton); + } + + readonly Components.Button mButton; + Action? mAction; + } + static Label CreateTitle(string title, double height) { var label = ObjectPoolManager.Get