☰
VOICEVOX 歌声合成(ソングレンダリング)实现解析:SongTrackRenderer 渲染流水线与缓存机制详解
2026/10/4 10:34:48 网站建设 项目流程
  • 桌面应用
  • 语音

【免费下载链接】voicevox

無料で使える中品質なテキスト読み上げソフトウェア、VOICEVOXのエディター

项目地址:https://gitcode.com/gh_mirrors/vo/voicevox
点击查看免费下载

导读

本文基于 VOICEVOX 编辑器的官方开发文档 docs/ソングのレンダリング.md 及其配套流程图,深入剖析编辑器内"ソング(歌曲)渲染"的完整实现:从 Vuex 的RENDER动作入口,到SongTrackRenderer的框架生成、缓存复用、引擎 API 调用与事件通知,直至最终歌声数据落库的每一步。读完本文,你将理解 VOICEVOX 如何把钢琴卷帘上的音符按休符切分成"フレーズ(Phrase)",如何以再生ヘッド(播放头)为基准按优先级逐段合成歌声,以及为什么反复编辑时渲染能如此迅速——一切答案都藏在快照(Snapshot)与四层缓存(Query/Pitch/Volume/Voice)的设计中。

一、ソングレンダリングの全体像:从RENDER动作到歌声合成

在 VOICEVOX 中,"ソングのレンダリング"(歌曲渲染)指的是将钢琴卷帘上的音符数据转换为实际歌声数据的整条流水线,其中包括对歌声合成引擎 API 的调用。根据官方文档,这条流水线由两个核心角色驱动:

  1. RENDER动作:位于 src/store/song.ts 的SongStore中,负责编排渲染的启动、重启动、停止,以及渲染循环的调度。
  2. SongTrackRenderer.render():位于 src/song/songTrackRendering.ts,是真正执行渲染逻辑的类,负责框架生成、缓存管理、引擎连携和事件通知。
用户操作(编辑音符/移动播放头) │ ▼ SongStore 的 RENDER action ──► SongTrackRenderer.render(snapshot) │ ├─ generatePhrases(按休符切分框架) ├─ filterRenderablePhrases ├─ applyCachedDataToPhrases(读缓存) ├─ filterPhrasesRequiringRender └─ 逐框架渲染:Query → Pitch → Volume → Voice

文档同时提供了完整的 Mermaid 流程图 docs/res/ソングのレンダリングのフローチャート.md,本文后续将结合该流程图逐段解读。

二、核心数据结构:SnapshotForRender与PhraseForRender

2.1SnapshotForRender(スナップショット)

渲染过程中,用户可能仍在编辑音符、修改速度等,若直接读取实时状态,会导致渲染结果与画面不一致。因此 VOICEVOX 在渲染开始前对项目数据做一次快照,渲染全程只基于这份不可变拷贝进行。

// src/song/songTrackRendering.ts export type SnapshotForRender = Readonly<{ tpqn: number; // Ticks Per Quarter Note(每四分音符的 tick 数) tempos: Tempo[]; // 速度(BPM)序列 tracks: Map<TrackId, Track>; // 全部音轨 trackOverlappingNoteIds: Map<TrackId, Set<NoteId>>; // 各音轨上重叠的 NoteId(渲染时剔除) engineFrameRates: Map<EngineId, number>; // 各引擎的帧率(frameRate) editorFrameRate: number; // 编辑器侧帧率 defaultLyricMode: "doremi" | "la"; // 缺省歌词模式(do-re-mi 或 la) }>;

快照的实际构造逻辑在SongStore.RENDER动作内的createSnapshot()(src/store/song.ts):

  • tpqn、tempos、tracks直接取自 store state,其中tempos与tracks使用cloneWithUnwrapProxy深拷贝(见 src/helpers/cloneWithUnwrapProxy.ts),避免 Vuex Proxy 干扰;
  • trackOverlappingNoteIds通过getters.OVERLAPPING_NOTE_IDS(trackId)获取;
  • engineFrameRates从state.engineManifests中提取每个引擎的frameRate;
  • defaultLyricMode取自state.defaultLyricMode。

2.2PhraseForRender(フレーズ)

フレーズ是渲染的基本单位,由音轨中的音符按休符切分而成。它同时承载渲染的输入(音符)与输出(中间/最终数据),并且是可变(ミュータブル)对象——渲染过程中它的属性会被逐步填充。

// src/song/songTrackRendering.ts export type PhraseForRender = { readonly firstRestDuration: number; // 框架先头休符时长(tick) readonly notes: Note[]; // 属于该框架的音符 readonly startTicks: number; // 框架起始 tick readonly endTicks: number; // 框架结束 tick readonly startTime: number; // 框架起始时间(秒) readonly minNonPauseStartFrame: number | undefined; // 非休止区间最小起始帧 readonly maxNonPauseEndFrame: number | undefined; // 非休止区间最大结束帧 readonly trackId: TrackId; // 所属音轨 queryKey?: EditorFrameAudioQueryKey; // 查询缓存键 query?: EditorFrameAudioQuery; // 音素タイミング(音素时机)查询 singingPitchKey?: SingingPitchKey; // 歌唱音高缓存键 singingPitch?: SingingPitch; // 歌唱音高(f0 序列) singingVolumeKey?: SingingVolumeKey; // 歌唱音量缓存键 singingVolume?: SingingVolume; // 歌唱音量序列 singingVoiceKey?: SingingVoiceKey; // 歌声缓存键 singingVoice?: SingingVoice; // 最终歌声数据 };

其中minNonPauseStartFrame/maxNonPauseEndFrame用于界定"非休止(发音)区间"的帧范围,后续的音素时机调整、音量淡出等操作都以它为边界,确保框架间的呼吸声不会重叠。

三、SongTrackRenderer:ソングトラックのレンダラー

SongTrackRenderer(src/song/songTrackRendering.ts)承担五项核心职责,与文档描述一一对应:

职责实现要点
渲染执行render(snapshot)方法:框架生成 → 缓存应用 → 逐框架合成
缓存活用内部持有 4 个缓存 Map:queryCache、singingPitchCache、singingVolumeCache、singingVoiceCache
引擎连携通过注入的engineSongApi接口调用引擎 API
事件通知addEventListener/removeEventListener注册监听器,dispatchEvent广播 9 种渲染事件
中断处理requestRenderingInterruption()请求中断,isRendering暴露渲染状态

3.1 构造函数与渲染配置

// src/song/songTrackRendering.ts constructor(args: { config: SongTrackRenderingConfig; engineSongApi: EngineSongApi; playheadPositionGetter: () => number; }) { ... }

渲染配置SongTrackRenderingConfig包含三个可调参数,其实际取值在CREATE_AND_SETUP_SONG_TRACK_RENDERER动作中给出(src/store/song.ts):

config: { lastRestDurationSeconds: 0.5, // 框架末尾追加的休止时长(秒) fadeOutDurationSeconds: 0.15, // 末尾 pau 段淡出时长(秒) firstRestMinDurationSeconds: 0.12, // 框架先头休符的最小时长(秒) }

engineSongApi将引擎调用桥接到 store 动作:fetchFrameAudioQuery→FETCH_SING_FRAME_AUDIO_QUERY、fetchSingFrameF0→FETCH_SING_FRAME_F0、fetchSingFrameVolume→FETCH_SING_FRAME_VOLUME、frameSynthesis→FRAME_SYNTHESIS。playheadPositionGetter则读取实时播放头位置,供后续"就近优先"的框架选择使用。

3.2 渲染结果类型

export type SongTrackRenderingResult = | { readonly type: "complete"; readonly phrases: Map<PhraseKey, PhraseForRender>; } | { readonly type: "interrupted"; };

正常完成返回complete及全部框架;被中断则返回interrupted。render()内部以finally保证无论成功、中断还是异常,都会复位interruptionRequested与_isRendering,避免死锁。

四、レンダリングの流れ:逐段深度解析

本节对照文档步骤与 src/store/song.ts 的RENDER动作实现。

4.1 第一步:确认 Renderer 实例(未创建则初始化)

RENDER动作首先检查模块级变量songTrackRenderer是否存在;若不存在,调用CREATE_AND_SETUP_SONG_TRACK_RENDERER动作创建实例、配置引擎 API 桥接与播放头 getter,并注册全部事件监听器(见 src/store/song.ts)。

4.2 第二步:渲染循环状态检查(重渲染请求)

mutations.SET_START_RENDERING_REQUESTED({ startRenderingRequested: true }); // 渲染中なら中断を要求して終了 if (songTrackRenderer.isRendering) { songTrackRenderer.requestRenderingInterruption(); return; }

若上一次渲染仍在进行(例如用户快速连续编辑),则只置位"开始渲染请求"并请求中断当前渲染后立即返回。RENDER动作随后进入外层while循环:

while (state.startRenderingRequested && !state.stopRenderingRequested) { mutations.SET_START_RENDERING_REQUESTED({ startRenderingRequested: false }); const snapshot = createSnapshot(); // ③-1 快照 await songTrackRenderer.render(snapshot); // ③-2 渲染循环 }

只要"开始请求"为真且"停止请求"为假,循环就会重新生成快照并再次渲染——这就是"渲染中请求重渲染 → 中断 → 重启动"的机制。STOP_RENDERING动作(src/store/song.ts)则置位stopRenderingRequested并等待nowRendering变为false,实现优雅停止。

4.3 渲染循环(SongTrackRenderer.render内部)

③-1createSnapshot:项目快照

见 2.1 节,不再赘述。

③-2generatePhrases:框架生成 → 发PhrasesGeneratedEvent

框架生成分三层:

  1. 剔除重叠音符(generatePhrases):从音轨音符中过滤掉trackOverlappingNoteIds标记的重叠音符(重叠音符无法确定语义,不参与渲染)。
  2. 按休符切分(extractPhraseNotes,src/song/songTrackRendering.ts):顺序扫描音符,一旦发现当前音符结束位置 !== 下一个音符开始位置(存在空隙),就切出一个新框架。这印证了文档所述"フレーズは、ノーツを休符で区切ることによって生成されます"。
  3. 框架属性计算(createPhrasesFromNotes):
    • calcPhraseFirstRestDuration:计算框架先头休符时长。首小节的第一个框架若从 0 tick 开始,则先取四分音符长度;否则取到上一框架末音符的实际空隙,并夹取在"四分音符长度"与"最小时长(0.12s)"之间,且至少 1 tick;
    • minNonPauseStartFrame/maxNonPauseEndFrame:结合上一框架的结束帧与下一框架的起始音符,使用interpByDiff(a, b, k, p)(src/song/songTrackRendering.ts)计算框架间的平滑边界时间,再换算为帧号;
    • 框架键PhraseKey:对{ firstRestDuration, notes, startTime, trackId }计算哈希(calculatePhraseKey,见 src/song/domain.ts)。

生成完毕后通过dispatchEvent发出PhrasesGeneratedEvent(携带框架 Map 的浅拷贝与快照,防止外部修改内部数据)。此时框架尚未包含任何音频数据或详细参数。

③-3filterRenderablePhrases:提取可渲染框架

只有"该框架所属音轨的singer(歌手)与singingTeacher(歌い方)都已分配"的框架才可渲染(src/song/songTrackRendering.ts)。未分配歌手的音轨对应框架会被跳过。

③-4applyCachedDataToPhrases:缓存应用 → 发CacheLoadedEvent

对每个可渲染框架,按Query → Pitch → Volume → Voice的顺序逐一尝试读取缓存(src/song/songTrackRendering.ts):

  1. 计算 Query 缓存键并查queryCache,命中则写入phrase.queryKey/phrase.query;未命中则continue跳到下一个框架(因为后续 Pitch 的生成依赖 Query);
  2. 命中 Query 后,计算 Pitch 键查singingPitchCache,未命中同样跳到下一框架;
  3. 依此类推 Volume、Voice。

全部处理完后发出CacheLoadedEvent。这正是"缓存命中链"的设计:只要链路中任一层缺失,其后各层都不会尝试命中,而是留给后续实际渲染阶段补齐。

③-5filterPhrasesRequiringRender:提取真正需要渲染的框架

若框架的query、singingPitch、singingVolume、singingVoice任一为undefined,说明缓存未完全覆盖,进入待渲染集合(src/song/songTrackRendering.ts)。

③-6 逐框架渲染(selectPriorPhrase+renderPhrase)

主循环条件为"待渲染框架非空且无中断请求":

while (phrasesToRender.size > 0 && !this.interruptionRequested) { const phraseKey = selectPriorPhrase(phrasesToRender, this.playheadPositionGetter()); ... try { await this.renderPhrase(phrase, phraseKey, snapshot); } catch (error) { this.dispatchEvent({ type: "phraseRenderingError", phraseKey, error }); continue; // 出错框架跳过,继续下一个 } }

selectPriorPhrase的优先级规则(src/song/domain.ts):

  1. 优先选择播放头位置包含在内的框架;
  2. 若无,选择播放头之后最近的框架;
  3. 若无,选择播放头之前最近的框架。

该逻辑由单测 tests/unit/lib/selectPriorPhrase.spec.ts 验证:测试构造 5 个连续框架,将播放头置于第 3 个框架内,断言选择顺序依次为"包含播放头的框架 → 其后的框架(按时间近→远)→ 其前的框架(按时间近→远)",并在空集合时抛错"phraseRanges.size is 0."。这就是文档所述"再生ヘッドに近いフレーズから優先的に処理"的实现。

renderPhrase的四阶段流水线(src/song/songTrackRendering.ts):

阶段触发条件引擎 API完成后事件
① 框架渲染开始——PhraseRenderingStartedEvent
② クエリ(音素タイミング)生成query未生成fetchFrameAudioQueryQueryGenerationCompleteEvent
③ 歌唱ピッチ生成singingPitch未生成fetchSingFrameF0PitchGenerationCompleteEvent
④ 歌唱ボリューム生成singingVolume未生成fetchSingFrameVolumeVolumeGenerationCompleteEvent
⑤ 歌声合成singingVoice未生成frameSynthesisVoiceSynthesisCompleteEvent
⑥ 框架完成——PhraseRenderingCompleteEvent

每一步生成的数据都会同时写入框架属性与对应缓存 Map,以便下次渲染直接命中。若某一步抛错,则发出PhraseRenderingErrorEvent并continue处理下一框架——源码注释说明多数错误源于歌词(FIXME 标注:非歌词类错误未来应改为抛错并弹错误对话框)。

五、引擎 API 连携的底层细节

5.1EngineSongApi接口

渲染器不直接依赖 HTTP 层,而是通过注入的接口解耦(src/song/songTrackRendering.ts):

type EngineSongApi = Readonly<{ fetchFrameAudioQuery: (args: { engineId; styleId; engineFrameRate; notes }) => Promise<EditorFrameAudioQuery>; fetchSingFrameF0: (args: { notes; query; engineId; styleId }) => Promise<number[]>; fetchSingFrameVolume: (args: { notes; query; engineId; styleId }) => Promise<number[]>; frameSynthesis: (args: { query; engineId; styleId }) => Promise<Blob>; }>;

5.2 请求音符的构造(createNotesForRequestToEngine)

在调用引擎前,框架音符会被转换成引擎要求的格式(src/song/songTrackRendering.ts):

  • 先头休符:将notes[0].position - firstRestDuration到notes[0].position的区间换算为{ key: undefined, frameLength, lyric: "" };
  • 音符本体:每个音符按tickToSecond+ 引擎帧率换算为{ id, key: noteNumber, frameLength, lyric },歌词为空时使用getDefaultLyric(noteNumber, defaultLyricMode)按doremi/la模式补缺省歌词;
  • 末尾休符:追加lastRestDurationSeconds(0.5s)长度的休止;
  • 帧长兜底:保证每个frameLength >= 1,不足 1 帧的差值会从下一项中"借"出,避免引擎端出现 0 帧片段。

5.3 各生成函数的后处理

生成函数关键后处理
generateQueryshiftKeyOfNotes(-keyRangeAdjustment)移调后再请求 →shiftPitch(f0, +keyRangeAdjustment)还原 →adjustPhonemeTimings按minNonPauseStartFrame/maxNonPauseEndFrame裁剪音素时机
generateSingingPitch对 Query 应用applyPhonemeTimingEdit(用户的音素时机编辑)与adjustPhonemeTimings→fetchSingFrameF0→ 还原移调
generateSingingVolume在 Pitch 基础上应用applyPitchEdit(音高曲线编辑)→fetchSingFrameVolume→muteLastPauSection将末尾 pau 段音量淡出至 0(避免与相邻框架的呼吸声重叠)→ensureNonNegativeVolume钳制非负
synthesizeSingingVoice合并f0 = singingPitch、volume = singingVolume,叠加音素时机编辑、音高编辑、音量编辑(applyVolumeEdit)与shiftVolume(音量范围调整,用decibelToLinear换算)后调用frameSynthesis

值得注意:为了不污染框架内已缓存的数据,generateSingingPitchSource/generateSingingVolumeSource/generateSingingVoiceSource都会先对phrase.query(以及 Pitch/Volume)做structuredClone,再在其上叠加各类用户编辑,因此缓存数据始终是引擎原始结果,用户编辑只在生成链的某个阶段临时叠加。

5.4 store 层的引擎调用

引擎 API 的实际网络调用位于SongStore动作(src/store/song.ts):通过INSTANTIATE_ENGINE_CONNECTOR取得 src/infrastructures/EngineConnector.ts 实例,分别invoke("singFrameAudioQuery")、invoke("singFrameF0")、invoke("singFrameVolume")、invoke("frameSynthesis"),并在调用前用IS_ENGINE_READY(engineId)检查引擎就绪状态。出错时日志会带上歌词/音素序列便于排查。

六、缓存机制的深入解读

6.1 四层缓存与哈希键

private readonly queryCache: Map<EditorFrameAudioQueryKey, EditorFrameAudioQuery> = new Map(); private readonly singingPitchCache: Map<SingingPitchKey, SingingPitch> = new Map(); private readonly singingVolumeCache: Map<SingingVolumeKey, SingingVolume> = new Map(); private readonly singingVoiceCache: Map<SingingVoiceKey, SingingVoice> = new Map();

每层缓存的键都是"输入源数据的哈希"(calculateHash,见 src/song/utility.ts),并通过品牌类型(branded type)区分,如EditorFrameAudioQueryKey(hash)、SingingPitchKey(hash)(相关类型定义见 src/store/type.ts)。这意味着:

  • 只要框架的输入(音符、休止、音程调整、歌い方等)不变,键就不变,缓存必然命中;
  • 任何影响生成结果的输入变化(如keyRangeAdjustment、volumeRangeAdjustment、defaultLyricMode)都会反映在QuerySource/SingingPitchSource/SingingVolumeSource/SingingVoiceSource的结构中,从而改变哈希键、自然失效。

6.2 缓存命中后的框架状态

CacheLoadedEvent的处理器onCacheLoaded(src/store/song.ts)会根据缓存覆盖情况为每个框架设置状态:

  • 音轨未分配歌手/歌い方→SINGER_IS_NOT_SET;
  • 缓存未完全覆盖(仍需渲染)→WAITING_TO_BE_RENDERED;
  • 缓存完全命中→RENDERED(无需再调用引擎)。

同时,该处理器将缓存的数据同步写入store.state(SET_PHRASES、SET_PHRASE_QUERIES、SET_PHRASE_SINGING_PITCHES等 mutation),并调用syncPhraseSequences将框架状态与播放序列同步。这意味着:即使完全命中缓存,用户也能立刻看到完整的波形与可播放的歌声,而无须等待引擎响应——这正是缓存对交互体验的核心价值。

七、事件通知体系与 store 联动

SongTrackRenderer共定义 9 种事件(src/song/songTrackRendering.ts),统一收束为联合类型SongTrackRenderingEvent,监听器通过addEventListener注册:

事件语义store 侧处理器效果
phrasesGenerated框架生成完毕仅输出日志(当前)
cacheLoaded缓存应用完毕批量更新 state、设定框架状态、同步序列
phraseRenderingStarted单框架渲染开始框架状态置NOW_RENDERING
queryGenerationComplete查询生成完毕SET_PHRASE_QUERY+ 绑定queryKey
pitchGenerationComplete音高生成完毕SET_PHRASE_SINGING_PITCH+ 绑定singingPitchKey
volumeGenerationComplete音量生成完毕SET_PHRASE_SINGING_VOLUME+ 绑定singingVolumeKey
voiceSynthesisComplete歌声合成完毕写入phraseSingingVoices+ 绑定singingVoiceKey
phraseRenderingComplete单框架全部完成状态置RENDERED,syncPhraseSequences同步
phraseRenderingError单框架出错状态置COULD_NOT_RENDER,输出错误日志

事件分发使用dispatchEvent遍历listeners集合逐个调用(src/song/songTrackRendering.ts),监听器注册在CREATE_AND_SETUP_SONG_TRACK_RENDERER中完成,并以switch (event.type)分发到各处理器(src/store/song.ts)。UI 层即可依据这些事件实时刷新"框架正在渲染 / 已渲染 / 渲染失败"的视觉状态。

八、中断与再渲染机制

文档特别强调渲染支持外部中断,其实现要点如下:

  • 粒度:requestRenderingInterruption()仅置位interruptionRequested标志(src/song/songTrackRendering.ts),该标志只在框架之间被检查(while循环条件),不会中断正在进行的单个框架渲染——保证引擎请求的原子性;
  • 收尾:render()的finally块复位标志与_isRendering,若中断则返回{ type: "interrupted" };
  • 再渲染:RENDER动作在检测到songTrackRenderer.isRendering === true时,先请求中断再返回;而外层while循环因startRenderingRequested仍为真,会在中断完成后立即用新快照重启动渲染——形成"编辑 → 中断 → 重渲染"的闭环;
  • 停止:STOP_RENDERING动作置位stopRenderingRequested并等待nowRendering归位,用于切换曲目、关闭工程等需要完全停止渲染的场景。

九、总结与开发者要点

回顾 VOICEVOX ソングレンダリング 的设计,可以提炼出以下工程要点:

  1. 快照先行:SnapshotForRender保证渲染与用户编辑解耦,任何渲染输入都以快照为准;
  2. 框架为单位:音符按休符切分为PhraseForRender,每个框架独立完成 Query → Pitch → Volume → Voice 四步,天然支持局部失效与增量渲染;
  3. 哈希缓存:四层缓存共用"输入哈希即键"的思路,编辑只使相关框架的缓存失效,其余框架直接命中;
  4. 播放头优先级:selectPriorPhrase让最靠近播放头的框架先渲染,保证试听体验;
  5. 事件驱动:9 种渲染事件 + store 监听器,使 UI、播放序列与渲染进程保持同步,错误框架以COULD_NOT_RENDER隔离而不阻塞整体。

对二次开发者而言,最值得研读的三个文件是:

  • src/song/songTrackRendering.ts:渲染器本体(快照/框架类型、四阶段流水线、缓存、事件);
  • src/store/song.ts:RENDER/STOP_RENDERING/CREATE_AND_SETUP_SONG_TRACK_RENDERER动作及引擎调用桥接;
  • docs/res/ソングのレンダリングのフローチャート.md:官方配套流程图,可与本文对照阅读。

若需为歌曲功能扩展新的引擎能力(例如新的生成阶段),可参照EngineSongApi接口的注入方式,在渲染流水线中新增一个"生成 → 缓存 → 事件"的阶段,并保持与 store 事件监听体系的对接,即可复用现有的中断、缓存与优先级调度能力。

  • 桌面应用
  • 语音

【免费下载链接】voicevox

無料で使える中品質なテキスト読み上げソフトウェア、VOICEVOXのエディター

项目地址:https://gitcode.com/gh_mirrors/vo/voicevox
点击查看免费下载

相关推荐

上一篇:AutoValue Builder 完整使用指南:从生成原理到 17 个实战技巧
下一篇:TypedStruct 技术实践:如何在 Elixir 项目中实现类型安全的领域建模

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询