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 @@
+[33mf2c6b51[m[33m ([m[1;36mHEAD[m[33m -> [m[1;32mfeature/agent[m[33m, [m[1;31morigin/release/2.0.0[m[33m, [m[1;31morigin/feature/agent[m[33m, [m[1;32mrelease/2.0.0[m[33m)[m Merge branch 'feature/agent' into release/2.0.0
+[33m23ce42a[m refactor: 收紧 agent 工具描述——pitch line 与 vibrato 加性叠加、勿断开音高线
+[33m2bbf396[m feat: agent 对话分步可见 + 流式实时 Markdown + 取消回复气泡
+[33mf8e55db[m feat: agent 加 add_vibrato 工具(创建真正的 Vibrato 对象)
+[33m8a72108[m Revert "refactor: effect 收口改订 effect.Modified,退掉 SynthesisBatch band-aid"
+[33mf7823b9[m fix: OpenAI 兼容流式 tool_call 被后续空 id/name 帧覆盖而丢失
+[33mc343b21[m feat: agent 工具集三层扩充(只读 / 业务写 / 批量 DSL)
+[33m1b4b209[m fix: Agent 报错/停止文案可复制——提示行改用 SelectableTextBlock
+[33me64f983[m feat: Agent 换模型保上下文 + 连接模型提示 + 标题加固 + 报错保留已输出
+[33m0644c43[m refactor: effect 收口改订 effect.Modified,退掉 SynthesisBatch band-aid
+[33m2d5b708[m refactor: ITiming 死契约从 TuneLab.SDK 下沉到宿主 TuneLab.Data.Timing
+[33m08e3a84[m docs: 订正 plugin-development 的 voice/effect 章节到当前 SDK 模型
+[33m74cda39[m feat: effect 收敛阶段三——回显富类型换形 + effect 回显端到端
+[33m6962336[m fix: Agent 短回复时 token 数与 Copy 挤在一起——token 文本加右边距
+[33m3576611[m refactor: Agent 设置页 provider 选择复用属性面板样式 + 改名 Model Provider
+[33m909ced3[m feat: Agent 撞工具回合上限时强制收尾——再请求一次不带工具让模型总结
+[33m65cbdad[m fix: DataObject merge 通知上行——中间态不漏成祖先 settled、收口沿父链补发
+[33m9a7b359[m feat: effect 收敛阶段二——厚 IEffectProcessor/每段一个 + 反应式 EffectGraph
+[33me4e922d[m feat: Agent 会话菜单 UI 完善——✕ 对齐 + 全名 tooltip + 原生观感
+[33mf4e9907[m 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("");
public static SvgIcon Appearance = new("");
public static SvgIcon Editing = new("");
+ public static SvgIcon PhonemeAdd = new("");
+ public static SvgIcon PhonemeDelete = new("");
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