适用于 TuneLab 新版扩展系统(V1)。本文介绍如何编写、打包、发布一个 TuneLab 插件。 老插件(Legacy)的兼容说明见文末「附录:Legacy 插件」。
英文基准文档见 plugin-development.md,本文为其中文翻译;如有出入以英文版为准。
- 插件包 = 一个文件夹。它是部署、安装、卸载的原子单位,被放进 TuneLab 的扩展目录(见 §10)。
manifest.json是包的身份标识,必须放在包文件夹的最外层。新版(V1)插件必须带它;TuneLab 先读这个文件,再据其内容有选择地加载包内程序集——不会盲目扫描整个文件夹。- 一个包可以含多个插件。若你有一套共用的基建程序集,可以把基于它开发的多个插件打进同一个包,基建只分发一份、运行时也只加载一份(它们共享同一个加载上下文)。
- 插件类别(type):当前支持
format(工程文件导入/导出)、voice(歌声合成引擎)、instrument(多声部音源引擎,如合成器/采样器)与effect(效果器,对合成音频做整段离线变换,如换声)。包还可以是纯资源包(无代码,如音色资源)。
每个插件包在加载时被放进一个独立的 AssemblyLoadContext(ALC):
- TuneLab 的 SDK 契约程序集(
TuneLab.Foundation+TuneLab.SDK.*)和 .NET 运行时由主程序统一提供、所有插件共享——你引用它们即可,不要把它们打进包里。 - 你的私有依赖(第三方库、原生 dll 等)放进包文件夹,会被加载进你这个包专属的 ALC,与其他插件隔离,因此不同插件捆绑不同版本的同一个库不会冲突。
包级(最外层):
| 字段 | 必填 | 说明 |
|---|---|---|
id |
✅ | 包的唯一标识,建议用反向域名,如 com.example.myplugin。它也是 V1 的判别标志——有 id 即按新版加载。 |
name |
✅ | 展示名 |
version |
包版本(semver),默认 1.0.0 |
|
author |
作者(展示在扩展侧边栏) | |
description |
一句话讲整个包(展示在扩展侧边栏的悬浮 tooltip 与详情窗头部)。具体某个能力是什么,写在该条目自己的 introduction 里;这句绝不顶替它。 |
|
icon |
包内相对路径的图标,位图(.png/.jpg 等)或矢量(.svg)均可。原样展示——侧边栏不给它加背景、不裁圆角,所以圆角/透明/留白都由你自定(想要圆角就自己画进图标)。建议提供方形图标(如 64×64 及以上)。省略则侧边栏用名称首字母 + 深色圆角方块占位。 |
|
sdk-version |
含代码插件时✅ | 你编译时使用的 SDK 版本(如 "1.0")。TuneLab 据此做兼容校验:插件要求的版本高于宿主提供的版本则被跳过。资源包可省略。写你实际用到的那一档,不要无脑写最新的——写高了会被老宿主拒之门外,而你可能根本没用到新东西。各档见下表。 |
SDK 版本各档提供了什么(宿主提供的版本随 TuneLab 一起升,插件声明的版本不会):
sdk-version |
起自 | 这一档新增了什么 |
|---|---|---|
1.0 |
TuneLab 2.0.0 | V1 SDK 的全部初始面 |
1.1 |
TuneLab 2.1.0 | 路径选择控件(PathPickerConfig / FileTypeFilter)、多行文本框(TextBoxConfig.WithMultiline) |
1.2 |
尚未发布 | 标签组(TagConfig / TagItem)、按钮(ButtonConfig / IButtonClickContext / IDialogService / IProgressReporter,见 §9)、声明失效(InvalidatePart / InvalidateNotes 及配套的 PartId / note Id,见 §5.2)、+ 菜单与下拉的分组和分隔线(AddableElement / AddableKey 分组、Separator、ComboBoxItem.Separator) |
插件级(描述「这个包提供什么」)。身份内联进 manifest:一个条目 = 一个具体可注册能力,自带身份(引擎 id / 文件扩展名)+ 实现类全名。宿主读完 manifest 即知插件提供什么、无需加载程序集反射。
| 字段 | 必填 | 说明 |
|---|---|---|
type |
✅ | 类别:format / voice / instrument / effect / 资源类。宿主不认识的 type 若还声明了 assembly/class,会被判为「本宿主不支持的插件类型」而跳过。 |
engine |
voice/instrument/effect ✅ | 引擎类型 id(唯一身份,如 "MyEngine")。不可变——它会写进工程文件,改了旧工程会失配。绝不本地化。 |
suffixes |
format | 「两个方向都认这些文件后缀」的简写(不带点,如 ["mid", "midi"]),等价于把 import-suffixes 与 export-suffixes 同时设成这个值。 |
import-suffixes / export-suffixes |
format | 各方向认的后缀。没声明的方向就是不存在——只导入或只导出的格式就这么写。别名不对称也在这里表达:"import-suffixes": ["mid","midi"], "export-suffixes": ["midi"] = 读两种、只写一种,仍是一个条目。与 suffixes 互斥;至少一个方向非空。 |
name |
显示名(UI 展示用),可与身份 id 不同、可翻译。省略则 UI 退回显示身份 id。 | |
introduction |
包内相对路径,指向一份 markdown 介绍(能干什么、怎么开始用、有什么坑)。宿主在扩展详情窗渲染它,agent 按需拉取(get_extension_introduction)。 |
|
localizations |
按语言覆盖 name / introduction,如 { "zh-CN": { "name": "增益", "introduction": "Introduction.zh-CN.md" } }。缺当前语言则回退基础值。introduction 的语言变体就走这里(各语言可指不同文件名),没有 <base>.<lang>.md 那种隐式后缀约定。 |
|
class |
含代码时✅ | 本条目那唯一一个入口类的全名(如 "My.Ns.MyVoiceEngine")。宿主校验它实现了本条目所声明的一切(见下)、且有无参构造函数;不满足即加载错误。 |
assembly |
含代码时✅ | 含该类的程序集(相对包文件夹的路径,单个)。资源包省略。 |
platforms |
平台过滤,如 ["win", "osx", "linux"] 或带架构 ["win-x64"]。留空 = 全平台。 |
入口类必须实现什么:
| 条目 | 入口类必须实现 |
|---|---|
voice |
IVoiceSynthesisEngine |
instrument |
IInstrumentSynthesisEngine |
effect |
IEffectSynthesisEngine |
format,声明了导入后缀 |
IImportFormat |
format,声明了导出后缀 |
IExportFormat |
format,两个方向都声明了 |
两个接口都要——同一个类兼做读写 |
一个条目 = 一个实现类 = 一份 introduction = 一份设置。 这是同一件事的三个侧面,所以各自只声明一次。两份实现就写两个条目。宿主不猜:manifest 写什么就是什么,不匹配即加载错误,不做静默降级。同一个类不得出现在两个后缀相交的条目里——那是一份实现假装成两份、领两份东西。
方向不进
type。 它已由"你填了哪几个后缀字段"完整表达,故type对一切格式都是同一个值。宿主内部仍分format-import/format-export两种能力位——路由一直是逐后缀 × 逐方向的,用户照样能让别的包只接管某个后缀的导出——但那是从你的后缀字段推出来的,你从不书写。多余的后缀是别名:共用本条目的实现类、全部说明与那一份设置;但注册与路由仍逐后缀独立,别的包可以只接管其中一个。每个后缀各自是不可变身份。真的两种不同格式 → 写两个条目。
身份 id 与显示名分离:
engine/suffixes是不可变身份(注册键 + 工程序列化引用);name/localizations仅供 UI 展示、可随意改名翻译。
introduction是条目级、也是你唯一需要写的说明:AI 要的"一句话摘要"由它自己从这份全文提炼,不必你另写一行——你不清楚模型在找什么,写出来多半成了产品文案。多能力包请给每个条目各写一份。不写的后果:宿主【不会】拿包级
description冒充它。agent 只会被告知"该能力没有自己的介绍",外加一句明确标注为降级参考的包级自述,并被提醒那讲的是整个包(可能还涵盖包里别的能力)、不等于这个能力。于是 AI 对你插件的了解仅止于一个名字,也就不会主动推荐它。README 不是元数据:宿主只认 manifest 声明的
introduction。包里的README.md是你留给仓库读者的文件(build 步骤、license、贡献指南),宿主不读、不展示——两者受众不同,不必也不该复用同一份。一个程序集里有多个引擎/格式时,在
extensions[]里逐条列出(同assembly、各自engine/suffixes+class)。
直接把插件级字段写在顶层即可,不需要数组:
{
"id": "com.example.myformat",
"name": "My Format",
"version": "1.0.0",
"author": "Example",
"description": "Import/export .myfmt files",
"sdk-version": "1.0",
"type": "format",
"suffixes": ["myfmt"],
"introduction": "Introduction.md",
"class": "My.Ns.MyFormat",
"assembly": "MyFormat.dll"
}简写形态下顶层只有一个
name:它同时是包名与这唯一引擎/格式的显示名(localizations同理共用)。若要让包名与引擎显示名各不相同,改用下面的extensions[]形态、给条目单独写name。
introduction写在顶层同理——只有一个条目时,包就是那个能力。改用extensions[]形态后它属于各条目,此时写在顶层无效:包本身没有 introduction 这回事。
用 extensions[] 数组,每个元素是一个独立插件的元数据。包级字段(id/name/version/author/description/sdk-version)写在顶层、共用:
{
"id": "com.example.suite",
"name": "Example Suite",
"version": "2.0.0",
"sdk-version": "1.0",
"extensions": [
{ "type": "format", "import-suffixes": ["exfmt"], "name": "Example Format (Import)", "introduction": "Introduction.Import.md", "class": "Example.Format.Importer", "assembly": "Example.Format.dll" },
{ "type": "format", "export-suffixes": ["exfmt"], "name": "Example Format (Export)", "introduction": "Introduction.Export.md", "class": "Example.Format.Exporter", "assembly": "Example.Format.dll" },
{ "type": "voice", "engine": "ExEngine", "introduction": "Introduction.Voice.md", "class": "Example.Voice.ExVoiceEngine", "assembly": "Example.Voice.dll", "platforms": ["win"] }
]
}
Example.Format.dll、Example.Voice.dll可以共同引用同一个Example.Common.dll(放进包里),它只需分发一份。每个条目各带自己的
introduction,于是详情窗按条目分 tab、agent 也能准确描述每个能力。顶层description仍然只讲整个包。规则:有
extensions[]时以它为准,顶层的身份字段(type/engine/class/…)被忽略。
省略 assembly/class 等代码字段,只用 type 声明用途。TuneLab 只登记它、不加载代码,由对应引擎在运行时去发现包内资源:
{
"id": "com.example.mybank",
"name": "My Voicebank",
"version": "1.0.0",
"type": "voicebank"
}新建一个 .NET 类库工程,引用 SDK 程序集:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <!-- SDK ABI 地板,锁 net8 -->
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<!-- 引用你需要的 SDK 程序集;这些由 TuneLab 提供,打包时不要带上 -->
<Reference Include="TuneLab.Foundation" />
<Reference Include="TuneLab.SDK" /> <!-- format/voice/effect 全部插件类型同一程序集 -->
</ItemGroup>
</Project>规则:
- 目标框架锁
net8.0(SDK 的 ABI 地板)。宿主未来升级 .NET 时,按此地板编译的插件无需重编即可运行。 - 只引用
TuneLab.Foundation和TuneLab.SDK。不要引用TuneLab.Hosting.Foundation或主程序——它们不是插件契约。 - SDK 程序集设为「不复制到输出」(
Private=false/ Copy Local = No),打包时别把它们放进包,宿主会共享自己的那一份。 - 你的私有第三方依赖正常引用、随包分发即可。
实现 IImportFormat(导入)和/或 IExportFormat(导出)。需要无参构造函数。文件后缀与实现类写在 manifest.json(suffixes + class + assembly),代码里不再用 attribute 声明。
你只回答一个问题:这个格式能读哪些后缀、能写哪些后缀。方向由此而来,没有别的要选。
// 读写同一组后缀 → suffixes 就是两个方向的简写。
{ "type": "format", "suffixes": ["myfmt"], "name": "My Format",
"class": "My.Ns.MyFormat", "assembly": "MyFormat.dll" }// 读两种、只写一种。仍是【一个】条目:一个名字、一份 introduction、【一个】设置桶。
{ "type": "format", "import-suffixes": ["mid", "midi"], "export-suffixes": ["midi"],
"class": "My.Ns.MidiFormat", "assembly": "MyMidi.dll" }// 只导入——没声明的方向就是不存在。
{ "type": "format", "import-suffixes": ["myfmt"], "name": "My Format",
"class": "My.Ns.MyFormatImporter", "assembly": "MyFormat.dll" }入口类必须实现本条目声明的一切:有导入后缀就要 IImportFormat、有导出后缀就要 IExportFormat、两个都有就两个都要。不匹配会在加载期报 class 'X' does not implement IExportFormat——宿主不会悄悄把你要的方向抹掉。
如果导入器与导出器确实是两个不同的类,它们就是两个条目——一个条目只有一个 class:
"extensions": [
{ "type": "format", "import-suffixes": ["myfmt"], "name": "My Format (Import)",
"class": "My.Ns.MyFormatImporter", "assembly": "MyFormat.dll" },
{ "type": "format", "export-suffixes": ["myfmt"], "name": "My Format (Export)",
"class": "My.Ns.MyFormatExporter", "assembly": "MyFormat.dll" }
]它们于是各有各的名字、introduction 与各自的设置——这正是拆开的意义:两份实现各有各的选项,不拆就没地方放。
但不要用这种写法去表达同一个类的后缀不对称:把同一个 class 写进两个后缀相交的条目是加载错误。一份实现就是一个条目,用 §4.1 那样的 import-suffixes / export-suffixes。(拆开的代价是一份实现却要占两个详情窗 tab、两行设置、两个桶。)
using System.IO;
using TuneLab.SDK;
// 紧凑形态:一个类,两个接口。
public class MyFormat : IImportFormat, IExportFormat
{
public ProjectInfo Deserialize(Stream stream)
{
// 把 stream 解析成 TuneLab 工程模型
var project = new ProjectInfo();
// ... 填充 project ...
return project;
}
public void Serialize(Stream output, ProjectInfo info)
{
// 把 info 写进宿主提供的 output 流。宿主拥有 output 生命周期(创建/定位/关闭),
// 插件只顺序写入、不得 Dispose/Close/Seek/重置 Position。
// ... 把 info 写进 output ...
}
}同一方向里写多个后缀 = 「同一格式的多个别名」:共用实现类、说明与设置,但仍各自可被独立路由。每个后缀各自是不可变身份(路由 + 序列化);name 是可选显示名(可加 localizations 翻译)。
读得宽、只写一种规范后缀是带别名的格式的常态——那就是 §4.1 的第二个例子,仍是一个条目。
工程模型(ProjectInfo/TrackInfo/PartInfo/NoteInfo…)定义在 TuneLab.SDK。
voice 是歌声合成引擎(如 SVS 模型)。本章按「先建立心智模型 → 逐个接口讲清职责与坑 → 五个易错专题(音素 I/O、音高采样、快照、属性约定、原生依赖打包)」组织。照本章写完,你应能交付一个线程安全、增量重合成正确、产物归属无误的 voice 插件。
- 会话托管厚模型:你实现
IVoiceSynthesisEngine(每种引擎类型一个,需无参构造函数;引擎 id 写在manifest.json的engine,实现类写在class,宿主校验它实现IVoiceSynthesisEngine)。宿主为工程里每条 MidiPart 调一次CreateSession建一个IVoiceSynthesisSession。合成的全部状态由会话自己托管——分块、调度状态、音频缓冲、合成进度、失效(dirty)判定全在你这边。理由:失效依赖图(如「音素时长 → 音高 → 音频」的分级管线,改自动化只需重渲音频而不必重算音素)只有引擎自己懂,宿主无从复制。宿主只做三件事:把工程数据的变更流推给你、驱动调度、读你的产物来展示。 - 声明 vs 执行分层:会话对外有两类职责——声明(这个声源暴露哪些自动化轨/回显轨/属性面板、默认歌词)与执行(合成)。声明全部是「当前 part/note 参数值的纯函数」,宿主在参数 commit 时重算并 diff 到 UI(详见 §5.2)。
- 插件侧时间量一律全局秒:note 边界、曲线查询点、开窗区间、状态段范围、音频段对齐——全部是秒(
double)。tick 只是宿主乐谱内部表示、绝不外露给插件。全局 0 秒 = 采样点 0。tempo 变化(及 part 平移)不需要你显式处理,也没有增量通知:宿主直接整体重建会话(旧会话Dispose、新 context 新会话),新会话读到的就是新秒值——正确实现Dispose即天然正确(§5.9)。 - 两视图 + 线程纪律(最重要的坑):
- 活视图(
IVoiceSynthesisContext及其IVoiceSynthesisNote/ISynthesisAutomation):可订阅、只能在数据线程访问。用于「收变更通知 → 标脏」「GetNextPendingSynthesisRange分片决策」「SynthesizeNext同步前缀拉快照」。 - 冻结快照(
VoiceSynthesisSnapshot及*Snapshot家族、IAutomationEvaluator):不可变、无事件、可跨线程。后台 worker 只读快照,永不回碰任何活视图对象。 - 命名即纪律:活视图(
IVoiceSynthesisContext/IVoiceSynthesisNote/ISynthesisAutomation)仅数据线程;*Snapshot= 冻结物(可跨线程)。违反这条是 voice 插件最常见、最难查的 bug(worker 线程读活 note → 与编辑线程数据竞争)。开发期宿主会在活视图入口做数据线程断言,跨线程访问会直接抛异常帮你定位。
- 活视图(
manifest 条目:{ "type": "voice", "engine": "MyEngine", "name": "My Engine", "class": "My.Ns.MyVoiceEngine", "assembly": "MyVoice.dll" }(engine 是不可变身份、会写进工程文件,改了旧工程会失配;name 可选显示名、可加 localizations 翻译;宿主校验 class 实现了 IVoiceSynthesisEngine)。
using TuneLab.Foundation;
using TuneLab.SDK;
public class MyVoiceEngine : IVoiceSynthesisEngine // engine id 在 manifest 的 "engine" 声明
{
// 声库目录(菜单/选择器用,无需创建会话即可读)。
// 契约:必须【立即返回、不得阻塞】——宿主与 UI 同步读取、无异步等待。
// 正确做法:Init 期扫描声库并缓存,这里仅返回缓存引用。惰性加载(首次 get 才扫盘)会卡 UI。
public IReadOnlyOrderedMap<string, VoiceSourceInfo> VoiceSourceInfos => mVoiceInfos;
// 声库选择器的呈现布局(有序分组树):几百声库时把下拉折成嵌套子菜单,而非一长条平铺。
// 与 VoiceSourceInfos 平行——布局只管「怎么摆」,身份/查找/工程引用仍走那张 id→info map;
// 叶子只引用 id(VoiceSourceLayoutItem.Voice),显示名由宿主从 map 取;组名(VoiceSourceLayoutItem.Group)
// 你自己按当前语言产出(同 VoiceSourceInfo.Name)。同层可任意交织裸声库与子组、可多级嵌套。
// 未被布局引用到的 id:宿主自动在顶层按 map 序补出(故不必全覆盖);不覆盖本成员 = 全部平铺(= 不分组)。
public IReadOnlyList<VoiceSourceLayoutItem> VoiceSourceLayout =>
[
VoiceSourceLayoutItem.Voice("solo-a"), // 顶层裸声库(与组交织)
VoiceSourceLayoutItem.Group("日系", [ // 分组(组名已本地化)
VoiceSourceLayoutItem.Voice("jp-f1"),
VoiceSourceLayoutItem.Group("方言", [ // 可再嵌套子组
VoiceSourceLayoutItem.Voice("jp-kansai"),
]),
]),
// 其余未列入的声库 → 宿主兜底补在顶层
];
// 无参、失败抛异常:宿主在调用边界 catch(责任归属靠捕获点判定,不靠异常类型)。
// 不传安装路径——你的 DLL 经 typeof(MyVoiceEngine).Assembly.Location 即可自定位包目录(见 §5.10)。
// Init 是懒调用(首次用到才调),宿主也可主动预热。仅「跨调用持有昂贵常驻状态」(如加载模型)才需要 Init/Destroy。
public void Init() { /* 扫描声库填 mVoiceInfos;加载/预热模型 */ }
public void Destroy() { /* 释放常驻资源(卸载模型、关 ONNX session 等) */ }
// 每条 part 一个会话:选定的声库烘在 context 里(context.VoiceId,即 VoiceSourceInfos 的 key),
// 故 CreateSession 不再单列它;context 是该 part 的输入活视图,随会话同生共死。会话是轻量句柄——重模型加载应是懒的。
public IVoiceSynthesisSession CreateSession(IVoiceSynthesisContext context)
=> new MySession(context);
// —— 声明面(属性面板 / 自动化轨):见 §5.2,全在引擎上、不依赖会话实例 ——
public IReadOnlyOrderedMap<PropertyKey, AutomationConfig> GetAutomationConfigs(IVoiceSynthesisPartPropertyContext context) => mAutomationConfigs;
public IReadOnlyOrderedMap<PropertyKey, AutomationConfig> GetSynthesizedParameterConfigs(IVoiceSynthesisPartPropertyContext context) => mSynthesizedParameterConfigs;
public ObjectConfig GetPartPropertyConfig(IVoiceSynthesisPartPropertyContext context) => mPartConfig;
public ObjectConfig GetNotePropertyConfig(IVoiceSynthesisNotePropertyContext context) => mNoteConfig;
readonly OrderedMap<string, VoiceSourceInfo> mVoiceInfos = new();
}VoiceSourceInfo 字段(声库目录元数据,会话不重复承载这些):
public sealed class VoiceSourceInfo
{
public required string Name { get; init; } // 声库显示名(可本地化,见 §5.2 末「本地化」)
public required string Description { get; init; } // 一句话简介
public ImageResource? Portrait { get; init; } // 可选立绘(显示在钢琴窗);null = 无
}Portrait 用 FileImageResource(TuneLab.Foundation),传绝对路径——你按自己的包目录拼出(见 §5.10)。它可指向单张图,也可指向序列帧目录,宿主按需解码:
var portrait = new FileImageResource(System.IO.Path.Combine(packageDir, "voices", voiceId, "portrait.png"));
mVoiceInfos.Add(voiceId, new VoiceSourceInfo { Name = "Alice", Description = "...", Portrait = portrait });换声源(换引擎)时宿主丢弃旧会话、用新
voiceId重建会话(context 也随之重建);引擎对象本身长存,Init只在首次用到时调一次。
声明面的四个方法在 IVoiceSynthesisEngine 上(不是会话上),全部是纯函数(同输入同输出、无副作用、轻量),宿主在每次参数 commit 时调用并 diff 到 UI。静态声明的插件忽略 context 返回固定 map/config 即可;要做条件 UI(某开关打开才出现的控件/轨)就读 context 当前值来决定返回什么;多声库(一个引擎多个 voiceId)按 part 视图上的 VoiceId 分流(note 面是 context.Part.VoiceId,part 面是 context.Parts[i].VoiceId)。
为何在引擎而非会话:声明只依赖
(voiceId, part 当前值)、不碰任何合成运行时状态,本就是纯函数。放在引擎上让宿主能在建会话之前就求出声明(轨集合/面板),于是CreateSession返回的会话在构造函数里就能订阅自己声明的自动化轨(构造期context.Automations已含你声明的轨,TryGetValue取得 / 可枚举——声明已就绪)。若声明留在会话上则成死环:会话要订阅自己声明的轨,却要等自己构造完、宿主才拿得到声明——构造期context.Automations尚未填好你声明的轨。DefaultLyric是唯一留在会话上的取值(运行时才用,见 §5.3)。
// 自动化轨集合(part 级):连续轨与分段轨同在一张有序 map,声明序即呈现序。voiceId 选哪个声库经 context.VoiceId。
public IReadOnlyOrderedMap<PropertyKey, AutomationConfig> GetAutomationConfigs(IVoiceSynthesisPartPropertyContext context) => mAutomationConfigs;
// 只读回显轨声明(引擎产出、不可编辑的曲线,如 energy)。无回显返回空 map。
public IReadOnlyOrderedMap<PropertyKey, AutomationConfig> GetSynthesizedParameterConfigs(IVoiceSynthesisPartPropertyContext context) => mSynthesizedParameterConfigs;
// part 级属性面板(只依赖 part 自身稀疏值 + context.VoiceId)。
public ObjectConfig GetPartPropertyConfig(IVoiceSynthesisPartPropertyContext context) => mPartConfig;
// note 级属性面板(依赖 part 设置 + 选中 note 的合并值)。
public ObjectConfig GetNotePropertyConfig(IVoiceSynthesisNotePropertyContext context) => mNoteConfig;
IVoiceSynthesisPartPropertyContext(part 面板/自动化):IReadOnlyList<IVoiceSynthesisPartView> Parts(各选中 part 一个值视图,可多选 part)。IVoiceSynthesisNotePropertyContext(note 面板,独立接口、不继承):IVoiceSynthesisPartView Part(note 所属的单个 part——note 必属一个 part)+IReadOnlyList<IVoiceSynthesisNoteView> Notes(各选中 note 一个视图)。part 视图带VoiceId/PartProperties(PropertyObject)/Notes/Automations;note 视图带StartTime/EndTime/Pitch/Lyric/Properties/LeadingPhonemes/BodyPhonemes/BodyOffset。不在乎多选就用.Merge()(PropertyObjectExtensions扩展方法,在TuneLab.Foundation,收IEnumerable<PropertyObject>)把值还原成单个三态PropertyObject——如context.Notes.Select(n => n.Properties).Merge()(同 key 全等给值、不等/部分缺给Multiple)——按单选写;要逐成员真值(如把不等长数组的 seed 合成对)就直接遍历列表。voiceId 进 context 使 voice 的 context 与 effect 的IEffectSynthesisPropertyContext(无此对等物)永久分叉——这是有意的:effect 是单类型引擎,没有「选哪个库」的概念。
note / part 属性约定(keyed Properties,这是 per-note/per-part 参数的唯一通道):
IVoiceSynthesisNote的固定字段只有最小通用乐理量(Id/StartTime/EndTime/Pitch/Lyric/LeadingPhonemes/BodyPhonemes/BodyOffset)。所有 voice 专属的 per-note 参数(如张力、气声、性别)都走note.Properties(keyed)——加新参数 = 在GetNotePropertyConfig的ObjectConfig.Properties里加一个 key,不动接口固定面。part 级专属参数同理走GetPartPropertyConfig。- 面板用控件配置词汇搭(都在
TuneLab.SDK):SliderConfig(构造函数已封,只走静态工厂:SliderConfig.Linear(default, min, max)连续、SliderConfig.Integer(default, min, max)整数、SliderConfig.Create(default, scale)接自定义标度INormalizedScale;流式.WithFormat(INumberFormat)定制数值显示/回读、.WithRandomizable()声明可随机——宿主在右侧给随机入口、点一下在标度上按归一化均匀重取值,适合随机种子等场景;.WithMinLabel(text)/.WithMaxLabel(text)给量程两端加描述文本(如 min="Soft"、max="Hard",插件自译、可只设一端)——显示在滑条两端,该属性被用户钉到参数面板后同文本作上下界,与AutomationConfig的同名字段同一语义)、ComboBoxConfig.Create(options)(值/显示分离,可「界面中文/底层存枚举值」;.WithDefault(option)指定默认选中,缺省(或指定的项不能选)取第一个能选的叶子——跳过分组与分隔线,一个能选的都没有时默认值为空;选项值可为不同类型,存值不在选项里时原样保留、显示为琥珀色;new ComboBoxItem(组名, 子项)是分组——本身不可选、展开成子菜单、可再嵌套;ComboBoxItem.Separator(标签)是分隔线(标签可省),顶层与分组子项里都能放,顶层也可用.AppendSeparator(标签);叶子达 10 个时宿主自动在菜单顶部给搜索框)、CheckBoxConfig.Create(default)、TextBoxConfig.Create(default)(.WithPassword()掩码;.WithMultiline(最多行数)多行框——框贴着内容长、到该行数封顶、其后框内滚动(0= 不封顶);按键同单行框——Enter 确认、Shift+Enter 换行,值仍是一个 string(换行在里面),故存储与撤销都不变。掩码没有多行形态:两者同时声明时字段仍为单行)、PathPickerConfig.CreateFile(default)/PathPickerConfig.CreateFolder(default)(路径选择:文本框 + 浏览按钮开系统选择器,值就是路径字符串——.AppendFileType(名称, "*.exe")加类型过滤、.WithPickerTitle(标题)定制对话框标题;宿主不校验路径存不存在,自己在ApplySettings/Init里校);以上构造函数均已封、只走静态工厂。容器是ObjectConfig.Create(IReadOnlyOrderedMap<PropertyKey, IControllerConfig>)——与值配置一样封了构造函数、只走静态工厂。PropertyKey=(id, 可选显示名),从裸字符串或元组隐式转换,故{ ("tension", "张力"), … }即给键带上显示名;只写"tension"则显示 id。变长容器ListConfig/ExtensibleObjectConfig的+候选与下拉同构:new AddableElement(组名, 子项)/new AddableKey(组名, 子项)是分组(子菜单、可嵌套),AddableElement.Separator(标签)/AddableKey.Separator(标签)是分隔线,搜索框规则同下拉;键控容器隐藏已加入的键后,所辖区段空了的分隔线也随之隐藏;分组与分隔线都没有Template(AddableKey的也没有键 Id),读候选时先判SubItems。ExtensibleObjectConfig条目的显示顺序就是你给的Properties顺序:数据里的键无序(删掉再加会落进空位,先后不可靠),别照搬数据的键序,按你自己的候选目录排;要让用户自己调顺序,用元素为对象的ListConfig(支持拖序号重排)。要让用户从一组固定候选里挑若干个,用TagConfig.Create(候选):已选的值排成 chip、自动换行,末尾一个+弹候选菜单。值是所选候选值组成的PropertyArray,有序、不重复:新加的追加到末尾,用户可拖动 chip 调序(顺序可以有含义,如关键的排前面),已选的值不再出现在+菜单里。候选是TagItem,形状同ComboBoxItem(值/显示分离、值可为任意基础类型;new TagItem(组名, 子项)是分组、TagItem.Separator(标签)是分隔线,搜索框规则同下拉),但是独立类型。.WithDefault([值…])指定默认已选(缺省为空)。候选值与默认所选都必须是真实值,PropertyValue.Null/PropertyValue.Multiple会在构造时直接报参数错误。多选时宿主显示并集,只有部分对象带的 tag 显示为半透明——从+里选它即补给其余对象。存值里有候选之外的值时原样保留、显示为琥珀色(怎么生效由你决定),同下拉的非法值。元素为同一组选项的ComboBoxConfig的ListConfig数据形状相同,但允许重复、元素可逐个改——按需要的语义二选一。把已有字段(如用空对象当开关的ExtensibleObjectConfig)改成TagConfig时数据形状会变:tag 用新键,旧数据怎么兼容见本节下文「声明里的闭包与字段演进」。
readonly ObjectConfig mNoteConfig = ObjectConfig.Create(new OrderedMap<PropertyKey, IControllerConfig>
{
{ ("tension", "张力"), SliderConfig.Linear(0, -1, 1) },
{ ("breathiness", "气声"), SliderConfig.Linear(0, 0, 1) },
});- 读值:合成时从
VoiceSynthesisNoteSnapshot.Properties(PropertyObject值拷)读,用GetDouble(key, default)/GetBoolean/GetString(完整规则见下文「属性值的语义」)。稀疏存储——只有用户改过的字段才在里面;读不到就用你声明的默认值(PropertyObject的Get*第二参数就是 fallback,传与声明一致的默认值即可)。 AutomationConfig:DefaultValue/MinValue/MaxValue/Scale/Color(如"#E5A573";不设即默认全透明"#00000000",由宿主从调色板挑一个不与其它轨撞色的颜色,结果确定;alpha 为 0 的颜色一律算不设) /Randomizable(默认值面板的滑条右侧加随机入口,宜用于连续轨)/MinLabel/MaxLabel/Format,经AutomationConfig.Create(...)构造、流式WithColor/WithDefault/WithRandomizable/WithMinLabel/WithMaxLabel/WithFormat配置。轨的显示名不在 config 上——它随 map 的键走(PropertyKey的DisplayText),与属性面板同一套。DefaultValue = double.NaN⇒ 分段轨(无默认基线、段间断开,如 pitch 类、bend);实数 ⇒ 连续轨(处处有值、有基线,如 growl)。回显轨恒为分段形(DefaultValue = NaN)。- 值轴标度:
AutomationConfig.Create(minValue, maxValue)是线性轴;Create(INormalizedScale)重载可传自定义标度(与SliderConfig.Create对偶)——如NormalizedScale.Integer(min, max)让该轨成为整数轨,或自实现对数轴等。离散标度 ⇒ 信号处处落格:宿主不止在写入时吸附锚点,还在求值与渲染时把 Hermite 连续输出投影回标度(曲线呈阶梯、Evaluate返回的最终值即落格),所以引擎无需自行取整、也覆盖 load/preset/回喂等一切编辑路径。连续标度下投影为恒等(仅有浮点往返的 ULP 级扰动,勿依赖值被逐位原样透传)。 标度定义的是值轴形状与格点,不是值域保证。宿主不把求值结果钳进[MinValue, MaxValue]——这个保证无法兑现:标度单调递增只是文档约定(INormalizedScale是公共接口、NormalizedScale.Custom收任意两个 lambda),非单调标度的端点不是极值,钳位既不保证落进引擎理解的范围、又会破坏合法值。所以越界值确实会到达Evaluate——现实来源是改过量程后的旧工程、从别处带来的 preset、以及引擎自己回喂的值——请在引擎侧按算法需要自行校验(数组下标、查表参数、除数是最容易出事的几处)。宿主的落笔本身不越界(拖拽、锚点输入都钳在量程内),但值模型是加性的,所以正常使用也会出界:锚点存的是相对DefaultValue的偏移(用户事后改默认值滑条 ⇒ 整批锚点求值结果平移出界),vibrato 偏移也是加性叠到轨上(曲线贴近上界时让 vibrato 影响该轨 ⇒ 出界)。这两条是加性模型的刻意设计,不是缺陷。参数面板绘制裁剪到面板内,故越界锚点呈现为贴边。 - 二值区间轨(band / 开关色带):要一条"开/关区间"的轨(如气声开关、段落静音),声明分段轨 + 退化量程即可——
AutomationConfig.Create(v, v)(Create默认DefaultValue=NaN⇒ 分段;min==max⇒ 无值轴)。宿主识别此形态后渲染为满高开关色带(有段=开区间高亮、gap=关空白)而非折线,交互变为横拖涂开 / 右键横拖涂关(忽略纵向,无高度概念)。消费侧判定就是段的存在性:!double.IsNaN(evaluator.Evaluate(t)[i])即"开",段内数值不参与、不必读。区间边界 = 段锚点跨度、由用户精确拖出。 - 条件声明 + 孤儿数据:轨集合可随参数显隐(如某开关勾选才暴露 Growl 轨)。轨从声明里消失后,宿主保留其已画曲线(隐藏不删、不参与合成),参数回退使该轨复现即原样恢复——你不必担心条件轨切换会丢用户数据。
⚠️ 自动化参数名避开宿主保留名:GetAutomationConfigs的键会和宿主内置自动化合并展示,与内置项重名会被内置项占用、你的参数显示不出。已知保留名:Volume、VibratoEnvelope。请用自己的独特名(如Breathiness/Growl/ 加前缀)。
本地化:
DisplayText、ComboBox选项文本、轨名、声库名/简介都由你自译——读TuneLabContext.Global.Language(如"zh-CN"),用你自己的词典出文案,宿主不参与查表。未译时原样返回英文即可。manifest 的name/description走localizations字段本地化。
note 之外,引擎还可给音素声明用户可编辑的自定义属性——与 GetNotePropertyConfig 平行,但声明面与读值面都下沉到单个音素。典型用例:给元音(核)挂「咬字强度」滑杆、给辅音挂另一组属性。
// per-phoneme 属性声明(required,与 GetNotePropertyConfig 一样必须实现):
// **复用 note 声明上下文 IVoiceSynthesisNotePropertyContext**(不再有独立 phoneme context)——
// 每个 IVoiceSynthesisNoteView 带音素双列表 LeadingPhonemes / BodyPhonemes。返回**按核相对 slot 键控**的
// schema map:键 = slot(0 = 核、<0 = 引导辅音(越近核越接近 −1)、>0 = 核后),
// 值 = 该 slot(该「角色」)在整个选区上的 schema。
public IReadOnlyMap<int, ObjectConfig> GetPhonemePropertyConfigs(IVoiceSynthesisNotePropertyContext context)
{
var map = new Map<int, ObjectConfig>();
foreach (int slot in context.Notes.UnionSlots()) // SDK PhonemeSlots 助手:选区 slot 全集(升序)
map.Add(slot, slot == 0 ? VowelConfig : ConsonantConfig); // 按角色差异化:核 vs 辅音各一套
return map;
}-
required、不是默认接口方法:
GetPhonemePropertyConfigs与GetNotePropertyConfig/GetPartPropertyConfig一样必须实现。不声明音素属性的引擎直接return [];(空 map)即可。 -
复用 note 声明上下文:不再有独立的 phoneme context——phoneme 声明上下文与 note 声明上下文本就等价,故直接收
IVoiceSynthesisNotePropertyContext(即 note 面板那套{ Part; IReadOnlyList<IVoiceSynthesisNoteView> Notes }),每个IVoiceSynthesisNoteView带音素双列表LeadingPhonemes/BodyPhonemes。 -
按核相对 slot 键控:键 = 音素的核相对坐标
slot = 下标 − LeadingPhonemes.Count(0 = 核、<0 = 引导辅音、>0 = 核后),即音素在 note 内的角色;值 = 该角色在整个选区上的 schema。schema 授给角色而非单个音素——多选 note 时宿主把各 note 同 slot 的音素并到一行、共用这套 schema;单选时 slot 与逐音素一一对应、表达力无损。空 map = 所有音素均无属性;缺席的 slot = 该角色无属性(宿主按无属性处理,不报错)——键控自描述,不存在「长度须精确对齐」的位置契约。 -
多选合并归引擎(契约同
GetNotePropertyConfig):schema 依赖当前属性值时,自行对同 slot 各成员值做三态合并再条件化——context.Notes.Select(n => n.PhonemeAt(slot)?.Properties)取非空者Merge()。宿主只按 slot 合并值、从不合并 schema。 -
PhonemeSlots口径助手(SDK 共享纯函数,引擎与宿主同用一份、永不漂移):note.PhonemeAt(slot)(该位音素,无则 null)、notes.UnionSlots()(选区 slot 全集,升序连续区间);核下标即LeadingPhonemes.Count(表达式自明,不另设 API)。 -
IVoiceSynthesisNoteView.LeadingPhonemes/BodyPhonemes:该 note 的音素双列表(引导辅音;核 + 尾辅音),时间序,元素为IVoiceSynthesisPhonemeView。全序列 =LeadingPhonemes++BodyPhonemes(前置辅音 → 核 → 后辅音),音素在 note 内的位置 = 此拼接串的索引。视图上刻意不设合并Phonemes投影——自行拼接,或用上面的PhonemeSlots助手(跨两列表索引)。 -
IVoiceSynthesisPhonemeView(声明面读音素当前值):string Symbol/double Duration/double StretchWeight/PropertyObject Properties(该音素属性当前值快照)。引导/主体归属由 note 视图的LeadingPhonemes/BodyPhonemes列表成员(+double BodyOffset)给、不落每音素。可据这些当前值 + slot 进一步条件化 schema。 -
schema 仍用同一套控件配置词汇搭(
SliderConfig/ComboBoxConfig/CheckBoxConfig/TextBoxConfig/PathPickerConfig,容器ObjectConfig),与 note 属性写法一致。 -
合成时读值:从快照读——
VoiceSynthesisNoteSnapshot暴露LeadingPhonemes/BodyPhonemes(各IReadOnlyList<VoiceSynthesisPhonemeSnapshot>)+double BodyOffset,每项{ string Symbol; double Duration; double StretchWeight; PropertyObject Properties }。几何字段(Symbol/Duration/StretchWeight)平铺直读(引导/主体归属即列表成员);要喂PhonemeLayout.Resolve时按字段重建一个SynthesizedPhoneme(见 §5.7)。Properties是该音素属性的冻结值(未设 =PropertyObject.Empty),用GetDouble(key, default)等读,稀疏存储、读不到回退声明默认值。
foreach (var ph in note.LeadingPhonemes.Concat(note.BodyPhonemes)) // note 为 VoiceSynthesisNoteSnapshot(快照无合并 Phonemes 属性,消费方自拼)
{
string symbol = ph.Symbol; // 几何字段平铺直读
double stress = ph.Properties.GetDouble("stress", 0); // per-phoneme 属性值
}语义要点:
- 属性只在钉死音素上有意义(用户数据);引擎自动 G2P 产出的音素无属性。
- 输入活视图的钉死音素(
IVoiceSynthesisNote.LeadingPhonemes/BodyPhonemes)不带属性(见 §5.3 / §5.7);属性只出现在合成快照(VoiceSynthesisPhonemeSnapshot.Properties)里。 - 编辑 UI:侧栏音素属性面板已落地——逐 slot 一行(符号标签 + 该 slot 的控制器),多选 note 时各 note 同 slot 音素并到同一行、值三态合并、编辑扇出。仍待做的是音素的选中模型(音素目前没有自己的选中态,这是宿主侧的事、不在 SDK 面上);在选中模型补齐前,宿主以选中 note 的全体音素为面板范围。
有些面板选项取决于引擎自己算出来的东西,而不只是用户填的值——典型如后台解析歌词、分析声调:用户输入歌词后,解析完成才知道该给这个 note 暴露哪些选项。这类派生状态宿主看不见,宿主只在参数 commit / 选中变化时才重新询问声明,所以解析完成后要由你宣告一下:
// 会话里(任意线程均可调;宿主 marshal 回数据线程并合拍,连调几十次也只刷新一次)
mContext.InvalidateNotes([note.Id]); // 这些 note 的 note / 音素声明过期了
mContext.InvalidatePart(); // 本 part 的全部声明过期了(向下级联到 note,不必再逐个报 note)声明方法是引擎级调用,要靠身份把会话里算出的结果查回来:IVoiceSynthesisNoteView.Id 与会话活视图的 IVoiceSynthesisNote.Id 是同一个号,IVoiceSynthesisPartView.PartId 与 IVoiceSynthesisContext.PartId 同号。两者都在对象存活期间恒定、跨会话重建(换声库 / 改速度)不变,所以结果存在引擎上按 id 查即可:
public ObjectConfig GetNotePropertyConfig(IVoiceSynthesisNotePropertyContext context)
{
bool analyzed = mAnalyses.TryGetValue(context.Part.PartId, out var a)
&& context.Notes.All(n => a.Tones.ContainsKey(n.Id));
return analyzed ? mConfigWithToneOptions : mBaseConfig;
}要点:
- 只作废你自己的回答:不宣告用户数据变化,也不会让任何区段待重合成——解析结果若影响合成,按你原有的失效机制自己标脏。
- 级联方向:
InvalidatePart覆盖 part 属性面板、自动化轨集合、回显轨集合,并向下级联到 note / 音素声明;InvalidateNotes只覆盖这些 note,不向上级联——note 的结果若也改变了 part 面板,另调InvalidatePart。 - 不含延音判定:
IsContinuation必须由当前数据同步答出,不能"先给个答案、解析完再改";本信号不会让宿主重问它。 - 不得成环:派生状态只能是用户数据(歌词 / 音高 / 时间 / 属性值)的函数,不能依赖声明的输出。
- 未知 / 已删除的 id 静默忽略;会话销毁后的调用被忽略(新会话应自行重新分析)。
PartId为空串表示声明求值没有绑定具体 part(如静态 schema 枚举),按"无派生状态"作答。 - 结果没变就别调——虽然宿主会合拍,不调最省。
参考实现:tests/plugins/V1.Voice 的 LyricAnalysis(歌词以声调数字结尾如 ma3,约 0.6 秒后 note 面板出现 Tone Depth、part 面板出现 Tone Strength)。
本节适用于所有声明面板的地方:part / note / 音素属性、instrument、effect 参数、扩展设置。插件最常踩的坑都在这里,写面板前请通读;后面各控件的说明只讲各自特有的部分。
- 默认值就是"未设",也定了字段的类型。 用户没改过的字段不在数据里(稀疏存储),读不到就是默认值,不需要另找一个值(比如
PropertyValue.Null)当"未设"的哨兵。所以默认值必须是这个字段的一个真实值:用PropertyValue.Null或PropertyValue.Multiple作默认值,字段就永远存不进值——tag 在构造时直接报参数错误;下拉框没指定默认值、或指定的项不能选时,取第一个能选的选项。 - 恢复默认 = 删掉这个键。 标题行右键的「恢复默认」删掉存值,此后读到的就是默认值。别让含义依赖"存没存过":设成与默认相同的值,和没设过,读出来是一样的。
- 存值的类型与默认值不同时,按默认值处理。 滑条、数字框只认数,复选框只认布尔,文本框、路径只认字符串,tag、列表只认数组,对象容器只认对象。例外是下拉框:选项值可以混用类型,存什么读什么,不在选项里的值原样保留、显示为琥珀色。这条是宿主对非法数据的兜底,不能拿来做迁移(见下一节「字段形状演进」)。
- 读值用对 API。 读快照或声明 context 里的
PropertyObject:- 数值用
GetDouble(key, 默认值),布尔用GetBoolean,字符串用GetString。别写GetValue(key, 0):0让泛型推断成int,SDK 1.1 及以前的宿主上它恒返回默认值。 - 形状不定的值(数组、对象)按原值取:
props.Map.TryGetValue(key, out var value)。GetValue(key, PropertyValue.Null)在老宿主上同样恒返回默认值。 - 读到的值一律校验:撤销、preset、粘贴、旧版本的插件都可能带来任意形状。
- 数值用
- 多选。 声明时 context 给的是每个选中对象各自的值,要合并就自己
.Merge()(同键全等给该值,不等或部分缺给Multiple)。面板上的显示:标量按整值比较,不一致就显示为多值;列表按下标逐个合并;tag 显示并集。编辑时把同一个值写给全部对象(列表、tag 按元素扇出)。 Multiple只是多选读出时"各对象不一致"的标记,不是值。 不能作默认值,也不能写回——宿主拒绝写入。没改值就别提交。- 闭包、重建与字段形状演进:见下一节。
本节适用于所有声明面板的地方:part / note / 音素属性、instrument、effect 参数、扩展设置。
闭包只捕获值。 声明返回的 config 里可以带函数,如按钮的点击回调(§9)。它在声明调用结束之后才执行,而声明 context 与视图只在调用期间有效。需要什么——PartId、note Id、音素符号、同级字段已填的值——就在 GetXxxConfig 里先取出来,让闭包捕获这些值,不要捕获 context 或视图对象本身。
public ObjectConfig GetNotePropertyConfig(IVoiceSynthesisNotePropertyContext context)
{
var props = BaseNoteProperties();
// 单选时才给"重新分析":多选没有"一个"音符可对应
if (context.Notes.Count == 1)
{
string partId = context.Part.PartId, noteId = context.Notes[0].Id; // 取出值再捕获
props.Add(("reanalyze", "声调"), ButtonConfig.Create("重新分析", _ => ReanalyzeAsync(partId, noteId)));
}
return ObjectConfig.Create(props);
}依赖 context 的现建,其余照样缓存。 宿主既不要求 config 共用,也不要求现建。依赖 context 的 config(如上面的按钮)在每次声明时现建——闭包很便宜;不依赖 context 的照样可以缓存共用。
外部数据变了,让宿主重新声明。 闭包只活一次声明。用户改值、选中变化时宿主会自己重新声明;你自己算出的数据变了(如解析出的声调、合成后缓存的分析结果),调用 InvalidatePart / InvalidateNotes(上一节),宿主重新调用声明函数,闭包随之换成新值。
字段形状演进:用新键。 新版插件改了某个字段的值形状(比如从对象改成数组,或对象里换了字段),用户工程里仍是旧形状的值。宿主不解读形状,只有你知道旧值是什么意思,兼容由你负责:
- 新形状放在新键上,不要在旧键上直接换形状。宿主对类型不符的存值只做兜底——按缺省处理、显示声明的默认值——这是对非法数据的容错,判据是数据类型:新旧形状若只是对象里的字段不同、类型相同,它根本察觉不到。不要依赖它做迁移。
GetXxxConfig读得到当前值(各视图的Properties快照)。检测到旧键里有旧形状的值时,二选一:- 在旧键上继续返回旧形状的 config(可以只在它还有内容时显示、只删不加),合成时按旧逻辑读取它;
- 或者把旧值转换成新形状,作为新键 config 的默认值返回:用户看到的就是转换后的值,一编辑就以新形状存在新键上。合成时有新键用新键,否则按同一套逻辑转换旧键。
- 多选:默认值是这一次声明共用的。所选对象的旧值转换结果不一致时,没有一个默认值能同时代表它们,这时返回原本的默认值。
- 为什么原键换形状在新旧类型相同时一定出错:面板编辑是在当前显示的值上改、再整体写回(比如 tag 加一项,是把"显示的那组 + 新项"写回去)。新旧形状类型相同时,宿主照原样显示旧值,用户一编辑就在旧值上继续改,你在默认值里给的转换结果根本不会出现。
会话本身(
IVoiceSynthesisSession)的声明只剩DefaultLyric(string,新建 note 的默认歌词)——它是创建后才被取用的运行时值,不参与构造前声明,故留在会话实例上;其余声明(轨/面板)都在引擎上(§5.2)。
context 由宿主实现、会话级(随会话死)、仅数据线程访问。你订阅它来感知输入变化,并在 SynthesizeNext 同步前缀从它 GetSnapshot 物化快照。
public interface IVoiceSynthesisContext
{
string VoiceId { get; } // 选中的声库(VoiceSourceInfos 的键)——烘在 context 里,故 CreateSession 不再单列它
IReadOnlyNotifiableLinkedList<IVoiceSynthesisNote> Notes { get; } // 链表:枚举顺序消费、First/Last、note.Next/Previous 邻居导航;WhenAny 自动接线成员增删
IReadOnlyNotifiablePropertyObject PartProperties { get; }
IReadOnlyMap<string, ISynthesisAutomation> Automations { get; } // 取你声明过的可编辑轨(TryGetValue / 枚举)
ISynthesisAutomation Pitch { get; } // 绝对音高约束(分段:有值=钉死、NaN=自由),见 §5.6
ISynthesisAutomation PitchDeviation { get; } // 加性偏差(连续、默认 0、永不 NaN),见 §5.6
VoiceSynthesisSnapshot GetSnapshot(IReadOnlyList<IVoiceSynthesisNote> notes); // 见 §5.5
IAudioSegment CreateAudioSegment(long sampleOffset, int sampleCount, int sampleRate); // 见 §5.8
IActionEvent Committed { get; } // 逻辑编辑收口,见 §5.9
}Notes 排序与重叠:全序确定性——StartTime 升序 → 同起点 EndTime 降序(长 note 在前)→ 再同则保持插入序。note 可以重叠(和弦):序列原味直传可重叠 note,「后盖前」等去重叠是你的责任(单声部插件按需截断,和弦插件原味消费重叠)。
IVoiceSynthesisNote 字段全是可订阅属性(IReadOnlyNotifiableProperty<T>,有 Value / WillModify / Modified):StartTime/EndTime(全局秒)、Pitch(int 半音)、Lyric(string——该 note 要唱的文本:用户输入的歌词原文,或用户设定的显式发音覆盖。宿主不把它归一到任何单一音系,汉字可能原样到达;G2P 归你的引擎——正是这一点让引擎能照方言或其它非拼音音系发音)、LeadingPhonemes / BodyPhonemes(各为 IReadOnlyNotifiableProperty<IReadOnlyList<SynthesizedPhoneme>>)+ BodyOffset(见 §5.7)、Properties(keyed per-note 参数)。属性之外还有一个普通 string Id——该 note 的运行期身份,音素产物 map 就按它键控(§5.7)。还有 Next/Previous 邻居链——仅供数据线程的分片决策用(事件 handler 里只有 note 自身引用、无列表索引);合成时必须在快照的有序列表上按索引导航邻居,不要回碰活 note。
宿主掌握全局播放线,驱动逐步合成:先 peek 窗内「下一块待合成」的边界,再 commit 合成那一块。
// peek:窗内下一待合成块的纯值秒边界,【无副作用】。null = 窗内无待合成。
// 数据线程上廉价执行(会被多会话 speculative 地问,多数不中选——别在这里做重活或捕获)。
public SynthesisRange? GetNextPendingSynthesisRange(double startTime, double endTime)
{
// 基于完整 part 做【确定性】分片决策,返回下一脏块 [start,end]。
// 确定性是关键:commit 时会按同一窗口重算分块,须与本次 peek 得到同一块。
return FindNextDirtyPiece(startTime, endTime) is { } p ? new SynthesisRange(p.StartTime, p.EndTime) : null;
}
// commit 入参 = 选中它的那次 peek 的【同一窗口】(不是回灌 GetNextPendingSynthesisRange 自报的 SynthesisRange)。
public async Task SynthesizeNext(double startTime, double endTime, CancellationToken cancellation = default)
{
// —— 同步前缀(仍在数据线程):按同一窗口重算分块 + 圈定本块 note + GetSnapshot 物化快照 ——
if (FindNextDirtyPiece(startTime, endTime) is not { } piece) return;
var snapshot = mContext.GetSnapshot(piece.Notes, piece.Notes[0].StartTime.Value, piece.Notes[^1].EndTime.Value);
piece.Dirty = false; // 合成期间到达的新变更会重新标脏,完成后自然重排
mStatusChanged.Invoke(); // 标记本段进入 Synthesizing(mStatusChanged 是 IActionEvent StatusChanged 背后的 ActionEvent 字段)
// —— offload:worker 只读 snapshot 算 PCM/音素/曲线(绝不碰活视图)——
var report = new Progress<double>(p => { piece.Progress = p; mStatusChanged.Invoke(); });
var rendered = await Task.Run(() => Render(snapshot, piece.Notes, report, cancellation), CancellationToken.None);
if (rendered == null) return; // 取消:正常返回,产物保持上一版
// —— marshal 回数据线程发布产物(换引用即不可变)——
piece.Segment?.Dispose(); // 丢旧段建新段(见 §5.8)
piece.Segment = mContext.CreateAudioSegment((long)(rendered.StartTime * rate), rendered.Audio.Length, rate);
piece.Segment.Write(0, rendered.Audio);
piece.Segment.Commit();
piece.Phonemes = rendered.Phonemes;
mStatusChanged.Invoke();
}调度坑点:
- peek→commit 原子衔接:两者在同一调度 tick、同在数据线程,期间无编辑可插入;commit 收到的是选中它的那次 peek 的同一窗口。所以你的分片必须确定性(数据未变 + 同一窗口 ⇒ commit 重算得到 peek 报出的同一块)。peek 时若要为 commit 留信息(分块缓存),存进会话自己的字段,不要塞进
SynthesisRange(它只是 peek 返回的两个double)。 - 一个会话同时只合成一块;并行发生在不同 part 的不同会话之间,并发上限由宿主账本式管控。
- 取消是正常调度结局:
SynthesizeNext返回纯Task、无 outcome。取消时正常返回,绝不抛OperationCanceledException(否则逼每个 await 套 try-catch)。错误才抛异常(宿主 catch、该段标Failed)。槽位在await真正返回时才释放——不可中止的实现把这块跑完再返回即可,资源始终封顶在并发上限内。 - 进度经状态带上报:
SynthesisStatusSegment.Progress([0,1])+StatusChanged,不经SynthesizeNext参数传。Progress<T>在数据线程构造能捕获同步上下文,worker 的进度回报会 marshal 回数据线程。 - offload 用
Task.Run:Render里只读snapshot,传cancellation进去让其能尽早退出。注意Task.Run的第二参数传CancellationToken.None(取消由Render内部检查cancellation.IsCancellationRequested处理,而非让调度抛 TaskCanceledException)。
worker 不能碰活视图,所以 SynthesizeNext 的同步前缀要把本次合成所需的一切物化成不可变快照再 offload。GetSnapshot 一次返回一份:
VoiceSynthesisSnapshot GetSnapshot(IReadOnlyList<IVoiceSynthesisNote> notes);notes:本次合成需要的 note——段内 note + 协同发音邻居,由你自由圈定(如想看前一个 note 的尾辅音,就把它也放进来)。返回的snapshot.Notes与你递入的notes索引对齐——这是产物归属契约(见 §5.7:SynthesizedPhonemesmap 以snapshot.Notes[i].Id为键回指)。- 自动化与音高一律全量冻结、不开窗:快照不收时间区间——真实采样范围依赖音素时长,而时长在 offload 之后的合成阶段才知道,同步前缀无从正确圈窗(圈窄了,padding / 前置辅音的采样点就落在窗外取到错值)。全量冻的是原始控制点(锚点),很廉价——四分钟稠密 pitch 约几十万字节、亚毫秒,远小于一次推理——worker 按任意查询点插值、越界端与活曲线一致地钳夹。
- 一次合成可拉多份:如先拉音素级小窗定时,再据音素结果拉音频级大窗。但只能在同步前缀(offload 前、数据线程)调用。
VoiceSynthesisSnapshot 带什么(全是不可变值,可跨线程;将来跨进程时它就是序列化消息体):
public sealed class VoiceSynthesisSnapshot
{
IReadOnlyList<VoiceSynthesisNoteSnapshot> Notes { get; } // 与递入 notes 索引对齐;邻居按索引导航(不带 Next/Previous)
SynthesisAutomationSnapshot Pitch { get; } // 绝对音高约束(冻结求值器)
SynthesisAutomationSnapshot PitchDeviation { get; } // 加性偏差(冻结求值器)
PropertyObject PartProperties { get; } // part 参数值拷
IReadOnlyMap<string, SynthesisAutomationSnapshot> Automations { get; } // 取你声明的可编辑轨(同活视图函数式入口)
}
public sealed class VoiceSynthesisNoteSnapshot // 触底到值类型、无任何活引用
{
string Id { get; } // 该 note 的运行期身份——即 SynthesizedPhonemes 产物 map 的键(§5.7)
double StartTime { get; } double EndTime { get; } // 全局秒。EndTime = 有效末(宿主去重叠后盖前钳到下一 note 起点,单声部音频口径);宿主独占音素布局,不暴露满末
int Pitch { get; } string Lyric { get; }
IReadOnlyList<VoiceSynthesisPhonemeSnapshot> LeadingPhonemes { get; } BodyPhonemes { get; } // 钉死音素双列表(引导/主体);皆空=非钉死。每项 { Symbol; Duration; StretchWeight; Properties }(几何平铺),见 §5.7。刻意不设合并的 Phonemes 投影——消费方自行拼接两列表
double BodyOffset { get; } // 主体起点(两列表结合线 junction)相对 note 头的有符号偏移:junction = noteStart + BodyOffset(左负右正)
PropertyObject Properties { get; } // per-note 参数值拷
}
public sealed class SynthesisAutomationSnapshot { IAutomationEvaluator Evaluator { get; } }- automation 是冻结求值器,不是裸点:
SynthesisAutomationSnapshot.Evaluator.Evaluate(times)递一列秒时间点、拿回各点的值(double[])。插值算法恒在宿主侧(杜绝两套插值漂移),你只管递点取值。这正解决了「查询点常是合成中间产物(音素定时后才知道在哪采)、快照时刻预知不了」的问题。 - 想在前缀就采好的:在同步前缀直接调
Evaluator.Evaluate(...)把值采成double[]自存,再 offload——这样后台完全不依赖求值器(推荐给查询点已知的场景)。 - 唯一纪律:快照不可变、只写一次、此后只读。宿主从不修改已发布的快照——数据变了走活视图通知 → 你标脏 → 下次 peek 出新段 → 物化一份全新快照。替换而非同步,所以无需任何锁。
音高是两个平行通道,合成时按公式合成:
finalPitch(t) = resolve(Pitch(t)) + PitchDeviation(t)
Pitch(绝对约束,分段型):用户钉死的绝对音高曲线(半音)。有值 = 用户钉死、必须遵守;NaN= 自由区,你自己生成(典型回退到 note 的Pitch,并自加滑音/过渡)。PitchDeviation(加性偏差,连续型):处处有值、默认 0、永不 NaN。宿主侧的 vibrato 等偏差源都汇于此。它加在解析后的绝对面上,所以偏差对「自由区」同样生效(旧式把 vibrato 叠在绘制曲线上,自由区无载体会丢偏差——这里结构性修复了)。
按帧(控制率)采样的参照写法(worker 内,只读快照):
// 以控制率(如 100Hz)在 note 时间范围内布点,批量求值,再逐采样线性插值。
int controlCount = Math.Max(2, (int)((noteEnd - noteStart) * kControlRate) + 1);
var times = new double[controlCount];
for (int c = 0; c < controlCount; c++)
times[c] = noteStart + (noteEnd - noteStart) * c / (controlCount - 1);
double[] pitch = snapshot.Pitch.Evaluator.Evaluate(times); // 绝对约束(含 NaN)
double[] deviation = snapshot.PitchDeviation.Evaluator.Evaluate(times); // 加性偏差(无 NaN)
for (int c = 0; c < controlCount; c++)
pitch[c] = (double.IsNaN(pitch[c]) ? note.Pitch : pitch[c]) + deviation[c]; // NaN 自由区回退 note 音高
// 之后 pitch[] 即「最终半音曲线」,频率 = 440 * 2^((pitch-69)/12),逐采样线性插值。times是全局秒,与音频/音素同一时间系。批量Evaluate比逐点调用高效得多——攒一批点一次调。Evaluate永不要你懂插值:连续轨永不返回 NaN;分段轨段间返回 NaN(自己据此判断「自由/钉死」)。- 你产出的音高回显走
SynthesizedPitch(具名富类型SynthesizedPitch { IReadOnlyList<IReadOnlyList<Point>> Segments },分段折线,Point = (全局秒, 半音)),供宿主在音高轨上画回显线。空 =new() { Segments = [] }。其他声学量(如 energy)走回显轨(§5.2 + §5.8)。
音素描述符方向无关——读入(用户钉死约束)与输出(合成产物)都用同一个 SynthesizedPhoneme:只报「标称时长 + 权重」,不报绝对位置、不报前后归属。引导 / 主体归属做成结构化双列表(LeadingPhonemes / BodyPhonemes,成员即分类——引擎声明、抗帧抖动、跨拍音素可显式归属、结构上无交替洞);几何收进一个有符号 BodyOffset(主体起点 = 两列表结合线 junction 相对 note 头的偏移,左负右正)。分类与几何正交。定位 / 跨 note 去重叠压缩 / melisma 铺设全由宿主按 PhonemeLayout 独占布局(引擎报已压缩的绝对位置会让宿主布局误判相接判据,故只报自然时长)。
public readonly struct SynthesizedPhoneme { public string Symbol { get; init; } public double Duration { get; init; } public double StretchWeight { get; init; } }
// 合成产物 map 值型(IVoiceSynthesisSession.SynthesizedPhonemes 的值):一个归属 note 的音节 = 引导/主体双列表 + 有符号 BodyOffset。
public readonly struct SynthesizedSyllable {
public IReadOnlyList<SynthesizedPhoneme> LeadingPhonemes; // 引导:核前前置辅音
public IReadOnlyList<SynthesizedPhoneme> BodyPhonemes; // 主体:核 + 尾辅音
public double BodyOffset; // junction = noteStart + BodyOffset(左负右正)
public IReadOnlyList<SynthesizedPhoneme> Phonemes; // = Leading ++ Body 只读视图
}输入(host → engine):note.LeadingPhonemes / note.BodyPhonemes(各 IReadOnlyList<SynthesizedPhoneme>,per note)+ BodyOffset
- 用两条「时长 + 权重」列表(+ note 的
BodyOffset)表示,而非解析后的绝对时间——相邻钉死 note 的去重叠(后盖前 / 跨 note 辅音簇压缩)须由全局布局算法联合求解;且用时长便于「推挤式」编辑(改一个音素长度,相邻整体平移而非互相挤占)。Duration:辅音(StretchWeight=0)的固定时长;核(StretchWeight>0)的时长是填充派生量(布局忽略其记录值,见下)。StretchWeight:弹性伸缩权重,>0= 核可伸(全局压缩中先让)/0= 辅音刚性(按标称长度等比压)。LeadingPhonemes/BodyPhonemes:引导 = 核前前置辅音;主体 = 核 + 尾辅音。成员即分类(结构化、抗抖、无派生)。BodyOffset(per note,有符号自然秒):junction = noteStart + BodyOffset(左负右正)。0⇒ 主体起点精确落 note 头(干净、元音起手);<0⇒ 主体首元素(元音)跨头、拍前起声;>0⇒ 引导末元素(辅音)跨头、伸过拍点。头切分(供跨 note 压缩)恒以 note 头为切点——被切音素未必= 结合线那个(二者相距BodyOffset),正是分类与几何解耦。- 位置不存、由布局派生(单次锚定、无 Σ 往返):主体从 junction 往右累积、引导从 junction 往左累积——辅音用固定时长、核填充到 note 满末(含 melisma 铺过乘客)、多核按权重分摊。
BodyOffset=0时 junction ≡ note 头(同一数、无加减)→ 结合线零误差落头上。
- 钉死粒度为整 note:列表非空 = 用户钉死了全部音素(你必须遵守这组约束);列表为空 = 你从
Lyric做 G2P + 全自由定时。不支持单音素级部分钉死。
解析为真实时序:调 SDK 共享函数 PhonemeLayout.Resolve
Resolve 只接管「定位」这一半——把你给的「音素标称时长 + note 几何」跨 note 去重叠压缩成真实 [StartTime, EndTime]。标称时长怎么来(G2P、按元音分词分组、word_div/dur 模型、head/tail padding)仍全在你这边(引擎专属,不被消掉);交出去的只是末端对齐 / 去重叠那一层,不是整条音素链。
两种用途,别混为一谈:
- 音频布局(用
Resolve驱动帧时序——你基本必须用):若你按每音素时长顺序铺帧喂声学模型,就用Resolve输出的[Start,End]定帧长——重叠被压掉、帧总长不再溢出真实窗口。此时FillEnd的取法直接塑造音频:要音频 == 宿主显示(WYSIWYG),FillEnd必须取与宿主同一口径——自己的有效末 + 仅铺过延续乘客(你自己的会话判定IsContinuation(note)为 true 的 note,见下「延续与休止」)的 melisma;真发声 note 间的空隙就停在自己末(空隙是静音)、别把元音铺过空隙到下一发声 note。一旦FillEnd偏离此口径(如填过空隙),音频与显示就分叉,而这是听得见的,不是"非致命"。 - 显示对齐(可选):若你不用
Resolve驱动音频、只想让宿主画的音素线对上你的自由音频,调它即可一致;不调就自由放置——这种纯显示错位才是「顶多音素线与波形错位、非致命」。这句 escape hatch 只对显示成立、对音频不成立。
调用:把每个 note 物化成 PhonemeLayoutNote(FillStart = 音符头;FillEnd 见上;LeadingPhonemes / BodyPhonemes / BodyOffset = 该 note 的音素几何),整段传 Resolve,返回同构交错数组 PhonemeTiming[][]({ Start, End, Duration },可 var (s,e)= 解构)——result[i][j] = note i 在拼接序(LeadingPhonemes ++ BodyPhonemes)里第 j 个音素的真实落点。Resolve 对任意连续 note 区间成立,宿主显示传窗口、你传整段同一函数。冻结的只是 I/O 形状,压缩体宿主侧可演进、你运行时绑定那一份故不漂移。
钉死覆盖:snapshot.Notes[i] 的 LeadingPhonemes / BodyPhonemes 非空 = 该 note 用户钉死,物化 PhonemeLayoutNote 时用其钉死双列表 + BodyOffset 而非你的 G2P 预测;皆空才用预测。快照两列表元素是 VoiceSynthesisPhonemeSnapshot(几何平铺 + 属性),喂布局的几何按 ph 字段重建 SynthesizedPhoneme({ Symbol = ph.Symbol, Duration = ph.Duration, StretchWeight = ph.StretchWeight }),per-phoneme 属性取 ph.Properties(见 §5.2「音素属性」与 §5.7 末)。
- 全局分配语义(可用空间内乘法 / 等比分配,逐 note 独立、无单/跨两条路):每个 note 取可用空间
[音符头 … 核填充末 FillEnd],收集落在其中的音素——本 note 非前置(核 + 后辅音),外加仅当与后 note 相接时后 note 的前置辅音——按缩放比len/d = r^w统一分配:可伸音素(w>0)的长度按其原长乘以r^w,刚性音素(w=0)恒为原长不动;r是由「分配后总长 = 可用空间」唯一确定的全局基准缩放比(>1 拉伸 / <1 压缩 / =1 不变)。同权重 ⇒ 同缩放比(等比保形,相对比例不变),w越大伸缩越剧烈(w=2是w=1的平方)。两个退化:① 可用空间挤到连刚性原长都放不下(空间 ≤ Σ_{w=0}d)→ 可伸音素全归 0、刚性音素按原长等比压占满;② 全w=0(无可伸音素)→ 全部按原长整体等比缩放。例kas+bus相接:核a指数吸收伸缩、辅音s/b刚性,挤到极限才轮到辅音等比压。不变量:音素绝不溢出可用空间。 - 间隙(静音)= 音素互不影响:仅相接 / 重叠的相邻 note 才跨 note 协同(共享上面那份可用空间分配)。两 note 有空隙(前 note 内容末 < 后 note 核起点)时,两 note 音素各自保持自然几何、互不推挤压缩——后 note 前置辅音可自然探入空隙、显示上与前 note 重叠,但谁都不动。这样固定音素不因邻居在空隙内移动而跳变;用户想要可调(前置辅音推挤前 note 元音)就把音符拉到相接。合成侧间隙如何处理由你(引擎)自行决定:合并连续推理(→推挤)还是分片独立产出在时间轴重叠混音(→重叠发声),是你的音频决策;宿主只按理想形态(相接推挤、空隙独立)显示。若你用
Resolve驱动音频,想 WYSIWYG 就让FillEnd同口径(空隙停在自己末);偏离则音频与显示在空隙处分叉,驱动音频时这听得见、非"非致命"("非致命"只对纯显示错位成立,见上「两种用途」)。 - 延续与休止(判定权归你):透明(前一个 note 的音素正向铺过它、melisma)只由延续判定决定,与"有没有回传音素"无关——你判定为延续的 note 才透明;非延续的 note 即便你不为它回传任何音素,也只是它自己那一格空着、不影响邻居(见下面 map 的三态)。要让某个 note 发静音 / 换气,就为它输出一个静止音素(如
sp/AP)。哪些 note 是延续(连音 / melisma 乘客)由你判定:在会话上实现bool IsContinuation(IVoiceSynthesisNote note)——必须实现、刻意无默认体:判定与你的合成行为是一对绑定承诺,任何默认体都会替你的合成许诺它未必实现的语义("-" 铺末默认对不做 melisma 的引擎撒谎、恒false默认掩盖做了 melisma 却忘实现本方法的引擎),沉默继承即静默分叉,接口强制你显式表态。不做延音语义的引擎如实=> false(每个 note 都是内容)即可。宿主不叠加任何自己的判据、照单消费(显示布局 / 编辑手势同源)——你的记号("-"、"+"、「ー」、词典驱动铺音节)自动获得宿主 UI 支持。参考语义(编辑器"-"录入约定的对应判定,宿主对无声源 part 也按此显示;完整代码见样例插件tests/plugins/V1.Voice,十行链回溯):歌词"-"∧ 经不断裂相接链回溯到内容 note(相接 = 前末 ≥ 后起,严格比较——边界同源 tick 换算,相接即精确相等;空隙断链 / 链头缺失 → 孤儿false)∧ 本 note 无钉死音素(钉死即内容、退出乘客并成为合法链头)。链 / 相接 / 记号全是你的语义空间(比如"小间距视为相接"这类引擎策略),SDK 刻意不提供判定助手——判定绑定合成行为,你必须完全自有其语义。契约约束:数据线程同步、不留存 note 引用、观测确定性(相同当前数据 → 相同答案)、禁依赖合成进度/产物(判定不得随"合成过没有"改变)。绑定性:你判定为延续的 note 不得在SynthesizedPhonemes里回传音素——区段发音(含贴 melisma 末尾的尾辅音)全部挂链头 note 返回;违约不会被宿主纠正或丢弃,显示会如实回落到你回传的内容(自含渲染 + 骨架修正),属你的 bug。钉死音素与歌词/位置同级、都是你判定的输入而非宿主强制条件:Standard的默认语义选择了"钉死即内容、退出乘客"(钉死 note 并为合法链头),你的自定义判定可自由决定钉死在延续判定里的地位——宿主照单尊重返回值,判定为延续的 note 显示透明(即使带钉死,其语义由你向用户定义)。判定域是 live 数据:快照窗口可能裁掉链头,需要把延续身份带进 worker 就在SynthesizeNext同步前缀对 live note 调自己的判定、随自有快照结构一并冻结(按 note 间隙分块的引擎在块内自判即与 live 等价)。
输出(engine → host,合成时返回):按归属 note 键的音素 map
IReadOnlyMap<string, SynthesizedSyllable> SynthesizedPhonemes { get; } // 键 = 归属 note 的 Id
// 每个归属 note → SynthesizedSyllable{ LeadingPhonemes, BodyPhonemes, BodyOffset }(音素只报 Symbol / Duration / StretchWeight,无绝对位置)
// SynthesizedSyllable 是 sealed class,经 new SynthesizedSyllable(leadingPhonemes, bodyPhonemes, bodyOffset) 构造
//(取 class 而非 struct:TryGetValue 未命中时得到 null,而不是两列表为 null 的结构体)- 按归属 note 键(而非扁平时间线 + 出身字段):音素描述符不报绝对位置,无主音素无锚不可定位、也落不进 note 失效链,故没有「无主音素」契约——
SynthesizedPhoneme不带Note字段,归属全由 map 的键表达。辅音入侵上一 note 尾巴这类越界,由宿主按时长模型派生位置时自然产生,不需你声明位置。(breath 等将来用「归属 note 的前置 / 后置音素」或专属事件通道承载。) - map 键怎么填:键就是归属 note 的
Id——一个普通字符串,活 note(IVoiceSynthesisNote.Id)与其快照(VoiceSynthesisNoteSnapshot.Id)都带着它,所以snapshot.Notes[i]的产物就以snapshot.Notes[i].Id为键,worker 全程不需要任何活引用(id 是值:可跨线程持有与比较)。键在与否即语义(三态,宿主按此显示):键不在 = 还没有答案(该块脏 / 合成中)——宿主留白,且相接的邻居也一并留白(邻居的布局边界依赖本 note 的音素几何,未知就不能先铺、免得数据到达时跳变);键在、音节零音素 = 答案是"这个 note 没有音素"(已算完,本 note 不发音 / 发音不归自己)——宿主本 note 留白,但邻居照常显示;键在、有音素 = 常态。两个空态语义不同、不可互换:拿"零音素"当"还没算完"用,会让相邻 note 跟着一起消失。 StretchWeight(伸缩 / 压缩权重):用户锁定音素后,宿主把产物固定为钉死几何(note.LeadingPhonemes/note.BodyPhonemes里的StretchWeight即由此而来),此后显示 / 合成的去重叠按缩放比len/d = r^w用它分配:元音(w>0)指数吸收伸缩、辅音(w=0)刚性不动(仅在空间挤到连辅音都放不下时才按标称长等比压)。典型一个音节[前辅音 w0, 元音 w1, 后辅音 w0]。没有音韵学知识就全填同一正值(如w = 1)——所有音素等比缩放、安全默认;区分辅音=0/元音=1 是有音韵学知识才做的优化,要某元音伸缩更剧烈就给更大w(如双元音主元音w=2)。w全 0(含没设、默认全零)退化为按原长整体等比缩放,无除零。- 核时长是基准比例、单核时被抵消:核(
StretchWeight>0)的Duration是其原长——多核时定彼此的基准比例(各乘r^w);单核时原长被抵消、退化为填满核空间,报多少都一样。辅音(w=0)的Duration即其固定长。引导 / 主体归属即列表成员(结构化,非派生),BodyOffset定结合线(引导往左累积、主体往右)。因此输出无需自己摆位,只诚实报每个音素的时长 + 权重、其所属列表、note 的BodyOffset即可,宿主按 junction 统一派生位置——零跳变。 - preview 纯显示、绝不反馈给你当约束:权威时长由全量合成重新定时返回(带新权重),覆盖 preview。你只管每次合成诚实输出当前时长 + 权重。
音频经段握柄 IAudioSegment 交付(不是扁平 pull)——因为下游 effect 链按段增量重渲染,段是 effect 的失效/重渲染单元。
public interface IAudioSegment : IDisposable // Dispose() = 删除该段(重分片/改长度或位置时重建)
{
void Write(int offset, ReadOnlySpan<float> samples); // 段内 [offset, offset+len) 就地写;span 借用语义,返回后可复用缓冲
void Commit(); // 标该段音频已固定——送 effect 的【唯一闸门】
void Resize(long sampleOffset, int sampleCount); // 内容延续的扩展/裁剪:身份不变(下游缓存存活)——交集内容按绝对位置保留、新增区清零、段回未提交态,写完新增区再 Commit()
}- 段经
context.CreateAudioSegment(sampleOffset, sampleCount, sampleRate)申请:sampleOffset= 全局起始采样位置(插件 native 率,全局 0 秒 = 采样点 0);sampleCount= 段长(采样数);sampleRate= 该段的 native 采样率(你传入,宿主据此解释——等于工程率直读、不等套一层重采样,集中宿主一处)。采样率随段走、可逐段不同(如提供合成采样率下拉)。 - 段的起始与长度创建时固定(宿主一次性分配缓冲,你就地写、渐进合成不累积重拷)。几何要变分两种意图:语义整改 →
Dispose()旧段 +CreateAudioSegment新段(下游身份重建);内容延续的扩展/裁剪 →Resize(offset, count)(身份保留、下游缓存存活)。 Commit()是送 effect 的唯一闸门:Commit 前的Write只供进度/波形展示;冻结数据(Commit)才进 effect。所以合成爆发期不会拖着昂贵 effect 频繁重跑。- 写入/提交/释放全在数据线程(worker 渲染完,在 marshal 回数据线程的续延里写)。
- 静音段:宿主缓冲零初始化,
CreateAudioSegment后直接Commit()、无需Write。
状态带 SynthesisStatusSegment(Status 返回,宿主据此着色/进度/报错):
public readonly struct SynthesisStatusSegment
{
public double StartTime { get; init; } public double EndTime { get; init; } // 秒
public SynthesisSegmentStatus Status { get; init; } // Pending / Synthesizing / Synthesized / Failed
public string? Message { get; init; } // Failed=错误信息;Synthesizing=可选阶段文案(如「正在算音素时长」),宿主原样展示
public double Progress { get; init; } // Synthesizing 时 [0,1],不报进度保持 0
}- 状态段与音频段解耦:前者是 UI 状态带、后者是 effect 失效单元,两套分区可不同,宿主不假设对齐。
StatusChanged是唯一刷新信号:产物(音频/音高/回显/音素)或状态有任何更新,触发它,宿主收到即重读重绘。出方向事件允许任意线程触发、宿主负责 marshal——但你的产物字段须在数据线程换引用(换引用即不可变发布)。
回显轨数据走 SynthesizedParameters(IReadOnlyMap<string, SynthesizedParameter>,key 对齐 GetSynthesizedParameterConfigs):
public sealed class SynthesizedParameter { IReadOnlyList<IReadOnlyList<Point>> Segments { get; } } // 分段折线,段内 Point=(秒,值),段间断开正确的增量重合成 = 「廉价标脏 + 收口重活」。在会话构造时订阅 context,handler 里只做廉价标脏,把重活(如重分块)推迟到 Committed:
// 构造时接线(数据线程)
mNotesSub = context.Notes.WhenAny(SubscribeNote, UnsubscribeNote); // 自动覆盖成员增删
context.Notes.ItemAdded += _ => mNeedResegment = true;
context.Notes.ItemRemoved += _ => mNeedResegment = true;
context.PartProperties.Modified += MarkAllDirty;
context.Pitch.RangeModified.Subscribe(OnRangeModified); // (startTime, endTime) 秒:只标脏相交的块
context.PitchDeviation.RangeModified.Subscribe(OnRangeModified);
if (context.Automations.TryGetValue("Growl", out var growl)) growl.RangeModified.Subscribe(OnRangeModified); // ← 构造期即可订阅自己声明的轨
context.Committed.Subscribe(() => { if (mNeedResegment) Resegment(); }); // 逻辑编辑收口:一次性做重活构造期订阅自己声明的自动化轨是可靠的:声明(
GetAutomationConfigs)在引擎上、宿主在建会话之前已据此填好轨集合,故会话构造函数里context.Automations已含你声明的轨(TryGetValue取得 / 可枚举)。绘制该轨后的区间失效经此回调送达 → 标脏 → 下个调度 tick 重渲。若漏订阅,绘制完参数将不触发重渲(轨数据变了但没人标脏)。
- 三种最小变更事实:①字段变了(订阅
note.StartTime/EndTime/Pitch/Lyric/Phonemes/Properties的Modified,必要时用WillModify抓旧值作废旧区间);②区间变了(ISynthesisAutomation.RangeModified带秒范围);③集合变了(Notes增删,WhenAny自动接线新成员)。这些事实映射到哪些段、重合成到管线哪一级(失效依赖图)归你——机制粒度支撑最精细策略,也允许「任何通知 → 全部标脏」的懒实现。 Committed是收口点:每个逻辑编辑(一个 command,含单条编辑)的全部通知发完后触发一次(单条编辑也补发,所以你无需区分「在不在批量中」)。批量编辑(移调几百个 note)因此只重分块一次。- tempo 变化(及 part 平移)无独立信号、也无增量分解通知:宿主直接整体重建会话——旧会话
Dispose、以新 context 重建新会话,新会话读到的即新秒值。你无需任何专门处理,正确实现Dispose(退订 + 释放音频段)即天然正确;边界Modified/ 轨RangeModified只在 note / 曲线自身被编辑时触发。 - 务必在
Dispose退订——虽然 context 短命(随会话死,泄漏结构性不可能),但退订是好习惯,也便于释放你持有的模型/段句柄。Dispose里还要Dispose所有音频段。
重叠 note(和弦)分块陷阱:按 note 间隙分块时,判间隙要用「组内最大结束」而非「上一 note 的结束」——同起点和弦里上一 note 可能结束更早,用它会把仍在响的长音错误切出去。块尾同理取
notes.Max(n => n.EndTime)。
voice 引擎常依赖原生运行时(ONNX Runtime 等)、模型权重、发音词典(dict)。打包规则:
-
私有依赖随包分发、与其他插件隔离:你的第三方托管库、原生
.dll/.so/.dylib放进包文件夹,会被加载进你这个包专属的 ALC。不同插件捆绑不同版本的同一个库不会冲突。SDK 程序集(TuneLab.Foundation/TuneLab.SDK)和 .NET 运行时由宿主共享,不要打进包(见 §3)。 -
定位包内资源(模型/dict/原生库):用你自己程序集的位置拼绝对路径,不要用工作目录或
AppContext.BaseDirectory(那是宿主目录):static readonly string PackageDir = System.IO.Path.GetDirectoryName(typeof(MyVoiceEngine).Assembly.Location)!; // 之后:Path.Combine(PackageDir, "models", voiceId, "acoustic.onnx") 等
-
原生库的加载:把原生
.dll与你的托管.dll放在同一目录(包根),默认探测通常能直接 P/Invoke 到。若用 ONNX Runtime 这类带原生后端的 NuGet 包,让其原生库随包输出到包根即可;跨平台时按目标平台分别提供对应原生库,并在 manifest 用platforms过滤(如某声库只发 Windows)。 -
大模型权重不要塞进
.tlx:.tlx是即装即载的安装包,几百 MB 的模型塞进去会让安装/加载很重。推荐两种形态:- 资源包分离:模型作为独立的资源包(无代码,
type声明用途),引擎运行时去发现;或 - 走扩展设置让用户配模型路径:引擎实现
IExtensionSettings,用PathPickerConfig.CreateFolder()暴露「模型目录」设置项(文本框 + 浏览按钮;指向可执行文件/单个文件时用CreateFile()配.AppendFileType(...)),用户在「设置 → 扩展」选好路径,你在ApplySettings收下、Init/CreateSession时从该路径加载(见 §8)。API key 等密钥用TextBoxConfig.Create().WithPassword(),宿主掩码显示 + 安全落盘。
- 资源包分离:模型作为独立的资源包(无代码,
-
Init里加载、失败抛异常:模型/词典加载放Init(或更懒,首次CreateSession时)。加载失败直接抛异常,宿主在调用边界 catch、把该插件标为加载失败并在侧边栏反映原因,不会崩溃主程序。
| 成员 | 线程 | 职责 |
|---|---|---|
IVoiceSynthesisEngine.VoiceSourceInfos |
任意(同步读) | 声库目录;必须立即返回不阻塞(Init 期缓存) |
IVoiceSynthesisEngine.VoiceSourceLayout |
任意(同步读) | 选择器分组树(可选,DIM []=平铺);叶子引 id、未覆盖 id 宿主顶层兜底 |
IVoiceSynthesisEngine.Init/Destroy |
— | 加载/释放常驻状态(模型);失败抛异常 |
IVoiceSynthesisEngine.CreateSession |
数据线程 | 每 part 建一会话 |
IVoiceSynthesisEngine.GetPartPropertyConfig/GetNotePropertyConfig |
数据线程 | 属性面板(纯函数 of voiceId+当前值,可条件显隐) |
IVoiceSynthesisEngine.GetPhonemePropertyConfigs |
数据线程 | per-phoneme 属性面板(required;收 IVoiceSynthesisNotePropertyContext,返回按核相对 slot 键控的 schema map(口径=PhonemeSlots),空 map=无属性,多选合并归引擎) |
IVoiceSynthesisEngine.GetAutomationConfigs |
数据线程 | 可编辑自动化轨集合(NaN⇒分段;避开保留名) |
IVoiceSynthesisEngine.GetSynthesizedParameterConfigs |
数据线程 | 只读回显轨声明(恒分段形) |
IVoiceSynthesisSession.DefaultLyric |
数据线程 | 新建 note 默认歌词(会话级运行时取值) |
GetNextPendingSynthesisRange |
数据线程 | peek 下一脏块边界(无副作用、确定性) |
SynthesizeNext |
同步前缀=数据线程;之后 worker | 拉快照 → offload 渲染 → 回数据线程发布 |
GetSnapshot |
仅同步前缀 | 物化不可变快照(notes 由你圈定;自动化/音高全量冻结、不开窗) |
CreateAudioSegment / IAudioSegment.Write/Commit |
数据线程 | 申请并写音频段;Commit 是送 effect 的闸门 |
SynthesizedPitch/Parameters/Phonemes、Status |
数据线程发布、可跨线程读 | 产物;发布即不可变 |
StatusChanged |
任意触发、宿主 marshal | 唯一刷新信号 |
Dispose |
数据线程 | 退订、释放模型与段句柄 |
效果器(effect)对已合成的整段音频做变换。它面向耗时较长的离线模型(如 SVC 换声、神经音色转换),不是实时的 VST 式效果器。
实现 IEffectSynthesisEngine。需要无参构造函数。效果器 id 写在 manifest.json 的 engine,实现类写在 class(宿主校验它实现 IEffectSynthesisEngine,不再用 attribute)。引擎是每种效果器类型一个;宿主为工程里每条「effect 实例 × 上游音频段」创建一个持久厚处理器 IEffectSynthesisSession 驱动它。处理器持有自己那一段的上下文 IEffectSynthesisContext;失效判定与调度归宿主(作用域信号保守标脏),处理器被调到 Process 就按当前真相干活(电平语义),跨调用复用内部缓存、自比缓存早退去重——「厚」在常驻缓存与私有重算图,不在失效上报。
manifest 条目:{ "type": "effect", "engine": "MyEffect", "name": "My Effect", "class": "My.Ns.MyEffectEngine", "assembly": "MyEffect.dll" }(engine 是不可变身份;name 可选显示名、可加 localizations 翻译;宿主校验 class 实现了 IEffectSynthesisEngine)。
using TuneLab.Foundation;
using TuneLab.SDK;
public class MyEffectEngine : IEffectSynthesisEngine // engine id 在 manifest 的 "engine" 声明
{
// 参数面板 / 自动化轨 / 回显轨:均为当前参数值(context.Effects——各选中 effect 实例一个只读视图,各带 Properties / Automations)的纯函数——宿主在参数 commit 时按当前值重算
// 并 diff 到 UI,故控件/轨可随参数显隐(条件声明)。静态的忽略 context 返回固定值即可(如下例)。
public ObjectConfig GetPropertyConfig(IEffectSynthesisPropertyContext context) => mPropertyConfig;
public IReadOnlyOrderedMap<PropertyKey, AutomationConfig> GetAutomationConfigs(IEffectSynthesisPropertyContext context) => mAutomationConfigs;
// 合成参数回显轨声明(只读、独立于可编辑自动化轨):处理产出的只读曲线(如 loudness)暴露为一等只读轨,
// 分段形(DefaultValue=NaN)、自带 Min/Max/Color(显示名随 PropertyKey 走)。无回显的引擎返回空 map 即可。
public IReadOnlyOrderedMap<PropertyKey, AutomationConfig> GetSynthesizedParameterConfigs(IEffectSynthesisPropertyContext context) => mSynthesizedParameterConfigs;
// 无参:包目录经 Assembly.Location 自定位(无需宿主递路径)。失败直接抛异常,宿主在调用边界 catch → passthrough 降级。
public void Init() { /* ... 加载模型 ... */ }
public void Destroy() { /* 释放资源 */ }
// 每条「effect 实例 × 一个上游音频段」一个持久厚处理器;context 由宿主实现、暴露本段输入 + 参数/自动化 + 产出口 + 收口事件。
public IEffectSynthesisSession CreateSession(IEffectSynthesisContext context) => new MyEffectProcessor(context);
readonly ObjectConfig mPropertyConfig = ObjectConfig.Create(new OrderedMap<PropertyKey, IControllerConfig>
{
{ ("amount", "强度"), SliderConfig.Linear(1.0, 0.0, 2.0) },
});
readonly OrderedMap<PropertyKey, AutomationConfig> mAutomationConfigs = new();
readonly OrderedMap<PropertyKey, AutomationConfig> mSynthesizedParameterConfigs = new()
{
{ ("loudness", "Loudness"), AutomationConfig.Create(0, 2).WithColor("#00B0FF") },
};
}失效判定权归宿主:宿主按作用域信号(本段输入重 Commit / 本 effect 参数 settled 变更 / 本 effect 自动化变更区间与本段相交)保守调度 Process,处理器零上报义务——Process 是电平语义:「让输出与当前输入一致」,不是「应用某次变更」;同步前缀读到的就是此刻最新真相,期间发生过几次编辑与处理器无关也不可见。Process 的同步前缀(数据线程)经 Input.Read 把所需区间拷出到自有缓冲 + 预采参数/自动化值,之后才可 offload 到 worker;产物经 context.CreateAudioSegment 写出并 Commit。宿主是保守调度,缓存型引擎自比缓存后判定输出不会变时直接返回、不重 Commit 即可(下游被跳过);没有内部增量可做的引擎,被调到就整段重处理即可。颗粒事件(Input.RangeModified / Properties.Modified / automation RangeModified)保留为可选的缓存刷新提示——最简引擎可以零订阅。
class MyEffectProcessor : IEffectSynthesisSession
{
public MyEffectProcessor(IEffectSynthesisContext context)
{
mContext = context;
// 可选的缓存刷新提示(调度归宿主;最简引擎可以零订阅):
mContext.Input.RangeModified.Subscribe(OnInputRangeModified); // 上游内容变更区间账本(绝对采样位置)
mContext.Properties.Modified.Subscribe(OnDirty); // 本 effect 参数变
}
// 状态声称时间线(与 voice 会话同词汇;全局秒、主语=本处理器自己的产物、不受输入几何约束):
// Synthesizing 段带 Progress(状态带呈纵向水位);Synthesized 段是"声称完成"(呈现为非最终软色,
// 最终绿只来自链尾音频事实)。不报返回空列表 = 宿主按调度事实兜底。StatusChanged 允许任意线程触发
//(worker 就地报),宿主 marshal 后拉 Status;引擎宜自持粒度(如每整数百分比一报)。
public IReadOnlyList<SynthesisStatusSegment> Status => mStatus;
public IActionEvent StatusChanged => mStatusChanged;
readonly ActionEvent mStatusChanged = new();
// 本段回显曲线(key 与 GetSynthesizedParameterConfigs 对齐):数据线程发布、宿主只读、收尾随产物一并重读。无回显返回空 map。
public IReadOnlyMap<string, SynthesizedParameter> SynthesizedParameters => mSynthesizedParameters;
public Task Process(CancellationToken cancellation = default)
{
// —— 同步前缀(数据线程):抓输入 PCM 引用 + 预采参数/自动化值 ——
var input = mContext.Input;
int rate = input.SampleRate;
long offset = input.SampleOffset;
int count = input.SampleCount;
var src = new float[count];
input.Read(0, src); // 拷出到自有缓冲(也可只 Read 所需区间)
double amount = mContext.Properties.GetValue("amount", PropertyValue.Create(1.0)).ToDouble(out var a) ? a : 1.0;
// 自动化(可选):活的 ISynthesisAutomation 只报 RangeModified,取值要走冻结快照——同步前缀拉一份、采它的
// 求值器。查询轴 = 全局秒(与音频同一时间系)。
double[]? env = null;
if (count > 0)
{
double segStart = rate > 0 ? (double)offset / rate : 0;
double segEnd = rate > 0 ? (double)(offset + count) / rate : 0;
var snapshot = mContext.GetSnapshot(segStart, segEnd); // EffectSynthesisSnapshot { Properties; Automations }
if (snapshot.Automations.TryGetValue("intensity", out var automation))
{
var times = new double[count];
for (int i = 0; i < count; i++) times[i] = segStart + (double)i / rate;
env = automation.Evaluator.Evaluate(times); // 也有 Span 重载、可免数组分配
}
}
// —— 此后可 offload 到 worker(只读上面物化的不可变值,永不回碰宿主活数据)——
var dst = DoProcess(src, amount, env);
// 产出:登记表语义(与 voice 同构)——产出分段自由(1→N 合法,如按静音切分的 splitter);
// 各段独立 Write/Commit/Dispose(改几何 = Dispose 旧段建新段)。
var outSegment = mContext.CreateAudioSegment(offset, dst.Length, rate);
outSegment.Write(0, dst);
outSegment.Commit();
// 回显:与输出同步在数据线程换引用(此例 Process 全同步,直接换即可)。
mSynthesizedParameters = BuildLoudness(dst, rate, offset);
return Task.CompletedTask; // 错误抛异常(宿主 catch → passthrough),此处不吞
}
void OnDirty() => mDirty = true; // Process 里消费、决定重算哪块(仅缓存细化用)
void OnInputRangeModified(long start, int count) => mDirty = true; // 有缓存就按账本收窄重算窗
public void Dispose()
{
mContext.Input.RangeModified.Unsubscribe(OnInputRangeModified);
mContext.Properties.Modified.Unsubscribe(OnDirty);
/* 释放该段常驻状态、输出段句柄 */
}
readonly IEffectSynthesisContext mContext;
bool mDirty;
IReadOnlyList<SynthesisStatusSegment> mStatus = []; // 发布 = 换引用(原子)
IReadOnlyMap<string, SynthesizedParameter> mSynthesizedParameters = new Map<string, SynthesizedParameter>();
}要点:
- 厚会话、失效归宿主:
CreateSession(context)为「该 effect × 该上游段」建一个会话(与 voice 会话同族的持久有状态实体——持活视图、跨Process养缓存、发布声称与回显;作用域差异由 context 绑定表达);段销毁 / 删 effect / 重分段 / 换采样率时宿主Dispose。宿主按作用域信号保守调度(自动化区间与本段的相交判定由宿主通用完成——改在别的段区间的编辑不会惊动本节点);参数依赖/值级去重是引擎在Process内的可选早退(不重 Commit 即跳过下游)。 - 参数与自动化取值来自冻结快照:
context.GetSnapshot(startTime, endTime)返回EffectSynthesisSnapshot(Properties值拷 +Automations冻结求值器SynthesisAutomationSnapshot)——仅同步前缀可调,也是 offload 之后唯一可跨线程取值的途径。音频不在其中:用Input.Read自己拷出。 - 输入是整段不可分割的音频面:
context.Input(IEffectSynthesisAudio)暴露SampleOffset/Count/Rate+Read(offset, span)(copy-out;宿主存储形态是实现细节)。没有「已提交」脉冲——被调到Process即输入已就绪(调度归宿主);Input.RangeModified(start, count)是内容变更的区间账本(可选;start为绝对采样位置——内容钉在绝对轴,上游Resize扩展/裁剪后旧积累区间无需重定基):缓存型引擎累积区间、按账本收窄重算窗(只Read/重算/写回变更区,重算量 O(变更区)——完整范式见参考实现 Slow Gain)、在自己成功产出后才清账(取消退账、不丢更新)。账本完备且忠实于裁剪:上游Resize的几何对称差照报(裁剪掉的区域在绝对轴上「从有到无」,且它是边界邻域的上下文——被裁内容曾喂过邻接输出的计算),区间可越出当前段界;重算范围由会话自决——收账后外扩自己的上下文余量(让新旧内容对上)∩ 段界,点态引擎自决为零(几何跟随Resize+ 空提交即可)。真无变更的空提交才静默;整段上报只出现在强制失效(首建快照/工程采样率变更重采)。 - 产出经握柄、登记表语义:
context.CreateAudioSegment(offset, count, rate)——产出分段自由(1→N 合法:如按静音切分的 splitter,为下游全体重建段粒度、免费获得按段增量与并行);各输出段独立生灭,每个已提交段各自喂一个下游节点。输入面恒单段(消费单元 = 宿主的失效/调度/身份粒度)。唯一红线:不得重新分布时间轴(automation/回显与 part 显示共全局秒轴);几何微差(补边、加尾)合法。采样率随段走(与工程率不同时宿主套一层重采样)。几何变更分两种意图:语义整改换新走Dispose+Create(下游身份重建);内容延续的扩展/裁剪走Resize(offset, count)(身份不变、下游缓存存活——交集内容按绝对位置保留、新增清零、回未提交态,写完新增区重Commit收口)。 - 状态声称(可选):经
Status发布状态时间线(不可变列表换引用)+ 任意线程触发StatusChanged——Synthesizing 段带Progress在状态带呈纵向水位;Synthesized 段是「声称完成」(软色、非最终,最终绿只来自链尾音频事实)。不报的引擎由宿主按调度事实兜底呈现。 - 回显轨(可选):引擎产出的只读曲线经
GetSynthesizedParameterConfigs声明 +IEffectSynthesisSession.SynthesizedParameters承载本段数据(宿主把同一 effect 各段按 key 拼接呈现)。只读、不可编辑、不进数据层、不序列化;与 voice 回显同构、在参数区标题栏按源显隐。 - 条件声明:
GetPropertyConfig/GetAutomationConfigs/GetSynthesizedParameterConfigs是当前参数值的纯函数——IEffectSynthesisPropertyContext就是IReadOnlyList<IEffectSynthesisView> Effects(多选壳:各选中实例一个视图,各带Properties与已画Automations的求值器;三态合并归引擎)——(同输入同输出、无副作用、轻量),参数 commit 时重算——可据此让控件/轨随参数显隐。轨从声明消失后宿主保留其已画曲线(隐藏不删),参数回退即原样恢复。 - 效果链:一条 MidiPart 上可挂多个 effect,按声明顺序串行——上一个输出是下一个输入;链尾各段按绝对时间混音。链顺序、bypass、增删由用户在属性面板管理。
- 失败优雅降级 / 取消:抛异常时宿主把该段当直通(passthrough),不中断播放;取消经
cancellation请求、正常返回(不要抛OperationCanceledException),await真正返回才释放调度槽位。 - 线程纪律:
context(Input/Properties/ 自动化)仅可在Process同步前缀(数据线程)读取;offload 后只读已物化的不可变值;SynthesizedParameters与输出段须在数据线程发布。
相关接口都在 TuneLab.SDK:IEffectSynthesisEngine / IEffectSynthesisSession / IEffectSynthesisContext / IEffectSynthesisAudio / IAudioSegment / IEffectSynthesisPropertyContext / ISynthesisAutomation。
Instrument 是多声部音源(合成器 / 采样器 / 和弦音源)。它与 voice 机制同构(引擎生命周期、调度 peek/commit、隔离快照、音频段交付、effect 链、扩展设置全部一样),接口族用 IInstrument* 前缀平行成套,与 voice 族无继承。只在三处与 voice 实质不同:
- note 满末、不去重叠:
IInstrumentSynthesisNote.EndTime/InstrumentSynthesisNoteSnapshot.EndTime是 note 满末(Pos+Dur),宿主不钳到下一 note 起点。Notes直传原始可重叠 note(和弦 / 多声部),引擎自行叠加发声(参考实现按每个 note 的 pitch 各加一段波形、混音求和)。 - 无歌词 / 无音素:
IInstrumentSynthesisNote没有Lyric,也完全没有音素面(LeadingPhonemes/BodyPhonemes/BodyOffset都没有);会话没有DefaultLyric、不产SynthesizedPhonemes。 - 无 pitch 曲线、产物仅音频:
IInstrumentSynthesisContext没有Pitch/PitchDeviation(v1 纯按 note 整数Pitch发声);会话不产SynthesizedPitch。仍可声明 automation 轨与SynthesizedParameters回显(引擎不声明即无)。
manifest 条目:{ "type": "instrument", "engine": "MyInstrument", "name": "My Instrument", "class": "My.Ns.MyInstrumentEngine", "assembly": "MyInstrument.dll" }。
音源目录与 voice 同形:IInstrumentSynthesisEngine.InstrumentSourceInfos(按 id 键)。一插件一乐器 = 单条目;容器式(如 Kontakt:一个引擎挂多个外置资源包乐器)在 Init() 扫描已装资源包、填多条目,InstrumentId 选具体乐器。音色多时可实现 InstrumentSourceLayout(与 voice 的 VoiceSourceLayout 同构)把选择器折成嵌套子菜单;不实现则平铺。
声明依赖派生状态时同 voice(§5.2):IInstrumentSynthesisContext.InvalidatePart / InvalidateNotes,身份经 IInstrumentSynthesisNote.Id / IInstrumentSynthesisNoteView.Id 与 PartId 对上;instrument 无音素,级联里少音素一项。
完整接口契约与设计依据见 instrument-sdk-design.md;最小参考实现(一个引擎挂 sine/square 两音色、多声部叠加合成)见
tests/plugins/V1.Instrument。
相关接口都在 TuneLab.SDK:IInstrumentSynthesisEngine / IInstrumentSynthesisSession / IInstrumentSynthesisContext / IInstrumentSynthesisNote / InstrumentSynthesisSnapshot / InstrumentSynthesisNoteSnapshot / InstrumentSourceInfo / IInstrumentSynthesisPartPropertyContext / IInstrumentSynthesisNotePropertyContext(音频 / automation / 状态 / 回显等叶子类型与 voice 共用)。
让你的扩展(extension,即一个 voice/effect 等能力实现)声明一组随宿主持久化、跨工程共享的设置——典型如 API key、模型路径、设备选择。宿主在「设置」窗口渲染面板、按 extension 落盘、运行时回喂。
与属性面板的区别:voice 的
GetPartPropertyConfig/GetNotePropertyConfig、effect 的GetPropertyConfig声明的是随工程序列化的实例/段级属性(每个 part/note/effect 实例各一份,存进.tlp)。本节的设置则是扩展自身的配置,与具体工程无关、跨工程共用、单独落盘。两者用同一套控件配置词汇(ObjectConfig),但生命周期与存储位置完全不同。粒度是 per extension(一个 voice/effect 能力一份),不是 per 安装包(ExtensionPackage 可含多个 extension,各自独立设置)。
设置是 opt-in 的:让你的能力实现类额外实现 IExtensionSettings 即可,无设置的扩展不必理会。宿主对每个已注册能力做 x is IExtensionSettings 探测,实现了才显示其设置面板。
public sealed class MyVoiceEngine : IVoiceSynthesisEngine, IExtensionSettings
{
// —— 声明 schema(复用属性面板同款控件配置)——
// 是 context 当前值的纯函数(宿主在值变更后重算并 diff 到控件);且【必须在 Init 之前可调】
//("先填模型路径,Init 才加载得了模型"——schema 不能依赖 Init 后的状态)。
public ObjectConfig GetSettingsConfig(IExtensionSettingsContext context)
{
var props = new OrderedMap<PropertyKey, IControllerConfig>();
props.Add(("model_path", "模型路径"), PathPickerConfig.CreateFolder("")); // 路径选择(单个文件用 CreateFile)
props.Add(("api_key", "API Key"), TextBoxConfig.Create().WithPassword()); // 密钥:掩码显示 + 加密落盘
props.Add(("use_gpu", "使用 GPU"), CheckBoxConfig.Create(false));
// 动态/条件项:据已填值决定显隐(如勾了 GPU 才暴露设备字段)。
if (context.Settings.GetBoolean("use_gpu", false))
props.Add(("gpu_device", "GPU 设备"), TextBoxConfig.Create(""));
return ObjectConfig.Create(props);
}
// —— 接收持久化的值 ——
// 宿主在【加载完成后】灌一次(早于任何 Init / 会话),用户在设置窗口【保存后】再灌一次。自存自用。
public void ApplySettings(PropertyObject settings)
{
mModelPath = settings.GetString("model_path", "");
mApiKey = settings.GetString("api_key", "");
mUseGpu = settings.GetBoolean("use_gpu", false);
// 之后在 Init / CreateSession 里用这些值。
}
// IVoiceSynthesisEngine 的其余成员……
}- 密钥字段:用
TextBoxConfig.Create().WithPassword()标出。宿主据此掩码显示,并按平台安全落盘:Windows 用 DPAPI 把密文就地存进配置文件(仅原用户原机可解);macOS 存进钥匙串(Keychain)、配置文件只留空串。无安全存储可用时不保存该密钥字段(绝不明文)并告警。官方支持 Windows / macOS。 - schema 须 Init 前可达:
GetSettingsConfig不得依赖Init后才有的状态——用户得先在设置面板填好(如模型路径)你才Init。把它当纯函数写(同输入同输出、无副作用、轻量)。 - 动态/条件项:
GetSettingsConfig(context)是context.Settings(当前已填值)的纯函数;用户改值后宿主按当前值重算并 diff 到控件树,故可据已填值显隐字段(如某开关打开才出现的字段)。 - 回喂时机:宿主在启动加载完所有扩展后回喂一次(此时尚未
Init),用户保存设置后再回喂一次。设置变更对已在运行的会话/处理器的影响(是否需要重建)由你自己决定与处理。 - 本地化:设置项
DisplayText由你自译(与属性面板同范式,按TuneLabContext.Global.Language出文案),宿主不参与查表。 - manifest 无需声明:设置 schema 纯走代码(
GetSettingsConfig),manifest.json不掺和。
「设置」窗口(顶部菜单进入)→「扩展」分页:每个声明了设置的扩展一段「显示名 + 设置面板」。编辑在关闭窗口 / 切走分页时统一落盘并回喂。
agent 模型引擎有自己的侧边栏设置入口,不在「扩展」分页里。
相关接口在 TuneLab.SDK:IExtensionSettings / IExtensionSettingsContext(+ 控件配置 ObjectConfig / TextBoxConfig / PathPickerConfig / CheckBoxConfig / ComboBoxConfig / SliderConfig)。
format 条目接入方式相同——在 IImportFormat / IExportFormat 之外再实现 IExtensionSettings。与引擎有两点不同,根源是 format 注册的是工厂(每次导入、每次导出宿主都现造一个实例),而不是一个长驻实例:
- 一个条目一份设置。一个条目可以认多个后缀别名(
"suffixes": ["mid", "midi"]),但设置属于那一份实现,所以「扩展」页里它只占一行、值也只存一份,绝不会逐后缀各存一份。方向子集也不会把它劈开:export-suffixes收窄的是注册什么,不是实现是谁。 - 两份实现两份设置。导入类与导出类是两个条目(§4.2),各拿一个独立的桶、各有自己的 schema。这正是拆开的意义:两个类挤在一个条目里只有一个桶,两份 schema 必有一份无处安放。
ApplySettings每次导入/导出都会被调用,就在实例创建之后——工厂造出来的实例只有这一个时机能拿到设置。因此它要轻,也不要把它当成「设置变了」的通知:它是每造一个实例响一次,不是每改一次设置响一次。- 宿主会多造一个探测实例来问你的 schema(没有长驻实例可问)。它不参与任何导入导出。请保持无参构造函数轻量(别在里面加载模型、读文件)——反正每次导入导出也都会走一遍构造函数。
- 改后缀等于换一份新设置。桶键由条目的方向与后缀共同决定——双向条目是
format:mid|midi,只导出的是format-export:midi。所以增、删、重排任一后缀都会落到另一个桶,且宿主不迁移旧值:条目回到默认值,用户此前填的(含密钥)要重填。发布前把后缀清单定下来;日后真要改,就当作一次用户可见的重置来对待。
public sealed class MyFormat : IImportFormat, IExportFormat, IExtensionSettings
{
public ObjectConfig GetSettingsConfig(IExtensionSettingsContext context) { /* 同 §8.1 */ }
// 宿主刚造出这个实例就调用,早于对它的 Deserialize/Serialize。
public void ApplySettings(PropertyObject settings) => mEncoding = settings.GetString("encoding", "utf-8");
public ProjectInfo Deserialize(Stream stream) { /* 这里 mEncoding 已经填好了 */ }
public void Serialize(Stream output, ProjectInfo info) { /* 这里也是 */ }
}任何声明面板的地方都能放按钮:扩展设置、part / note / 音素属性面板、effect 参数面板。按钮是控件配置词汇里的一员,点击时调用你传进去的函数:
public ObjectConfig GetSettingsConfig(IExtensionSettingsContext context)
{
var props = new OrderedMap<PropertyKey, IControllerConfig>();
// 闭包只捕获值:在声明时把需要的东西读出来,不要捕获 context
string apiKey = context.Settings.GetString("api_key", "");
props.Add(("cache", "缓存"), ButtonConfig.Create($"清理({mCacheMB} MB)", ClearCacheAsync));
props.Add(("test", "连接"), ButtonConfig.Create("测试", click => TestAsync(click, apiKey)));
return ObjectConfig.Create(props);
}- 按钮没有值:它不读写任何属性,恢复默认、preset、设置落盘都跳过它,CLI / agent 也设不了它。按钮不改工程数据——它只动你自己的状态(缓存、连接、模型……)。
- 闭包只捕获值(§5.2「声明里的闭包与字段演进」):点击可能发生在几分钟以后,声明 context 和视图早已失效。设置页的
context.Settings是窗口里当前填写的值(含尚未保存的),所以"测试连接"能直接用用户刚输入的密钥。 - 线程:
OnClick在主线程上调用,与Init、CreateSession、声明函数、会话事件同一条线程;await之后默认也回到主线程(除非你写了ConfigureAwait(false))。所以只在主线程上访问的资源,按钮里用不着加锁。 await是交错点:主线程在await期间会去处理别的事,宿主可能这时调用你的其他方法(合成调度、声明函数、会话事件)。要注意的不是多线程,而是状态跨越await时是否一致——比如清缓存清到一半await,合成调度可能正好来读缓存:要么让清理一步完成、中间不await,要么保证await前后状态都可用。你自己开的后台线程(如合成 worker)的并发安全照旧由你负责。- 别阻塞主线程:宿主拦不住,后果由你承担(界面卡住、系统报"未响应")。瞬间完成的操作直接同步执行就行;耗时的要
await,或放进RunWithProgress(§9.2)。同步等待弹窗结果(.Result/.Wait())会死锁。 - 执行期间宿主照常可操作:按钮执行本身不是模态的,只有这个按钮置灰、不可重入(面板重建后仍然置灰);弹窗显示时才挡住按钮所在的窗口。用户可能在此期间编辑甚至删除工程内容,闭包里捕获的 PartId / note Id 可能已经失效,按 id 查自己的数据时要能接受"查不到"。
- 异常:由宿主记日志并弹错误提示(标明你的插件)。
- 点完自动刷新:执行结束后宿主重新调用该面板的声明函数,按钮文字、旁边的选项都能反映新状态。若影响到别的 note / part 的声明,再调
InvalidatePart/InvalidateNotes(§5.2)。 - 位置:只能作为
ObjectConfig的成员(任意嵌套深度,含列表元素里的对象)。直接当ArrayConfig/ListConfig的元素或ExtensibleObjectConfig的可加键时不支持——那些位置每一格都是一个值。 - 两种形态,都占一行:有标题时(键的显示名;不给显示名则用 id)按钮在标题右侧;键的显示名显式给空字符串——
("run", "")——则不显示标题、按钮铺满整行,适合按钮文字本身就说明一切的操作。除此之外没有布局属性;按钮不可收起(它没有值)。
弹窗只能在按钮点击期间弹,入口只有回调参数 click.DialogService:
static async Task ClearCacheAsync(IButtonClickContext click)
{
if (!await click.DialogService.Confirm("清理缓存", "删除全部缓存文件?")) return;
await click.DialogService.RunWithProgress("正在清理…", async (progress, cancel) =>
{
for (int i = 1; i <= n; i++) { cancel.ThrowIfCancellationRequested(); /* … */ progress.Report((double)i / n, $"第 {i} / {n} 个文件"); }
});
await click.DialogService.ShowMessage("清理缓存", "完成。");
}
// 点了按钮后要用户填几个参数:返回完整的值(没改的字段为默认值),取消返回 null
var inputs = await click.DialogService.PromptInputs("导出预设", ObjectConfig.Create(new OrderedMap<PropertyKey, IControllerConfig>
{
{ ("name", "预设名"), TextBoxConfig.Create(lastName) }, // 初始值写在默认值里
{ ("quality", "质量"), SliderConfig.Integer(5, 0, 10) },
}));- 宿主按自身风格画出来:主题一致,确定 / 取消等按钮文字由宿主翻译,标题和正文由你自译;弹窗以按钮所在的窗口为父窗口模态显示,并标明来源插件。
- 有效期:从回调开始到它返回的 Task 完成。把
click.DialogService存起来、事后再用会抛InvalidOperationException。 - 任意线程可调。不要同步阻塞等待返回的 Task(
.Result/.Wait())——弹窗要用 UI 线程,在 UI 线程上阻塞即死锁。 RunWithProgress:进度窗从 0% 开始;Report(fraction, status)的fraction为null时显示不确定进度(滚动条),status是进度条下方的一行文字(自译),省略或传null表示保持上一次的文字(想清空传"");fraction没有"保持不变"的写法,只更新文字时要传入当前进度;progress.SetCancellable(false)让取消按钮在不能打断的关键段(替换文件、写索引……)里变灰,出关键段再置回true(默认可取消;应用退出时 token 照样取消,关键段仍须能应对进程被结束);Report可任意线程、高频调用(宿主按帧合并);进度窗恒带取消按钮,用户取消或应用退出时 token 取消;work抛出的异常原样传回给你。PromptInputs:用和属性面板同一套控件渲染你的 config(与脚本工具的 inputs 是同一个表单窗);表单数据与工程隔离、不进撤销。表单要随填写的值变化(某项选了 X 才出现别的字段)时,传一个函数而不是固定的 config:PromptInputs(title, values => BuildForm(values))——和属性面板一样,值一变就以表单当前的值重新调用它;values只含用户改过的字段,读不到即默认值。
想要自己布局、自己管数据的界面(不经过宿主的 config 和属性数据机制),做成独立进程:自己写一个 exe,按钮只负责启动它。这个界面与宿主无关——宿主不提供父窗口,也不做模态;exe 用什么框架、带什么运行时、怎么和插件交换数据(文件、命令行、管道……),都由你自己决定。
props.Add(("editor", "编辑器"), ButtonConfig.Create("打开...", async _ =>
{
var start = new ProcessStartInfo(Path.Combine(packageDir, "editor", "MyEditor.exe")) { UseShellExecute = false };
start.ArgumentList.Add("--data"); start.ArgumentList.Add(dataPath);
using var process = Process.Start(start)!;
await process.WaitForExitAsync(); // 等它关闭:期间按钮置灰,关闭后宿主刷新面板(不等也行,那就立刻返回)
}));插件进程内的 UI 框架不在契约里:宿主的 Avalonia 不对插件开放;WinForms / WPF 属于 .NET 的桌面运行时,宿主进程里没有,插件里引用它们会在运行时找不到程序集。若坚持在进程内引入别的 UI 框架,由你自行保证不与宿主冲突。
参考实现:tests/plugins/V1.CustomWindow + tests/plugins/V1.CustomWindow.Editor(按钮启动 WinForms 界面 exe,等它关闭 / 只启动两种方式)、tests/plugins/V1.Settings(设置页:清理缓存 / 测试连接 / 导出预设(PromptInputs)/ 抛异常 / 嵌套对象与列表元素里的按钮)、tests/plugins/V1.Voice(part / note / 音素面板的按钮)、tests/plugins/V1.Effect(effect 面板的按钮 + 进度)。
- 包格式:把包文件夹打成 zip,扩展名改为
.tlx,要求manifest.json在 zip 的根目录。 - 安装:在 TuneLab 里把
.tlx拖进窗口,或用扩展侧边栏的「Install Extension」。安装即解压到扩展目录并立即加载(无需重启)。 - 扩展目录:
%AppData%/TuneLab/Extensions/<包名>/(Windows)。侧边栏「Open Extensions Folder」可直接打开。 - 卸载:扩展侧边栏每个条目的「Uninstall」。卸载在编辑器关闭后由独立的
ExtensionInstaller完成(释放文件锁后删除),可选择「立即重启」生效。 - 禁用(不卸载):在扩展侧边栏点开某个包的详情窗——header 上有一个开关可关掉整个包,每个 tab 上还有一个可只关那一个条目(于是"voice 好用、effect 崩了"的包能留下好用的那一半)。被禁的条目根本不会注册,被禁的包连加载都不加载,侧栏卡片会显示「已禁用」徽标。两者都重启后生效。自测时插件"没加载",先看一眼这个开关再去查 bug。
TuneLab 加载每个包时:发现 → 读 manifest.json 判代际(有 id = V1)→ 校验(sdk-version 兼容?平台匹配?)→ 为包建一个 per-folder ALC → 逐条按 assembly 加载、校验声明的 class 实现了本 type 所需接口后实例化注册(不再反射扫 attribute)。
- 任何一步失败都优雅降级:只跳过出问题的插件/条目,不会让主程序崩溃,并在扩展侧边栏与日志里反映加载状态。
- 用户的禁用开关先于上述一切校验:被用户关掉的包(或单个条目)不校验、不加载、不注册——侧边栏报
Disabled而非报错。它声明的条目照样列出来,用户才有地方把它重新打开。 sdk-version高于宿主 → 该包被跳过并提示。platforms不含当前平台 → 该插件被跳过。- 条目级校验失败(
assembly找不到、声明的class不存在或未实现该type所需接口、缺无参构造)→ 只这一条目失败,原因写进侧边栏 tooltip;同包其余条目照常加载(部分加载)。
改版前发布的老插件(链接旧的 TuneLab.Base / TuneLab.Extensions.Formats / TuneLab.Extensions.Voices)属于 Legacy:它们的 manifest.json 没有 id(或根本没有该文件)。TuneLab 据此识别为 Legacy 并交给兼容层处理。
- 新插件请勿沿用 Legacy 形态——务必带
id与新版字段,按本文编写。 - Legacy 兼容层会长期保留,老插件无强制迁移压力;但新功能(如 effect)只在 V1 提供。