Skip to content

SDK 2.0 API 设计质量复审(第三轮:存在意义/更优设计/隐患/加性兼容/命名) #148

Description

@LiuYunPlayer

第三轮 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 合成插件契约 —— 整体健康度:成熟、论证充分、无错抽象

  • [加性兼容] 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 优化)。
  • [隐患] 值类型 DTO 的 default(T) 绕过构造在非空引用成员产 null。 SynthesizedPhoneme.SymbolVoiceSynthesisPhonemeSnapshot.Symbol/PropertiesSynthesizedSyllable.Leading/BodyPhonemes 皆不可空契约却 default(T)/预分配空槽会给 null。建议宿主对产物成员防御性 null 容错,或文档声明"default 非法"。 ✅ 已解(提交 3287ec0):SynthesizedSyllable(map 值型、高危——TryGetValue 未命中直接吐 default 给外部插件)readonly structsealed class,default 变 null 对象、配合 IReadOnlyMap.TryGetValue[MaybeNullWhen(false)] 让"不查 bool 就用 out"编译期告警(struct 给不了此保护);宿主 SynthesizedSyllableExtensions 值+nullable 双重载在 class 下冲突、合并为单 nullable 版。叶描述符 SynthesizedPhoneme/VoiceSynthesisPhonemeSnapshotreadonly 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 便捷方法。 ✅ 已解(判为刻意非缺陷、无需改):差异如实反映两类型角色不同——live ISynthesisAutomation行为接口(求值 + 订阅区间失效,is-a 求值器自然)、snapshot SynthesisAutomationSnapshot会增长的冻结数据容器(has-a,将来加 DefaultValue/值域/单位等元数据纯加性不破 ABI,其类型注释已为此辩护)。取用差异用在不同阶段/目的(live 主要挂 RangeModified 于数据线程做失效 / snapshot 在 worker 求值),插件极少写一段代码多态跨两者;强行让 snapshot 也 is-a 会搅浑它刻意分开的「容器 vs 求值器」语义。按「兼容/对称按本质区别对待、勿为统一观感强行抹平」保留。

簇 2–N(待评)

  • 簇 2 · instrument 合成契约(voice 平行族;无真插件背书——真插件是纯 voice 引擎,本簇仅靠三域对称论证):IInstrumentSynthesisEngine/Session/Context/Note、声明面 IInstrumentSynthesisPartView/NoteView/*PartPropertyContext/*NotePropertyContextInstrumentSynthesisSnapshot/NoteSnapshotInstrumentSourceInfo(与 voice 共用 SynthesisRange/SynthesisStatusSegment/IAudioSegment/ISynthesisAutomation)。 ✅ 忠实对称、0 改动(按对称性 + 本轮 voice 改动传导核对):
    • 分叉均有据且文档化:无 Lyric/Phonemes/DefaultLyric/SynthesizedPhonemes/SynthesizedPitch/IsContinuation;EndTime=满末不钳位(原味消费重叠,宿主不去重叠);无 Pitch/PitchDeviation 双通道(v1 整数 pitch);余皆与 voice 同构。
    • 本轮 voice 改动正确地不传导:note Id voice 加是因 SynthesizedPhonemes 按 note 键,instrument 无任何按 note 键产物(只出音频 + 按 string 键 SynthesizedParameters)→ 不需 Id;值 DTO 空安全无平行隐患(快照 sealed class+required、无 struct 值产物)。命名 renames(Status 属性/GetNextPendingSynthesisRange/GetSnapshot 收 IReadOnlyList)已一致应用。
    • standing caveat:满末/重叠模型、纯整数 pitch 等特性层假设待真实多声部 instrument 引擎接入验真(对称性够不到、非缺陷)。
  • 簇 3 · effect 合成契约(整段音频离线变换 audio-in→audio-out;无真插件背书,但有 V1.Effect(Slow Gain) 夹具):IEffectSynthesisEngine/Session/Context/PropertyContext/ViewIEffectSynthesisAudio(音频流握柄)、EffectSynthesisSnapshot。 ✅ 设计精深、0 改动
    • 本轮 voice 改动正确 N/A:effect 根本无 note(audio-in→audio-out)、无按 note 键产物;EffectSynthesisSnapshot sealed 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 多段输出。
    • standing caveat:无真实第三方 effect 插件,特性层验真待真插件——但比 instrument 轻(有可跑夹具 + 本就重度设计过:终端形态/画家算法状态层/IEffectSynthesis* 家族已落地)。
  • 簇 4 · 控件 config 族(插件构造、宿主渲染属性面板):基 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 校验背书、外部真插件两侧构造/渲染无摩擦)。
    • [一致性] ComboBoxItem structreadonly struct(提交 f44f3ea):全簇唯一破「值 DTO=readonly struct」房规者,成员全 get/init 实质不可变,改动 ABI 兼容(无成员签名变化、PublicAPI 不动)、消除只读上下文防御性拷贝。
    • [存在意义] IControllerConfig public 可实现空 marker、未知类型宿主静默跳过(假扩展点)won't-fix:public 是其「异型 config 容器基类」角色的必需(ObjectConfig.Properties 等元素型);SDK 已覆盖全部 config 需求、无人有理由自实现空 marker,静默不渲染纯理论风险,不值加注释扰动极简 marker。
    • 确认为刻意设计、无需动:两套构造机制(private init 一次性 vs private set+Clone+With/Append,按有无可选修饰项分流)、IValueConfig<T>.DefaultValuenew T 成员隐藏(标准 is-a + 强类型双面)、AutomationConfig NaN 哨兵区分 IsPiecewise(前轮定案)、PropertyKey 自防御 default(Id=null 全 null-safe,值 struct 自处理 default 的正面先例)、ArrayConfig 允许异型元素、Extensible(Key) vs List(Element) 附件不对称(键控有唯一键+标签)。
  • 簇 5 · Foundation(共享基元;焦点=业界惯例对照 / 重造轮子审视):集合 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/ReadOnlyKeyValuePair vs IReadOnlyDictionary/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,不值)。
    • PropertyValue vs JsonElement/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 会破坏可能的可变消费方、仅换观感,保持。
  • 簇 6 · Format/Data DTO 族(工程序列化契约,格式插件产出/消费):*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 的强完整性)、格式插件可增量构造。两套房规按角色分流、正确。
    • 字段正交(通过):派生值刻意不存(PartInfo 的 Dur、NoteInfo 的 EndPos/扁平 Phonemes 皆不序列化,避免反射器写出重复);MidiPartInfo 三曲线(Pitch 专属通道/Automations 连续/PiecewiseAutomations 分段)镜像 SDK 语义、不冗余;PhonemeInfo.Properties=PropertyObject?(null 不序列化,pay-as-you-go)。
    • 序列化健壮(通过):IImport/IExportFormat 流契约干净(宿主拥有流生命周期、插件只顺序读/写不 Dispose/Seek);PartInfo abstract 无判别字段非缺陷(插件从不泛型反序列化 PartInfo,判别是宿主持久格式私事);内嵌 Foundation.Map 因 PropertyObject 本就无处不在、宿主序列化器必然处理故一致。
    • won't-fix:DTO 未 sealed(理论上子类化加字段→静默丢失),但无人子类化序列化 DTO、同 IControllerConfig 假扩展更低危。
  • 零散共享件(随相邻簇收尾):PhonemeLayout/PhonemeLayoutNote/PhonemeTiming/PhonemeSlotsSynthesizedPitch/Parameter/Phoneme/SyllableILoggerITuneLabContext/TuneLabContext。 ✅ 健康、0 改动
    • SynthesizedPitch/Parameter/Phoneme/SyllablePhonemeSlots 已在簇 1 + 值 DTO 空安全 + 命名一致性各条覆盖(Syllable 已改 class)。
    • PhonemeLayoutResolve 纯函数(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:IAutomationEvaluatorExtensionExtensions(提交 034236b,随 IAutomationEvaluator 轴无关化一并);PhonemeSlots 是领域概念名(非泛化桶)保留。Foundation:IReadOnlyMapExtension/IReadOnlyOrderedMapExtension/IReadOnlyNotifiableEnumerableExtension/IReadOnlyNotifiablePropertyObjectExtension*Extensions(提交 e69f412),与既有 IEventExtensions/PropertyObjectExtensions 看齐。零调用方影响(全实例式)。
  • IAutomationEvaluator 轴无关化(提交 034236b):「查询轴=全局秒」从接口级降为提供方级(context.Pitch 等如实标);形参 timespositions(一维标量、去时间轴暗示、避免 points 的二维歧义)。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions