本文记方案期事实:文中的状态、版本与读数是方案制定与实施当时的快照;现行状态见
docs/otter-design.md。
本文档性质:本地预开发的方案与实施记录(滚动追加)。远端 otter 正在重构,本轮的改动与结论只落在本地分支
analysis-level-1,不推送、不发布;迁移者按 §4 的变体形状与 §10 的实施记录接手即可。 状态:V1 方案完成;D1–D4 与 D7–D10 已获用户确认(结论见 §1 表末与 §9)。P0–P4 与 P7 已完成并实测通过; P5(诊断指标)与 P6(完整 G2P)按 D2/D3 缓办(与契约相关的部分不混进主干,见 §9 分期表)。 门禁最终读数(当时快照):宿主调用形态(逐 take)下 42/42、逐边界零差异;batch-8 口径同时报出,成因见 §10.8.1。ctest实测 10/10(含test_Tifa)。 2026-09-30 迁移(历史):四个仓的远端分支被强制重写,本地分支已不在远端历史内;otter 侧当时把本地 tifa 提交 rebase 到重写后的新头f4820d9(保持单分支);此后本地历史又从根重写为 4 个提交,与f4820d9无共同祖先。那次重写做了什么、为什么、旧号去哪找,见 §11 的历史索引附录。 本文不含本机绝对路径、用户名与机器名;模型与素材一律用<...>占位或仓内相对路径(docs/plans/hfa-align.md早期版本曾在 oracle 与素材说明里残留本机路径,现已改为中性描述)。
范围:给 otter 增加第四个出厂变体 tifa(TIFA 强制对齐器):把 TIFA 的推理链路(五张现成 ONNX 图 + 宿主侧解码/打分)移植成 otter 的 inference 模块,并配套声明、打包、测试与实测比对。
锚点(引用行号前请按快照自行复核):
| 树 | 观察点 | 引用写法 |
|---|---|---|
| otter | 本仓 analysis-level-1;方案期的锚点 f4820d9(2026-09-30 重写后的远端头)已不再是基线——本地历史此后从根重写为 4 个提交(tip bc36a95),与它没有共同祖先,所以 git rev-list --count f4820d9..HEAD、git diff f4820d9..HEAD 之类的对账都作废。旧提交号与备份分支的去向见 §11 的历史索引附录 |
src/...:行号 |
| TIFA | 上游仓 openvpi/TIFA @ tag v1.0.0(commit 32a0a13) |
TIFA@v1.0.0:infer.py:90 |
| TIFA 权重 | release 资产 TIFA-1.0-ST.zip,sha256 6da6832cd2cb981aae1ccc2d9330f8f7c085ce7977d1eb13bff63531d038a88b(实测复核一致) |
§10.1 |
| OTTER 设计 | docs/otter-design.md 的 A 系台账与未决 |
otter-design.md:A27 |
| wolf | 同层兄弟仓,scheme 命名与语义的唯一来源(pinyin/jyutping/romaji/arpabet) |
wolf:docs/linguist-distribution.md:72-75 |
- 形态天然适配。 TIFA 的发布物里没有端到端推理脚本:
ONNX.md规定五个模块图(spectrogram/model/prepare/score/select)由宿主拼装,宿主还要实现候选网格、整词候选 DP、Viterbi 解码、时间轴与文本层(TIFA@v1.0.0:ONNX.md:5-34)。这正是 otter「抽参器只跑模型、宿主备音频、otter 不做解码」的定位(otter-design.md:A24)。 - 上游只发
model.pt,ONNX 需自行导出,且已实测可行。 release 资产只含model.pt与配置/词典/G2P 资产;用上游deploy.py导出成功(约 15 s),五图签名与ONNX.md一致(§2.3、§10.1)。因此不需要在 otter 里维护导出器,只在记录里写清导出命令、参数与产物指纹(D6)。 - 契约面不需要新增 Level。 Align L1 的
languages(ISO 639-3 + scheme + lyrics + phonemes)、defaultLanguage、silenceLabel、knobs足以装下 TIFA 的对齐输出(AlignApiL1.h:99-154)。但 TIFA 的三处功能装不进现有结果类型: 发音书写层(TextGrid 的wordstier)、发音候选与分数、四个诊断指标(§3.3);这三处按 D2 决定「扩契约 / 分期内做 / 只记录」。 - 主干是「一个变体插件 + 五会话 + 宿主算法」。 新目录
src/plugins/inferenceinterpreters/tifa/、声明按packages/tifa/的形状(声明不入库,装配时由make-package.py --declarations <声明目录>指向,一变体一子目录);check-declarations.py与make-model-fixtures.py两处当前写死hfa,不参数化则新变体被静默跳过全部 align 校验(scripts/check-declarations.py:510-513)。 (时效注 2026-10-03:check-declarations.py已参数化——MODEL_KEYS现含(ALIGN, "tifa")(:105、:134、:150), ALIGN 的语言表文案也按 variant 取名(:553);make-model-fixtures.py的变体是否已参数化以该脚本现文为准。 上面写死hfa与所引:510-513是方案制定时的现状。) - 验证有现成 oracle。 同一份权重、同一段音频,
TIFA@v1.0.0:infer.py产的 TextGrid 即参考输出;判据沿用 hfa 先例:标签逐条相同、起止偏差 ≤ 1 帧(10 ms)(docs/plans/hfa-align.md:83、build/hfa-run/compare.py:52-53)。
| 项 | 现状 | 对本次移植的含义 |
|---|---|---|
| 远端 otter | 正在重构(用户口径;本仓无「预开发/远端」章节) | 本地只提交、不推送;改动尽量落在「新增变体」这一维度,避免动 Align 契约与库内共用代码 |
契约(include/otter/Api/Align/1/) |
已由 A19/A27 定稿,读取函数对一切变体共用(AlignApiL1.h:156-159) |
变体只实现 run();若确需扩契约(D2),单列一期并与远端同步,不混进变体提交 |
| lint / 夹具脚本 | 变体表写死 rmvpe/game/hfa |
本次必须参数化(§5.2),否则校验静默失效 |
| 包版本 | 四个变体的声明当前都是 0.1.0.0(布局 inferences/*/inference.json,A26 后),由 release models-v0.1 提供(四包 + 4 项 manifest);声明不入库,装配时由 make-package.py --declarations <声明目录> 指向打包机上的目录(一变体一子目录) |
tifa 声明同版 0.1.0.0 |
| 发布会话 | 未发令 | 本轮不打包上传、不建 release;只做本地装配与 manifest 验证 |
| 编号 | 决策点 | 推荐 | 备选与取舍 |
|---|---|---|---|
| D1 | 「TIFA 全部功能」的边界与分期 | 按 §3.4 的 P1–P6 分期,全部功能都做,但契约相关的部分单独成期(③诊断/④G2P 完整版不与主干混提交) | 一次性全做:单批过大、与远端重构相撞;只做对齐主干、其余不做:与用户"全部功能"口径不符 |
| D2 | 诊断指标(agreement/confidence/determinacy/monotonicity)与「发音候选+分数」的契约承载 | 先不动 Align L1:主干期把它们算在变体内部、作为可选出口(先只写进记录与未决),待远端重构落地后再提 A 系条目扩契约 | ①本轮直接扩 Align L1(AlignResult 加 diagnostics、PhoneInfo/WordInfo 加候选与分数):同步面大(头文件、读取器、docs/schemas、lint、测试、远端合并冲突);②永不外露:等于砍掉 TIFA 的两个卖点 |
| D3 | 歌词形式与 G2P 归属 | lyrics="scheme":宿主给 scheme 写法(拼音/粤拼/romaji/arpabet),变体自带 「书写单位→音素」四本词典(release 内随包,共约 3.4 MB);不移植 cpp-pinyin / MeCab / LSTM 三套 text→scheme 转换器 |
①变体自带完整 G2P:要到 MeCab+UniDic 与 LSTM ONNX beam search,依赖与工作量最大,且与 wolf 的分工重复;②只声明 cmn:覆盖面倒退 |
| D4 | 实测通道与素材 | 以「真实素材 + TIFA Python oracle 逐音素比对」为门禁;ds-editor-lite 无头合成作为可选补充(当前合成声库不在本机,且 lite 尚无 Align 通道,docs/lite-integration.md) |
只用合成音频:需先解决声库与 lite 侧新接口,链路长;只用真实素材:覆盖面受素材限制 |
| D5 | 模型与图产物是否入库 / 是否发布 | (历史)当时的决定:声明入库、权重不入库(当时做法:packages/.gitignore 只忽略 *.onnx,见 §5.3),本轮只本地装配、不发布。现行口径:声明亦不入库——包(声明 + 模型)随模型发布,装配读 make-package.py --declarations <声明目录>(一变体一子目录);现行版本 0.1.0.0、发布标签 models-v0.1(四包 + 4 项 manifest) |
入库权重:仓库膨胀(五图约 160 MB);发布:远端重构期不做 |
| D6 | ONNX 导出器的归属 | 用上游 deploy.py 导出,otter 只记录命令、参数与产物 sha256 |
在 otter/scripts/ 维护导出脚本:把上游模型代码搬进 otter,多一份维护面与依赖 |
| D7 | 变体/包命名与版本 | variant="tifa"、包 id="otter/tifa";(历史)当时定 version=compatVersion=0.2.0.0(与当时三包同版),现行四包统一 0.1.0.0、随 release models-v0.1 发布 |
与现有包不同版:manifest 组装复杂化 |
确认结果(选项式提问,本会话第 1 轮,逐条由用户选定):
| 编号 | 选定 |
|---|---|
| D1 + D2 | 分期全做,主干期不动契约:先落地对齐主干(P1–P4,含影响对齐选择的发音打分),诊断指标与候选报告先记录为未决,待远端重构落地后再提契约扩展(P5)。 |
| D3 | lyrics="scheme" + 变体自带四本「书写单位→音素」词典;不移植 cpp-pinyin / MeCab / LSTM 三套文字→书写单位转换器(完整 G2P 列为 P6,按需再启动)。 |
| D4 | 以现有真实素材做门禁:TIFA Python infer.py 的 TextGrid 作 oracle,与变体逐音素比对;ds-editor-lite 无头合成作为可选补充(当前无声库)。 |
| 语言覆盖(新增 D10) | eng/jpn/yue 也要实测,不只做 cmn;素材来源见 §8.6(待定,本轮需解决)。 |
| D7/D8/D9(见 §9) | 按推荐执行(variant="tifa"、otter/tifa(历史版本号 0.2.0.0,现行 0.1.0.0);扁平 dictionary<Language> 键;silenceLabel="SP"、nonSpeechPhonemes 不声明)。 |
其余 D 项(D5 打包与发布、D6 导出器归属)用户未单独选择,按推荐执行且可回退(D5:只本地装配、不发布;D6:用上游
deploy.py导出并把指纹写进 §10.1)。
§2.2/§2.4 中带
[子代理]的条目来自只读探索子代理的阅读记录,引用前请回源码复核;§2.3 与 §10.1 的图签名、字节数与 sha256 为主代理本机实测(导出脚本实跑 +onnx.load读取)。
| 事实 | 证据 |
|---|---|
Align 是独立契约 org.openvpi.otter.inference.Align Level 1;读取函数 readAlignSchema 对所有变体共用 |
include/otter/Api/Align/1/AlignApiL1.h:23、:26、:157-169 |
结果类型只有 language/scheme/words[{text,start,duration,phones[{text,start,duration}]}];结果必须铺满整段(未归属的间隔成为 silenceLabel 的词) |
AlignApiL1.h:261-271;silenceLabel 为空则「不命名静音、不保证铺满」(otter-design.md:467) |
声明面:sampleRate 必填,channelCount/maxSegmentDuration/languages[]/defaultLanguage/nonSpeechPhonemes/defaultNonSpeechPhonemes/silenceLabel/knobs 可选 |
AlignApiL1.h:99-154、AlignApiL1.h:164-169(必填/可选语义);src/lib/Api/Align/1/AlignApiL1.cpp:205-207(knobs 白名单) |
Align 目前只有三个 knob:nonSpeechThreshold / nonSpeechMinDuration / gapFill(白名单硬编码) |
AlignApiL1.h:145-154;scripts/check-declarations.py:190-191 |
变体形状(hfa 为唯一先例):plugin.json 的 interpreters[].variant、解释器构造、声明的 variant、AlignSchema 构造四处一致;插件 IID 为 org.openvpi.synthrt.plugin.InferenceInterpreter |
src/plugins/inferenceinterpreters/hfa/plugin.json:1-9;docs/otter-design.md:607-620 |
ONNX 不直连 onnxruntime:经 dsinfer 驱动 spec.package().synthUnit().runtimeService(ds::InferenceDriverPlugin::IID, "onnx") → createSession() → SessionOpenArgs |
src/plugins/inferenceinterpreters/onnx/OnnxSupport.cpp:33-61;otter-design.md:A3 |
会话输入输出名在 C++ 侧硬编码;hfa 用 waveform 入,ph_frame_logits/ph_edge_logits/cvnt_logits 出 |
src/plugins/inferenceinterpreters/hfa/main.cpp:64-69 |
| 模型自带文件的键名、采样率、帧移来自模型文件,声明只做一致性对撞(不一致即拒载) | src/plugins/inferenceinterpreters/hfa/main.cpp:160-163(模型 config.json 的 sample_rate/hop_size)、src/plugins/inferenceinterpreters/hfa/main.cpp:742-833(加载期对撞) |
一次执行一个;取消由 AnalysisTask 承载,提供者只在 run() 里轮询 cancelled(),并在 stop()/waitForFinished() 里先调基类 |
AlignApiL1.h:309-320;docs/otter-design.md:A15 |
lint 对未登记的 (interface, variant) 静默降级:只 warn 后 return,后续 exports/configuration/模型互核全部不发生 |
scripts/check-declarations.py:510-513 |
check_align_declaration 按契约分派、内容写死 hfa 模型语义(读 config.json 的 mel_spec_config、vocab.json 的 vocab/silent_phonemes/non_lexical_phonemes/dictionaries),错误文案含变体名 |
scripts/check-declarations.py:598-611、scripts/check-declarations.py:614-726 [子代理] |
CI 不跑 packages/ 的声明 lint(声明不入库,CI 没有声明可跑);全仓唯一真实调用点是打包脚本 |
.github/workflows/ci.yml:102-106;scripts/make-package.py:236-251、scripts/make-package.py:388 [子代理] |
夹具由脚本生成真签名假图,CMake 探测到「Python + onnx + numpy」才生成,否则相关用例 DISABLED;运行期缺件 SKIP_RETURN_CODE 77 |
src/tests/auto/Analysis/CMakeLists.txt:76-133 |
make-model-fixtures.py 里 align 夹具的 "variant" 由 align_declaration() 的 variant 参数给出(默认 "hfa",tifa 夹具显式传 "tifa") |
scripts/make-model-fixtures.py:1654-1655(默认值)、scripts/make-model-fixtures.py:1701(hfa 夹具)、scripts/make-model-fixtures.py:1790(tifa 夹具)[子代理] |
三个模型测试注入 OTTER_TEST_FIXTURE_DIR/OTTER_TEST_PLUGIN_DIR/OTTER_TEST_DRIVER_PLUGIN_DIR/OTTER_TEST_ONNXRUNTIME_DIR |
src/tests/auto/Analysis/CMakeLists.txt:129-135 |
本机已有可用构建树与依赖(Debug 全量测试 exe、onnxruntime.dll/DirectML.dll、vcpkg 依赖树);CUDA 关、DirectML 开、测试走 CPU EP |
[子代理]:build/agent-tests/、scripts/vcpkg-ports/synthrt-main/portfile.cmake:36-50、src/tests/auto/Analysis/test_Hfa.cpp:80。迁移后已过时:这类手工指定 include/lib 的树不能再配置,改用 README 的 vcpkg manifest 流程(先 git submodule update --init scripts/vcpkg,再 vcpkg install + cmake -B <dir> …) |
| 项 | 事实 | 证据 |
|---|---|---|
| 定位 | Token-Imputing Forced Aligner:音频 + 文本 → 词/音素时间区间;自带发音打分与无参考诊断指标 | TIFA@v1.0.0:README.md:1、:7-13 |
| 发布物 | release 只有 TIFA-1.0-ST.zip(一个 PyTorch 检查点 + 配置 + 词典 + 英文 LSTM-G2P 资产),不含 ONNX |
release 资产清单;§10.1 开箱核对 |
| ONNX 形态 | 五图分工:spectrogram(log-mel + maskT)、model(相似度 + token logits)、prepare(打分模板)、score(片段代价)、select(按 choices 选路径);宿主负责候选网格、整词 DP、Viterbi、时间轴与 TextGrid |
TIFA@v1.0.0:ONNX.md:5-34、:54-66 |
| 导出器 | deploy.py -m <model.pt> -o <out>,opset 默认 18(域 18–20,注释写明 18 支持打分归约与 DirectML);导出时关 RoPE 缓存、STFT 用实数幅度、onnxslim+checker |
TIFA@v1.0.0:deploy.py:11-30;deployment/exporter.py:18-43、:94-119、:137-175 |
| 导出器产出的配置 | config.json = samplerate/timestep/hop_size/fft_size/win_size/num_mels/vocab_size |
deployment/api.py:26-37;§10.1 |
| 音频前端 | 全部在图内(spectrogram.onnx):反射填充 (784,784) + hann 窗 2048 + hop 480 + 幅度 + librosa mel 基(80 bins, fmin 0, fmax 8000)+ log(clamp(1e-5));无归一化/预加重/dither |
lib/feature/mel.py:39-81;configs/base.yaml:7-16(deployment/context.py 的 export 分支)[子代理] |
| 采样率/帧移 | 48 000 Hz、hop 480(10 ms/帧)、win=fft=2048 | 导出后 config.json(§10.1);TIFA@v1.0.0:configs/base.yaml:8-11 |
| 词表 | vocabulary.json = {"symbols": {"language/phone": id}};保留 id 0/1/2 = PAD/MASK/SPACE(不出现在符号表里);实测 220 个符号、219 个不同 id、id 域 3–221;AP=3/EP=4/GS=5;ja/um 与 zh/um 共 id 83 |
TIFA@v1.0.0:ONNX.md:52;§10.1 实测 |
| 语言前缀 | 剥本次在用语言的模型码前缀(不是「只剥默认语言」;更正见 §10.8 缺陷 1);混合语言靠有序候选标签;global_symbols/stop_symbols/merged_groups 在 g2p 配置里 |
TIFA@v1.0.0:README.md:154;configs/g2p.yaml:46-52 |
| 解码 | 扁平 Viterbi(decode_alignment_flat):token 状态吃 1 帧并加原始余弦相似度,gap 状态加 0;跳帧代价 skip_penalty(默认 0.5),含序列边界;groups 约束只限制 gap 等待;零宽 span 先统一到允许的 gap 位置再按 --skip-handling 处理 |
modules/decoding.py:300-421 [子代理];infer.py:89-92 |
| 打分解码 | 整词候选 DP(前向/后向 + 段尾 SPACE 尾巴),分数 = 归一化后的联合最优值(不是概率);`--score-unit levenshtein | word |
| 诊断指标 | agreement(token 分类 softmax 的均值概率)、confidence(span 内相似度均值)、determinacy(正相似度在近邻 token 上的集中度)、monotonicity(证据指向当前或更后 token 的比例);写 statistics/{scores,diagnosis}.json |
README.md:191-206;modules/metrics/reference_free.py、inference/backend.py:237-239 [子代理] |
| TextGrid | 三个 tier:texts(语义词)/words(发音书写,如拼音/romaji)/phones(音素);共享时间轴,空档为空串;默认省略零宽音素 |
README.md:132-154 |
| 与 G2P 的分层 | 「书写单位 → 音素」是词典(key<TAB>phonemes,四本:ds-zh-pinyin-lite / japanese_dict_full / jyutping_dict / ds_cmudict-07b);「文字 → 书写单位」才是 cpp-pinyin / MeCab / LSTM |
§10.1 词典开箱;configs/g2p.yaml:7-45 |
| CLI 默认值 | --skip-handling omit、--skip-penalty 0.5、--score-unit levenshtein、--oov-handling discard、--batch-size 8、--input-formats wav,flac,opus,mp3,aac,ogg |
infer.py:51-117 |
| 图 | 输入 | 输出 |
|---|---|---|
spectrogram.onnx |
waveform[B,L]:f32、duration[B]:f32 |
spectrogram[B,T,80]:f32、maskT[B,T]:bool |
model.onnx |
spectrogram[B,T,80]:f32、tokens[B,N]:i64、maskT[B,T]:bool、maskN[B,N]:bool |
similarities[B,T,N]:f32、logits[B,N,256]:f32 |
prepare.onnx |
paths[B,P,C]:i64、words[B,P]:i64、candidates[B,W,C]:bool、grouped[]:bool(标量) |
tokens[B,P]:i64、segments[B,P]:i64、mapping[B,P]:i64 |
score.onnx |
logits[B,P,256]:f32、paths[B,P,C]:i64、words[B,P]:i64、segments[B,P]:i64、mapping[B,P]:i64 |
descriptors[B,P1]:i64、lengths[B,P1,C]:i64、costs[B,P1,C,P1]:f32、tails[B,P1,P1]:f32、capacity[B,P1]:i64 |
select.onnx |
paths[B,P,C]:i64、words[B,P]:i64、groups[B,P,C]:i64、choices[B,W]:i64 |
best_tokens[B,P]:i64、best_words[B,P]:i64、best_groups[B,P]:i64、maskN[B,P]:bool |
一处已查清的"表里不一"(实测,见 §10.3):
score.onnx的descriptors输出声明只标了两维(['B','P1'],onnxslim 按dynamic_axes重写了形状注解),但图里它是Concat(Unsqueeze(owner), Unsqueeze(segment)),实际产出是[B,P1,2],与ONNX.md:105及 Python 参考实现(inference/scoring.py:106-112)一致。结论:导出的形状声明不可信 → 载入期不能用它做校验,要在首次run之后按实际张量断言(§8 第 2 条)。
| 项 | 值/要点 | 来源 |
|---|---|---|
| 采样率 / 帧移 | 48 000 Hz / 0.01 s(hop 480,win=fft=2048,mel 80,fmin 0,fmax 8000) | §2.3 实测 config.json |
maskT |
t < round(duration/timestep),四舍六入五成双(ties-to-even) |
ONNX.md:92 |
| 图内帧数 | T = floor((L + win - fft)/hop)(win=fft 时即 floor(L/hop)) |
ONNX.md:74 |
| 语言前缀省略 | 剥本次在用语言(CLI 的 -l 即其默认语言)的模型码前缀;其余语言的前缀保留在标签里。早期转述成「只剥默认语言」是错的,更正见 §10.8 缺陷 1 |
README.md:154;inference/callbacks.py:158-163 [子代理] |
| 静音/非语音 | 模型没有自己的静音/呼吸检测头:词表里有 AP/EP/GS 三个无前缀标签,但推理时不自动产生(只可能由文本字面带入);TextGrid 的空档是"没有词"而不是"静音词" |
ONNX.md:52;§10.1 词表;README.md:152 |
| 跳帧 | skip_penalty 是原始余弦分的代价(不是 log 概率),默认 0.5,边界同样计价 |
infer.py:89-92;modules/decoding.py:396-401 [子代理] |
| 零宽 | --skip-handling omit(默认)剔除零宽音素;preserve 给零宽串分配 1 ms;discard 整条样本丢弃 |
infer.py:83-88;inference/callbacks.py:178-217 [子代理] |
| 时间基准 | TextGrid 总时长 = round(T * timestep, 3)(帧数×10 ms,不是音频真实时长),且若末音素越界则撑到该 offset |
inference/callbacks.py:172-217 [子代理] |
| 批量 | 只有当批内有样本存在可打分片段时才调 model/score;无音频/无 token 的样本整条跳过 |
ONNX.md:64 [子代理] |
词典实测规模(TIFA-1.0-ST/dictionaries/,本次清点)
| 词典 | 键 | 行 | 同键多行(=多候选) | 最长音素数 |
|---|---|---|---|---|
ds-zh-pinyin-lite.txt(cmn) |
615 | 615 | 0 | 2 |
jyutping_dict.txt(yue) |
639 | 639 | 0 | 2 |
japanese_dict_full.txt(jpn) |
177 | 177 | 0 | 2 |
ds_cmudict-07b.txt(eng) |
125027 | 133570 | 7934(6.3%) | 32 |
结论:只有 eng 会产生多候选网格(cmn/jpn/yue 每个书写单位只有一个候选);候选顺序 = 文件行序,两侧读的是同一本词典、同一顺序,所以候选编号可以直接对齐。
| 用途 | 内容 |
|---|---|
| 参考输出(oracle) | TIFA@v1.0.0:infer.py <audio> -m <model-dir>/model.pt -l <lang> [--skip-penalty …] [--skip-handling …] → 同目录 <name>.TextGrid(三 tier);--stat 另出 statistics/{scores,diagnosis}.json |
| 比对脚本形态 | 判据沿用 hfa 先例「标签逐条相同、起止偏差 ≤ 1 帧(10 ms)」,做法沿用 hfa 的「与参考二进制逐区间对拍」(docs/plans/hfa-align.md:270、docs/plans/hfa-align.md:395-400);脚本形态照 hfa 那轮的 scratch 工具(build/hfa-run/ 的 runner + compare.py,两侧都不入库),本变体另装一套到 build/tifa-run/(§6.1) |
| 素材 | 本机「精标数据集」fox_data 各版本(已实测清点,见下表)提供真实 cmn 与 jpn 素材;eng/yue 无真实素材 → 走 §2.6 的合成路线。用户已选定合成路线并指定声库,歌词随机自造。 |
素材清点(本机实测;只记数据集名与计数,不记绝对路径)
| 语料 | 规模 | 附带标注 | 用途与实测结论 |
|---|---|---|---|
fox_v2.1 |
297 条 wav | .lab(空格分隔拼音音节)+ TextGrid(words + phones 两层)+ 84 个 json(含 raw_text/lab_without_tone) |
cmn 主门禁素材:.lab 正是 TIFA 数据集直接读取的伴生文本格式(inference/data.py:71-78 先找 <stem>.txt 再找 .lab) |
fox_v2.2 |
287 条 wav | transcriptions.csv:ph_seq/ph_dur/ph_num |
cmn 的音素级时间参考;实测 ph_dur 之和 = 9.37501 s 与 fox_v2.1 同 id 的 TextGrid xmax = 9.37499 一致(差 2e-5 s,取整),说明该时间轴就是音频的真实时间轴 |
fox_v2 |
213 条 wav | TextGrid | 早期版本,备用 |
fox_jp_v1 |
97 条 wav | transcriptions.csv:ph_seq/ph_dur/ph_num/note_seq/note_dur |
jpn 真实素材(曲目前缀 banira/beat/daze/gatsu/hello/kun);音素级时间 + 音符数据齐备,但没有现成的「书写单位」歌词 → 需要从 ph_seq 反推 romaji 词(§8.6) |
record |
10 条 wma | 无标注 | 本轮不用 |
cmn 的 TextGrid/
ph_dur属独立于 TIFA 的人工/精标时间轴:它不能替代 TIFA oracle 做等价门禁,但可以作为「对齐是否可信」的绝对参考(§6 的第二类读数)。
| 项 | 事实 |
|---|---|
| 声库 | 0913_wolf_club@1.0.0,vendor = 夜燐Yarin,packager 格式(desc.json + characters/ + inferences/ + linguists/),目录内带 Terms_of_Use_1.2.0.zh-CN.pdf 与多语言使用说明 → 许可受限,只在本机用于实测,不进仓库、不进包 |
| 语言覆盖 | 依赖 wolf/lang-cmn wolf/lang-eng wolf/lang-jpn wolf/lang-yue;四位歌手(yelin/huyinyi/shark/xuanyi);四个书写系统接口齐全:cmn-pinyin / eng-arpabet / jpn-romaji / yue-jyutping —— 与 TIFA 的四门语言、四本词典一一对应,因此同一批歌词可以直接喂给两边 |
音素表对撞(本机实测,声学模型 vs TIFA vocabulary.json,按 cmn↔zh、eng↔en、jpn↔ja、yue↔yue 对齐) |
eng 42 = 42 完全相同;cmn 63 ⊆ TIFA 65(TIFA 多 iai、um);jpn 39 ⊆ TIFA 40(TIFA 多 um);yue 73 ⊇ TIFA 70(声库多 em/ep/eu) |
| 差异的两种表达方式 | 共享音素 um:声库写作无前缀(id 82),TIFA 写作 ja/um 与 zh/um 共享同一 id 83(对应 configs/g2p.yaml 的 merged_groups: [[ja/um, zh/um]])——语义相同、编码不同 |
SP |
声库把 SP 列为无前缀共享符号(id 4);TIFA 词表里没有 SP。本变体把 silenceLabel 声明为 "SP" 因此与宿主生态的既有写法一致(§4.4 第 5 条),不是自造新词 |
| 对选素材的约束 | 合成歌词只使用交集内的书写单位:cmn 避开 iai;yue 避开 deu/lem/gep(§2.7)与会产出 em/ep/eu 的韵母;这样两边都能处理,比对才有意义 |
| 驱动方式(实测) | 走 ds-editor-lite 的无头自动化端点:一个最小 JSON-RPC over HTTP 客户端拉起无头编辑器(端口写在本机暂存状态文件里;回环地址加入 NO_PROXY,避免请求被本机 HTTP 代理截走),逐条提交歌词、收回 wav;只读地用 lite,不写它的仓库。驱动脚本与编辑器日志留在本机暂存区(含本机路径,故不入库);迁移后照本节事实重做即可:起无头编辑器 → 用上表的歌手与书写系统接口 → 歌词只取交集 → 收到 wav 后统一成 48 kHz / 16-bit / 单声道 |
| 素材规模(实测) | 真实 11 条(cmn buhuji_high_* 5 条、jpn banira_* 6 条,来自 §2.5 的本机精标数据集)+ 合成 31 条(第一批 material-{cmn,eng,jpn,yue} 共 10 条,第二批 material-syn2 共 21 条)= 门禁的 42 个 take(四门语言都有)。喂给两侧的是 material-48k/<语言>[-<批次>]/ 下的 42 组 16-bit 副本,oracle 目录里另有 53 份 TextGrid(含早期批次),门禁只取与 take 同名的那一份 |
| 项 | 事实 |
|---|---|
| 现象 | jyutping_dict.txt 共 639 个键,其中 3 个键的候选含词表里不存在的音素,且没有替代写法:deu → [d eu]、lem → [l em]、gep → [g ep](eu/em/ep 在 vocabulary.json 里查不到) |
| 对照 | cmn 词典产出的音素全部可在词表拼出(iai/um 是「词表有、词典没用到」,无害);jpn 同理(多 um);eng 的 42 个完全对齐 |
| 声库侧 | 声库(§2.6)的 yue 音素表有 em/ep/eu——即「声库能唱、词典能产出、TIFA 词表缺失」 |
| 影响 | 变体若把这类音节照常送进图,Vocabulary::id() 返回 0(padding),会静默给出错误对齐;Python 侧默认 --oov-handling discard 则丢掉整条样本 |
| 处置 | 变体按 §4.5 第 7 条处理(丢候选 → 丢词 → 整句无可用词才拒);选词纪律避开这 3 个音节;不把 3 个音素补进声明(声明必须能被词表拼出,lint 也会拦) |
| TIFA 概念 | otter 落点 | 说明 |
|---|---|---|
语义词(TextGrid texts tier) |
WordInfo.text |
调用方给的词(scheme 写法),逐词原样回写 |
发音组(TextGrid words tier) |
无落点 | L1 的词只有一个文本字段;见 §3.3 |
音素(phones tier) |
WordInfo.phones[] |
按语义词聚合:一个词的所有发音组的音素按序拼接 |
| 空档(无词区间) | silenceLabel 的插入词 |
L1 要求结果铺满整段(现以 silenceLabel 非空为条件,见 docs/otter-design.md:450-452);TIFA 的 gap 状态不产标签,由变体补标签 |
AP/EP/GS |
声明的 phonemes(无前缀标签按语言无关处理)或 nonSpeechPhonemes |
TIFA 不自行检测它们 → 见 §4.4 差异登记 |
| 语言/写法 | language(ISO 639-3)+ scheme(wolf 命名)+ lyrics="scheme" |
cmn/pinyin、yue/jyutping、jpn/romaji、eng/arpabet |
skip_penalty / skip-handling / score_unit |
knob 面装不下(三个 knob 白名单硬编码) | 见 §3.3 与 D2 |
sampleRate 48000 · channelCount 1 · maxSegmentDuration <待定,见 D-风险 §8.1>
languages:
cmn/pinyin lyrics=scheme phonemes=<词表中 zh 前缀集合去前缀;四个集合的计数见 §10.1>
yue/jyutping lyrics=scheme phonemes=<yue 前缀集合去前缀>
jpn/romaji lyrics=scheme phonemes=<ja 前缀集合去前缀>
eng/arpabet lyrics=scheme phonemes=<en 前缀集合去前缀>
defaultLanguage cmn
silenceLabel "SP" (TIFA 词表里没有 SP 这个符号,它是本变体给"空档词"的拼写)
nonSpeechPhonemes <空> (模型不自带非语音检测 → 如实声明"不检测")
knobs <按 §3.3 决定>
音素表必须与 vocabulary.json 里带该语言前缀的集合逐一相等(多一个少一个都拒载,沿用 hfa 的加载期互检)。
| # | 装不下的东西 | 现状依据 | 三个候选处置 |
|---|---|---|---|
| a | 发音书写层(TextGrid words tier) |
WordInfo 只有 text/start/duration/phones |
①接受丢失(宿主/变体各自 G2P 可复现);②扩 WordInfo 增 script;③扩成"词→发音组"两层 |
| b | 发音候选与分数(scores.json) |
同上 | ①只在变体内部使用(选择结果影响对齐,这已足够);②扩契约回传候选与分数 |
| c | 四个诊断指标 | AlignResult 只有 language/scheme/words;docs/otter-design.md §11 未决 · 「Align 的置信度」已明确「Level 1 不含置信度,凭空加字段等于猜语义」 |
①先记录、后扩;②本轮就扩(AlignResult 加可选 diagnostics) |
| d | 解码/打分 knob(skipPenalty/skipHandling/scoreUnit) |
knobs 白名单硬编码三键(AlignApiL1.h:145-154;scripts/check-declarations.py:190-191) |
①不暴露(写死默认:skipPenalty=0.5、skipHandling=omit、scoreUnit=levenshtein,与 Python 默认一致);②扩契约暴露 |
推荐:a/b/d 走 ①,c 走「①先记录、后扩」,即主干期不动契约(D2)。
| 期 | 内容 | 对应 TIFA 功能 |
|---|---|---|
| P1 | 声明与包形状 + lint 参数化 | 底座 |
| P2 | 插件骨架:五会话装配、模型文件读取、加载期互检、声明导出 | 底座 |
| P3 | 宿主算法:候选网格、prepare/score/select 驱动与整词 DP、Viterbi、静音补词、时间轴 |
对齐主干 + 发音打分(影响对齐选择) |
| P4 | 真实权重实测:与 Python TextGrid 逐音素比对 | 等价性门禁 |
| P5 | 诊断指标 + 打分报告出口(按 D2) | 「诊断指标」「发音候选报告」 |
| P6 | G2P 完整版(若 D3 选②:cpp-pinyin/MeCab/LSTM-ONNX/多语言混排/OOV 策略) | 「多语言 flex 配置」「文字输入」 |
src/plugins/inferenceinterpreters/tifa/
├── plugin.json # name=ottertifa,interpreters[0]={Align, 1, "tifa"}
├── main.cpp # 声明读取、模型文件读取、加载期互检、会话、run()
├── Grid.{h,cpp} # 候选网格:scheme 词 → 词典音素 → paths/words/groups/candidates
├── Scoring.{h,cpp} # prepare/score/select 三图驱动 + 整词候选 DP + 归一化分数
├── Decode.{h,cpp} # 扁平 Viterbi(含 skip penalty、group 约束、零宽归位、span 提取)
└── Metrics.{h,cpp} # (P5)四个诊断指标
迁移修正:本目录没有
CMakeLists.txt—— 上游改成在src/plugins/inferenceinterpreters/CMakeLists.txt里用otter_add_interpreter_plugin(ottertifa tifa SOURCES… LINKS…)集中声明(连带 IID 与plugin.json拷贝)。早期草稿写的"照 hfa 建project(ottertifa)+otter_add_plugin"在新基线上不成立。
键名与逐键路径(五张图、config、vocabulary、四本 dictionary*、languages 映射)的唯一权威是 docs/otter-design.md **§6「模型包结构」**一节的 TIFA 段(:566-603,标题见 :483);本文不复制那份清单——同一份键值抄两处必然漂移。本节只记两条与改代码直接相关的约束:
- 声明的每门语言都必须有对应词典键,缺一即拒载(§4.3 第 3 条)。
- 键名与集合需同时登记进
scripts/check-declarations.py的三张表(MODEL_KEYS/CONFIGURATION_KEYS/LANGUAGE_NUMBERING),否则该包被静默跳过校验(§2.1)。
config.samplerate==exports.sampleRate(48 000);config.num_mels/vocab_size与图输入输出形状一致(80/256)。channelCount== 1。- 每门声明语言:
configuration.languages有模型码,且该码对应的dictionary*键存在、文件在包内且非空。 - 声明语言的
phonemes==vocabulary.json中带该模型码前缀的符号集合(去前缀后逐一相等,打印两侧差集)。 silenceLabel非空且不与任何语言的音素重名(它是变体自造的拼写,不与词表冲突即可)。nonSpeechPhonemes为空(本变体不检测);若将来声明,必须 ⊆{AP,EP,GS}。- 同一
(language, scheme)只出现一次;defaultLanguage∈ 声明语言。 - 每门语言的 scheme 必须是 wolf 的既有命名(
pinyin/jyutping/romaji/arpabet),否则拒载(防止同一记法出现两种拼写)。
流程意图:先校验(非空 lyrics、语言/scheme 在声明内、采样率与 span 长度)→ 音频前端(库内 prepareSamples 之后交 spectrogram 图)→ 文本侧(歌词按空白切词、查词典成候选网格 → prepare 出模板 → model/score 与宿主整词 DP 选候选 → select 出 best_tokens)→ 相似度(model 第二次进图)→ 帧解码(Decode 的扁平 Viterbi)→ 落到宿主时间轴(词/音素聚合、剥本次在用语言的模型码前缀、空档补 silenceLabel 词);每步之间轮询取消。多候选、空标签与零宽归位的细节见 §4.5 与 §4.7。
步骤编号与进度值以代码为准:
src/plugins/inferenceinterpreters/tifa/main.cpp的run()内有分段注释与report()调用点,本文不复述。本节早先抄过一份数字,且漏了其中一档——这正是「同一读数只允许一处权威」的理由;那次缺陷与修法见 §10.8。
| # | 差异 | 理由 |
|---|---|---|
| 1 | 歌词只吃 scheme 写法(不跑 cpp-pinyin/MeCab/LSTM) | D3:与 wolf 分工一致;完整版列为 P6 |
| 2 | 结果多出 silenceLabel 的插入词,且铺满整段(现以 silenceLabel 非空为条件,见 docs/otter-design.md:450-452) |
L1 的结果语义(AlignApiL1.h:261-271);Python 版只留空档 |
| 3 | 零宽处理固定 omit(Python 默认),preserve/discard 不暴露 |
§3.3d;以 D2 决定是否扩 knob |
| 4 | skip_penalty 固定 0.5、score_unit 固定 levenshtein |
同上(两者都是 Python 默认值) |
| 5 | 不输出 words tier(发音书写)与 scores.json/diagnosis.json |
§3.3a/b/c;P5 按 D2 处理 |
| 6 | 单样本执行(L1 一次一个 span),无 batch/多进程 | L1 与 AnalysisTask 的既定语义 |
| 7 | 「候选里含词表没有的音素」与「书写单位未收录」同样处理:丢该候选 → 丢该词 → 整句无可用词才拒 | 上游词典与词表不自洽(§2.7);Python 默认 --oov-handling discard 会丢掉整条样本,对宿主过于激进,故取 force 那一侧的行为 |
| 8 | 打分模板的退化表上不复制参考的失控行为 | 实测(build/tifa-run/review-probe/scoring_diff.py:一侧是 Scoring.cpp 的逐行转写、一侧直接 import 参考 inference.scoring._select_sample):3000 张随机表0 选择分歧、0 错误形态差异;6 类退化表里 4 类不同——参考对"分片只写词号不写段号"与"capacity 少写哨兵项"直接抛错(含 IndexError),对"词号越界"与"代价越界为 -inf"静默取首个候选;移植版前者给出选择、后者按具体理由拒绝。导出图不会写出这些表,所以只承诺在真实表上等价 |
| 9 | 时间用宿主时间轴上的绝对秒,不是相对这段音频起点的偏移 | L1 的结果是绝对时间(AlignApiL1.h:261-271);参考脚本按整文件推理、只会给相对起点的偏移,宿主一切片就会放错位置(src/plugins/inferenceinterpreters/tifa/main.cpp:607-608、src/plugins/inferenceinterpreters/tifa/main.cpp:1331-1338) |
- 骨架与加载:
plugin.json的name必须等于库文件名,也就是 CMake 工程名ottertifa(bundle 解析器按{"","lib"}×{"",".dll",".so",".dylib"}在该目录里找);bundle 目录为inferenceinterpreters/tifa;interpreters[]的三元组不得重复。注册点在src/plugins/inferenceinterpreters/CMakeLists.txt:56-90的OTTER_HAS_DSINFER块内(tifa 的注册在src/plugins/inferenceinterpreters/CMakeLists.txt:85-89;不要放进OTTER_BUILD_TESTS块,那是 stub 的位置),新目录的CMakeLists.txt逐行照hfa/CMakeLists.txt:1-30。(迁移修正:这一段已过时——注册点不再是add_subdirectory里的逐行照抄,而是OTTER_HAS_DSINFER块内的一句otter_add_interpreter_plugin(ottertifa tifa SOURCES… LINKS…)集中声明,插件目录里没有CMakeLists.txt;行号也请以新头快照为准。) - 声明的读取时机:
readAlignSchema只强制sampleRate,未知键与未知 knob 键直接拒。createExports先于createConfiguration被调用,所以在createExports里不能读spec.configuration()——要用spec.manifestConfiguration()自己再读一遍(hfa 即如此)。 - 模型自带文件的读取:dsinfer 的公共 API 没有包内文件读取接口,包内文件由共享的 ONNX 支持层读(
otter::onnx::readTextFile里的std::ifstream:src/plugins/inferenceinterpreters/onnx/OnnxSupport.cpp:89;JSON 入口otter::onnx::readJsonObject在同文件:102-104),基准是spec.declarationPath().parent_path()(src/plugins/inferenceinterpreters/hfa/main.cpp:933、src/plugins/inferenceinterpreters/tifa/main.cpp:1457)。tifa 的config.json/vocabulary.json/四本词典同法,声明里的相对路径按此基准解析。 - 五会话:先例是 game 变体(多模型容器 + 循环开会话 + 失败即整体回滚)。驱动比会话活得久;镜像按 (realpath, useCpu) 引用计数共享。
SessionOpenArgs2在本 dsinfer 版本不存在,只有ds::Api::Onnx::SessionOpenArgs{bool useCpu};建议把prepare/score/select这三张纯 i64/bool 图设为useCpu=true,避开 DirectML/CUDA 对 i64 算子的不确定性(推断,未实测)。 - 取消与析构:
stop()/waitForFinished()必须先调基类,再逐会话 stop/waitForFinished;OnnxSession::stop()在空闲时报错,必须吞掉。 - 张量:f32 与 i64 用
createFromView,bool 用createFromRawView(ITensor::Bool, …)(1 字节/元素),0 维标量(grouped)传空 shape。 - 加载期互检:hfa 的九条(采样率对撞、单声道、语言不重复、型号码存在、词典名存在、词典文件存在、音素集合相等、非语音标签已知、静音标签在词表内)在
hfa/main.cpp:742-833各有实现;tifa 的差别见 §4.3 与 §5.2 的清单。 - 夹具与测试:
scripts/make-model-fixtures.py的 align 分支已把variant参数化(align_declaration()的variant,scripts/make-model-fixtures.py:1654-1655),tifa 的五张真签名假图与 tifa 声明模板也已随 P2 落地(五个build_tifa_*在scripts/make-model-fixtures.py:614-1031,tifa 夹具声明在scripts/make-model-fixtures.py:1790);夹具缺失时test_Tifa仍按运行期exit(77)跳过。
- 候选网格:
P是「来源网格容量」——各词的多序列 Levenshtein 对齐列数之和(不是音素数);words[p]=w+1(1 基,0 = padding);paths/words/groups的列c就是第c个候选;candidates是前缀真值掩码;groups是候选内 1 基组号,同组内禁止留空。(g2p/encoding.py:49-156) - 五图顺序:
spectrogram→(prepare→model→score→ 宿主整词 DP →choices)→select→model(喂best_tokens)→ 宿主 Viterbi。prepare的标量grouped=false等价于--score-unit levenshtein(CLI 默认);none时不跑评分三图,choices取每个词第一个候选。 - 整词 DP:状态 =(当前段, 该段已用槽位);跨段要结算上一段的
tails(剩余槽位全读 SPACE 的对数概率和);总目标是联合最优值(max递推,不是概率),最后/capacity.sum() + log(256)归一化;平局必须按(源状态 rank, 候选号)确定性打破,否则与 Python 结果不同。(inference/scoring.py:95-273) - Viterbi:帧得分用原始余弦相似度,gap 状态 +0;跳过扣
skip_penalty=0.5(初始化的跳前缀与每帧G_i→G_{i+1}各扣一次,序列尾部结束不扣);groups只限制 gap 等待,不限制零时间转移;零宽 span 先按「极大零宽 run + 第一个允许的 gap 位置」归位到左右边界。(modules/decoding.py:272-421) - 零宽三态:
omit(默认,剔除零宽音素)、preserve(全量改写为整数毫秒、每段至少 1 ms)、discard(整条样本不产出)——本变体固定omit(§4.5 第 3 条)。 - 输出:时间基准是
round(T*timestep, 3),即帧数×10 ms 而非音频真实时长;剥本次在用语言的模型码前缀(不是「只剥默认语言」——转述更正见 §10.8 的缺陷 1);空档不产词 → 本变体按 L1 的语义补SP词。(inference/callbacks.py:118-254) - 数值坑:mel 基是 librosa 的 Slaney 公式(已固化学在图内,宿主无需复刻);帧数与毫秒取整均为四舍六入五取偶(C++ 用
std::nearbyint,不要用std::round);costs的非法槽位是-inf;归一化的运算顺序不可调换。(lib/feature/mel.py:36-37、inference/callbacks.py:173-176、inference/scoring.py:136,266-268) - 全局符号/停用符号在推理路径根本没有传入(只有训练/建词表路径传),因此运行时不会按
SP/sil/pau过滤音素;而configs/g2p.yaml里的global_symbols/stop_symbols/merged_groups会被误读成推理期行为 → 移植时不要顺手塞进去。(inference/data.py:90-93、g2p/encoding.py:54-71)
| # | 事实 | 证据 | 为什么要写下来 |
|---|---|---|---|
| a | choices 是 1 基:候选 id 1..C,0 = 「该词未读/无候选」;图内把 choices 前面补一列 0,再用 words 当行下标 gather,所以 words=0 的行取到 0 |
lib/path_traversal.py:5-6,49-51,56-63;inference/scoring.py:256 |
本方案早期草稿写成 0 基,已实测纠正(夹具与插件现按 1 基实现) |
| b | prepare 不算任何编辑距离:unit="levenshtein" 只是「按分歧行打 MASK」这一策略的名字;真正的多序列列对齐在宿主(列向 consensus 作参照、两侧同时展开、回溯偏好 match > delete > insert,gap 进张量写 0) |
inference/scoring.py:27-82(无 DP);g2p/encoding.py:128-137;lib/levenshtein.py:88-125 |
以为 prepare 会做对齐会漏掉整段宿主职责 |
| c | 分歧的参照列是第 0 列(paths[..., :1]),不是 consensus、也不是最长候选 |
inference/scoring.py:52 |
ONNX.md:97 不足以推出这一条 |
| d | 保留常量:PAD=0、MASK_TOKEN=1、SPACE_TOKEN=2,真实音素 token ≥ 3 |
lib/vocabulary.py:6-10;g2p/encoding.py:96-97 |
判定「这一行有没有音素」要用 0/1 的语义,不能只看非零 |
| e | score 吃的是原始 logits(图内做 log_softmax(-1)),similarities 只进对齐 Viterbi,不进打分 |
deployment/exporter.py:83、ONNX.md:16-17,96 |
宿主预先 softmax 会双重归一化 |
| f | 输入不变式:words=0 的 padding 行、「无候选词」的行,其 paths 必须全 0;否则 prepare 的 shared 会退化成真,把第 0 列 token 当真实音素写进模板 |
inference/scoring.py:52,73 |
违反时静默错对齐,必须在宿主侧校验 |
| g | choices 初值:代码是 candidates.argmax(-1) + 1(首个 True 的 1 基下标),文档 ONNX.md:64 写的是 any(candidates, -1);在前缀打包不变式下二者恒等 |
lib/path_traversal.py:15-18 vs ONNX.md:64 |
移植按代码实现,并自己强制「候选列前缀打包」 |
| h | skip_penalty 不在整词 DP 里(inference/scoring.py 全仓命中 0 次),只在帧×token 的 Viterbi 里 |
modules/decoding.py:306,313,332-335 |
别把它塞进候选选择 |
packages/tifa/ # 形状约定;声明不入库,声明目录下一变体一子目录
├── desc.json # id=otter/tifa, version=compatVersion=0.1.0.0, runtimeLevel=1
└── inferences/align/inference.json # interface/level/variant/name/exports/configuration(§3.2 + §4.2)
装配后的包形状与逐键路径见 docs/otter-design.md §6「模型包结构」的 TIFA 段(唯一权威, :566-603):打包时才装配五张 .onnx、config.json、vocabulary.json 与四本词典;模型文件与声明本身都不入库(声明从 --declarations 指定的目录读取,见 D5)。
| 文件 | 改什么 | 不改的后果 |
|---|---|---|
scripts/check-declarations.py |
①MODEL_KEYS 增 (ALIGN,"tifa");②CONFIGURATION_KEYS 增白名单;③LANGUAGE_NUMBERING 增 "string";④check_align_declaration 按变体分派(tifa 的模型文件语义与 hfa 不同),去掉写死的 "hfa variant" 文案 |
该包全部 align 校验被跳过(scripts/check-declarations.py:510-513) |
scripts/test_check_declarations.py |
增 tifa 的正/反例(新变体、缺词典、音素表多/少、错采样率) | lint 变更无门禁 |
scripts/make-model-fixtures.py |
新增 build_tifa()(五张真签名假图)+ tifa 声明模板;把 align_declaration() 的 variant 参数化;坏包矩阵同 hfa 形状 |
test_Tifa 无夹具 → 运行期 exit(77) 静默跳过 |
src/tests/auto/Analysis/CMakeLists.txt |
加 test_Tifa 到 _otter_tests 与模型测试 foreach 列表 |
用例与注入宏不生效 |
src/plugins/inferenceinterpreters/CMakeLists.txt |
迁移后:在 OTTER_HAS_DSINFER 块内用 otter_add_interpreter_plugin(ottertifa tifa SOURCES… LINKS…) 集中声明(不再是 add_subdirectory(tifa),tifa 目录也不再自带 CMakeLists.txt) |
插件不构建 |
packages/.gitignore(历史,当时随仓库跟踪,现已不需要) |
若把模型自带文件复制进 packages/tifa/ 再入库会被 git 收进去 → 补规则(或改为只在本地产物目录装配) |
160 MB 权重入库 |
scripts/make-package.py |
实测无需改动(§10.6):它按声明的后缀收集文件并保持相对路径,tifa 一趟跑通 | — |
packages/README.md(历史)、README.md |
变体表与计数、打包示例 | 文档漂移 |
docs/otter-design.md |
§1 契约表变体列、§4.3 分层树、§7 插件形状、§9 新增 A 系条目、§10 里程碑、§11 未决 | 迁移者看不到入口 |
不推送、不发布、不建 release;不改 include/otter/Api/Align/1/*(除非 D2 选扩契约);不动 lite(docs/lite-integration.md 的 Align 范围说明另议)。
| 层 | 手段 | 通过口径 |
|---|---|---|
| 声明与包 | python scripts/check-declarations.py <声明目录>/tifa(必须真的跑到 align 专项,即 MODEL_KEYS 已登记;本机声明目录就是 packages/) |
errors = 0;故意破坏(缺词典/音素表差一)时能被拦下 |
| 夹具单测 | test_Tifa(新写):声明导出面、happy path 时间线、knob 生效、坏声明矩阵、会话取消 |
全绿;夹具缺失时是 SKIP(77) 而非"通过" |
| lint 自测 | python -m unittest discover -s scripts -p "test_*.py" |
全绿(含新加的 tifa 用例) |
| 构建门禁 | 本地构建树 configure + build + ctest |
全绿;新目标被 add_subdirectory 收进 |
| 数值等价(核心门禁) | 同一权重、同一音频、同一歌词:TIFA@v1.0.0:infer.py 的 TextGrid vs 本变体结果 |
标签逐条相同、起止偏差 ≤ 1 帧(10 ms);同素材重复跑同一实现结果一致(工具自证) |
| 语言覆盖 | 四门语言各至少一条素材:cmn 用 fox_v2.1(真实),jpn 用 fox_jp_v1(真实),eng/yue 用 §2.6 的合成素材 |
四门都要在报告里出现读数;缺哪门就写明「缺失及原因」,不用别的语言的结论代替 |
| 绝对参考(附加读数,非门禁) | cmn 的 fox_v2.2 ph_dur 时间轴 / fox_v2.1 TextGrid,jpn 的 fox_jp_v1 ph_dur |
只报「偏差分布」,用于判断对齐是否可信;不作为通过条件(这些精标时间轴是另一套流程的产物) |
| 打包 | 本地装配 + manifest.json 逐条校验(size/sha512 回读) |
与 hfa 同口径;不上传 |
| 反调参声明 | 若某语言/某素材不达标,先记录读数再讨论,不做"把阈值调到看起来对齐"的补偿 | 记录在 §10 |
otter 是库,仓内没有任何 int main(只有插件与测试),所以"跑变体"这一步由测试可执行文件承担:
| 步 | 工具 | 说明 |
|---|---|---|
| 1 | TIFA oracle | python infer.py <素材目录或单个 wav> --model <TIFA-1.0-ST>/model.pt -l <语言> -o <oracle 目录>;伴生歌词读 <stem>.txt,没有则读 <stem>.lab(inference/data.py:71-78);输出三 tier TextGrid:texts(词级)、words、phones(音素级;空档写 "",本次在用语言的前缀已剥)。实测样例:buhuji_high_001 → texts/words 各 23 条、phones 43 条,xmax = 9.38 |
| 2 | scratch runner(照 hfa 先例 build/hfa-run/runner.cpp) |
编到 build/tifa-run/runner.exe:加载真实包(本机声明目录的 tifa/ + 真 ONNX + 真词典),走 Align L1 跑一条素材,把结果拍平成 音素:起点:时长 的 TSV(或同结构 TextGrid)。scratch,不进仓库 |
| 3 | scratch 比对脚本(照 build/hfa-run/compare.py:17-53) |
把 oracle 的 TextGrid 与 runner 的输出都拍平成音素区间序列,报「条数、标签是否逐条相同、起止最大偏差」,退出码表达结论。scratch,不进仓库 |
| 4 | 工具自证 | 比对脚本先在同一份输出的两份副本上跑出 0 差异,再对同一份输入做一次"人为挪 2 帧"的变异必须变红;两侧实现各自重复跑两遍结果须一致(tool-metric-selfcheck) |
本变体的 L1 结果用
silenceLabel = "SP",而 oracle 的 TextGrid 把空档写成"":比对脚本按已知映射把SP归一成""再比,不把差异算成标签不符(这是两条契约的写法差异,不是缺陷)。
语言标签有两套拼写,都是事实而非笔误:Align L1 的
languages[].language用cmn/yue/jpn/eng——与既有先例一致(当时先例:本机声明目录的hfa/inferences/align/inference.json的cmn/pinyin, eng/arpabet, jpn/romaji,defaultLanguage: cmn);模型自己的词表前缀与 TIFA 的 G2P 用zh/en/ja/yue(configs/g2p.yaml:9-43的 converterlanguage:、inference/data.py的--language)。两者由configuration.languages映射(逐项取值见 §4.2 指向的设计文档一节,本文不复制),C++ 侧按 hfa 的做法消费这张表(src/plugins/inferenceinterpreters/hfa/main.cpp:487-527);dictionary<Language>的键名与dictionary*的键用 L1 那一套(Cmn/Yue/Jpn/Eng)。
| 期 | 内容 | 交付物 | 状态 |
|---|---|---|---|
| P0 | 事实核对:release 开箱、ONNX 导出、签名与指纹 | 本文 §10.1 | 完成 |
| P1 | 声明与包 + 三处脚本参数化 | packages/tifa/、check-declarations.py、test_check_declarations.py、packages/README.md(包内的声明与 packages/README.md 属历史:现已不入库,声明从 --declarations 目录读取) |
完成(f841875) |
| P2 | 插件骨架(会话、模型文件、互检、声明)+ 单候选端到端链路 | src/plugins/inferenceinterpreters/tifa/、夹具五图与坏包矩阵、test_Tifa 用例 |
完成(436708b、66f1eb8;§10.7) |
| P3 | 打分路径(候选网格、prepare/score、整词 DP、1 基 choices) |
Grid.{h,cpp} 扩展 + Scoring.{h,cpp} + 用例 |
完成(配置合批 + 帧解码按参考重写;空标签与多读音选择都已修,test_Tifa 覆盖;见 §10.3–§10.7) |
| P4 | 真实权重实测与数值比对 | 门禁工具(scratch)+ 读数(§10.8) | 完成:读数以逐 take 口径为主(42/42、逐边界零差异),batch-8 口径同时报出(口径与成因见 §10.8.1) |
| P5 | 诊断指标与报告出口(按 D2) | Metrics.{h,cpp}(+ 契约扩展若 D2 选②) |
未做(D2 已定本轮缓办;L1 契约没有该输出通道) |
| P6 | G2P 完整版(若 D3 选②/③) | text→scheme 转换器 | 未做(D3 已定只支持 lyrics="scheme") |
| P7 | 文档与迁移收尾 | otter-design.md A 系与未决、README、packages/README.md |
完成(设计文档台账 A27–A29、包 README、本文 §10 的实施记录与 §11 的历史索引附录) |
构建纪律:构建/测试命令按 README.md:106-118 的相对路径写法(vcpkg manifest、build/<dir>),不写本机绝对路径;每期先跑门禁再提交。 迁移后补充:流程仍是这一套,但多了一步前置——先 git submodule update --init scripts/vcpkg(overlay 必须先 checkout),再 vcpkg install + cmake -B <dir> …;旧的手工指定 include/lib 的构建树已不能配置。
提交号说明(2026-09-30 迁移后):上表与 §10.7 引用的旧提交号(
f841875、436708b、66f1eb8等)属 rebase 前的历史,新历史与当前分支上都不存在——所有safety/*安全分支(含safety/otter-20261001-1754、safety/otter-presquash)都已删除,这些号只在git reflog里,随 reflog 过期或git gc失效(§11 附录)。本地历史此后从根重写为 4 个提交,f4820d9不再是基线。
maxSegmentDuration取多少(未定)。TIFA 是全长注意力,帧数二次复杂度;hfa 用 60 s。→ 需实测(P3/P4)后在声明与文档里定值,并记录依据。- 导出图的形状声明不可信(已实测,§10.3):
score.onnx把descriptors的声明形状写成[B,P1],实际产出[B,P1,2]。→ 载入期不做形状对撞,改成首次run后断言实际张量;另外dsinfer的公共 API 没有任何图内省(无法枚举输入输出名与 dtype),名字只能像 hfa/game 一样硬编码成常量。 - [推断] 推理路径不传
global_symbols/stop_symbols:inference/data.py调encode_paths时未传这两个参数(训练路径传了),可能导致AP/SP等字面标签在推理下的行为与训练不一致。→ 实现前用一次 Python 运行钉死(P3 前置)。 prepare.onnx的grouped是标量入参,而 CLI 没有对应开关(--score-unit走 Python 侧模板选择):本变体按grouped=false(levenshtein 模板,等于 CLI 默认)实现,word模板暂不暴露。- 词表里没有
SP:silenceLabel用"SP"是变体拼写而非模型符号;若宿主下游按"标签必须来自模型词表"假设处理会不一致 → 在 §4.4 与声明注释里写明。 - 素材:cmn(
fox_v2.1297 条,直接带.lab)与 jpn(fox_jp_v197 条,带音素级时间)都有真实素材; eng/yue 走合成(§2.6 声库),选词只取两边词表的交集。剩下的风险是 jpn 没有现成的「书写单位」歌词:fox_jp_v1只给ph_seq,需要反推 romaji 词(用 TIFA 自己的japanese_dict_full.txt反查,或按ph_num切分音节)。等价门禁只需要两边喂同一份歌词,所以反推结果的质量只影响「对齐是否可信」的读数,不影响门禁有效性 → P4 记录反推手段与命中率。素材与声库均不得入库、不得随包分发(§2.6 的声库带 Terms_of_Use,fox_data属本机精标数据),仓库里只记数据集/声库名、版本与计数。 - 与远端重构的合并风险:变体插件是新增目录,冲突面小;但 P1/P2 会碰
check-declarations.py、make-model-fixtures.py、测试 CMakeLists —— 迁移时按 §5.2 表逐项核对。 - 未决(承接
docs/otter-design.md§11 未决 · 「Align的置信度」):Align L1 的置信度/诊断字段,本轮不扩;P5 若扩,需同时给出docs/schemas与 lint 的同步面。
| 编号 | 决策 | 依据 / 来源 | 状态 |
|---|---|---|---|
| D1 | 「全部功能」分期(P1–P6),契约相关部分单独成期 | §3.4;用户原始请求「支持 tifa 的全部功能」 | 已确认(第 1 轮) |
| D2 | 主干期不动 Align L1;诊断指标/候选报告先记录、后扩 | §3.3;docs/otter-design.md §11 未决 · 「Align 的置信度」 |
已确认(第 1 轮) |
| D3 | lyrics="scheme" + 变体自带四本「书写单位→音素」词典;不移植 cpp-pinyin/MeCab/LSTM |
§2.2 G2P 分层;wolf:docs/linguist-distribution.md:72-75(romaji/cl 命名一致) |
已确认(第 1 轮) |
| D4 | 以真实素材 + Python oracle 逐音素比对为门禁;合成通道可选 | docs/plans/hfa-align.md:83(先例);D 子代理实测「本机无声库、lite 无 Align 通道」 |
已确认(第 1 轮) |
| D5 | (历史)声明入库、权重不入库、本轮不发布;现行:声明亦不入库,装配读 make-package.py --declarations <声明目录>(见上文决策表 D5) |
历史依据:packages/.gitignore:10;用户口径「远端重构中,本地预开发」 |
按推荐执行(可回退) |
| D6 | 用上游 deploy.py 导出 ONNX,otter 只记录 |
§10.1 实测(约 15 s,五图签名符合 ONNX.md) |
按推荐执行(可回退) |
| D7 | variant="tifa"、otter/tifa;(历史)0.2.0.0,现行 0.1.0.0 |
当时先例(本机声明目录):packages/hfa/desc.json:2-14 |
按推荐执行 |
| D8 | configuration 用扁平 dictionary<Lang> 键(lint 只对字符串路径做校验) |
scripts/check-declarations.py:531-544 |
按推荐执行(P1 已落地) |
| D9 | 静音补词用 silenceLabel="SP",nonSpeechPhonemes 不声明 |
§2.4「模型无静音/非语音检测头」;AlignApiL1.h:137-143 |
按推荐执行(P1 已落地) |
| D10 | 四门语言(cmn/yue/jpn/eng)都要实测,不只做 cmn | 用户第 1 轮的选定;素材来源见 §8.6 | 已确认,素材待定 |
确认轮次:本会话提交方案时 D1–D9 一次性列出(选项式提问);用户确认后在此行记录"逐条选定/范围确认/实施方式确认"的落点。
| 项 | 结果 |
|---|---|
| 上游 | openvpi/TIFA @ v1.0.0(32a0a13) |
| release 资产 | TIFA-1.0-ST.zip,150 789 526 B,sha256 6da6832cd2cb981aae1ccc2d9330f8f7c085ce7977d1eb13bff63531d038a88b(与 release 页公布值逐字符一致) |
| 解包内容 | model.pt(158 588 551 B)、config.yaml(1 567 B)、vocabulary.json(4 115 B)、dictionaries/(ds_cmudict-07b.txt 3 370 331 B / ds-zh-pinyin-lite.txt 6 494 B / jyutping_dict.txt 6 897 B / japanese_dict_full.txt 1 552 B)、assets/LstmG2p-Eng/(encoder.onnx 2 536 734 B、decoder.onnx 1 491 741 B、char.json、phonemes.json、config.json);无 LICENSE、无 ONNX 推理图 |
模型配置(config.yaml 实测键值) |
model.arch=ForcedAlignmentModel、max_vocab_size=256、in_dim=80、embedding_dim=256、out_dim=256;inference.features.audio_sample_rate=48000、hop_size=480、fft_size=2048、win_size=2048、spectrogram={type: mel, num_bins: 80, fmin: 0.0, fmax: 8000.0};inference.g2p 为五转换器链(chinese-pinyin/japanese-mecab/yue-jyutping/lstm(en)/dictionary(zh,ja,yue)/passthrough),词典与资产均以 @ 前缀相对模型目录引用 |
| 导出 | python deploy.py -m <model-dir>/model.pt -o <out>(上游 deploy.py:11-30,opset 默认 18)→ 成功,约 15 s(日志:五图逐个 Exporting,Deployment completed) |
| 产物 | spectrogram.onnx 339 493 B、model.onnx 159 792 782 B、prepare.onnx 30 776 B、score.onnx 23 980 B、select.onnx 13 463 B、config.json 165 B、vocabulary.json 4 115 B |
| 产物指纹(sha256) | config.json 3380e63d13754364808ca92c9090e6a489b733864a99f07188ed6e5f457b2549;model.onnx bc58d7fe8c5fd5f41582e6ef06dd801049df1c0097c198f7d5289f78539f9dcd;prepare.onnx f55667ed220ba3208d1b121bfc228cd04ef2d10c4e49eabcee72c1f7e08af7ed;score.onnx 2133cb89066e46a2ca1b78f303d11c45ec2670e346b63689639ecc2d39833e97;select.onnx abfab83229bf3a8e188c137553ed04edefcc59500435e4aba7e6ddc77f71d9d5;spectrogram.onnx 2fe85f8507d1acd998fa854869fda75f3a2fa1d316f1e56ef4ea29df8b2a51a4;vocabulary.json 644c85ac28cfe4f26fcf60de5a6d1fe192c11a6a837746119ef2dbc6dac9246c |
| 导出环境 | 独立 venv(--system-site-packages,复用已有的 torch 2.8.0+cu128 / onnx 1.22.0 / onnxslim),按上游 requirements.txt 补齐其余依赖;未改动上游仓工作树(PYTHONDONTWRITEBYTECODE=1,git status 干净) |
| 词表实测 | 220 个符号 / 219 个不同 id(id 3–221);保留 id 0/1/2 不在符号表;AP=3、EP=4、GS=5;ja/um 与 zh/um 共享 id 83;按前缀计数:zh 65、yue 70、ja 40、en 42、无前缀 3 |
| 结论 | 五图接口与 ONNX.md 相符(除 descriptors 秩一处,见 §8.2);ONNX 路线可行,无需在 otter 内维护导出器(D6 得证) |
| 项 | 结果 |
|---|---|
| 新增声明(历史:当时随仓库跟踪,现已不入库) | packages/tifa/desc.json、packages/tifa/inferences/align/inference.json:四门语言(cmn/pinyin、yue/jyutping、jpn/romaji、eng/arpabet,全部 lyrics="scheme")、defaultLanguage=cmn、silenceLabel="SP"、sampleRate=48000、channelCount=1、maxSegmentDuration=60(待 P3/P4 复核)、configuration 十一处文件加 languages(cmn→zh、yue→yue、jpn→ja、eng→en);音素表直接取自 §10.1 的 vocabulary.json(四门语言各 65/70/40/42 个,另加三个无前缀共享符号) |
| lint 参数化 | scripts/check-declarations.py:登记 (ALIGN,"tifa") 的 MODEL_KEYS/CONFIGURATION_KEYS/LANGUAGE_NUMBERING;check_align_declaration 改为按变体分派(hfa 保持原路径,改名 check_hfa_align_declaration),新增 TIFA_DICTIONARIES 表把语言接到词典键 |
| tifa 专项校验 | 采样率对 config.json 的 samplerate;声明语言必须有对应 dictionary<Language> 键且文件在包内;phonemes 与 vocabulary.json 里带该语言前缀的集合互核(无前缀的共享符号允许出现在任何语言里);silenceLabel 不得与任何声明的音素重名;nonSpeechPhonemes 必须是无前缀符号;声明了 dictionary* 却没有对应语言 → 警告 |
| 自测 | scripts/test_check_declarations.py 增 4 组用例(tifa 好包 0 error/0 warning、采样率不符、缺词典与多词典、音素多一个与少一个、静音标签撞音素);python -m unittest discover -s scripts 20 个用例全绿(迁移后为 25 个;此后随仓库重写与用例增加,现行整套为 49 个用例) |
| 真包对撞 | 用 §10.1 的真实导出装配一份包(真实 config.json/vocabulary.json/四本词典,五张图用占位文件)→ 0 error(s), 0 warning(s);再做四处变异(采样率改 44100、cmn 少列一个音素、eng 多列一个音素、删掉 dictionaryYue)→ 4 条 error 全部命中,证明该校验确实在跑,而不是像未登记变体那样被静默跳过 |
| 文档 | packages/README.md(四变体表、tifa 装配命令与专项校验说明)、README.md(采样率一栏补 48 kHz)、packages/.gitignore(补 config.json/vocabulary.json/dictionaries/ 三条)——(历史)后两者现已不入库 |
| 未做 | 插件与夹具(P2)、宿主算法(P3)、真实权重实测(P4)。scripts/make-package.py 经核对无需改动:它按文件名平铺查找模型文件、再落到声明写的位置,(directory/inside).parent.mkdir(parents=True) 已经覆盖 dictionaries/ 这类子目录 |
| 项 | 结果 |
|---|---|
| 手段 | 用 onnx.reference.ReferenceEvaluator 直接跑导出的 score.onnx,输入取导出器自带的示例(B=2, T=32, N=6, W=2,paths/words/candidates 同 deployment/exporter.py:129-131),与 Python 的 prepare_scoring + score_fragments 逐张量比对 |
| 结果 | 五个输出的形状与样本值完全一致:descriptors[2,7,2]、lengths[2,7,2]、costs[2,7,2,7]、tails[2,7,7]、capacity[2,7](costs 的 -inf 位置也一致) |
| 由此确认 | score.onnx 的输出声明秩 2 是注解错误(onnxslim 按 dynamic_axes 重写了形状注解),实际张量秩 3,与 ONNX.md:105 和 inference/scoring.py:106-112 相符 |
| 更正 | 本文档早前记录的「实测 [B,P1]、与 ONNX.md 不一致」是误读形状声明得出的错误结论,已在 §2.3、§8.2 更正。教训:读 ONNX 元数据不等于读运行时张量,校验以运行时为准(与 tool-metric-selfcheck 的纪律一致) |
| 附带收获 | 这段比对脚本本身就是 P4 之前最有用的「图语义」验证器:换任意随机输入即可继续压测五张图,不必等 C++ 侧写好 |
| 项 | 结果 |
|---|---|
| 声库 | 用户指定 0913_wolf_club@1.0.0(packager 格式,含 cmn-pinyin/eng-arpabet/jpn-romaji/yue-jyutping 四个语言接口与四个歌手),许可受限 → 只在本机使用 |
| 对撞 | 把声库 inferences/acoustic/*.phonemes.json 与 TIFA vocabulary.json 按 cmn↔zh、eng↔en、jpn↔ja、yue↔yue 逐语言比对:eng 两边 42 个完全一致;cmn 63 ⊆ TIFA 65(多 iai、um);jpn 39 ⊆ TIFA 40(多 um);yue 声库 73 ⊇ TIFA 70(多 em/ep/eu) |
| 结论 | 共享音素 um 在声库侧是无前缀符号、在 TIFA 侧是 ja/um≡zh/um 的合并 id(语义相同);SP 在声库侧是真实共享符号、在 TIFA 侧只作为本变体的静音拼写。选词只取交集 → 写进 §2.6 与 §8.6 |
| 项 | 读数 |
|---|---|
| oracle 可用性 | infer.py(-l zh / -l ja)在 48 kHz 素材上跑通:cmn 5 条、jpn 6 条全部 SUCCESS,各出三 tier TextGrid(texts/words/phones) |
| 素材配平 | 原素材是 44.1 kHz/24-bit 单声道;统一转成 48 kHz/16-bit 单声道后再喂两侧,两条链路都不再重采样(转换是 scratch 命令,不入库) |
| jpn 歌词反推 | 用 japanese_dict_full.txt 反查 ph_seq:6/6 成功;自证方式是把反推词再展开回音素序列,要求与原标注逐条相等(脚本 scratch,不入库) |
| 标注时间轴可信度 | fox_jp_v1 的 ph_dur 之和与 wav 时长逐条完全相等(6/6,差 0.0000 s)→ 标注时间轴就是音频时间轴 |
| 绝对参考读数(非门禁) | jpn:phone 条数与标签与精标完全一致,最大边界差 26.2 ms / 32.5 ms;cmn:条数一致,少量韵母写法不同(uo↔o、ui↔ei、uan↔an),一条最大边界差 19.7 ms,另一条因开头长静音被 TIFA 判得比精标短而整条平移约 0.30 s |
| 结论 | oracle 与素材两条前置均自证通过。第二条也说明了为什么门禁必须是「oracle vs 变体」:精标时间轴来自另一套流程,与 TIFA 在静音处理上本就不同 |
| 项 | 结果 |
|---|---|
| 结论 | scripts/make-package.py 无需任何改动即可装 tifa:--variant tifa --models <导出的 7 个文件 + 4 本词典> 一次跑通 |
| 原因 | 脚本按声明里 MODEL_KEYS 登记为该变体模型文件的配置键收集文件(scripts/make-package.py:104-129),并按声明内的相对路径保持目录结构(scripts/make-package.py:126-128),所以 ../../<图>.onnx 落在包根、../../dictionaries/*.txt 落在包子的 dictionaries/ ✓ |
| 装配实测 | 包目录 otter-tifa/:五张图 + config.json + vocabulary.json + dictionaries/(4 本)+ inferences/align/inference.json + desc.json;内置 lint 通过;产出确定性 zip otter-tifa-0.2.0.0.zip(144 MB;历史:当时的包版本号,现行四包均 0.1.0.0)与逐文件 sha512 片段 |
| 未做 | 没有并入发布用 manifest.json(不属本轮范围,也没给定 bundleVersion);产物全在 build 目录,未提交 |
| 项 | 结果 |
|---|---|
| 提交 | 436708b(夹具:五图 + 好包 + 6 个坏包 + 声明模板)、66f1eb8(插件 tifa/、test_Tifa.cpp、两处 CMake 注册) |
| 夹具自证 | 脚本 _check_signature 逐图断言名字/元素类型/秩/动态轴 + opset=18;每张图与脚本内的 numpy 参照实现逐张量比对(checked 20 tensors of 5 graphs);另用外部脚本 + onnxruntime 复核(tol 1e-5);旧夹具(hfa/rmvpe/note)逐文件 SHA256 0 changed / 0 missing |
| 反例矩阵 | fixture-align-tifa-{wrong-rate,no-dictionary,missing-dictionary,wrong-phonemes,unknown-silence,extra-dictionary}:命名沿用先变体后缺陷(与 test_Tifa.cpp 的消费方一致);wrong-rate 取 16000 以便与提供者的报错文案对撞;extra-dictionary 必须仍能加载(0 error / 1 warning) |
| 测试 | ctest --test-dir build/agent-tests → 9/9 通过(含 test_Tifa 7 个用例与既有 5 个模型测试、声明 lint 测试)——迁移后修正:_otter_tests 是 9 个目标 = 5 个非模型 + 4 个模型(test_Rmvpe/test_Game/test_Hfa/test_Tifa),原文"既有 5 个模型测试"与该清单不符;新基线上 Debug 树只有 6 个非模型用例通过,4 个模型用例因 ONNX Runtime 运行目录未就绪报"找不到指定的模块"(Release 树实测 10/10) |
| 本阶段实现范围 | 五图各一会话;实际调用 spectrogram → select → model → 宿主 flat Viterbi(单候选捷径:choices 取每词首个可用候选);prepare/score 会话已打开但未调用(P3 接上) |
| 落地时修掉的缺陷 | ① run() 里 features->heard 被 std::move 后仍被 runModel 读 → 空 vector .back(),异常被 catch(...) 吞成无后缀消息;② choices 语义按上游纠正为 1 基(见 §4.7.1a);③ 测试里 1.005 * 48000 截断取整少 1 个样本 → 改 std::llround |
| 有意差异的落地 | §4.5 第 7 条(未收录音素 = 未收录书写单位)已实现并带端到端用例(复制夹具包到临时目录、只改副本词典追加 zq → qq,验证「单说被拒」「与真词同句时只保留真词且不冒充静音」) |
工具:build/tifa-run/{runner.cpp,build.ps1,compare.py,gate.ps1}(scratch,不入库)。
build.ps1从build/agent-tests/build.ninja里读出测试目标自己的DEFINES/FLAGS/INCLUDES/LINK_LIBRARIES来编译 runner(避免第二份路径清单漂移)。迁移后过时:该树已不能配置,门禁工具要改从新构建树的build.ninja读同一组标志,重跑前先补这一段。compare.py的门禁 = 音素标签逐个相等 且 每个边界 ≤ 1 帧(10 ms);自证:把某一侧边界人为移动 2 帧必须被判红,同输入重复跑读数必须逐位一致——两条都在实测中通过。- 两侧读同一批文件:原始素材与合成素材都先统一转 48 kHz/16-bit 单声道,两条链路都不再重采样。
| 样本 | 读数 |
|---|---|
cmn 真实 5 条 |
全部 PASS:音素数逐个相等(40/39/44/37/50),标签全同,最大边界差 1.00 帧,其中一条 100 个边界零差异 |
cmn 合成 1 条 |
PASS:16 音素,32 个边界零差异 |
jpn 真实 6 条 + 合成 3 条 |
计数全部相等、时间逐个吻合(例:ja/a at 0.120 对 a at 0.120),但标签带着 ja/ 前缀 → 判红(见下方「发现的缺陷 1」) |
yue 合成 3 条 |
同上:26/30/26 音素一一对应、时间吻合,标签带 yue/ 前缀 |
eng 合成 |
2 条同上带 en/ 前缀;syn-eng-002 计数 44 对 45(见「发现的缺陷 2」) |
| 词级交叉印证 | cmn/buhuji_high_001:变体 23 个词、末词结束 9.375 s;参考 23 个区间、xmax=9.38 ✓ 总时长一致(帧数×10 ms) |
发现的缺陷 1(真缺陷,门禁抓到的):非默认语言的音素前缀没剥。 变体只剥声明默认语言(cmn)的前缀,于是 jpn/eng/yue 的结果里带着 ja/、en/、yue/ ——而 exports.languages[].phonemes 承诺的是裸音素,宿主拿到的标签会不在自己的白名单里。根因是方案里「只剥默认语言前缀」这条转述有误:它原写在 §2.4 的「语言前缀省略」行与 §4.7 第 6 条(两处已就地更正)。参考实现剥的是本次在用的语言(CLI 的 -l 就是它的默认语言),故单语言运行时它永远剥掉。修正:剥「本次对齐所用语言」的模型码前缀;cmn 5/5 通过正是因为它恰好等于声明默认语言。
发现的缺陷 2(预期差异,等 P3):syn-eng-002 音素数 44 对 45。 eng 是唯一有多候选书写单位的语言(§2.4 实测:6.3% 的键有多行),参考实现跑 prepare→score→整词 DP 挑候选,而当前变体走单候选捷径(取每词首个候选)→ 候选选得不同,音素数自然不同。这正是 P3 要接上的路径;接上后重跑本条即可。
读数快照:analysis-level-1(含 006fd55 的帧解码重写),强制重跑 → 本快照是 batch-8 口径;逐 take 口径(宿主调用形态)的读数是 42/42、逐边界零差异,两种口径的成因见下方的「读数口径」。下表只是该快照的分组视图,不作为任何结论的出处。
读数口径(2026-09-30 复核):本表是对着 batch-8 形态的
oracle-48k量出来的,该 oracle 可复现 (与当前 Python 原版逐字节相同)。但参考对批组成敏感(同一 take 换批,其相似度 mean|Δ| = 0.049),而宿主调用本变体是一次一个 span——所以**逐 take(--batch-size 1)**才是"等价移植"的判据。本表的数字要读作"与 batch-8 oracle 的差异",其中的词内音素差异多半由此而来;两种口径都要报,不许只留一侧。
| 语料 | 通过 | 判红 |
|---|---|---|
| cmn 真实 5 + 合成第一批 1 | 6/6 | — |
| cmn 合成第二批 | 3/5 | syn2-cmn-001(词内音素:我们 h iao 对参考 h)、syn2-cmn-002 |
| jpn 真实 6 | 6/6 | — |
| jpn 合成第一批 | 2/3 | syn-jpn-001 |
| jpn 合成第二批 | 4/4 | — |
| yue 合成第一批 3 + 第二批 4 | 7/7 | — |
| eng 合成第一批 | 1/3 | syn-eng-002(music 少词尾 k)、syn-eng-003 |
| eng 合成第二批 | 2/8 | syn2-eng-001/002/003/005/006/008(002 少词尾 k;006 读 ae 对参考 ax) |
三条独立的失败原因(不要混为一谈):
| 代号 | 现象 | 归因 | 状态 |
|---|---|---|---|
| A | 结果里出现空字符串音素标签( at 2.940 对 ax at 2.940),其后整条链错位一位 |
打分路径选列后取标签的环节(P3 新增逻辑) | 已修:标签改取候选元数据(42c08fe)。本轮 42 个样本里已无空标签(classify-failures.py 输出全部为真实音素) |
| B | syn2-cmn-001 音素数 27 对 26(cmn 词条只有一个候选,排除候选选择) |
待查:丢词/多词、或同一书写单位两边读音个数不同 | 已实测定位:词级音素数一致,差异在词内——hiao 我们 h iao 对参考 h |
| C | 帧解码终局状态选择与参考不等价(§10.9 C1,已复验) | Decode.cpp 未忠实移植参考状态机 |
已按参考重写(006fd55,含取自参考输出的用例);实测它只解释 13 个失败里的 2 个,其余 11 个与之无关 |
同时确认的事实:37 份参考输出里没有一个零宽音素(build/tifa-run/count-skipped.py 实测 0/37),所以 C1 在本语料上没有以"整个音素被吃掉"的形式出现——但它仍可能表现为边界偏移(B/C 之前不做结论)。
复核子代理只读(未改仓库、未构建、未 git 写),目标是推翻已落地的实现。它报了 5 条,我亲自抽复 了最重的两条(用它自己的证据不够——它的复现是"把 C++ 逐行转写成 Python",转写本身可能是错的):
| # | 断言 | 我的复验结果 | 处置 |
|---|---|---|---|
| C1 | Decode.cpp 的终局状态选择与参考不等价:会把帧给错音素 |
成立(P0)。我用 cl /std:c++20 直接编真实 Decode.cpp(build/tifa-run/check-decode.cpp)跑它的反例 T=1,N=2,groups=[0,1],sim=[[1.0052632,1.2999038]]:真实输出 [0,1) [0,0)(得分 0.50526),参考给第二个音素(0.79990,暴力枚举确认为最优) |
开修:忠实移植参考 flat 路径(swap 纪律、next_gap 更新顺序、被禁 gap 补丁、退出循环不再 swap、终局三路比较、gap_back 逐帧后向指针、平局取最小 state 序号);要求用真实 C++ vs 真实 Python 的随机差分自证 |
| C2 | Scoring.cpp 建分片列表时多加了 segment > 0 过滤,参考只按词号选(scoring.py:178) |
结论:真实表上恒等,非缺陷。参考的 descriptors 只对 mapping > 0 的行写入(scoring.py:101-105),而 mapping 只对分歧行非零 → 非哨兵行的 segment 恒 ≥ 1,故该过滤在真实权重产出的表上不可能命中;它只在夹具的简化表上起作用(那是为兼容夹具而写的 shim,已在 Scoring.cpp 注释写明依据,并用 segmentSlots 判两种布局) |
可选后续(不是缺陷修复):把夹具的 prepare/score 做成参考形状(capacity 带哨兵、padding 片段不带真词号、分歧与第 0 列比),再把 shim 删掉——收益是"DP 在参考形状的表上被验到",不改也不影响真实路径 |
| C3 | Decode.cpp 的平局规则(先 position 后 kind)与参考(最小 state 序号)不同 |
与 C1 同源,未单独剥离 | 随 C1 一起按参考实现 |
| C4 | 零宽归位:Decode.h 声明"参考把丢掉的连段移到 groups 允许的 pause 位置、并按左右分段",原实现恒给 (0,0) |
已闭环,无需再改:006fd55 的重写已按该声明实现,代码逐字对应(Decode.cpp:189-218:left = end[lo-1]、right = start[hi+1]、按第一个 allowed(anchorAt) 分裂、无处可放则整段压在后边界);且可观察行为与参考逐边界相同(逐 take 口径读数 42/42、逐边界零差异)。注意归位只移动零宽点、不撑宽度,上层照旧按 start >= end 丢弃——这正是参考 --skip-handling=omit 的默认行为。 |
无(原判"对上层无影响"成立;中途我一度误判为"参考靠归位撑开",已实测更正——参考自己就丢零宽,--skip-handling omit) |
| C5 | 测试覆盖:夹具没有多候选词,P3 之前整词 DP 一次都没跑过;decodeFrames/buildGrid 无直接单测 |
成立(P3 已新增 eng 多候选用例,DP 路径现在会被走到;DP 的数值正确性在夹具上仍不可表达——夹具的 cost/tails 恒 0) | 夹具向参考形状靠拢;decodeFrames 的差分测试随 C1 补 |
方法学提示(值得记下来):复核者用"C++ 的 Python 转写"当证据,这一步本身不可信;但它的结构读法 (逐行对比参考的状态机)是对的,我用真实 C++ 一跑就复现了。→ 复核结论必须用被测语言本身复验。
(待实施;每期一节,含门禁表、缺陷发现与修法、仍未验证项。)
本节性质:方案期的迁移过程记录已收束成这一节索引,只留仍然成立的知识:那次历史重写做了什么、为什么这么做、旧提交号现在去哪找,以及当时踩过的工具坑。本节不含可执行的门禁读数:读数与口径见 §10.8.1,本仓现行状态见
docs/otter-design.md。
重写做过什么:2026-09-30 前后,四个仓(otter / ds-editor-lite / synthrt / wolf)的远端分支被强制重写(重新撰写的提交序列,不含任何 tifa 工作),本地分支因此都不再位于远端历史内;otter 侧把本地 tifa 增量落到重写后的新结构上(保持单分支),冲突一律按上游的结构与措辞落位。
为什么这么做:重写是上游的意图表达——新分支的结构与措辞是权威,本地增量只该落到新结构上,不该反向覆盖它;所以那一轮是「纯增量落到新结构」,不是「合并两套同源改动」。
此后又一次重写(现状):本地历史随后从根重写为 4 个提交(88417af → bc36a95),tifa 与包装配的改动都并进这 4 个提交。当前 analysis-level-1 与 origin/analysis-level-1(f4820d9,2026-09-30 的重写头)没有共同祖先:f4820d9 不再是基线,git rev-list --count f4820d9..HEAD、git diff f4820d9..HEAD 之类的对账一律作废,不要再照旧引用。
旧提交号去哪找:f4820d9 之前本地那一串提交号、以及 safety/* 安全分支下的号,都已不在任何 ref 上(safety/* 分支已全部删除):它们只在 git reflog 里,随 reflog 过期或 git gc 失效。要留证据就当下摘录进文档,不要指望以后还能 git show。
门禁结论(仍然成立):宿主调用 Align 的形态是一次一个 span,故等价判据取逐 take 口径——当时读数 42/42 通过、逐边界零差异;此后不要再按「零宽音素的去向」或「词典多读的候选选择」去改解码器,两条路都已实测排除(口径与成因见 §10.8.1)。
仍在代码里的一条上游修复:OnnxSupport.h 的 runModel() 调用自由函数必须写成 otter::onnx::run(...)——不限定就会被同名成员函数挡住(name hiding),所有 Align 变体都编译不过。该文件属上游,其注释目前引用的是本文删掉的「归档 §11.4」,即本条;接手者可按需把注释改指本节。
耐久教训(工具与门禁;跟人走不跟版本走):
- 任何门禁读数都必须强制重跑:门禁脚本原先「输出不存在才跑」,于是把一次中断运行留下的空文件读成「该 take 由通过变 0 词」,得出了「解码重写引入回归」的错误结论。
- 探针的编译期常量指向旧构建树时会静默跑旧产物:插件目录、驱动目录、ONNX Runtime 都是硬编码常量,换树后不改不重编,读出来的不是当前代码(据此误报过一次「迁移后行为一致」)。
- 比较器与指标先自证再报数:判据要能「故意挪两帧必须判红、同输入两次读数逐位一致」;比较器自己启动失败时(f-string 跨行拼接在 Python 3.12+ 是
SyntaxError),整批 take 会被记成「失败 + 自检不可用」——整批红时先单独手跑一次比较器。 - 复核证据要用被测语言本身复验:把 C++ 逐行转写成 Python 当证据不可信;结构性的读法可以提示方向,结论要用真实构建产物重算。
- 参照侧的语言码要剥掉批次后缀(
jpn-syn2→jpn):否则参考一个文件都不产出,比对读成假失败。 - 比对素材必须与 oracle 同批:两侧都吃同一批统一成 48 kHz/16-bit 的副本,拿原始素材去比副本的 oracle 会引入编码差异。
- 声明上限的实测边界:
maxSegmentDuration = 60接受 60.000 s、超一帧即被拒(当时用的测量脚本在 scratch 目录,不入库)。 - 清理临时产物前先 grep 文档里的引用:只有
build/开头的写法会被正则抓到,用反斜杠写、或省略build/前缀的引用会被漏掉(当时因此删掉了被本文引用的两个工具)。
- 提交卫生:本地提交的消息正文、作者与提交者身份、新增文件内容三处都不含本机绝对路径或用户名。