第三轮 SDK 复审:从"最小化/去投影"(见 #147「六」)转向逐 API 的设计质量评审,5 个维度:① 存在意义 ② 更优设计 ③ 隐患/footgun ④ 加性兼容 ⑤ 命名/业界标准。
方法:逐 type/member 按上述评分卡定性评审,对照外部真实引擎插件(跨仓 ProjectReference 直引 2.0 SDK 源,权威消费方)的真实用法找设计摩擦。已吸收 #147 与 docs/sdk-api-evolution.md 既决共识,不重开已定案(命名中缀体系、三域平行不抽基类、IsContinuation 无默认体、config 工厂、快照 required-init)。
注:真实消费方目前仅 voice 一个引擎插件;instrument/effect 两域无真实插件背书,其设计仅靠"跨域对称"论证支撑,待相应插件出现后再交叉核。
簇 1:Voice 合成插件契约 —— 整体健康度:成熟、论证充分、无错抽象
高
中
低
簇 2–N(待评)
命名一致性(跨 SDK + Foundation,2026-07-22)
第三轮 SDK 复审:从"最小化/去投影"(见 #147「六」)转向逐 API 的设计质量评审,5 个维度:① 存在意义 ② 更优设计 ③ 隐患/footgun ④ 加性兼容 ⑤ 命名/业界标准。
方法:逐 type/member 按上述评分卡定性评审,对照外部真实引擎插件(跨仓 ProjectReference 直引 2.0 SDK 源,权威消费方)的真实用法找设计摩擦。已吸收 #147 与 docs/sdk-api-evolution.md 既决共识,不重开已定案(命名中缀体系、三域平行不抽基类、IsContinuation 无默认体、config 工厂、快照 required-init)。
簇 1:Voice 合成插件契约 —— 整体健康度:成熟、论证充分、无错抽象
高
IVoiceSynthesisSession.SynthesizedPhonemes : IReadOnlyMap<IVoiceSynthesisNote, SynthesizedSyllable>以活接口引用为键,与 v2 跨进程隔离承诺正面冲突。 整个快照体系为 out-of-process 把活引用降解为索引/blittable(note 快照砍邻居链改索引、Point blittable、ITiming 留宿主),唯独这条 engine→host 产物把活IVoiceSynthesisNote重新引入并冻进 ABI;引擎移出进程后此 map 无法透明过线。真插件佐证:Render()在 worker Task 上为填此键被迫把活 note 列表拖进 worker,与"worker 不碰活对象"张力。建议:键改快照索引/稳定 note-id,宿主凭 origins 回映活 note。现在改=加性重塑,冻结后改=3.0 breaking。 先决问题:v2 跨进程隔离是否硬目标? ✅ 已解(提交b3aaf0e,文档21812e4):先决问题定为否(v2 out-of-process 非硬目标,真插件已自做进程隔离);但即便不跨进程,活 note 键仍非最优(泄漏可变可订阅面、逼引擎跨 offload 抓活对象、正确性押在 proxy 不重写 Equals 的隐形不变量、与产物族值键不对称)。键改IReadOnlyMap<string, SynthesizedSyllable>,取归属 note 的IVoiceSynthesisNote.Id(宿主发号的 string 身份 token、不持久、会话内稳定;引擎从snapshot.Notes[i].Id取、worker 零活引用;宿主按当前 id 反查回填)。选 string 而非专用类型:将来若需持久 note uuid,宿主纯换实现不动 ABI,且冻结面不多类型。ABI:+IVoiceSynthesisNote.Id、+VoiceSynthesisNoteSnapshot.Id(加性),键类型变更(breaking,冻结前重塑)。派生结论:不加 id→note 反查方法(id 单向 plugin→host、零消费者,pay-as-you-go)。中
IVoiceSynthesisContext.GetSnapshot开窗区间与引擎实际采样范围易错位、窗外静默不保真。 automation 冻结开窗只承诺[start,end](+每侧 2 锚点)内逐点保真;真插件采样帧起点renderStart = phones[0].StartTime - head*frameSec(含前置辅音越界 + head padding)早于它传入的窗口起点,例行在窗外求曲线值 → 段边缘数帧可能取锚点外推错值,静默无报错。建议:契约明确"窗须覆盖全部实际采样点、否则未定义" + 宿主 DEBUG 断言窗外求值;真插件应按 renderStart..renderEnd 传窗(当前疑为真插件潜在 bug)。 ✅ 已解(提交 6015d17):加 DEBUG 窗外边界断言 AssertWithinWindow。设计结论=不重设计——v2 无回调迫使快照自足(预窗 or 全量二选一),全量已可经大窗取得且冻的是控制点故便宜;边界断言把「低估窗口」从静默变响亮即为修。真插件按 renderStart..renderEnd 传窗为其自身责任。 ⟳ 后续修正(round-3,提交7ee8d70)——原「不重设计」结论对 voice/instrument 部分推翻:其前提「插件传 renderStart..renderEnd 为责任」经真插件复核证伪——renderStart依赖音素时长、而时长在 offload 后合成阶段才知,同步前缀架构上填不对(legacy adapter 当初靠 0.5s 魔法余量绕坑即证)。故 voice/instrument 的GetSnapshot去窗、automation/pitch 全量冻结(工厂 ±∞ 取全锚点、越界端钳夹=live 等价,结构上无错值);实测 4 分钟稠密 pitch 全量 227µs/次、可忽略;反向可逆(将来真需窗可加性重载)。effect 的窗保留(其窗可正确圈:段范围±引擎自知上下文窗、无 dur 鸡生蛋),AssertWithinWindow现服务 effect、对 ±∞ 显式早退。IAutomationEvaluator.Evaluate(times)的"必须非降序"是未强制前提,违反即静默返回错值。 注释已钉口径(SDK/Foundation 2.0 冻结前全面审查清单(ABI 收口) #147 item 15)但无强制;乱序传入无异常无降级。建议:DEBUG 断言单调性即抛,或对未排序输入抛 ArgumentException。 ✅ 已解(提交 6015d17):加 DEBUG 非降序断言 AssertAscending,落 AutomationSnapshot/PiecewiseAutomationSnapshot 前进游标处;Release 经 [Conditional("DEBUG")] 零开销。IVoiceSynthesisContext.Notes排序契约(StartTime 升序→同起点 EndTime 降序)使"末元素即区间末"成陷阱。 区间末须notes.Max(n => n.EndTime)而非notes[^1].EndTime(长 note 排前会截短窗口丢尾);每个分片插件都要独立踩对此隐性不变量。建议:注释显式写此反直觉后果 + 示例;长期可让宿主在SynthesisRange/快照物化时代算有效末。 ⛔ won't-do:对可重叠区间(instrument)「末元素=片段末」是固有伪命题——宿主无法靠排序保证、插件也不应据此取片段末(应用宿主给的 SynthesisRange 边界);voice 已去重叠天然免疫。无解可忽略。低
GetStatus()是方法,而SynthesizedPitch等产物是属性——取用成本相同却形态不一致。 建议统一为Status只读属性(或反之)。(冻结前 or never) ✅ 已解(提交defdfe3):三会话 GetStatus()→Status 属性,与其余可观测产物(各带 Changed 事件)对齐。GetNextSegment() : SynthesisRange?方法名把"Segment"带回来了,而返回类型特意改名SynthesisRange就为规避"段"三义(IAudioSegment/SynthesisStatusSegment)。建议GetNextPendingRange/PeekNextRange。(冻结前 or never) ✅ 已解(提交defdfe3):voice/instrument 会话 GetNextSegment→GetNextPendingSynthesisRange,对齐返回类型 + Pending 词汇。GetSnapshot(IEnumerable<IVoiceSynthesisNote> notes, …)用IEnumerable承载"返回值按入参索引对齐"契约,而索引对齐要求稳定可索引单次一致的序列。建议收紧回IReadOnlyList(doc 草案本为此、真插件本就传 List)。(冻结前 or never) ✅ 已解(提交defdfe3):voice/instrument 上下文 GetSnapshot 入参收紧为 IReadOnlyList,索引对齐契约入类型。SynthesizedPhonemesChanged/SynthesizedParametersChanged/SynthesizedPitchChanged分立,但真插件恒锁步齐发。 拆分收益(宿主选择性刷新)在权威实现落空、只剩三个订阅点样板。建议评估合并为ProductsChanged+StatusChanged两信号;若保留,接受其正当性是"宿主分派便利"而非"选择性刷新"。 ⛔ won't-do(按设计保留):细分是更全面的设计——宿主可分派到不同刷新器、并为将来插件独立触发某一产物留粒度;合并成单信号会永久丢失该粒度、属不可逆窄化。真插件当前锁步齐发不构成删据。IAutomationEvaluator.Evaluate输出改入参 span(原返回double[]每次分配)。 ✅ 已做(提交7c1136f,与最初「入参 ReadOnlySpan won't-do」区分开——那条仍不做):void Evaluate(IReadOnlyList<double> times, Span<double> results),调用方掌控输出内存、热路径复用 scratch 免分配;新增同构扩展IAutomationEvaluatorExtension.Evaluate(times)->double[],调用方(插件/宿主)代码零改动透明绑扩展。入参维持IReadOnlyList(ReadOnlySpan 会把内存布局泄进契约 + ref struct 全链约束;索引提速另归宿主内部 span 优化)。default(T)绕过构造在非空引用成员产 null。SynthesizedPhoneme.Symbol、VoiceSynthesisPhonemeSnapshot.Symbol/Properties、SynthesizedSyllable.Leading/BodyPhonemes皆不可空契约却default(T)/预分配空槽会给 null。建议宿主对产物成员防御性 null 容错,或文档声明"default 非法"。 ✅ 已解(提交3287ec0):SynthesizedSyllable(map 值型、高危——TryGetValue未命中直接吐 default 给外部插件)readonly struct→sealed class,default 变 null 对象、配合IReadOnlyMap.TryGetValue的[MaybeNullWhen(false)]让"不查 bool 就用 out"编译期告警(struct 给不了此保护);宿主SynthesizedSyllableExtensions值+nullable 双重载在 class 下冲突、合并为单 nullable 版。叶描述符SynthesizedPhoneme/VoiceSynthesisPhonemeSnapshot保readonly struct(值语义、列表元素、非 map 值型无外部可达 default 路径),仅加注释点明 default 非有效实例 + 零 ABI 的防御 getter 补救路径({ get; init; }演进为?? ""backing getter 冻结后也不破 ABI,故不为够不到的坑现付样板——pay-as-you-go;注:struct 上属性初始化器对 default/new[]不运行,唯 null 合并 getter 有效)。ISynthesisAutomation : IAutomationEvaluator(is-a,.Evaluate)与冻结SynthesisAutomationSnapshot(has-a,.Evaluator.Evaluate)取用路径不一致,削弱镜像对称。可接受(容器化收益 > 摩擦);若抹平,让 Snapshot 也暴露转发Evaluate便捷方法。 ✅ 已解(判为刻意非缺陷、无需改):差异如实反映两类型角色不同——liveISynthesisAutomation是行为接口(求值 + 订阅区间失效,is-a 求值器自然)、snapshotSynthesisAutomationSnapshot是会增长的冻结数据容器(has-a,将来加 DefaultValue/值域/单位等元数据纯加性不破 ABI,其类型注释已为此辩护)。取用差异用在不同阶段/目的(live 主要挂RangeModified于数据线程做失效 / snapshot 在 worker 求值),插件极少写一段代码多态跨两者;强行让 snapshot 也 is-a 会搅浑它刻意分开的「容器 vs 求值器」语义。按「兼容/对称按本质区别对待、勿为统一观感强行抹平」保留。簇 2–N(待评)
IInstrumentSynthesisEngine/Session/Context/Note、声明面IInstrumentSynthesisPartView/NoteView/*PartPropertyContext/*NotePropertyContext、InstrumentSynthesisSnapshot/NoteSnapshot、InstrumentSourceInfo(与 voice 共用SynthesisRange/SynthesisStatusSegment/IAudioSegment/ISynthesisAutomation)。 ✅ 忠实对称、0 改动(按对称性 + 本轮 voice 改动传导核对):EndTime=满末不钳位(原味消费重叠,宿主不去重叠);无 Pitch/PitchDeviation 双通道(v1 整数 pitch);余皆与 voice 同构。Idvoice 加是因 SynthesizedPhonemes 按 note 键,instrument 无任何按 note 键产物(只出音频 + 按 string 键 SynthesizedParameters)→ 不需 Id;值 DTO 空安全无平行隐患(快照 sealed class+required、无 struct 值产物)。命名 renames(Status 属性/GetNextPendingSynthesisRange/GetSnapshot 收 IReadOnlyList)已一致应用。V1.Effect(Slow Gain) 夹具):IEffectSynthesisEngine/Session/Context/PropertyContext/View、IEffectSynthesisAudio(音频流握柄)、EffectSynthesisSnapshot。 ✅ 设计精深、0 改动:EffectSynthesisSnapshotsealed class+required、无 struct 值产物 → note Id / 值 DTO 空安全皆不适用。Process(非 peek+SynthesizeNext)因会话绑单段、无"下一块可挑",且电平语义(让输出与当前输入一致、非边沿"应用某次变更",根治边沿触发 bug);声明单层(无 Part 前缀/无 GetNotePropertyConfig,effect 无 note 子级);Status/SynthesizedParameters/Changed 事件与 voice 对齐(无 Pitch/Phonemes 产物)。IEffectSynthesisAudio=Read(offset, Span<float>)copy-out(跨进程就绪、宿主存储形态可演进)+RangeModified(long start, int count)绝对轴账本(Resize 后无需重定基、对称差记账、下游自决感受野→局部重合成 O(变更区));(start,count)/long-int 对采样域是恰配、仍守"平铺参数对不引冻结区间类型"房规。失败 passthrough 降级、splitter 多段输出。IControllerConfig/IValueConfig/IValueConfig<T>;容器ObjectConfig/ArrayConfig/ListConfig/ExtensibleObjectConfig;叶子ComboBoxConfig/ComboBoxItem/SliderConfig/DraggableNumberBoxConfig/CheckBoxConfig/TextBoxConfig/AutomationConfig;附件AddableElement/AddableKey/PropertyKey/NormalizedScale/NumberFormat/DragResponse/DragAxis。焦点=工厂+With*链一致性、declare→render 契约。 ✅ 整簇成熟健康(工厂 +With链一致、加载期 ABI 校验背书、外部真插件两侧构造/渲染无摩擦)。ComboBoxItemstruct→readonly struct(提交f44f3ea):全簇唯一破「值 DTO=readonly struct」房规者,成员全 get/init 实质不可变,改动 ABI 兼容(无成员签名变化、PublicAPI 不动)、消除只读上下文防御性拷贝。IControllerConfigpublic 可实现空 marker、未知类型宿主静默跳过(假扩展点) → won't-fix:public 是其「异型 config 容器基类」角色的必需(ObjectConfig.Properties等元素型);SDK 已覆盖全部 config 需求、无人有理由自实现空 marker,静默不渲染纯理论风险,不值加注释扰动极简 marker。private init一次性 vsprivate set+Clone+With/Append,按有无可选修饰项分流)、IValueConfig<T>.DefaultValue的new T成员隐藏(标准 is-a + 强类型双面)、AutomationConfigNaN 哨兵区分IsPiecewise(前轮定案)、PropertyKey自防御default(Id=null 全 null-safe,值 struct 自处理 default 的正面先例)、ArrayConfig允许异型元素、Extensible(Key)vsList(Element)附件不对称(键控有唯一键+标签)。Map/OrderedMap+I(ReadOnly)(Ordered)Map+Builders+ReadOnlyKeyValuePair;事件ActionEvent/IActionEvent(0–8 元)/IEvent/IEventExtensions;属性PropertyValue/PropertyObject/PropertyArray/PropertyType;响应式IReadOnlyNotifiable*家族 + WhenAny/Where/Field;杂Point/DisposableManager/ImageResource。对照:Map↔IDictionary、IActionEvent↔IObservable/event、IReadOnlyNotifiable*↔INotifyPropertyChanged/IObservable、PropertyValue↔JsonElement。 ✅ 整簇成熟健康、0 改动——BCL 交叉核对确认每个自造基元都填补真空、非 NIH:IReadOnlyMap/IReadOnlyKeyValuePair/ReadOnlyKeyValuePairvsIReadOnlyDictionary/KeyValuePair:核心理由=协变(IReadOnlyMap<TKey, out TValue>+IReadOnlyKeyValuePair<out,out>,BCL 的 KeyValuePair 是 struct 不能协变、IReadOnlyDictionary 不协变)。协变out TValue不允许出现在out参数位 →TryGetValue(out TValue)非法 → 只能TValue? GetValue(out bool success)(值走返回=协变合法)+ 扩展补回TryGetValue。"怪签名"是协变必然代价、非缺陷。代价=枚举每项分配 ReadOnlyKeyValuePair(若确知协变从不被依赖,理论上可退 BCL struct 省分配,但巨大 breaking 换微小 perf,不值)。PropertyValuevsJsonElement/JsonNode:域内Multiple(多选三态);readonly struct 字段联合免标量装箱;无 JsonDocument 缓冲/Dispose 包袱。IActionEvent/IEvent/Merge/When/WhenAny vs C# event / Rx:C# event 非一等值(不能存/返回/合并);比 Rx 轻(无 OnError/Completed);When 按值坍缩、WhenAny 动态成员自动接线是域内价值。IReadOnlyNotifiable(WillModify/Modified) vs INotifyPropertyChanged:before/after 双事件 + 非 stringly-typed,服务 undo/merge 捕获旧值。Point(double) vs Vector2(float)/Avalonia.Point:需 double 精度、BCL 无无依赖 double 二维点。Point是唯一 public 可变字段 struct(破 readonly 纪律),但属 Vector2 类热几何/blittable 惯例、==(IEEE)/Equals(自反) 分叉已文档化;改 readonly 会破坏可能的可变消费方、仅换观感,保持。*Info树(ProjectInfo/TrackInfo/PartInfo/MidiPartInfo/AudioPartInfo/NoteInfo/PhonemeInfo/VibratoInfo/EffectInfo/TempoInfo/TimeSignatureInfo/EditorInfo/ExportConfigInfo/SoundSourceInfo/AutomationInfo)、接口IImportFormat/IExportFormat、枚举SourceKind。焦点=DTO 形态(可变类 vs required init)、字段正交性、序列化健壮性。 ✅ 健康、0 改动(逐三焦点核对):{get;set;}可变类 + 无参 ctor + 字段默认,刻意区别于合成域 required-init 值 DTO——序列化 DTO 恰需此:反射序列化器友好、缺字段落默认不抛(旧/部分工程加载鲁棒,胜过 required 的强完整性)、格式插件可增量构造。两套房规按角色分流、正确。PropertyObject?(null 不序列化,pay-as-you-go)。PartInfoabstract 无判别字段非缺陷(插件从不泛型反序列化 PartInfo,判别是宿主持久格式私事);内嵌 Foundation.Map 因 PropertyObject 本就无处不在、宿主序列化器必然处理故一致。PhonemeLayout/PhonemeLayoutNote/PhonemeTiming/PhonemeSlots、SynthesizedPitch/Parameter/Phoneme/Syllable、ILogger、ITuneLabContext/TuneLabContext。 ✅ 健康、0 改动:SynthesizedPitch/Parameter/Phoneme/Syllable、PhonemeSlots已在簇 1 + 值 DTO 空安全 + 命名一致性各条覆盖(Syllable 已改 class)。PhonemeLayout族:Resolve纯函数(junction 单原点单次摆放规避 Σ 往返亚帧漂移、跨拍前后半分投回装、r^w指数伸缩=同权闭式/异权二分到 double 精度),确定性无状态、冻 I/O 形状内部逻辑可演进(WYSIWYG);PhonemeTiming只 double 无隐患。观察:PhonemeLayoutNote是 readonly struct 带非空引用成员(同类 default footgun),但消费方Resolve已?.防御 → 天然当空、最低危;补注释为可选一致性动作,未取(消费侧已防御)。ILogger:对标 M.E.Logging 刻意极简(4 级、object? 消息、无结构化/scope),避依赖 + 契合 ALC 隔离、每插件注入自动 id 前缀。正当。ITuneLabContext/TuneLabContext:service-locator 一般反模式,但 ALC 约束逼出(SDK 全插件 ALC 共享唯一份,静态点才能共享宿主句柄);setter internal防插件劫持 +NullContext空对象防 NRE,文档充分、正当。命名一致性(跨 SDK + Foundation,2026-07-22)
*Extensions(ABI 韧性:public 静态类可显式「类名.方法()」调用、名字即冻结;单数将来加方法想改复数会破坏 ABI)。SDK:IAutomationEvaluatorExtension→Extensions(提交034236b,随 IAutomationEvaluator 轴无关化一并);PhonemeSlots是领域概念名(非泛化桶)保留。Foundation:IReadOnlyMapExtension/IReadOnlyOrderedMapExtension/IReadOnlyNotifiableEnumerableExtension/IReadOnlyNotifiablePropertyObjectExtension→*Extensions(提交e69f412),与既有IEventExtensions/PropertyObjectExtensions看齐。零调用方影响(全实例式)。IAutomationEvaluator轴无关化(提交034236b):「查询轴=全局秒」从接口级降为提供方级(context.Pitch 等如实标);形参times→positions(一维标量、去时间轴暗示、避免 points 的二维歧义)。