Inference Pipeline 推理管线

The Singing Voice Synthesis (SVS) pipeline is the heart of SXSEditor. It is implemented in src/inference/pipeline/index.js by the OnnxSVSPipeline class, which loads 9 ONNX models and runs SoulX-Singer diffusion-based synthesis.

歌声合成(SVS)管线是 SXSEditor 的核心。它由 src/inference/pipeline/index.js 中的 OnnxSVSPipeline 类实现,加载 9 个 ONNX 模型并运行基于扩散的 SoulX-Singer 合成。

ℹ️

All pipeline constants live in two files: src/inference/shared/constants.js (shared with the WebNN module) and src/inference/pipeline/constants.js (pipeline-specific). The two inference paths (main-process ONNX Runtime + DirectML, renderer WebNN) must use the same values — that file is the single source of truth.

所有管线常量分布在两个文件:src/inference/shared/constants.js(与 WebNN 模块共享)和 src/inference/pipeline/constants.js(管线专属)。两条推理路径(主进程 ONNX Runtime + DirectML、渲染进程 WebNN)必须使用相同的数值——该文件是唯一来源。

Pipeline Overview 管线总览

Notes + Lyrics + BPM
        │
        ▼
┌─ Stage 1: Text Processing (textProcessing.js) ─────────────┐
│  Chinese → pinyin-pro  │  English → ARPAbet G2P dict        │
│  Japanese → hiragana/katakana map                          │
│  Output: phoneme ID sequences (phone_set.json, 2820 entries)│
└────────────────────────────────────────────────────────────┘
        │
        ▼
┌─ Stage 2: Duration Statistics (durationStats.js) ──────────┐
│  Lazy-loaded en_phoneme_durations.json (~4.1MB)            │
│  Lookup chain: trigram_full → trigram → bigram → unigram    │
│  Falls back to vowel-priority for very short notes         │
└────────────────────────────────────────────────────────────┘
        │
        ▼
┌─ Stage 3: Preprocessing (preprocessing.js) ────────────────┐
│  Build F0 frame sequence, mel2token, note type ids         │
│  Pitch → MIDI → frequency (440 * 2^((midi-69)/12))         │
│  F0 quantization to F0_BIN=361 bins, F0_MIN=32.70Hz        │
└────────────────────────────────────────────────────────────┘
        │
        ▼
┌─ Stage 4: Encoders (parallel) ─────────────────────────────┐
│  note_text_encoder  →  text embedding     (EMBED_DIM=512)   │
│  note_pitch_encoder →  pitch embedding    (EMBED_DIM=512)   │
│  note_type_encoder  →  type embedding     (rest/vocal/slur) │
│  f0_encoder         →  quantized F0 emb                        │
│  preflow (ConvNeXtV2) + cond_emb → condition (COND_DIM=1024)│
└────────────────────────────────────────────────────────────┘
        │
        ▼
┌─ Stage 5: Diffusion (diffusion.js) ────────────────────────┐
│  Iterative denoising, DEFAULT_DIFF_STEPS=32                │
│  CFG: schedule-based (constant/linear/cosine/custom)       │
│  Per-step: cond + uncond branches, flow_pred output        │
│  NPU static shapes: pad to NPU_STATIC_SEQ_LEN=2048         │
└────────────────────────────────────────────────────────────┘
        │
        ▼
┌─ Stage 6: Vocoder / Postprocessing (postprocessing.js) ────┐
│  Mel (MEL_DIM=128) → waveform at SAMPLE_RATE=24000         │
│  Chunked vocoding: VOCODER_CHUNK_FRAMES=1008, overlap=32   │
│  Default vocoder: vocoder_dml.onnx (Vocos, DirectML)       │
│  Optional: SiFiGAN (SIFIGAN_HOP_SIZE=120, 200Hz mel)       │
│  Output validation: reject all-zero / NaN waveforms (OOM)  │
└────────────────────────────────────────────────────────────┘
        │
        ▼
Float32Array PCM audio (24kHz model rate, output upsampled to 48kHz)

Stage 1 — Text Processing 第 1 阶段 — 文本处理

src/inference/pipeline/textProcessing.js converts lyrics into phoneme ID sequences. The TextProcessing class loads phone_set.json (2820 entries) and en_g2p_dict.json (126k English words → ARPAbet) on construction.

src/inference/pipeline/textProcessing.js 将歌词转换为音素 ID 序列。TextProcessing 类在构造时加载 phone_set.json(2820 条目)与 en_g2p_dict.json(12.6 万英文词 → ARPAbet)。

Language auto-detection is done by OnnxSVSPipeline.detectJapanese(notes) (checks for jp_* phonemes or hiragana/katakana characters in lyrics).

语言自动检测由 OnnxSVSPipeline.detectJapanese(notes) 完成(检查歌词中是否含 jp_* 音素或平假名/片假名字符)。

Stage 2 — Duration Statistics 第 2 阶段 — 时长统计

durationStats.js lazily loads src/inference/en_phoneme_durations.json (~4.1MB), generated by build_en_phoneme_duration_stats.py from MFA-aligned LJSpeech. The lookup fallback chain (most precise → most robust):

durationStats.js 懒加载 src/inference/en_phoneme_durations.json(约 4.1MB),由 build_en_phoneme_duration_stats.py 从 MFA 对齐的 LJSpeech 生成。查表回退链(精度从高到低):

  1. trigram_full (prev | curr | next | pos | stress)(前 | 当前 | 后 | 位置 | 重音)
  2. trigram (prev | curr | next)(前 | 当前 | 后)
  3. bigram (prev | curr)(前 | 当前)
  4. unigram (curr) — always hits(当前)— 总能命中

Application policy (in preprocessing.js via _allocateByStats):

应用策略(在 preprocessing.js_allocateByStats 中实现):

💡

The 4.1MB stats file is loaded asynchronously in the Preprocessing constructor — startup is never blocked. If loading fails, the pipeline silently falls back to linear allocation.

4.1MB 统计表在 Preprocessing 构造函数中异步加载——绝不阻塞启动。若加载失败,管线会静默回退到线性分配。

Stage 3 — Preprocessing 第 3 阶段 — 预处理

preprocessing.js builds the frame-level inputs consumed by the encoders:

preprocessing.js 构建编码器消费的帧级输入:

Stage 4 — Encoders 第 4 阶段 — 编码器

Five encoder models run in parallel to produce the conditioning tensor cond of dimension COND_DIM=1024:

五个编码器模型并行运行,生成维度为 COND_DIM=1024 的条件张量 cond

Model模型 Input输入 Output dim输出维度
note_text_encoder.onnx phoneme ids音素 id EMBED_DIM=512
note_pitch_encoder.onnx MIDI pitch idsMIDI 音高 id EMBED_DIM=512
note_type_encoder.onnx note type ids (rest/vocal/slur)音符类型 id(休止/人声/连音) EMBED_DIM=512
f0_encoder.onnx quantized F0 ids量化 F0 id EMBED_DIM=512
preflow.onnx sum of the four embeddings (ConvNeXtV2 pre-flow)四个嵌入之和(ConvNeXtV2 预流) EMBED_DIM=512
cond_emb.onnx preflow outputpreflow 输出 COND_DIM=1024

Stage 5 — Diffusion 第 5 阶段 — 扩散

diffusion.js implements the iterative denoising loop using diff_step_dml.onnx. Each step takes xt_input (current noisy mel), t (timestep), cond (conditioning), and xt_mask; it outputs flow_pred used to update xt. The loop is driven through a pluggable sampler interface (see Samplers Module below).

diffusion.js 使用 diff_step_dml.onnx 实现迭代去噪循环。每步接收 xt_input(当前带噪 mel)、t(时间步)、cond(条件)、xt_mask,输出 flow_pred 用于更新 xt。循环通过可插拔的 求解器 接口驱动(见下方 求解器模块)。

Dynamic Thresholding 动态阈值

Per-frame quickselect percentile clipping (arXiv:2507.08965) is applied after CFG combine. The percentile is configurable (0.9–0.999). This technique clips extreme values in the diffusion output to a percentile band and then linearly remaps the clipped values back, preventing rare outlier frames from dominating the audio. It is integrated into both the DML (nativeSvsPipeline.js) and WebNN diffusion paths.

在 CFG 合并之后,应用逐帧 quickselect 百分位裁剪(arXiv:2507.08965)。百分位可配置(0.9–0.999)。该技术将扩散输出中的极值裁剪到百分位区间内,再将裁剪后的值线性重映射回去,防止罕见的离群帧主导音频。已集成到 DML(nativeSvsPipeline.js)与 WebNN 两条扩散路径中。

CFG Schedule CFG 调度

Classifier-Free Guidance now supports configurable schedule modes: constant, linear, cosine, and custom. This replaces the fixed CFG strength with a per-step schedule, allowing stronger guidance early in the diffusion process and gentler guidance near the end. The three-pass CFG combine (conditional + unconditional + variance) has been replaced by a single-pass Welford online variance algorithm, reducing computation per step.

无分类器引导现支持可配置的调度模式:恒定线性余弦自定义。这用逐步调度替代了固定 CFG 强度,允许在扩散过程初期使用更强引导、末期使用更柔和引导。三遍 CFG 合并(条件 + 无条件 + 方差)已被单遍 Welford 在线方差算法替代,减少了每步计算量。

Samplers Module 求解器模块

src/inference/pipeline/samplers/ contains the pluggable ODE-solver abstraction for the flow-matching diffusion loop. The model outputs a velocity field flow_pred = v(x, t); sampling solves dx/dt = v(x, t) with t going 0 → 1 (equivalent to the paper's reverse integration). The solver only decides when to call diffStep and how to combine predictions into the xt delta; CFG / Rescale / tensor lifecycle stay with the caller so both inference paths share one algorithm.

src/inference/pipeline/samplers/ 包含 flow-matching 扩散循环的可插拔 ODE 求解器抽象。模型输出速度场 flow_pred = v(x, t);采样即求解 dx/dt = v(x, t)t 从 0 到 1(等价于论文的反向积分)。求解器只决定何时调用 diffStep如何组合预测为 xt 增量;CFG / Rescale / 张量生命周期仍由调用方管理,保证两条推理路径共用同一份算法。

Unified interface — every solver implements:

统一接口——每个求解器实现:

async step({ evalDiffStep, combine, step, totalSteps, xtData, buffers }) → { nfe }
//   evalDiffStep(t, xtOverride?) → Promise<{condPred, uncondPred}>
//   combine(condPred, uncondPred) → Float32Array  // writes buffers.vBuf, returns it
//   buffers: { vBuf, deltaBuf, v1Buf, xPredBuf }  // caller-allocated, reused across steps
//   delta is written into buffers.deltaBuf; the caller accumulates it onto xt.data
Solver求解器 File文件 NFE / step每步 NFE Algorithm算法
euler (default)(默认) euler.js 1 First-order explicit, midpoint time t = (step + 0.5) / totalSteps. Equivalent to the pre-refactor loop.一阶显式,中点时间 t = (step + 0.5) / totalSteps。等价于重构前的循环。
heun heun.js 2 RK2 trapezoidal: predict x_pred = x + v1·dt, then correct delta = 0.5·(v1 + v2)·dt. Final step degrades to Euler to avoid t > 1.RK2 梯形:预测 x_pred = x + v1·dt,再校正 delta = 0.5·(v1 + v2)·dt。末步退化为 Euler 以避免 t > 1
extrap extrap.js 1 Velocity-extrapolation heuristic inspired by STORK (ICLR 2026). v2 = v1 + γ·(v1 − v_prev) with γ=0.5; delta = 0.5·dt·(v1 + v2). First step and unsafe extrapolation fall back to Euler. Stability guards: velocity-jump ratio > 2, |v2|/|v1| > 3, sign-flip with growing amplitude, and NaN/Inf.受 STORK(ICLR 2026)启发的速度外推启发式。v2 = v1 + γ·(v1 − v_prev),γ=0.5;delta = 0.5·dt·(v1 + v2)。首步与不安全外推退化为 Euler。稳定性保护:速度突变比 > 2、|v2|/|v1| > 3、符号翻转且幅度增大、NaN/Inf。
stork2 stork2.js 1 Paper-faithful STORK-2 (Tan et al., ICLR 2026, arXiv:2505.24210). Runge-Kutta-Gegenbauer 2nd-order recurrence with s=8 sub-stages and Taylor-expansion virtual NFE. First step bootstraps as Euler. b(j) coefficients via closed-form RKG formula. Designed for stiff ODEs (stability region ~2s² = 128×).论文原版 STORK-2(Tan et al., ICLR 2026, arXiv:2505.24210)。Runge-Kutta-Gegenbauer 二阶递推 + s=8 个 sub-stage + Taylor 展开 virtual NFE。首步以 Euler 启动。b(j) 系数用 RKG 闭式公式。专为刚性 ODE 设计(稳定性域约 2s²=128 倍)。

Registry & factorysamplers/index.js exports SOLVERS (id → {label, labelKey, descKey, create()}), DEFAULT_SOLVER='euler', resolveSamplerName() (validates + normalizes), and createSampler(). LEGACY_ALIASES = { stork: 'extrap' } keeps old user settings working. The dropdown options in src/renderer/exportDialog.js and the settings UI must stay aligned with SOLVERS.

注册表与工厂——samplers/index.js 导出 SOLVERS(id → {label, labelKey, descKey, create()})、DEFAULT_SOLVER='euler'resolveSamplerName()(校验 + 归一化)与 createSampler()LEGACY_ALIASES = { stork: 'extrap' } 兼容旧用户设置。src/renderer/exportDialog.js 与设置 UI 的下拉选项必须与 SOLVERS 保持一致。

⚠️

Chunked inference caveat: extrap and stork2 keep cross-step velocity state (_vPrev / _velPreds). A fresh sampler instance is created per runDiffusionLoop call, so each vocoder chunk starts from Euler-equivalent behavior at its first step. For chunked previews this resets their advantage at every chunk boundary — prefer euler or heun there. reset() is provided for callers that reuse a sampler instance across runs.

分块推理注意事项extrapstork2 保留跨步速度状态(_vPrev / _velPreds)。每次 runDiffusionLoop 调用都会新建 sampler 实例,因此每个 vocoder 块的首步都相当于 Euler 行为。分块预览时每块边界都会重置其优势——建议分块预览使用 eulerheun。跨运行复用 sampler 实例的调用方可调用 reset()

Stage 6 — Vocoder & Postprocessing 第 6 阶段 — 声码器与后处理

postprocessing.js converts the mel spectrogram (MEL_DIM=128) into a PCM waveform at the model rate SAMPLE_RATE=24000, which is then resampled to the target output rate (default 48 kHz). Because vocoding is the most VRAM-hungry stage, it runs in chunks:

postprocessing.js 将 mel 频谱(MEL_DIM=128)转换为模型速率 SAMPLE_RATE=24000 的 PCM 波形,随后重采样到目标输出速率(默认 48 kHz)。由于声码器是最耗显存的阶段,因此采用分块运行:

Key Constants 关键常量

Constant常量 Value Defined in定义于 Meaning含义
SAMPLE_RATE 24000 (model rate)(模型速率) shared/constants.js Model internal sample rate (Hz). Default output/playback rate is now 48000 Hz.模型内部采样率(Hz)。默认输出/播放采样率现为 48000 Hz。
HOP_SIZE 480 shared/constants.js Mel frame hop (50 Hz frame rate)Mel 帧步长(50Hz 帧率)
SIFIGAN_HOP_SIZE 120 shared/constants.js SiFiGAN mel hop (200 Hz)SiFiGAN mel 步长(200Hz)
MEL_DIM 128 shared/constants.js Mel spectrogram dimensionMel 频谱维度
EMBED_DIM 512 shared/constants.js Encoder embedding dimension编码器嵌入维度
COND_DIM 1024 shared/constants.js Diffusion conditioning dimension扩散条件维度
VOCODER_CHUNK_FRAMES 1008 shared/constants.js Default vocoder chunk size默认声码器分块大小
VOCODER_OVERLAP_FRAMES 32 (user-adjustable 8–96)(用户可调 8–96) shared/constants.js Cross-fade overlap frames (was 8)交叉淡入淡出重叠帧数(原为 8)
NPU_STATIC_SEQ_LEN 2048 shared/constants.js NPU encoder/diffusion fixed seq lenNPU 编码器/扩散固定序列长度
NPU_VOCODER_SEQ_LEN 500 shared/constants.js NPU vocoder fixed seq len (WebNN 2GB pad limit)NPU 声码器固定序列长度(受 WebNN 2GB pad 限制)
N_FFT 1920 pipeline/constants.js FFT size for mel transformmel 变换的 FFT 大小
NUM_MELS 128 pipeline/constants.js Mel filter banksmel 滤波器组数
F0_BIN / F0_MIN 361 / 32.703 pipeline/constants.js F0 quantization bins / min Hz (C1)F0 量化 bin 数 / 最低 Hz(C1)
CFG_STRENGTH / CFG_RESCALE 3.0 / 0.75 pipeline/constants.js Classifier-Free Guidance params无分类器引导参数
DEFAULT_DIFF_STEPS 32 pipeline/constants.js Default diffusion denoising steps默认扩散去噪步数
LONG_AUDIO_THRESHOLD_SEC 30 pipeline/constants.js Audio longer than this triggers segmentation超过该时长触发分段合成
SEGMENT_MIN_SEC / SEGMENT_MAX_SEC / SEGMENT_OVERLAP_SEC 15 / 30 / 2 pipeline/constants.js Segment size range and overlap分段大小范围与重叠
MAX_SAFE_FRAMES 40000 pipeline/constants.js Hard cap on mel frames per synthesis每次合成 mel 帧数硬上限
SILENCE_THRESHOLD_SEC 1.5 pipeline/constants.js Silence detection threshold (was 20s/8s/20s multi-tier)静音检测阈值(原为 20s/8s/20s 多级)
LONG_REST_SPLIT_SEC 1.5 pipeline/constants.js Rest notes ≥ this duration trigger segment splitting休止音符达到此时长即触发分段切分
DEFAULT_OUTPUT_SAMPLE_RATE 48000 shared/constants.js Default audio output / playback sample rate (Hz). Export rate is user-selectable: 24 / 44.1 / 48 / 96 kHz.默认音频输出/播放采样率(Hz)。导出速率可选:24 / 44.1 / 48 / 96 kHz。

Audio Segmentation 音频分段

audioSegmentation.js splits long vocals (> LONG_AUDIO_THRESHOLD_SEC=30s) into segments of SEGMENT_MIN_SEC=15sSEGMENT_MAX_SEC=30s with SEGMENT_OVERLAP_SEC=2s of overlap. Segment boundaries are chosen at rest-note midpoints to minimize stitching artifacts.

audioSegmentation.js 将长人声(> LONG_AUDIO_THRESHOLD_SEC=30s)切分为 SEGMENT_MIN_SEC=15sSEGMENT_MAX_SEC=30s 的分段,重叠 SEGMENT_OVERLAP_SEC=2s。分段边界选择在休止音符中点,以最小化拼接瑕疵。

fillNoteGaps also inserts rest notes between non-adjacent notes so the diffusion model has silent reference frames.

fillNoteGaps 还会在不相邻的音符之间插入休止符,让扩散模型拥有静音参考帧。

The segment silence detection threshold has been unified to SILENCE_THRESHOLD_SEC=1.5s (previously a multi-tier 20s/8s/20s scheme). Rest notes ≥ LONG_REST_SPLIT_SEC=1.5s now trigger segment splitting, ensuring diffusion chunks are bounded by natural pauses.

分段静音检测阈值已统一为 SILENCE_THRESHOLD_SEC=1.5s(此前为 20s/8s/20s 多级方案)。休止音符 ≥ LONG_REST_SPLIT_SEC=1.5s 时触发分段切分,确保扩散分块以自然停顿为边界。

Float16 Patch Float16 补丁

float16Patch.js is loaded as a side-effect at the top of pipeline/index.js. It patches onnxruntime-common's NUMERIC_TENSOR_TYPE_TO_TYPEDARRAY_MAP so that float16 tensors use Uint16Array instead of the native Float16Array.

float16Patch.js 作为副作用在 pipeline/index.js 顶部加载。它修补 onnxruntime-commonNUMERIC_TENSOR_TYPE_TO_TYPEDARRAY_MAP,使 float16 张量使用 Uint16Array 而非原生 Float16Array

⚠️

Node.js v24+ / Electron 42+ (Chromium 138) natively supports Float16Array, but the onnxruntime-node C++ binding cannot read its buffer, causing "not enough space: expected N, got 0". The patch is required for FP16 models to work. Under webpack, it uses __non_webpack_require__ to reach the native require.cache.

Node.js v24+ / Electron 42+(Chromium 138)原生支持 Float16Array,但 onnxruntime-node 的 C++ binding 无法读取其 buffer,会报 "not enough space: expected N, got 0"。该补丁是 FP16 模型正常工作的前提。在 webpack 下,它通过 __non_webpack_require__ 访问原生 require.cache

Model Loading & Execution Providers 模型加载与执行提供者

modelLoader.js handles device enumeration, execution-provider selection, and session creation with validation:

modelLoader.js 负责设备枚举、执行提供者选择,以及带校验的会话创建:

Synthesis Cache 合成缓存

The pipeline keeps a segment-level LRU cache of recent synthesis results (_synthCacheMaxEntries=32, _synthCacheMaxBytes=300MB). Cache keys are derived from an FNV-1a hash of segment content (notes, bpm, and option fields). Switching vocoder type, SiFiGAN precision, or language invalidates the cache. During iterative editing, unchanged segments return cached audio without re-running the vocoder — only modified segments are re-synthesized.

管线维护一个分段级 LRU 合成结果缓存(_synthCacheMaxEntries=32_synthCacheMaxBytes=300MB)。缓存键由分段内容(音符、bpm 与选项字段)的 FNV-1a 哈希派生。切换声码器类型、SiFiGAN 精度或语言会使缓存失效。迭代编辑时,未修改的分段直接返回缓存音频,不再运行声码器——仅修改过的分段会被重新合成。

ℹ️

Concurrent synthesize() calls are serialized via _synthPromise. This prevents a reload race where synthesis B starts while synthesis A is still rebuilding heavy sessions (diff_step 846MB / vocoder), which would double VRAM and trigger OOM.

并发的 synthesize() 调用通过 _synthPromise 串行化。这避免了一种重载竞态:合成 A 仍在重建重量级会话(diff_step 846MB / vocoder)时合成 B 启动,导致显存翻倍并触发 OOM。

Cooperative Synthesis Cancellation 协作式合成取消

Synthesis cancellation now uses AbortController-based cooperative cancellation instead of worker.terminate(). The GPU inference loop checks the abort signal at safe checkpoints between diffusion steps and exits cleanly, releasing tensor resources in an orderly fashion. The cancel command bypasses the serial worker queue for immediate response — the user does not have to wait for the current step to finish before cancellation takes effect.

合成取消现使用基于 AbortController 的协作式取消,而非 worker.terminate()。GPU 推理循环在扩散步之间的安全检查点检查中止信号并干净退出,有序释放张量资源。取消命令绕过串行 worker 队列以实现即时响应——用户无需等待当前步骤完成即可生效。

Pipeline API Surface 管线 API 表面

const pipeline = new OnnxSVSPipeline(modelDir, {
  modelPrecision,       // 'fp32' | 'fp16' | 'int8' | 'int8-npu'
  deviceId,             // DML adapter index (undefined = auto)
  preferredDeviceType,  // 'npu' | 'webnn-gpu' | undefined
  inferenceProvider,    // 'ortnode' (default) | 'ortweb'
  languageOverride,     // 'ja' | null
});

await pipeline.init();                 // loads all 9 sessions
await pipeline.swapLanguageModels('ja');   // hot-swap JP models
await pipeline.swapVocoder('sifigan');     // hot-swap vocoder only
await pipeline.swapSifiganPrecision('fp16');

const audio = await pipeline.synthesize(notes, bpm, {
  nSteps,               // default 32
  cfg, cfgRescale,      // default 3.0 / 0.75
  cfgSchedule,          // 'constant' | 'linear' | 'cosine' | 'custom'
  sampler,              // 'euler' (default) | 'heun' | 'extrap' | 'stork2'
  autoShift, pitchShift,
  f0Envelope, pitchCurveF0,
  refAudioWavBuffer,    // reference audio for prompt mel + autoShift
  outputSampleRate,     // 24000 | 44100 | 48000 (default) | 96000
  signal,               // AbortSignal for cooperative cancellation
  onProgress,           // 0..100
  onChunkAudio,         // streaming vocoder chunks
});

pipeline.dispose();                   // release all sessions