OTTER — Orchestratable Transcription, Timing and Expression Runtime
本文是 otter 的总体方案,规定分层、契约面、宿主责任与实施路径。决策及其依据集中在《决策台账》,其余章节只陈述结论。
各来源发生冲突时按下列顺序裁决,上位来源优先:
- DiffSinger 数据格式与推理接口规范 2.4:上位规范;
- synthrt / dsinfer 实际代码:框架能力的唯一事实来源;
- 本文:分层与跨层决策;
- wolf:同构先例,不属于规范。wolf 的做法可供参考,但不构成 otter 的约束。
| 代码树 | 观察点 | 引用写法 |
|---|---|---|
| spec 2.4 | synthrt 仓 docs/ds-spec-2.4.md |
spec 2.4 §<章节名> |
| synthrt / dsinfer | 分支 onnxruntime-builds-uptake(由 scripts/vcpkg-ports/synthrt-main 固定) |
文件路径与符号,如 synthrt/include/synthrt/Core/ContribSpec.h |
| refactor 抽参子系统 | lite main 分支所引用的 synthrt 版本 |
lib/Extract/...、plugins/Extract/... |
| lite | 分支 main |
src/app/Modules/Extractors/... |
otter 是链接进宿主的库,为 synthrt 的 inference 模块定义一组分析契约。从音频中得出结构化标注的模块,均为接口属于这些契约之一的 inference 模块。库本身发布契约头文件并定义解释器抽象;模型由解释器插件执行。otter 不注册自有的贡献类别(A26)。
Level 1 定义三份契约:
| interface | 产出 | 变体 |
|---|---|---|
org.openvpi.otter.inference.F0 |
基频曲线与清浊标志 | rmvpe |
org.openvpi.otter.inference.Note |
音符区间(可以已知音符为条件) | game |
org.openvpi.otter.inference.Align |
词与音素的时间区间(强制对齐,歌词由调用方给出) | hfa、tifa |
另预留一份契约,本轮不实现,也不定义其词汇:
| interface | 产出 |
|---|---|
org.openvpi.otter.inference.Transcribe |
文本(ASR) |
- 音频处理。 otter 不解码、不重采样、不切片、不读写文件,不依赖 ffmpeg 或 libsndfile。输入是宿主已准备好的
floatPCM。 - 独立程序。 与 wolf 相同,otter 是供编辑器或工具链接的库。
- 时间轴。 otter 一律输出绝对秒,不涉及 tick、BPM 与变速曲。
lite 此前经 srt-audio、srt-extract 与 plugins/Extract/{rmvpe,game} 抽参。otter 对其各部分的处置如下:
| refactor | otter | 说明 |
|---|---|---|
lib/Audio(约 1700 行,ffmpeg/swresample) |
丢弃 | 解码与重采样归宿主(lite 中为 talcs) |
lib/Audio/Slicer(约 230 行,RMS 切片) |
交由宿主 | 见 A7 |
lib/Extract/AudioPreprocessor |
丢弃 | 其职能仅为重采样与切片,二者均归宿主 |
lib/Extract/{Pitch,Midi}Extractor 接口 |
重写为契约 | 见第 5 章 |
lib/Extract/ExtractorDriver |
丢弃 | InferenceDriver 是 RuntimeService,可直接获取(A3) |
plugins/Extract/rmvpe(约 350 行) |
移植 | 保留推理与 interpF0 逻辑,重写外壳 |
plugins/Extract/game(约 1030 行) |
移植并补回 | 保留四阶段推理;补回 refactor 遗漏的 dur2bd session(A12) |
| 模型为磁盘上的裸文件 | spec 2.4 Package | 见第 6 章 |
| 输出 tick(480 PPQ,单一 tempo) | 绝对秒 | 见 A5;同时修正下述 tick 换算缺陷 |
refactor 中的已知缺陷,移植时一并修正:
GameExtractor缺少dur2bdsession。 旧GameModel包含该 session,用于将已知音符时长转换为边界并送入 segmenter;refactor 迁移时未保留该 session,known_boundaries/prev_boundaries因此恒为全零。模型具备对齐能力,而代码未使用该能力。language读取后未生效。GameExtractor::open()在config.contains("languages")分支中直接赋值m_language = 0。- tick 按单一 tempo 换算。 lite 传入
timeline.tempoAt(0),变速曲中的音符位置因此系统性偏移。 uv名实不符。 refactor 的注释为「true=浊音 voiced」,字段名却为uv(unvoiced)。otter 将其改名为voiced,语义为 1 表示浊音(A6)。
srt::InferenceSpec::createInference(importOptions, runtimeOptions) 是公开方法,只要求模块已载入、两份选项与模块的 (interface, level, variant) 一致,不要求该模块被其他模块 import (synthrt/include/synthrt/SVS/InferenceContrib.h、lib/SVS/InferenceContrib.cpp)。因此宿主可以直接在顶层创建分析器,方式与 wolf 的测试直接创建 G2P/S2P 执行体相同。每份契约头文件提供 createAnalyzer(spec),该函数用模块自身的 variant 构造该契约的两份选项,调用 createInference,再以 dynamic_cast 检查并转换为该契约的执行体;解释器返回其他契约的执行体时报 AnalysisError::ContractViolation(A30 修订:原报 Internal)。其他模块仍可以用普通的 imports 条目(options 为空)引用分析器,此时框架以相同方式代为创建分析器。
分析器与 wolf 的 G2P/S2P 共用 inference 类别,插件安装在 lib/plugins/otter/inferenceinterpreters,嵌入 synthrt 的 InferenceInterpreterPlugin::IID。安装后的 CMake 包以 OTTER_PLUGINS_DIR 导出插件树的位置。宿主调用 setPluginPaths(srt::InferenceCategory::NAME, …) 时必须将该目录与 dsinfer、wolf 的同名目录一并给出,因为该调用替换而非追加路径。
ds::InferenceDriver 派生自 srt::RuntimeService(dsinfer/include/dsinfer/Inference/InferenceDriver.h),任何解释器都能经 spec.package().synthUnit().runtimeService(ds::InferenceDriverPlugin::IID, "onnx") 取得该驱动,wolf 的 multig2p-onnx 即采用此方式。因此分析模块直接运行 ONNX 模型,无须再 import 另一个 inference 模块。
otter (库,链接进宿主)
├── Analysis/AnalysisExecutive.h AnalysisExecutive(派生 srt::InferenceExecutive)与两类选项的基类
├── Analysis/AnalysisError.h 取消、工作线程失败、内部错误、契约违规、模型失败与模型不匹配的错误类别(A18、A28、A30)
├── Analysis/Provider/ 提供者面:解释器实现者在树内或作为第三方变体读取的头,契约头也按值包含其中的 AnalysisTask.h(A31)
│ ├── AnalysisInterpreter.h 解释器抽象(派生 srt::InferenceInterpreter,拒绝非空 import options)
│ ├── AnalysisTask.h 执行生命周期:建于 srt::ITask 之上的任务面(A15 及补记),
│ │ 非模板基类 AnalysisTaskBase 在库内实现(A28)
│ └── AnalysisInput.h 输入校验:prepareSamples()、chooseKnob()(第 9.5 节)
├── Support/ 私有,不安装,编译进静态库 otter_support(A28)
│ ├── ManifestValues.h 声明读取器,库与各插件共用
│ ├── F0Curve.h 清音插值 interpolateUnvoiced()
│ └── KnownNotes.h knownNotes 检查 checkKnownNotes()
└── Api/
├── Common/1/CommonApiL1.h AudioSegment、Knob / IntKnob / FlagKnob、ProgressCallback
├── F0/1/F0ApiL1.h org.openvpi.otter.inference.F0 契约面
├── Note/1/NoteApiL1.h org.openvpi.otter.inference.Note 契约面
└── Align/1/AlignApiL1.h org.openvpi.otter.inference.Align 契约面
otter 插件(独立动态库,嵌入 srt::InferenceInterpreterPlugin::IID)
├── inferenceinterpreters/rmvpe (F0, 1, "rmvpe")
├── inferenceinterpreters/game (Note, 1, "game")
├── inferenceinterpreters/hfa (Align, 1, "hfa")
├── inferenceinterpreters/tifa (Align, 1, "tifa")
├── inferenceinterpreters/stub otterstub:F0 / Note / Align 三契约的测试桩,不依赖 dsinfer
└── inferenceinterpreters/onnx 私有静态库 otter_onnx_support:四个 ONNX 提供者共用的会话外壳(A28)
inference 类别不追加任何字段。宿主在设置界面列出已安装抽参器所需的信息(name、 interface、variant)均属于 spec 2.4 的公共字段,DataOnly 模式即可读取。宿主按 interface 从 inference 模块中筛选出 otter 的契约;新增契约只需增加头文件与解释器,不涉及任何类别。
SynthUnit::openPackage(...) // 装载模型包
└─ InferenceCategory 解析声明 → InferenceSpec
└─ 解释器(插件)读取 exports 与 configuration
宿主:
F0Api::readF0Schema(spec, variant) // 读取能力:采样率、帧间隔、旋钮域
F0Api::createAnalyzer(*spec->as<InferenceSpec>())
// = spec.createInference(F0ImportOptions(variant), F0RuntimeOptions(variant))
// → unique_ptr<F0Executive>
executive->start(input) / startAsync(input, cb)
executive->stop() / waitForFinished()
执行体必须在其 Package 释放前销毁,这与 ContribExecutive 的通用规则一致。
以下代码为契约的摘要形式,完整定义与注释以 include/otter/Api/ 下的头文件为准。
namespace otter::Api::Common::L1 {
/// 一段连续 PCM,由宿主准备。
struct AudioSegment {
/// 采样率(Hz)。必须等于模块 exports 声明的 sampleRate,否则执行失败。
int sampleRate = 0;
/// 声道数。多于模块 exports 声明的 channelCount 时降混,少于时拒绝。
int channelCount = 0;
/// 交错排列的样本,样本数必须为整帧。宿主移入,执行期间由 StartInput 持有。
std::vector<float> samples;
/// 该段音频在宿主时间轴上的起点(秒)。结果以此为锚点。
double startTime = 0;
/// 该段的时长(秒);无样本时为 0。
double duration() const noexcept;
};
/// 执行进度,取值 0 到 1。可以为空。返回 false 表示请求停止,执行体按其他停止请求的方式处理:以 AnalysisError::Cancelled 返回错误;请求到达前已产出结果的执行保留该结果。
using ProgressCallback = std::function<bool(double)>;
/// 一个连续旋钮的声明:本模块是否采用、取值域与默认值。
struct Knob {
bool honored = false;
double minimum = 0;
double maximum = 0;
double defaultValue = 0;
};
struct IntKnob {
bool honored = false;
int minimum = 0;
int maximum = 0;
int defaultValue = 0;
};
struct FlagKnob {
bool honored = false;
bool defaultValue = false;
};
}namespace otter::Api::F0::L1 {
inline constexpr char API_INTERFACE[] = "org.openvpi.otter.inference.F0";
inline constexpr int API_LEVEL = 1;
/// 本模块公开的能力。宿主据此准备音频、生成设置界面。
class F0Schema : public srt::ContribExports {
public:
/// 模型要求的输入采样率(Hz)。宿主负责重采样到该值。
int sampleRate = 0;
/// 模型要求的声道数。
int channelCount = 1;
/// 输出帧之间的时间间隔(秒)。RMVPE 为 0.01。
double interval = 0;
/// 单次执行接受的最长音频(秒)。0 表示无上限。
double maxSegmentDuration = 0;
/// 清浊判定阈值。
Common::L1::Knob voicingThreshold;
/// 是否对清音段的 f0 进行插值填充。
Common::L1::FlagKnob interpolateUnvoiced;
};
/// 读取声明的 exports 块。契约语法归接口所有,每个 variant 均经此函数读取,再各自核对
/// 自身模型能否满足。configuration 块归 variant 所有,其类型定义在各自的提供者中。
OTTER_EXPORT srt::Expected<std::unique_ptr<F0Schema>>
readF0Schema(const srt::ContribSpec &spec, std::string variant);
class F0ImportOptions : public otter::AnalysisImportOptions { /* 只标识契约与 variant */ };
class F0RuntimeOptions : public otter::AnalysisRuntimeOptions { /* 只标识契约与 variant */ };
/// 一次执行的输入。未填的旋钮取模块默认值。
class F0StartInput : public srt::TaskStartInput {
public:
static constexpr const char *API_INTERFACE = L1::API_INTERFACE;
static constexpr int API_LEVEL = L1::API_LEVEL;
Common::L1::AudioSegment audio;
Common::L1::ProgressCallback progress;
std::optional<double> voicingThreshold;
std::optional<bool> interpolateUnvoiced;
};
/// 一次执行的结果。覆盖整段输入,以绝对时间为锚点。
class F0Result : public srt::TaskResult {
public:
static constexpr const char *API_INTERFACE = L1::API_INTERFACE;
static constexpr int API_LEVEL = L1::API_LEVEL;
/// 第一帧对应的绝对时间(秒),等于输入的 startTime。
double startTime = 0;
/// 相邻帧的时间间隔(秒)。
double interval = 0;
/// 基频(Hz)。清音帧的取值由 interpolateUnvoiced 决定。
std::vector<float> f0;
/// 与 f0 等长;1 表示浊音。
std::vector<uint8_t> voiced;
};
/// 执行面由库实现(AnalysisTask,A15),提供者只实现 run()。停止后 start() 返回
/// AnalysisError::Cancelled,不返回部分结果(A18、A28)。
class F0Executive : public otter::AnalysisExecutive {
public:
using AsyncCallback =
std::function<void(srt::Expected<std::unique_ptr<F0Result>>)>;
srt::Expected<std::unique_ptr<F0Result>> start(const F0StartInput &input);
srt::Expected<void> startAsync(std::shared_ptr<const F0StartInput> input,
AsyncCallback callback);
// state / stop / waitForFinished 转发给任务面;持有模型会话的提供者覆盖 stop / waitForFinished。
protected:
virtual srt::Expected<std::unique_ptr<F0Result>> run(const F0StartInput &input) = 0;
bool cancelled() const noexcept;
};
/// 创建已载入模块的分析器;以 dynamic_cast 检查执行体属于本契约(A28)。
srt::Expected<std::unique_ptr<F0Executive>> createAnalyzer(srt::InferenceSpec &spec);
}namespace otter::Api::Note::L1 {
inline constexpr char API_INTERFACE[] = "org.openvpi.otter.inference.Note";
inline constexpr int API_LEVEL = 1;
/// 转写得到的一个音符。
struct NoteInfo {
/// MIDI 音高编号。
int key = 0;
/// 绝对起点(秒)。
double start = 0;
/// 时长(秒)。
double duration = 0;
/// 置信度,0 到 1。
double confidence = 0;
};
/// 已知音符,转写以其为条件(对齐模式)。
struct KnownNote {
double start = 0;
double duration = 0;
};
class NoteSchema : public srt::ContribExports {
public:
int sampleRate = 0;
int channelCount = 1;
/// GAME 为 60。宿主切片时不得超过该值。
double maxSegmentDuration = 0;
/// 本模块支持的语言,ISO 639-3 代码。为空表示不区分语言。
std::vector<std::string> languages;
/// 调用方未指定语言时使用的语言。languages 非空时必填,且必须属于 languages(A27)。
std::string defaultLanguage;
/// 是否接受 knownNotes。
bool supportsKnownNotes = false;
/// 音符边界的判定阈值。
Common::L1::Knob boundaryThreshold;
/// 相邻边界的最小间隔(秒)。
Common::L1::Knob boundaryRadius;
/// 音符置信阈值,送入模型。
Common::L1::Knob noteThreshold;
/// 输出筛选阈值:置信度低于该值的音符不出现在结果中。
Common::L1::Knob notePresenceCutoff;
/// 迭代步数。
Common::L1::IntKnob steps;
};
/// 读取声明的 exports 块,与 F0 相同。game variant 的 configuration(五个模型路径、timestep、
/// 语言编号映射、扩散起点)是该 variant 自有的类型,宿主不读取。
OTTER_EXPORT srt::Expected<std::unique_ptr<NoteSchema>>
readNoteSchema(const srt::ContribSpec &spec, std::string variant);
class NoteImportOptions : public otter::AnalysisImportOptions { /* 同 F0 */ };
class NoteRuntimeOptions : public otter::AnalysisRuntimeOptions { /* 同 F0 */ };
class NoteStartInput : public srt::TaskStartInput {
public:
Common::L1::AudioSegment audio;
Common::L1::ProgressCallback progress;
/// 语言标识,必须属于模块声明的语言。未填时取模块默认值。
std::optional<std::string> language;
std::optional<double> boundaryThreshold;
std::optional<double> boundaryRadius;
std::optional<double> noteThreshold;
std::optional<double> notePresenceCutoff;
std::optional<int> steps;
/// 已知音符,时间为宿主时间轴上的绝对秒(与 audio.startTime 同一基准,X3)。为空表示自由转写。
std::vector<KnownNote> knownNotes;
};
class NoteResult : public srt::TaskResult {
public:
/// 按起点升序排列,时间为绝对秒。
std::vector<NoteInfo> notes;
};
class NoteExecutive : public otter::AnalysisExecutive {
// 与 F0 同形:非虚的 start / startAsync,受保护的纯虚 run 与 cancelled;另有 createAnalyzer
};
}namespace otter::Api::Align::L1 {
inline constexpr char API_INTERFACE[] = "org.openvpi.otter.inference.Align";
inline constexpr int API_LEVEL = 1;
/// 某语言歌词的书写形式。
enum class LyricsForm {
Scheme, ///< 以该语言 scheme 书写的音节(如拼音),即同一语言与 scheme 的 wolf G2P 的输出
Text, ///< 该语言的普通文字,由模块内部转换
};
/// 模块支持对齐的一门语言(A27)。
struct LanguageInfo {
std::string language; ///< ISO 639-3 代码
std::string scheme; ///< 结果中音素的 scheme,采用 wolf 的命名
LyricsForm lyrics = LyricsForm::Scheme;
std::vector<std::string> phonemes; ///< 结果中可能出现的音素(不含静音与非语音标签)
};
/// 对齐得到的一个音素。
struct PhoneInfo {
std::string text;
double start = 0;
double duration = 0;
};
/// 对齐得到的一个词:来自调用方经 lyrics 给出的词,或模块自行插入的段
/// (静音使用 silenceLabel,非语音使用 nonSpeechPhonemes 中的标签)。
struct WordInfo {
std::string text;
double start = 0;
double duration = 0;
std::vector<PhoneInfo> phones; ///< 按顺序排列,恰好覆盖该词
};
class AlignSchema : public srt::ContribExports {
public:
int sampleRate = 0;
int channelCount = 1;
/// 单次执行接受的最长片段(秒)。对齐模型编码超长音频会直接失败,因此该值是硬约束。
double maxSegmentDuration = 0;
/// 支持对齐的语言。同一语言可以以不同 scheme 出现多次;同一 (language, scheme) 只能出现一次。
std::vector<LanguageInfo> languages;
/// 调用方未指定语言时使用的语言。languages 非空时必填,且必须属于 languages。
std::string defaultLanguage;
/// 模块能自行检测的非语音标签(变体自有的拼写,如气口)。
std::vector<std::string> nonSpeechPhonemes;
/// 调用方未指定时检测的非语音标签,为 nonSpeechPhonemes 的子集。
std::vector<std::string> defaultNonSpeechPhonemes;
/// 代表静音的词的标签。为空表示本模块不为静音命名,也不将静音作为词报告。
std::string silenceLabel;
Common::L1::Knob nonSpeechThreshold; ///< 判定非语音的帧概率阈值
Common::L1::Knob nonSpeechMinDuration; ///< 报告的最短非语音段(秒)
Common::L1::Knob gapFill; ///< 短于该值(秒)的词间空隙并入相邻词,而非留作静音
};
OTTER_EXPORT srt::Expected<std::unique_ptr<AlignSchema>>
readAlignSchema(const srt::ContribSpec &spec, std::string variant);
class AlignStartInput : public srt::TaskStartInput {
public:
Common::L1::AudioSegment audio;
Common::L1::ProgressCallback progress;
/// 语言标识,必须属于模块声明的语言。未填时取 defaultLanguage。
std::optional<std::string> language;
/// language 的 scheme。模块对该语言只声明一个 scheme 时可以不填;声明了多个时必填。
std::optional<std::string> scheme;
/// 所唱的文本,以空格分隔,写法由所用语言与 scheme 的 LanguageInfo::lyrics 规定。必填,为空即拒绝。
std::string lyrics;
/// 要检测的非语音标签。为空时取 defaultNonSpeechPhonemes。含模块未声明的标签即拒绝,不静默忽略。
std::vector<std::string> nonSpeechPhonemes;
std::optional<double> nonSpeechThreshold;
std::optional<double> nonSpeechMinDuration;
std::optional<double> gapFill;
};
class AlignResult : public srt::TaskResult {
public:
/// 本次执行实际使用的语言与音素 scheme。
std::string language;
std::string scheme;
/// 按起点升序排列,绝对秒。silenceLabel 非空时覆盖整段:首词起于音频起点,末词止于音频终点,
/// 未归入任何词的空隙成为以 silenceLabel 为标签的词。silenceLabel 为空时,词可以不覆盖整段,
/// 宿主按音频边界补齐。模块可以不支持空标签,此时它拒绝执行(加载期或执行期报 InvalidFormat),而不是返回词不覆盖整段的结果,宿主按该错误处理。
std::vector<WordInfo> words;
};
class AlignExecutive : public otter::AnalysisExecutive {
// 与 F0 / Note 同形:start / startAsync / state / stop / waitForFinished / run / cancelled
};
}Align 契约与另外两份契约在以下三处取舍不同:
lyrics为必填项。 强制对齐的任务是在已知文本的前提下求时间,缺少文本则该次执行无意义。若将「未给歌词时按转写执行」设为默认分支,就相当于在同一份契约中并入另一份契约,即预留的Transcribe。- 语言由 ISO 代码与附加信息共同描述。 每门语言附带音素所用的 scheme、歌词写法(
scheme:以该 scheme 书写的音节,即同一语言与 scheme 的 wolf G2P 的输出;text:普通文字,由模块自行转换)以及结果中可能出现的音素表(A27)。调用方可以另行指定scheme,结果回写实际使用的language与scheme。模型自身的语言代码不进入契约,其映射位于configuration.languages(A11)。变体若声明了语言,每门语言都必须有词典,并在加载期校验(A21)。 - 静音与非语音均作为词返回。 契约只有
WordInfo一种段。静音使用silenceLabel,气口使用nonSpeechPhonemes中的标签,宿主按标签过滤即可,无须从空隙反推。silenceLabel为空表示该模块不为静音命名,此时结果不保证覆盖整段;这一差异由声明体现,而非由结果体现。本仓的scripts/check-declarations.py(:451-453要求 Align 声明该标签、:419-421要求它非空)在打包装配后运行(scripts/make-package.py),因此本仓出厂的 Align 包恒为非空,「空标签」这条路径只对跳过该 lint 的第三方变体有意义。
spec 2.4 将声明文件分为三层,三类内容分别对应:
| 内容 | 落点 | 读取方 | 示例 |
|---|---|---|---|
| 宿主需要理解的能力与旋钮域 | exports → Schema |
宿主 | sampleRate、maxSegmentDuration、boundaryThreshold 的取值域与默认值 |
| 仅解释器关心的实现参数 | configuration → 变体的配置类型 |
解释器 | 模型路径、timestep、语言编号映射、扩散起点 |
| 每次执行的可调值 | StartInput |
调用方填写 | boundaryThreshold = 0.3 |
判据沿用 spec 2.4 §《何时递增 Level》:导入方是否需要理解该内容。timestep 改变后模型无法正确运行,它描述模型本身而非可调参数,因此属于 configuration;boundaryThreshold 的松紧由用户直接感知,因此进入契约。
旋钮一律为 std::optional,未填时取模块默认值。将来增加旋钮时,不填写新旋钮的旧宿主行为不变(A9)。
抽参模型按 spec 2.4 打包为 Package,与声库包共用同一套 SynthUnit 与搜索路径(A13)。
+ otter-rmvpe
+ inferences
+ f0
- inference.json
- desc.json
- rmvpe.onnx # 图,约 345 MB
模型位于包根,声明从 inferences/f0/ 引用模型。规范 2.4 的相对路径以声明文件自身所在的目录为基准,因此写作 ../../rmvpe.onnx;写作 ./rmvpe.onnx 则指向声明文件所在的目录。
desc.json:
{
"$version": "1.0",
"id": "otter/rmvpe",
"version": "0.1.0.0",
"compatVersion": "0.1.0.0",
"runtimeLevel": 1,
"contributions": {
"inference": [
{ "id": "f0", "path": "./inferences/f0/inference.json" }
]
}
}inference.json:
{
"interface": "org.openvpi.otter.inference.F0",
"level": 1,
"variant": "rmvpe",
"name": "RMVPE",
"exports": {
"sampleRate": 16000,
"channelCount": 1,
"interval": 0.01,
"maxSegmentDuration": 60,
"knobs": {
"voicingThreshold": { "minimum": 0.0, "maximum": 1.0, "default": 0.03 },
"interpolateUnvoiced": { "default": true }
}
},
"configuration": {
"model": "../../rmvpe.onnx"
}
}GAME 的结构相同:contributions.inference 含一项,interface 为 org.openvpi.otter.inference.Note, exports 另含 languages(ISO 639-3 列表)、defaultLanguage、supportsKnownNotes 与五个旋钮, configuration 列出四到五个 session 的路径(durationToBoundary 可选)、timestep、语言标识到模型编号的映射,以及可选的 scheduleStart。三个契约的 exports 与 imports[].options 各有一份 JSON Schema,位于 docs/schemas/。
HFA 是第一个将模型自带文件一并打包的变体(A21):图、前端配置、词表与三本词典同置于包根,由 inference.json 的 configuration 引用。
+ otter-hfa
+ inferences
+ align
- inference.json
- desc.json
- model.onnx # 图,约 415 MB
- config.json # mel_spec_config:44100 Hz、hop 441
- vocab.json # 音素类、非语音类、词典名表
- ds-zh-pinyin-lite.txt
- ds_cmudict-07b.txt
- japanese_dict_full.txt
"configuration": {
"model": "../../model.onnx",
"config": "../../config.json",
"vocab": "../../vocab.json",
"languages": { "cmn": "zh", "eng": "en", "jpn": "ja" }
}
TIFA 是第二个将模型自带文件一并打包的变体(A29):五张图(合计约 160 MB)与导出器写出的 config.json、vocabulary.json 由上游产出,模型的四本「书写单位 → 音素」词典随包安装,inference.json 的 configuration 以十一处路径与一张语言映射指向它们。这十一处模型文件与声明都不入库;装配时用 scripts/make-package.py --variant tifa --declarations <声明目录> --models <模型目录> 把模型复制到声明写的位置。
+ otter-tifa
+ inferences
+ align
- inference.json
+ dictionaries
- ds-zh-pinyin-lite.txt # cmn
- jyutping_dict.txt # yue
- japanese_dict_full.txt # jpn
- ds_cmudict-07b.txt # eng
- desc.json
- spectrogram.onnx # 音频前端:log-mel 与 maskT
- model.onnx # 相似度与 token logits(进图两次)
- prepare.onnx # 打分模板
- score.onnx # 片段代价
- select.onnx # 按候选选择选路径
- config.json # 导出器写的:48000 Hz、hop 480、fft/win 2048、mel 80
- vocabulary.json # 符号 → id
"configuration": {
"spectrogram": "../../spectrogram.onnx",
"model": "../../model.onnx",
"prepare": "../../prepare.onnx",
"score": "../../score.onnx",
"select": "../../select.onnx",
"config": "../../config.json",
"vocabulary": "../../vocabulary.json",
"dictionaryCmn": "../../dictionaries/ds-zh-pinyin-lite.txt",
"dictionaryYue": "../../dictionaries/jyutping_dict.txt",
"dictionaryJpn": "../../dictionaries/japanese_dict_full.txt",
"dictionaryEng": "../../dictionaries/ds_cmudict-07b.txt",
"languages": { "cmn": "zh", "yue": "yue", "jpn": "ja", "eng": "en" }
}
四个变体的声明不入库(一个变体一个目录),版本均为 0.1.0.0(inferences/ 布局, A26);模型文件同样不入库,随各自的包发布。装配由 scripts/make-package.py 完成,声明目录由 --declarations 给出(缺省指仓根下的 packages/,该目录已移出 git 跟踪)。库版本、包版本与 manifest.json 的 bundleVersion 同源,唯一权威是 CMakeLists.txt 的 project(... VERSION ...):脚本从那里读,声明 lint 用它核对声明(A31)。发布标签 models-v0.1 提供前述四个包:一份列出四者的 manifest.json 与由该脚本生成的四个 zip。
按 spec 2.4 §3,插件嵌入 IID org.openvpi.synthrt.plugin.InferenceInterpreter,同目录下有 plugin.json:
{
"name": "otterrmvpe",
"interpreters": [
{ "interface": "org.openvpi.otter.inference.F0", "level": 1, "variant": "rmvpe" }
]
}game 与两个对齐变体结构相同(ottergame 对应 org.openvpi.otter.inference.Note,otterhfa 与 ottertifa 对应 org.openvpi.otter.inference.Align)。测试桩 otterstub 以 variant stub 同时声明三份契约。
插件的 CMake 目标由 otter_add_interpreter_plugin 声明(A28):otterrmvpe 与 ottergame 各只有 main.cpp;otterhfa 另有 Aligner、G2p、NonSpeech 三对源文件;ottertifa 的源文件为 tifa/main.cpp、tifa/Decode.{h,cpp}、tifa/Grid.{h,cpp} 与 tifa/Scoring.{h,cpp},统一链接 otter、otter_support、otter_onnx_support 与 dsinfer::dsinfer。各插件不再各有一份 CMakeLists,声明集中在 inferenceinterpreters/CMakeLists.txt。
宿主将 otter 的 inferenceinterpreters 目录加入 inference 类别的插件搜索路径: SynthUnit::setPluginPaths(srt::InferenceCategory::NAME, {…dsinfer, wolf, otter…})。
otter 将以下三项职责明确留给宿主,三者缺一不可:
- 重采样。 宿主读取
Schema::sampleRate/channelCount,将音频转换为该格式。RMVPE 要求 16000 Hz 单声道,GAME 与 HFA 要求 44100 Hz 单声道,TIFA 要求 48000 Hz 单声道。各算法要求不同,宿主必须采用模块声明的值,不得写死。执行体校验输入,不匹配即失败,不会自行重采样。 - 切片。 宿主以 RMS 静音切片将音频分段,每段不超过
Schema::maxSegmentDuration,逐段调用,并为每段正确填写audio.startTime。otter 不切片(A7)。 - 时间轴换算。 otter 输出绝对秒,宿主按自身的时间轴换算为 tick,变速曲由宿主处理。
在 lite 中,重采样由 talcs 完成;RMS 切片器取自 lite 历史中已删除的 src/libs/audio-util(该目录与 game-infer、rmvpe-infer 在同一次提交中被删除),现位于 Modules/Extractors/AudioSlicer.h,详见 lite-integration.md。三个算法共用一套切片参数(A8)。
每条决策记录决策内容、依据与代价。已被取代的决策保留原文,并在标题下注明取代它的决策;现状以取代方为准。
已被 A26 取代。 本条的前提与实现不符,otter 现不注册自有类别。以下内容仅作历史记录。
决策:otter 注册新贡献类别 analysis,并定义自有的 ContribSpecExtension 作为顶层执行体入口。
依据:ContribSpec 无公开的执行体入口;ContribCategory::createExecutiveFactory() 只接受 ContribImportBinding(synthrt/include/synthrt/Core/ContribCategory.h);框架唯一的顶层入口是 SingerPipelineExtension(SingerPipelineExecutive.h)。抽参器不被任何模块 import,因此只能自建入口;而扩展点的 traits 以 spec 类型为键,因此需要自有的 spec 类型,也就需要自有的类别。
备选与否决理由:
- 将抽参器实现为
inference贡献:当时认为InferenceSpec同样只能经 import 创建执行体,宿主无法获得顶层入口。否决。(A26 表明这一判断有误。) - 让抽参模块 import 一个
inference模块(wolf linguist 的做法):wolf 需要该结构,是因为 linguist 是 G2P/S2P/Onset 的组合;抽参器只是单个模型,多一层间接只会增加包结构的复杂度。且 A3 表明获取驱动不需要这一层。否决。
代价(已接受):宿主需要多配置一条 analysis 的插件搜索路径。
已被 A26 取代。
analysis类别已删除;「不追加字段」的结论对 inference 类别同样成立(第 4.3 节)。
决策:类别层不追加任何字段,只使用 spec 2.4 的公共字段。
依据:类别追加字段的意义在于「在选定解释器之前就需要读到」(spec 2.4《模块》三层表)。宿主在设置界面列出已安装抽参器时需要的是 name / interface / variant,均为公共字段。
代价(已接受):宿主无法在 DataOnly 模式下读取采样率等能力,这些能力位于 exports 中,需要 Load 之后才可读取。抽参器数量少,宿主本就全部装载,因此该代价可以接受。
决策:解释器经 spec.package().synthUnit().runtimeService(ds::InferenceDriverPlugin::IID, "onnx") 获取 ONNX 驱动。
依据:ds::InferenceDriver 派生自 srt::RuntimeService(dsinfer/include/dsinfer/Inference/InferenceDriver.h),与类别无关;wolf 的 multig2p-onnx 插件已采用此做法。refactor 的 srt::extract::getInferenceDriver(runtime) 属于 refactor 的抽参子系统,随 srt-core 一同丢弃。
已被 A26 取代。 四份契约现同属 synthrt 内置的
inference类别,仍以interface区分。
决策:四份契约同属 analysis 类别,以 interface 区分。
依据:spec 2.4 规定「输入输出已根本不同时另起一份 interface」,而非另起一个类别;类别的扩展点用于「需要一种全新的模块」。四者都由模块声明、解释器与顶层执行体构成,结构相同。
代价(已接受):宿主枚举 analysis 贡献时需要按 interface 筛选。
决策:所有对外时间量均为 double 秒,包括 NoteInfo::start / duration、F0Result::interval / startTime、AudioSegment::startTime、boundaryRadius。契约中不出现 tick,也不出现毫秒。
依据:dsinfer 的 L1 契约全部采用秒(CommonApiL1.h 中的 InputNoteInfo::duration、 InputParameterInfo::interval、InputPhonemeInfo::start)。同一宿主中并存两套时间单位会造成长期负担。此外模型本身产出秒(GAME 的 durations 以秒为单位),整数化会引入累加漂移,refactor 的 tick 路径正是因此产生偏移。
备选与否决理由:
- 整数毫秒:便于跨语言绑定传递,但逐音符累加时 1 ms 量化在长句上产生漂移,且与 dsinfer 契约口径不一致。否决。
- 音符用毫秒、f0 用秒:同一套契约使用两种单位,增加使用方的记忆负担。否决。
决策:字段名为 voiced,类型为 uint8_t,1 表示浊音。
依据:refactor 的字段名为 uv(unvoiced),注释却写「true=浊音 voiced」,名实相反,是明确的缺陷诱因。std::vector<bool> 同时改为 std::vector<uint8_t>:前者的位压缩特化不能取元素地址,也不能安全地跨线程按元素写入。
决策:执行体只接受一段连续音频,不做静音切片。宿主负责分段并逐段调用。Schema 声明 maxSegmentDuration 作为硬约束。
依据:该分工由需求方规定。切片所需的信息(编辑区域、可见范围、用户选区)由宿主掌握,宿主的切片更准确;otter 也因此完全不需要音频侧代码。
代价(已接受):Schema 不声明切片参数。若将来某个算法确实需要不同的切片参数,将其加入 exports 词汇需要递增 Level。本方案接受该代价,不为当前明确不读取这些参数的宿主预留词汇。
决策:宿主使用一套切片配置,不按算法区分。
依据:换算为时间后,两套参数几乎相同:hopSize 均为 10 ms(16 kHz 下 160、44.1 kHz 下 441), winSize 均为 40 ms,minInterval 均为 300 ms,maxSilKept 均为 500 ms,阈值均为 0.02;仅 minLength 不同(RMVPE 5 s,GAME 2 s)。因此采用一套参数,GAME 与 HFA 的 60 s 上限由宿主保证不被超过。
决策:StartInput 中每个旋钮为 std::optional<T>;未填写时解释器采用声明 exports.knobs 中的默认值(A17 起默认值与取值域一同归契约)。Schema 同时声明本模块是否采用该旋钮、取值域与默认值;对于模块未采用的旋钮,调用方给出的值被忽略。
依据:与 dsinfer 的 AcousticStartInput::depth 加 AcousticConfiguration::useVariableDepth 做法相同。宿主可以依据 Schema 自动生成设置界面;新算法增加旋钮时,不填写该旋钮的宿主行为不变。
备选与否决理由:
- 开放的 JSON 字典:新算法增加参数时宿主无须修改,但完全丧失编译期类型安全,且不同算法可能以不同键名表示同一参数,契约因此失去约束力。否决。
- 变体私有参数一律固定在
configuration中,用户不可调:契约最小、最稳定,但用户调整阈值需要重新发布模型包,与「参数尽量可调」的需求冲突。否决。
决策:
| 契约 | 旋钮 | refactor 中对应的硬编码 |
|---|---|---|
| F0 | voicingThreshold |
RMVPE RmvpeExtractor.cpp 中的 0.03f |
| F0 | interpolateUnvoiced |
RMVPE 无条件调用的 interpF0() |
| Note | boundaryThreshold |
seg_threshold,送入 segmenter 的 threshold |
| Note | boundaryRadius |
seg_radius_seconds,除以 timestep 得到 radius 帧数 |
| Note | noteThreshold |
est_threshold,送入 estimator 的 threshold |
| Note | notePresenceCutoff |
buildMidiNotes 中的 presence > 0.5f |
| Note | steps |
generateD3pmTs() 中写死的 n_steps = 8 |
| Note | language |
config.json 的 languages(改为字符串标识) |
| Note | knownNotes |
segmenter 的 known_boundaries / prev_boundaries |
依据:清单覆盖两个插件送入 ONNX session 的全部输入(segmenter:x_seg、maskT、 known_boundaries、prev_boundaries、language、threshold、radius、t;estimator: x_est、boundaries、maskT、maskN、threshold;RMVPE:waveform、threshold)。t(扩散时间步)由 steps 与 configuration 的 scheduleStart 生成,不整表公开。
实现时的修正:t 属于批次维而非步数维。真实导出模型的每个 segmenter 输入共享同一个批次维, t 也不例外,因此一次调用只携带一个时间步,采样循环由调用侧实现,每一步将上一步的输出作为 prev_boundaries 送回。三个阈值类输入(segmenter 的 threshold / radius、estimator 的 threshold)是标量,秩不符时被拒绝而不是广播。estimator 的 presence 输出为布尔张量,按张量自身的元素类型读为取值 1 或 0 的置信度,因此 notePresenceCutoff 的 0.5 仍能区分两类。
保留在 configuration 中的内容:timestep、scheduleStart、语言编号映射、各 session 路径。这些内容改变后模型无法正确运行,描述的是模型本身。sampleRate 与旋钮默认值按 A17 归 exports。
决策:契约中的 language 是字符串标识,按 A27 为 ISO 639-3 代码(如 "cmn"、"jpn");模型内部的整数编号由 game 变体 configuration 的 languages 映射。
依据:整数编号是单个模型的内部约定,两个模型的编号 1 未必表示同一种语言,而契约词汇必须跨变体稳定。dsinfer 的 AcousticConfiguration::languages(std::map<std::string, int>)采用相同做法。
决策:NoteStartInput::knownNotes 非空即为对齐模式;解释器经 dur2bd 将已知时长转换为边界,送入 segmenter 的 known_boundaries。NoteSchema::supportsKnownNotes 声明本模块是否接受已知音符。
依据:该能力由模型提供:旧 GameModel 包含 runDur2bd 与 InferenceInput::known_durations; refactor 的 GameExtractor 迁移时遗漏了该 session,inferSlice 中的 knownBoundaries 恒为全零。移植时按旧实现补回。
代价(已接受):ASR 与音素级强制对齐不经此路径实现,二者分别属于独立的 interface:强制对齐已由 A19 立为 Align 契约,ASR 为预留的 Transcribe。
决策:宿主使用同一个 SynthUnit、同一套 Package 搜索路径装载两类包,按类别与接口区分。
依据:spec 2.4 的 Package 体系本身是统一的;使用两个 unit 需要维护两套生命周期,且抽参模型将来可能被声库包的 dependencies 引用(例如某声库自带专用的音高模型)。
代价(已接受):SynthUnit::loadedPackages() 同时包含两类包,宿主枚举时需要按贡献的类别与接口筛选。
已被 A26 取代。 扩展机制与
AnalysisProvider已随analysis类别一并删除。以下内容仅作历史记录。
决策:AnalysisProvider 的构造函数接收其服务的 (interface, level, variant),createExtensions 声明为 final,先以 serves() 检查,再委托给 createAnalysisExtension()。
依据:PackageLoader 在挂载 spec 扩展时(synthrt/lib/Core/PackageLoader.cpp 中的 attachSpecExtensions)将每一个已注册解释器的 createExtensions 作用于每一份正在加载的声明。这是 spec 2.4 的有意设计,使其他类别可以为新 role 提供执行体。无条件接受的提供者会将扩展挂满整个包,宿主向某份声明索取其不具备的契约时,得到的是一个不属于该声明的执行体,而不是 nullptr。
代价(已接受):提供者不能自行决定扩展的挂载条件。确有需要时可以改为受保护的钩子,但须等到出现实际需求。
发现途径:当时的 test_AnalysisLoad 用例断言 f0 声明上不存在 Note 扩展;两个提供者当时均将扩展挂满整个包。
决策:执行的认领与上一次取消标志的清除在调用方线程上完成;工作线程只执行函数体,不清除标志。同步与异步两个入口均先完成认领。
依据:若在工作线程开始执行时清除标志,异步入口会出错:stop() 可能在 startAsync() 返回之后、工作线程进入函数体之前到达,该次取消会被清除。测试中该情形可稳定复现。
代价(已接受):增加一个类型。否则三个执行体须各自实现同一段并发代码,而该代码的错误不会产生任何可见症状。
补记(2026-09-13):最初的实现类 AnalysisRunner 与 srt::ITask 在状态、取消标志、工作线程与等待四个方面重复,现已由任务面取代。A28 之后的结构为:导出的非模板基类 otter::AnalysisTaskBase 派生自 srt::ITask,覆盖 start() / startAsync() / stop() / waitForFinished();模板 otter::AnalysisTask<Input, Result> 只负责载荷类型转换。任务面保持本条决策的两条规则:认领与清除标志在调用方线程上进行;回调结束后才释放执行,回调内启动的下一次执行按代数接管 running 状态,而不是被当作并发执行拒绝。同步入口 start() 使用同一份 running 状态认领,因此 waitForFinished() 同时等待同步与异步执行。任务由契约类(F0Executive / NoteExecutive / AlignExecutive)持有,契约类实现 start / startAsync / state / stop / waitForFinished;提供者只实现受保护的 run(),需要同时停止模型会话的提供者覆盖 stop() / waitForFinished() 并先调用基类实现。X5 所述「建线程失败后永久拒绝执行」的缺陷在任务面中同样由 startAsync() 释放认领来避免。
决策:ProgressCallback 是 StartInput 的字段,而非 RuntimeOptions 的字段。
依据:进度是单次执行的属性,而非执行体的属性:同一执行体复用于多段音频时,每段的进度接收方可能不同。synthrt 主线没有进度回调的先例,此处为新增;置于单次调用的输入中代价最小。
接口名与扩展 ID 部分已被 A26 取代。 接口名现为
org.openvpi.otter.inference.*,扩展已删除;exports与configuration的分工仍然有效。
决策:采样率、声道数、帧间隔、段上限、语言标识、supportsKnownNotes 与全部旋钮域写在声明的 exports 中,由库中每份契约各一个的读取函数(readF0Schema / readNoteSchema / readAlignSchema)解析,并发布 JSON Schema(docs/schemas/)。configuration 只包含变体私有内容:rmvpe 的模型路径; game 的五个模型路径、timestep、语言编号映射与 scheduleStart;hfa 的模型、mel 配置、词表的路径与语言编号映射(词典由词表定位,见 A21)。变体在加载期将两块内容相互核对:模型无法支持的采样率、声明了但缺少模型的对齐路径、词表中缺少对应词典的语言,均在加载期拒绝。接口名当时改为 org.openvpi.otter.analysis.F0 / .Note / .Align,扩展 ID 改为 org.openvpi.otter.extension.*。
依据:spec 2.4 §「三个语法块的归属」规定,exports 语法归 interface + level、由导入方读取, configuration 全部归 variant;「需要参与跨模块契约的内容应由 exports 公开,而不是作为 configuration 中的契约字段」。若将契约事实放入各变体的 configuration 再派生 exports,每增加一个变体就要为同一批事实另立一套键,DataOnly 模式读不到能力,文档示例也与 lint 规则相反。hfa 作为第三个变体验证了这一分工:其 configuration 只含私有的文件路径与语言映射,没有重复任何契约事实。接口名采用与 org.openvpi.dsinfer.inference.*、org.openvpi.wolf.inference.* 一致的项目段写法,并在尚无已发布包时完成改名。
取代:F0Configuration / NoteConfiguration 两个接口头中的配置类型(改由变体各自定义),以及「声明不得写 exports」的 lint 规则。
决策:新增 otter::AnalysisError 及其 std::error_category(otter/Analysis/AnalysisError.h),初始取值为 Cancelled 与 NoWorker(A28 增加 Internal 与 ModelFailed,A30 拆分为 Internal / ContractViolation / ModelFailed / ModelMismatch)。被 stop() 中止的执行以 AnalysisError::Cancelled 返回错误,state() 为 Canceled;startAsync() 无法创建工作线程时返回 NoWorker。
依据:synthrt 的 Error.h 规定框架的错误码枚举不由上层库扩展,上层库应注册自有的类别。此前三个提供者以 InvalidArgument 表示取消,宿主只能依靠 state() 区分取消与失败;且 spec 2.4 要求契约规定其错误语义。契约的错误条件因此为本类别的取值加上框架的 InvalidArgument / InvalidFormat / FeatureNotSupported。
代价(已接受):增加一个头文件与一个 .cpp;宿主比较 code() 时需要识别一个额外的类别。
决策:新增 Align Level 1 契约(include/otter/Api/Align/1/AlignApiL1.h、 src/lib/Api/Align/1/AlignApiL1.cpp、docs/schemas/align-1-exports.schema.json),由 hfa 变体实现。接口名当时为 org.openvpi.otter.analysis.Align,按 A26 现为 org.openvpi.otter.inference.Align。 Align 自此从预留名单移入 scripts/check-declarations.py 的 IMPLEMENTED_INTERFACES,该脚本增加了 Align 声明的校验规则。
依据:Align 的输入与输出均不同于另两份契约:输入多出一段必填的文本(所唱内容),输出是词与音素两层区间,而非曲线或音高。并入 F0 相当于将词的时间视为另一条曲线,并入 Note 相当于将音素视为音符,两种方式都会使宿主按契约名读到结构不符的结果。该契约名已在 A4 中登记,本条给出其定义。
来源:hfa 方案(plans/hfa-align.md)的决策 D1,经用户确认。
决策:AlignStartInput::lyrics 为必填的单个字符串,为空即拒绝。文本写法由变体规定(hfa 要求其词典可读的写法:中文为拼音音节、英文为单词、日文为罗马字,以空格分隔;A27 起写法由 LanguageInfo::lyrics 声明),契约只规定这是一段传给变体的文本。
依据:强制对齐是在已知文本的前提下求时间,缺少文本则该次执行无意义。将其降级为「未给歌词时按 ASR 执行」相当于在同一份契约中再并入一份契约。文本写法归变体,是因为不同对齐器的词典所接受的写法本就不同,而宿主只需将其作为文本转发。
来源:hfa 方案的决策 D4,经用户确认。
决策:hfa 包将模型的 config.json(前端配置:采样率与帧移)、vocab.json(音素类、非语音类、词典名表)、三本词典与 model.onnx 一并发布;configuration 以 model / config / vocab 三个路径指向这些文件,以 languages 将宿主语言标识映射到模型自身的语言代码(两项默认值此后移入 exports,见 A27)。加载期核对以下各项:声明的采样率等于 config.json 的 mel_spec_config.sample_rate;每个声明语言在 vocab.json.dictionaries 中有条目,对应文件确实存在于词典位置,且词表中存在带该语言前缀的音素; nonSpeechPhonemes 属于 vocab.json.non_lexical_phonemes;silenceLabel 属于 vocab.json.silent_phonemes;defaultNonSpeechPhonemes 属于 nonSpeechPhonemes。
依据:按 A17 的分工,能力进入 exports,模型的位置与接入方式进入 configuration。这些文件描述的是模型自身的事实,而非包声明的事实,因此由变体读取,不发布为 schema。但它们与声明之间的每一处矛盾都会表现为「可以运行但结果错误」(采样率错误导致整体速度错误,词典缺失导致整段没有词),因此必须在加载期拒绝。scripts/check-declarations.py 对同一批事实执行同样的静态校验,使不合格的包在入库前即被拦下。
来源:hfa 方案的决策 D1、D3,经用户确认。
决策:AlignResult 只有 WordInfo 一种段。模块检测到的静音以 exports.silenceLabel 为标签、非语音以 exports.nonSpeechPhonemes 中的标签为标签,作为没有对应歌词的 WordInfo 返回。silenceLabel 声明为空表示该模块不为静音命名,此时结果不保证覆盖整段。
依据:宿主需要「该时段属于何种内容」这一层信息。若由宿主从词的间隙反推,就相当于丢弃对齐器的分类结果,再由每个宿主重新计算。非语音类别的集合是模型的事实,因此以声明中的标签公开,而不在契约中枚举。
来源:hfa 方案的决策 D4,经用户确认。
决策:nonSpeechThreshold(参考实现中为 0.5)、nonSpeechMinDuration(0.1 s)、gapFill(0.1 s)写入 exports.knobs 并进入 StartInput。参考实现中另外两个常数,即非语音扫描允许的最大中断 5 帧与 10 帧的最短段,保留在实现中,按帧长换算,不公开。
依据:A10 的判据为「调用方是否直接感知其效果」。前三个常量在参考实现中即为用户可见参数(气口检测的松紧、空隙是否并入词),后两个是同一次判定的内部形状,公开后只会增加两个不会被调整的旋钮。
来源:hfa 方案的决策 D1,经用户确认。
决策:hfa 不读文件、不解码、不重采样(A7 的规则),也不实现参考实现中「超过 60 秒自动切段后逐段拼接」的逻辑。maxSegmentDuration 声明为 60,超限由 otter::prepareSamples() 拒绝。
依据:切段跨越契约边界(每段的结果需要各自回到宿主的时间轴),自动切段出错的后果是不可见的边界错误;参考实现的该路径还包含一个 Python 版本的均值补齐变体,otter 中没有对应实现。宿主本就负责切片(A7),声明清楚上限即可。
来源:hfa 方案的决策 D1、D3,经用户确认。
发布内容已由 A26 与 A31 更新。 本条所述的 0.1.0.0 包采用旧布局,当前 otter 不再能够载入;A26 之后的 0.2.0.0 包曾由
models-v0.3.0.0提供(历史,该标签已删除),A31 起包版本回到 0.1.0.0,现行发布为models-v0.1(四个包与一份列出四者的manifest.json)。本条的发布原则(清单全量重写、新版本另开标签)仍然有效。
决策(历史):发布标签 models-v0.2.0.0,内含 otter-rmvpe-0.1.0.0.zip、otter-game-0.1.0.0.zip、 otter-hfa-0.1.0.0.zip 与一份列出三者的 manifest.json;前两份与 models-v0.1.0.0 中的文件逐字节相同(经 SHA512 核对后重新上传),第三份为新增。hfa 的权重取自 HubertFA v0.0.7(Apache-2.0),包内不附 LICENSE,来源与许可在 release notes 中说明。
依据:manifest.json 是宿主发现包的入口,只列出新包会使 models-v0.1.0.0 的既有用户失去前两个包;而同一标签下的资产不可修改,因此另开新标签。权重的许可与再分发条件属于事实问题,应在发布说明中写明,而不是不加说明地放入包内。
来源:hfa 方案的决策 D2、D3,经用户确认。
决策:撤回 A1、A2、A4、A14。F0 / Note / Align 是 synthrt 内置 inference 类别下的模块,接口名为 org.openvpi.otter.inference.{F0,Note,Align};解释器派生自 srt::InferenceInterpreter,插件嵌入 srt::InferenceInterpreterPlugin::IID,安装在 lib/plugins/otter/inferenceinterpreters;执行体派生自 srt::InferenceExecutive。宿主通过契约头文件中的 createAnalyzer(spec) 在顶层创建分析器,该函数调用公开的 srt::InferenceSpec::createInference。
依据:A1 的前提「InferenceSpec 只能经 import 创建执行体」与 synthrt 的实现不符。 InferenceSpec::createInference 是公开方法,只校验模块已载入、选项与模块的契约一致,不要求存在 importer;wolf 的测试即以此方式在顶层创建 G2P/S2P 执行体。A1 的另一项理由「扩展 traits 以 spec 类型为键,因此需要自有的 spec 类型」也随之失效,因为不再使用扩展。自有类别不追加字段(A2 的结论对 inference 同样成立),其唯一作用是为扩展提供作为键的 spec 类型。
得失:删除类别层(AnalysisContrib、AnalysisExtension、AnalysisProviderPlugin、 linkAnalysisCategory() 与静态注册、checkRuntimeOptions)后,宿主无须为链接器保留锚点,也无须另配一条类别路径;包只依赖 Runtime Level 1 的内置类别,结构与 wolf 的 G2P/S2P 相同。分析器也因此可以被其他模块 import。test_AnalysisContrib 断言链接 otter 不注册任何类别。代价是接口改名、包布局从 analyzers/*/analysis.json 改为 inferences/*/inference.json,当时三个包升级到 0.2.0.0 重新发布(历史),旧布局包在当前 otter 下不再能够载入。发布标签 models-v0.3.0.0 曾提供这三个 0.2.0.0 包(一份 manifest.json 与由 scripts/make-package.py 生成的三个 zip),models-v0.1.0.0、models-v0.2.0.0 中均为 0.1.0.0 的旧布局包,三个标签此后都已删除;A31 起包版本回到 0.1.0.0,现行发布为 models-v0.1,提供四个 0.1.0.0 的包与一份列出四者的 manifest.json。
决策:
- Note 与 Align 的语言一律为 ISO 639-3 代码(
[a-z]{3}),由契约的读取函数、linter 与 JSON Schema 三处强制校验。 - Align 的
languages每项为{language, scheme, lyrics, phonemes}:scheme采用 wolf 的命名与语法,表示结果中音素的写法;lyrics取scheme或text,规定宿主提供歌词的写法;phonemes为结果中可能出现的音素。同一(language, scheme)只能出现一次。AlignStartInput增加可选的scheme,AlignResult回写language与scheme。hfa 在加载期将phonemes与vocab.json中带该语言前缀的音素逐一比对,存在任何多余或缺失的音素即拒绝载入。 defaultLanguage(Note、Align)与defaultNonSpeechPhonemes(Align)从 configuration 移至 exports。列出语言时必须声明默认语言,且默认语言必须属于所列语言。该规则由 GAME 的变体规则上升为契约规则。
依据:
- 仅有 ISO 代码时,宿主无法确定歌词应采用的写法:hfa 的中文需要拼音,日文需要罗马字,英文则需要普通单词。
- scheme 与音素表相互独立:yousa 的
cmn-pinyin使用 opencpop-extension,hfa 使用 ds-zh-pinyin-lite。两者 615 个共同音节的映射一致,hfa 的音素表是前者的子集。宿主必须依据声明的音素表判断对齐结果能否直接用于某个歌手,而不能依赖假设。 - configuration 为变体私有,宿主不读取;而调用方未指定时采用的默认值正是宿主需要获知的信息,因此属于 exports(依据 A17 的分工)。
- Align 当时尚无宿主使用,且 A26 已进行不兼容的改名,因此一并定型,不另行递增 Level。
本条记录 2026-09-29 联合审计(硬编码、设计缺陷、代码异味)之后契约层面的变化。
决策:
otter/Support不再属于公共面:头文件不安装,读取器不从共享库导出,而是编译进私有静态库otter_support,由库与各插件各自静态链接。安装后的包中没有otter/Support,CI 的消费者工程(.github/consumer/CMakeLists.txt)断言这一点。- 停止语义保持不变并写入文档:执行检测到停止请求后
start()返回AnalysisError::Cancelled,不返回部分结果,state()为Canceled。这与 wolf linguist「停止后返回已完成部分、状态为Canceled」有意不同。 - 执行结束后的状态只由错误码决定:
Cancelled对应Canceled,其他错误对应Failed,有结果对应Succeeded,不再参考停止标志;工作线程在回调前也不再将状态改为Canceled。 AnalysisError新增Internal(执行体抛出异常、解释器在加载期建立的声明对象缺失、产出其他契约的执行体或结果)与ModelFailed(模型输出缺失、元素类型不符、尺寸与输入或其他输出不一致)。这两类错误此前报InvalidFormat,与「声明不合格」混为一谈。取消错误统一由otter::cancelledError()构造。srt::ITask的公开入口start()/startAsync()在类型转换前校验输入的type()/version()等于XxxStartInput::API_INTERFACE/API_LEVEL(各 StartInput 与 Result 类新增这两个静态常量),不符时报InvalidArgument,且在认领执行之前拒绝,不改变状态。类型化入口将结果转换回契约类型前同样校验,不符时报ContractViolation,结果为空时报Internal。createAnalyzer()以dynamic_cast取代static_cast,失败时报ContractViolation:第三方变体按设计可以提供解释器,其返回的执行体不能被假定属于本契约(A30 修订:结果不符与dynamic_cast失败原报Internal)。- hfa 变体要求
exports声明silenceLabel,并要求它既在词表的silent_phonemes中,也是词表vocab的一个类。词典展开歌词时以它作为词间分隔符,解码时将它的类作为静音类;该类的下标从词表读取,不再像参考实现那样假定为 0。模型自带的config.json、vocab.json与exports所列语言的词典在加载期由createConfiguration读取一次并由 configuration 持有,创建分析器时只打开图;只为exports声明的语言装载词典,configuration 额外映射的语言不再要求有词典。音频短于模型的一帧时以InvalidArgument拒绝,不再返回空结果。 - Note:调用方给出的语言必须属于
exports声明的语言(game 先查exports.languages,再查 configuration 的编号;桩变体在未声明任何语言时也拒绝,与头文件中「Must be one the module declares」一致)。knownNotes除原有的三条约束外,还不得结束于片段之后;game 与桩经同一个私有检查函数(otter::support::checkKnownNotes),在任何模型运行之前拒绝。 - F0 的清音插值移入私有库(
otter::support::interpolateUnvoiced),由 rmvpe 与桩共用:锚点为频率大于 0 的浊音帧,频率为 0 Hz 的浊音帧既不作锚点也不被改写,其两侧照常插值(此前会使其后的整段清音保持模型原值);插值单遍完成,不再对每个清音帧重新扫描整段。 otter::prepareSamples()的返回类型由std::vector<float>改为otter::PreparedSamples:单声道片段以调用方缓冲区的视图返回,不再整体复制;多声道片段仍平均到自有缓冲区。视图的有效期与AudioSegment相同。- linter 与加载器对齐,原则是「加载器拒绝的声明,linter 必须拒绝」:补齐变体事实(rmvpe 16000 Hz / 0.01 s、 game 44100 Hz、三个变体均为单声道)、hfa 的
configuration.languages必填、config.json的hop_size与vocab.json的三个必需键、silenceLabel必须声明且是词表的类、game 的timestep > 0与scheduleStart ∈ [0, 1]、整数上界 2^31−1、包 id 与贡献 id 的文法、版本号各段不超过 int,以及写给本包分析器的imports[].options非空即拒绝;相对路径中的\按/解析。版本文法与加载器一致,为 1 至 4 段,不要求四段(已发布的包使用四段,但加载器不作要求)。自测逐条覆盖上述拒绝规则,并断言键表与docs/schemas一致;三份 exports JSON Schema 为sampleRate、channelCount与整数旋钮补上与读取器一致的上界。make-package.py改用 linter 的 JSON 读取器与MODEL_KEYS,分块写入,不再因文件名相同而使伴随文件相互覆盖;同一输入两次打包的结果逐字节一致。 AnalysisTask<Input, Result>的线程与状态逻辑移入导出的非模板基类otter::AnalysisTaskBase(在库内实现,不再在每份契约中各实例化一份),模板只保留载荷的类型转换。基类的受保护虚函数startWorker()负责启动工作线程,可被覆盖以测试建线程失败的路径。线程语义不变。- 三个 ONNX 提供者重复的会话外壳(stop / wait 转发、取消翻译、驱动获取与会话创建、模型调用与输出检查、
readFloats、路径读取、波形张量、插件守卫)移入私有静态库otter_onnx_support;插件的 CMake 目标由函数otter_add_interpreter_plugin声明,桩插件改名为otterstub。桩实现 Align 契约,使 Align 读取器在不含 dsinfer 的构建中也有测试覆盖。 - 以上改动不改变当时三个出厂包的声明(历史):随
models-v0.3.0.0发布的otter-rmvpe、otter-game、otter-hfa照常载入,版本保持 0.2.0.0;A31 起包版本回到 0.1.0.0,现行发布为models-v0.1下的四个包。
依据:
- 读取器是库与插件之间的实现细节,此前却随包安装,并作为导出符号出现在共享库中,宿主因此可能依赖一个从未承诺的接口。读取器是无状态的纯函数,每个模块各持一份不会引入重复的进程级状态,因此可以静态链接并完全隐藏,无须维护一个「内部导出」的符号面。wolf 同样不安装
wolf/Support。 - 部分分析结果在形式上与「覆盖较短音频的完整结果」无法区分(一段曲线、一串音符、一组对齐词),宿主无从得知结果覆盖了哪一部分;wolf 返回的是已转换完成的词,可以原样使用。两库的差异因此是有意的,并写入
AnalysisExecutive::stop()的注释。 - hfa 此前在每次创建分析器时重读全部模型文件与词典(cmudict 约 13.4 万行),且为 configuration 中的所有语言装载词典;解码隐含「词表第 0 类是 SP」的假设而加载期不校验,分隔符
"SP"在 G2P 与解码中硬编码,可能与声明的silenceLabel不一致。 - 若状态由停止标志决定,当执行体因校验失败返回
InvalidArgument而停止请求恰好随后到达时,会出现state() == Canceled而错误码为InvalidArgument的情形,与「调用方按错误码分支」的约定矛盾。 - 公开入口中未经检查的
static_cast:start(const TaskStartInput &)是srt::ITask的公开接口,任何契约的载荷都可能到达;TaskPayload的type()/version()正是为此而设。 - linter 与加载器之间的差异使「lint 通过而装载被拒」成为可能,而打包阶段是修正声明的唯一时机。
决策:第四个出厂变体、也是第二个对齐变体 tifa(TIFA 强制对齐器)按下列取舍落地。算法与实测细节留在 plans/tifa-align.md,这里只登记契约面与取舍。
- 四门语言全部
lyrics="scheme"(cmn/pinyin、yue/jyutping、jpn/romaji、eng/arpabet,defaultLanguage为cmn):歌词由调用方按该语言的 scheme 写法给出,变体不做「文字 → 书写单位」的转换。 - 音素表取自模型自带的
vocabulary.json,phonemes承诺裸音素:加载期将每门语言的phonemes与词表中带该语言前缀的符号集合去前缀后逐一比对,多一个少一个都拒绝载入(A27 的规则)。宿主语言标识到模型语言码的映射(cmn→zh、yue→yue、jpn→ja、eng→en)在configuration.languages中(A11)。 - 静音与非语音如实声明:
silenceLabel为"SP",但模型词表中没有这个符号——它是变体给「没有词的片段」的拼写,加载期只要求它不与声明的音素重名。nonSpeechPhonemes为空:模型没有非语音检测头,因此声明「不检测」,不拿词表中那几个无前缀标签充数。 - 五张 ONNX 图各开一个会话(
spectrogram、model、prepare、score、select),按「音频前端 → 候选打分 → 选择 → 取对齐特征」的顺序驱动,其中model进图两次;图外还有一层由变体自己实现的候选选择与解码。 - 一次执行一个样本、一个 span,没有 batch,切片仍归宿主(A7)。
- 诊断类输出暂不提供:发音候选与分数、四个无参考诊断指标算在变体内部但不经契约回传——Level 1 的
AlignResult只有words(A22),没有承载它们的通道。按迁移方案的 D2 缓办。 - 模型词表中查不到的书写单位按未收录处理:丢掉该候选、丢掉该词,整句没有一个可用词时才拒绝这次执行。与
hfa(词不在词典就跳过)相比,它多覆盖一种情形——候选中含词表拼不出来的音素。
依据:
- 上游将「书写单位 → 音素」做成随模型发布的数据,而「文字 → 书写单位」是另外三套转换器(拼音、日文分词、英文 LSTM-G2P)。只带词典就够四门语言使用,且与 wolf 的 G2P 分工不重复——宿主手里本来就有 scheme 写法的输出,这正是 A27 让
lyrics可以取scheme的用意。 - 音素表以模型自带词表为权威,因为宿主要用它判断对齐结果能否直接交给某个歌手(A27 的依据);承诺裸音素,模型自己的语言前缀不进结果。
- 五张图的分工由上游规定、发布物中没有端到端图,将它们接起来的是变体自己——与 A3「解释器直接驱动 ONNX」一致,也解释了为什么这个变体的会话数与另外三个不同。
- 诊断指标没有承载体(A22),现在增加字段等于凭空猜语义,与第 11 章「
Align的置信度」同一条口径。 - 词表查不到时照常喂进图,模型会拿到 padding 编号并静默给出错误对齐,比拒绝执行坏得多;而上游默认的「丢掉整条样本」对宿主过于激进,故取「丢词、不丢整句」这一侧。
代价(已接受):lyrics 的写法与语言强绑定,调用方必须自己(或经 wolf 的 G2P)将文字转换为 scheme 写法;发音候选与诊断指标在 Level 1 上拿不到。
来源:迁移方案的 D2、D3、D9 与其中的差异登记(用户选项确认)。
决策:AnalysisError 从四个取值扩展为六个,每个取值只对应一种条件。Internal 收窄为「执行体抛出异常、执行体没有产出结果、解释器在加载期建立的声明对象缺失」;新增 ContractViolation,表示产出了其他契约的执行体或结果;ModelFailed 收窄为「模型没有产出变体要读的输出(缺失)」;新增 ModelMismatch,表示输出存在但元素类型、形状或元素数不符。读取输出时「为空」与「类型不符」不再合判,两种情况各自的码与消息都只说明自己那一种。包自身的文件(manifest.json、config.json、词汇表、词典、exports 声明)与变体不符时仍用框架的 srt::Error::InvalidFormat,宿主传参不合法时仍用 srt::Error::InvalidArgument,两者都不属于本类别。「能力缺失」这类条件同样以 srt::Error::InvalidArgument 拒绝,如宿主请求了模型没有声明的非语音标签:不合法的是这次请求本身,与「包与变体不符」是两回事,故不取 FeatureNotSupported。例外是「不支持」的一方不是包与变体,而是运行它的一份构建、驱动或插件:驱动侧的三项在 openSession 中检查(本单元没有注册 ONNX 驱动、注册的服务不是驱动、驱动未能建立会话),插件只服务某个接口/级别/变体则由独立的 checkServed 比对,与驱动无关;这些仍用 srt::Error::FeatureNotSupported。库自身只支持单声道是执行期限制(prepareSamples,取 srt::Error::FeatureNotSupported),而四个出厂变体在 createExports 里已按「包与变体不符」于装载期以 srt::Error::InvalidFormat 拒绝声道数不为 1 的声明。桩没有这一层检查,按 2 声道声明的包仍能装载,直到第一次执行才被库拒绝。「能力缺失」的拒绝消息同样是一句话:说清调用方请求了什么、本模型做不到什么,句尾的「做不到」由本模型承担(which this model cannot <动词>),不写包、不写环境;tifa、hfa 与桩拒绝未声明的非语音音素时即 the caller asked for the non-speech phoneme <标签>, which this model cannot report。
依据:A28 引入的 Internal 与 ModelFailed 各自混合了多种互不相同的条件,宿主只能把它们一并当作「模型跑不动」处理,而「模型文件不完整」与「模型与变体不匹配」需要不同的处置。spec 2.4 要求契约规定其错误语义,一个取值对应多种条件时,宿主无法按码分支。
代价(已接受):宿主需要识别两个新取值;读取输出的代码多一次分支。level 保持 1:错误码位于库级的 AnalysisError.h,不在按 level 版本化的 Api/*/1/ 目录内,既有包因此不需要重新声明。
决策:include/otter/Analysis/ 下只保留宿主面与共用的错误类别(AnalysisError.h、 AnalysisExecutive.h),解释器实现者需要的三个头移入 include/otter/Analysis/Provider/: AnalysisInterpreter.h、AnalysisTask.h、AnalysisInput.h。两个面都安装且都是公共头,提供者面不并入不安装的 Support/,因为树内插件与第三方变体都要读它们。AnalysisInterpreter.h 不再传递包含 AnalysisExecutive.h,需要该类型的实现者显式包含它。
依据:一个目录同时服务两类受众时,宿主读到的是为提供者写的规则。宿主真正需要的只有执行体与错误码,把它与提供者要实现的解释器、任务与输入校验分开后,宿主面的符号从三十余个降到约九个。宿主面的路径因此不变,已接入 otter 的宿主不需要改动任何 include。
代价(已接受):全仓 include 路径一次性改动。三份契约头仍要包含提供者面的 AnalysisTask.h,因为它们的 XxxExecutive 持有该模板的成员,所以提供者面也会跟着契约头进入宿主,目录名说的是这个面为谁准备,不是包含方向。
提供者面不含 AnalysisInterpreter 上回答自身标识的三个访问器(interfaceName()、level()、variant()):变体名与级别在装配处已经确定,实现者不必再提供一遍。
来源:契约面清点(C1 的 S1、S2)与用户选项确认。
实施完成后,对 otter、synthrt 与 wolf 三层进行了联合审计,审计维度为稳定性、向后兼容、规范符合度与并行安全。synthrt 与 wolf 无新发现。otter 发现七项问题,均已修复,且每项都补充了能复现原缺陷的用例。
| 编号 | 类别 | 问题 | 处置 |
|---|---|---|---|
| X1 | 正确性 | channelCount 读入成员后未被使用,两个提供者无条件降混,声明为 2 声道的模型会被静默送入单声道输入 |
校验下沉到 otter::prepareSamples(),模型声明的声道数无法满足时直接拒绝 |
| X2 | 正确性 | 未校验 samples.size() 是否为整帧数,尾部不完整的帧被整除丢弃,表现为后续的时间漂移而非错误 |
同上,拒绝而非丢弃 |
| X3 | 契约 | knownNotes 的时间基准不一致:头文件规定与音频同一时间轴(绝对时间),实现却按相对于片段起点处理,而输出为绝对时间。宿主分析第一段时结果无差异,之后每一段均出错且无任何提示 |
统一为绝对时间;提供者减去 startTime 并校验落在片段内。用例改为 startTime = 30 |
| X4 | 规范符合度 | createImportOptions 无条件报错,使 spec 2.4 允许的情形变为加载失败;规范明确允许 import 一个不提供 Factory 的类别的贡献,加载器也接受空工厂 |
返回空 options 与惰性 binding;只有显式写出 options 时才拒绝(Level 1 契约不读取 import options) |
| X5 | 健壮性 | AnalysisRunner::spawn 创建线程抛出异常时 running 永久为真,分析器此后拒绝一切执行 |
捕获异常并释放认领;任务面中由 startAsync() 处理,startWorker() 失败时返回 NoWorker |
| X6 | 契约 | Schema 声明了旋钮的取值域但无任何校验,steps = -1 会生成空张量并送入模型 |
otter::chooseKnob() 按声明的取值域校验,越界即拒绝,不做钳位 |
| X7 | 测试有效性 | 桩提供者不做任何校验,与真实提供者行为不同,针对桩编写的契约用例因此不能证明契约成立 | 桩改为调用同一套库函数;X1、X2、X6 正是在桩上的用例失败后暴露的 |
X1、X2、X6 的共同原因是两个提供者各自实现同一套校验,实现必然产生分歧。与 AnalysisTask 相同,校验也下沉到库中(otter/Analysis/Provider/AnalysisInput.h),各实现因此保持一致。
| 里程碑 | 内容 | 状态 |
|---|---|---|
| M1 | 仓库骨架:CMake、vcpkg 清单与 port | 完成。find_package(otter) 可从安装树使用;CI 消费者工程校验 otter::otter 目标、OTTER_PLUGINS_DIR 与公共头文件,并断言安装树不含 otter/Support |
| M2 | 契约头与加载路径(最初为 analysis 类别,A26 后改为 inference 模块) |
完成。test_AnalysisContrib / test_AnalysisLoad / test_AnalysisRuntime |
| M3 | rmvpe 提供者 |
完成。test_Rmvpe,运行于 ONNX 图 fixture |
| M4 | game 提供者,补回 dur2bd |
完成。test_Game,含对齐路径 |
| M5 | 打包 lint 与模型 fixture | 完成。scripts/check-declarations.py、scripts/make-model-fixtures.py、scripts/make-package.py |
| M6 | lite 接入 | 完成。L1 至 L6 六个阶段全部实现,见 lite-integration.md |
| M7 | Align 契约与 hfa 提供者 |
完成。契约头、提供者、fixture 用例与打包脚本均已实现;0.1.0.0 版 hfa 包曾随 models-v0.2.0.0 发布,其后的 0.2.0.0 版曾由 models-v0.3.0.0 提供(历史,两个标签均已删除);A31 起版本回到 0.1.0.0,现行发布为 models-v0.1;实测与决策见 plans/hfa-align.md |
| M8 | tifa 提供者 |
完成(P5 诊断指标与 P6 完整 G2P 按 D2/D3 缓办,属分期决策而非漏做)。声明、插件(五张图各一会话、加载期互检)、候选打分路径、帧解码与时间轴均已落地,test_Tifa 随夹具一起通过。实测门禁:宿主调用形态(逐 take)下与参考逐边界一致;batch-8 口径的差异归因于参考自身对批组成敏感(不是移植缺陷)。两种口径的读数、口径裁决与迁移记录见 plans/tifa-align.md §11.10–§11.11 |
ctest 共注册 12 个测试;注册数以 src/tests/auto/Analysis/CMakeLists.txt 为准,条件见下表:
| 测试 | 内容 | 条件 |
|---|---|---|
test_AnalysisContrib |
链接 otter 不注册任何类别 | 始终构建 |
test_AnalysisLoad |
经 otterstub 的包加载、DataOnly 模式、声明读取与拒绝规则、其他模块对分析器的引用 |
始终构建 |
test_AnalysisRuntime |
经 otterstub 的分析器创建与执行、旋钮、输入校验与取消 |
始终构建 |
test_ManifestValues |
私有声明读取器 | 始终构建,链接 otter_support |
test_F0Curve |
清音插值 | 始终构建,链接 otter_support |
test_Rmvpe / test_Game / test_Hfa / test_Tifa |
四个提供者在生成的 ONNX fixture 上的完整链路 | 仅在含 dsinfer 的构建中存在;没有带 onnx 与 numpy 的 Python 时注册为禁用;运行期缺少 fixture、驱动或运行时时以退出码 77 报告跳过 |
test_OnnxSupport |
四个提供者共享的 ONNX 读取器与执行器封装:输出缺失与类型不符的区分,空结果与缺少输出的拒绝 | 仅在含 dsinfer 的构建中存在(要求 otter_onnx_support 目标) |
test_CheckDeclarations |
scripts/test_*.py(当前两份:声明 linter 与打包脚本的自测;测试包生成器没有自测) |
找到 Python 解释器时注册 |
test_InstalledSurface |
安装树形态(check-installed-surface.cmake 装入 scratch 前缀),含不得安装的私有头文件 |
OTTER_INSTALL 打开时注册 |
已验证:包加载(含 DataOnly)、解释器发现、链接 otter 不引入类别、经 createAnalyzer 创建执行体、同步与异步执行、取消、输入校验、旋钮传递、四个提供者在 ONNX 图上的完整链路、对齐路径确实运行 dur2bd、 find_package(otter) 从安装树消费、不含 dsinfer 的最小构建正常降级。
已用真实权重验证:
- GAME:三个合成音 A3 / C4 / E4 转写为 MIDI 57 / 60 / 64,边界位于整秒处,时长与占空比吻合。该验证同时表明,fixture 原先依照提供者的假设声明张量形状,因此测试始终通过而任何真实导出的模型都无法运行; A10 所列的形状修正即由此得出。fixture 现按真实导出编写。
- HFA:v0.0.7 权重经打包后的包,在
BaiMaRuLuHua_0.wav(中文)上与参考实现dstools-cli hfa逐音素比对区间:27 段,标签序列一致,最大边界差 7.5e-7 s(帧长 10 ms)。命令与产物见 plans/hfa-align.md 的真实权重验收一节。 - TIFA:四门语言均通过等价门禁——11 条真实素材 + 31 条合成素材共 42 条,对参考实现音素与词标签序列逐个相等、逐边界零差异(判据用宿主的调用形态:一次一个 span)。口径裁决与读数见 plans/tifa-align.md §11.10–§11.11。
未验证:
- RMVPE 的数值。权重随 rmvpe 包发布(现行版为随
models-v0.1发布的 0.1.0.0 版;此前的 0.2.0.0 版在models-v0.3.0.0中,该标签已删除),但未与参考实现进行数值比对;相关用例在缺少 fixture 时报告跳过,而不是报告通过。 - HFA 的英语与日语词典。词典是数据而非代码,但缺少对应素材,无法验证;
merged_phoneme_groups路径按 C++ 参考实现的行为对齐,与 Python 版本不一致(见第 11 章)。 - TIFA 的英语与粤语真实素材。真实录音只覆盖
cmn(5 条)与jpn(6 条),eng/yue的等价性由合成素材覆盖,尚无真实录音进场。四门语言共 42 条的读数见 plans/tifa-align.md §11.11。
Transcribe的契约面。 只登记interface名称与所属类别,不定义类型。(Align已由 A19 定义。)Align的等价性与跨语言覆盖。hfa与参考实现(dataset-tools 的dstools-cli hfa)的数值等价性只在一套中文素材上比对过;英语与日语词典缺少素材,未经验证。参考实现的 Python 版与 C++ 版在merged_phoneme_groups上存在差异(C++ 版未使用该字段),本轮按 C++ 版的行为对齐。Align的置信度。 Level 1 不含逐词置信度。参考实现在解码时计算帧置信度与段总置信度,这些数据能否支撑「某个字对齐质量不佳」这一层信息,需要在宿主实际使用后确定;在此之前增加字段,其语义缺乏依据。tifa的诊断输出与完整 G2P(A29)。 发音候选与分数、四个无参考诊断指标算在变体内部、不经契约回传;「文字 → 书写单位」的转换也按lyrics="scheme"留在调用方。是否为前者扩展 Align Level 1、是否将后者补成完整 G2P(会引入分词与 LSTM-G2P 这类依赖),都等宿主实际使用后再定。AnalysisSession的必要性。 wolf 以LinguistSession承载目录、就绪状态与执行体池。otter 的抽参器数量少且一次只运行一个,暂不实现。lite 接入后,获取分析器、读取 schema、准备音频、切片与逐片执行这些步骤确实重复;但三个任务的后半部分(结果形式与时间轴换算)并不共用,下沉之前需要先明确下沉的范围。- 跨段边界连续性。 segmenter 的
prev_boundaries是采样循环自身的状态:每一步将上一步的输出送回,与上一段音频无关(已由真实模型确认)。因此该模型没有现成的跨片连续性入口。对齐同理:hfa每段独立解码,跨段的词边界连续性由宿主将切点置于词与词之间来保证。