Skip to content

Latest commit

 

History

History
1170 lines (908 loc) · 126 KB

plugin-development.zh-CN.md

File metadata and controls

1170 lines (908 loc) · 126 KB

TuneLab 插件开发指南

适用于 TuneLab 新版扩展系统(V1)。本文介绍如何编写、打包、发布一个 TuneLab 插件。 老插件(Legacy)的兼容说明见文末「附录:Legacy 插件」。

英文基准文档见 plugin-development.md,本文为其中文翻译;如有出入以英文版为准。


1. 核心概念

  • 插件包 = 一个文件夹。它是部署、安装、卸载的原子单位,被放进 TuneLab 的扩展目录(见 §10)。
  • manifest.json 是包的身份标识,必须放在包文件夹的最外层。新版(V1)插件必须带它;TuneLab 先读这个文件,再据其内容有选择地加载包内程序集——不会盲目扫描整个文件夹。
  • 一个包可以含多个插件。若你有一套共用的基建程序集,可以把基于它开发的多个插件打进同一个包,基建只分发一份、运行时也只加载一份(它们共享同一个加载上下文)。
  • 插件类别(type):当前支持 format(工程文件导入/导出)、voice(歌声合成引擎)、instrument(多声部音源引擎,如合成器/采样器)与 effect(效果器,对合成音频做整段离线变换,如换声)。包还可以是纯资源包(无代码,如音色资源)。

每个插件包在加载时被放进一个独立的 AssemblyLoadContext(ALC):

  • TuneLab 的 SDK 契约程序集(TuneLab.Foundation + TuneLab.SDK.*)和 .NET 运行时由主程序统一提供、所有插件共享——你引用它们即可,不要把它们打进包里。
  • 你的私有依赖(第三方库、原生 dll 等)放进包文件夹,会被加载进你这个包专属的 ALC,与其他插件隔离,因此不同插件捆绑不同版本的同一个库不会冲突。

2. manifest.json

2.1 字段

包级(最外层):

字段 必填 说明
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)。

2.2 单插件(最常见)

直接把插件级字段写在顶层即可,不需要数组:

{
  "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 这回事。

2.3 一包多插件

用 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/…)被忽略。

2.4 资源包(无代码)

省略 assembly/class 等代码字段,只用 type 声明用途。TuneLab 只登记它、不加载代码,由对应引擎在运行时去发现包内资源:

{
  "id": "com.example.mybank",
  "name": "My Voicebank",
  "version": "1.0.0",
  "type": "voicebank"
}

3. 工程配置

新建一个 .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),打包时别把它们放进包,宿主会共享自己的那一份。
  • 你的私有第三方依赖正常引用、随包分发即可。

4. 编写 Format 插件

实现 IImportFormat(导入)和/或 IExportFormat(导出)。需要无参构造函数。文件后缀与实现类写在 manifest.json(suffixes + class + assembly),代码里不再用 attribute 声明。

4.1 声明你能读什么、能写什么

你只回答一个问题:这个格式能读哪些后缀、能写哪些后缀。方向由此而来,没有别的要选。

// 读写同一组后缀 → 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——宿主不会悄悄把你要的方向抹掉。

4.2 两个类就是两个条目

如果导入器与导出器确实是两个不同的类,它们就是两个条目——一个条目只有一个 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 ...
    }
}

4.3 后缀别名

同一方向里写多个后缀 = 「同一格式的多个别名」:共用实现类、说明与设置,但仍各自可被独立路由。每个后缀各自是不可变身份(路由 + 序列化);name 是可选显示名(可加 localizations 翻译)。

读得宽、只写一种规范后缀是带别名的格式的常态——那就是 §4.1 的第二个例子,仍是一个条目。

工程模型(ProjectInfo/TrackInfo/PartInfo/NoteInfo…)定义在 TuneLab.SDK。


5. 编写 Voice 插件

voice 是歌声合成引擎(如 SVS 模型)。本章按「先建立心智模型 → 逐个接口讲清职责与坑 → 五个易错专题(音素 I/O、音高采样、快照、属性约定、原生依赖打包)」组织。照本章写完,你应能交付一个线程安全、增量重合成正确、产物归属无误的 voice 插件。

5.0 心智模型(先读这一节)

  • 会话托管厚模型:你实现 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)。

5.1 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 只在首次用到时调一次。

5.2 引擎声明面:属性面板与自动化轨

声明面的四个方法在 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 字段本地化。

音素属性(GetPhonemePropertyConfigs)

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 的全体音素为面板范围。

声明依赖派生状态:InvalidatePart / InvalidateNotes

有些面板选项取决于引擎自己算出来的东西,而不只是用户填的值——典型如后台解析歌词、分析声调:用户输入歌词后,解析完成才知道该给这个 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 加一项,是把"显示的那组 + 新项"写回去)。新旧形状类型相同时,宿主照原样显示旧值,用户一编辑就在旧值上继续改,你在默认值里给的转换结果根本不会出现。

5.3 输入活视图 IVoiceSynthesisContext 与 IVoiceSynthesisNote

会话本身(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。

5.4 调度:GetNextPendingSynthesisRange(peek)与 SynthesizeNext(commit)

宿主掌握全局播放线,驱动逐步合成:先 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)。

5.5 合成快照 VoiceSynthesisSnapshot(隔离的核心)

worker 不能碰活视图,所以 SynthesizeNext 的同步前缀要把本次合成所需的一切物化成不可变快照再 offload。GetSnapshot 一次返回一份:

VoiceSynthesisSnapshot GetSnapshot(IReadOnlyList<IVoiceSynthesisNote> notes);
  • notes:本次合成需要的 note——段内 note + 协同发音邻居,由你自由圈定(如想看前一个 note 的尾辅音,就把它也放进来)。返回的 snapshot.Notes 与你递入的 notes 索引对齐——这是产物归属契约(见 §5.7:SynthesizedPhonemes map 以 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 出新段 → 物化一份全新快照。替换而非同步,所以无需任何锁。

5.6 音高曲线采样:双通道 + 按帧取值

音高是两个平行通道,合成时按公式合成:

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)。

5.7 音素 I/O:SynthesizedPhoneme(读入 / 输出同型)

音素描述符方向无关——读入(用户钉死约束)与输出(合成产物)都用同一个 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。你只管每次合成诚实输出当前时长 + 权重。

5.8 音频产物与状态

音频经段握柄 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=(秒,值),段间断开

5.9 失效与增量重合成

正确的增量重合成 = 「廉价标脏 + 收口重活」。在会话构造时订阅 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)。

5.10 原生依赖与模型打包

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、把该插件标为加载失败并在侧边栏反映原因,不会崩溃主程序。

5.11 接口职责速查

成员 线程 职责
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 数据线程 退订、释放模型与段句柄

6. 编写 Effect 插件

效果器(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。


7. 编写 Instrument 插件

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 共用)。


8. 扩展设置(IExtensionSettings)

让你的扩展(extension,即一个 voice/effect 等能力实现)声明一组随宿主持久化、跨工程共享的设置——典型如 API key、模型路径、设备选择。宿主在「设置」窗口渲染面板、按 extension 落盘、运行时回喂。

与属性面板的区别:voice 的 GetPartPropertyConfig/GetNotePropertyConfig、effect 的 GetPropertyConfig 声明的是随工程序列化的实例/段级属性(每个 part/note/effect 实例各一份,存进 .tlp)。本节的设置则是扩展自身的配置,与具体工程无关、跨工程共用、单独落盘。两者用同一套控件配置词汇(ObjectConfig),但生命周期与存储位置完全不同。

粒度是 per extension(一个 voice/effect 能力一份),不是 per 安装包(ExtensionPackage 可含多个 extension,各自独立设置)。

8.1 接入方式

设置是 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 的其余成员……
}

8.2 要点

  • 密钥字段:用 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 不掺和。

8.3 用户在哪里改

「设置」窗口(顶部菜单进入)→「扩展」分页:每个声明了设置的扩展一段「显示名 + 设置面板」。编辑在关闭窗口 / 切走分页时统一落盘并回喂。

agent 模型引擎有自己的侧边栏设置入口,不在「扩展」分页里。

相关接口在 TuneLab.SDK:IExtensionSettings / IExtensionSettingsContext(+ 控件配置 ObjectConfig / TextBoxConfig / PathPickerConfig / CheckBoxConfig / ComboBoxConfig / SliderConfig)。

8.4 format 扩展:单位是条目,不是后缀

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) { /* 这里也是 */ }
}

9. 按钮(ButtonConfig)

9.1 声明与执行

任何声明面板的地方都能放按钮:扩展设置、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", "")——则不显示标题、按钮铺满整行,适合按钮文字本身就说明一切的操作。除此之外没有布局属性;按钮不可收起(它没有值)。

9.2 点击期间的弹窗服务(click.DialogService)

弹窗只能在按钮点击期间弹,入口只有回调参数 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 只含用户改过的字段,读不到即默认值。

9.3 插件自己的设置界面

想要自己布局、自己管数据的界面(不经过宿主的 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 面板的按钮 + 进度)。

10. 打包、安装、卸载

  • 包格式:把包文件夹打成 zip,扩展名改为 .tlx,要求 manifest.json 在 zip 的根目录。
  • 安装:在 TuneLab 里把 .tlx 拖进窗口,或用扩展侧边栏的「Install Extension」。安装即解压到扩展目录并立即加载(无需重启)。
  • 扩展目录:%AppData%/TuneLab/Extensions/<包名>/(Windows)。侧边栏「Open Extensions Folder」可直接打开。
  • 卸载:扩展侧边栏每个条目的「Uninstall」。卸载在编辑器关闭后由独立的 ExtensionInstaller 完成(释放文件锁后删除),可选择「立即重启」生效。
  • 禁用(不卸载):在扩展侧边栏点开某个包的详情窗——header 上有一个开关可关掉整个包,每个 tab 上还有一个可只关那一个条目(于是"voice 好用、effect 崩了"的包能留下好用的那一半)。被禁的条目根本不会注册,被禁的包连加载都不加载,侧栏卡片会显示「已禁用」徽标。两者都重启后生效。自测时插件"没加载",先看一眼这个开关再去查 bug。

11. 加载与校验行为

TuneLab 加载每个包时:发现 → 读 manifest.json 判代际(有 id = V1)→ 校验(sdk-version 兼容?平台匹配?)→ 为包建一个 per-folder ALC → 逐条按 assembly 加载、校验声明的 class 实现了本 type 所需接口后实例化注册(不再反射扫 attribute)。

  • 任何一步失败都优雅降级:只跳过出问题的插件/条目,不会让主程序崩溃,并在扩展侧边栏与日志里反映加载状态。
  • 用户的禁用开关先于上述一切校验:被用户关掉的包(或单个条目)不校验、不加载、不注册——侧边栏报 Disabled 而非报错。它声明的条目照样列出来,用户才有地方把它重新打开。
  • sdk-version 高于宿主 → 该包被跳过并提示。
  • platforms 不含当前平台 → 该插件被跳过。
  • 条目级校验失败(assembly 找不到、声明的 class 不存在或未实现该 type 所需接口、缺无参构造)→ 只这一条目失败,原因写进侧边栏 tooltip;同包其余条目照常加载(部分加载)。

附录:Legacy 插件

改版前发布的老插件(链接旧的 TuneLab.Base / TuneLab.Extensions.Formats / TuneLab.Extensions.Voices)属于 Legacy:它们的 manifest.json 没有 id(或根本没有该文件)。TuneLab 据此识别为 Legacy 并交给兼容层处理。

  • 新插件请勿沿用 Legacy 形态——务必带 id 与新版字段,按本文编写。
  • Legacy 兼容层会长期保留,老插件无强制迁移压力;但新功能(如 effect)只在 V1 提供。