- 桌面应用
- 语音
【免费下载链接】voicevox
無料で使える中品質なテキスト読み上げソフトウェア、VOICEVOXのエディター
导读
本文基于 VOICEVOX 编辑器的官方开发文档 docs/ソングのレンダリング.md 及其配套流程图,深入剖析编辑器内"ソング(歌曲)渲染"的完整实现:从 Vuex 的RENDER动作入口,到SongTrackRenderer的框架生成、缓存复用、引擎 API 调用与事件通知,直至最终歌声数据落库的每一步。读完本文,你将理解 VOICEVOX 如何把钢琴卷帘上的音符按休符切分成"フレーズ(Phrase)",如何以再生ヘッド(播放头)为基准按优先级逐段合成歌声,以及为什么反复编辑时渲染能如此迅速——一切答案都藏在快照(Snapshot)与四层缓存(Query/Pitch/Volume/Voice)的设计中。
一、ソングレンダリングの全体像:从RENDER动作到歌声合成
在 VOICEVOX 中,"ソングのレンダリング"(歌曲渲染)指的是将钢琴卷帘上的音符数据转换为实际歌声数据的整条流水线,其中包括对歌声合成引擎 API 的调用。根据官方文档,这条流水线由两个核心角色驱动:
RENDER动作:位于 src/store/song.ts 的SongStore中,负责编排渲染的启动、重启动、停止,以及渲染循环的调度。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
框架生成分三层:
- 剔除重叠音符(
generatePhrases):从音轨音符中过滤掉trackOverlappingNoteIds标记的重叠音符(重叠音符无法确定语义,不参与渲染)。 - 按休符切分(
extractPhraseNotes,src/song/songTrackRendering.ts):顺序扫描音符,一旦发现当前音符结束位置 !== 下一个音符开始位置(存在空隙),就切出一个新框架。这印证了文档所述"フレーズは、ノーツを休符で区切ることによって生成されます"。 - 框架属性计算(
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):
- 计算 Query 缓存键并查
queryCache,命中则写入phrase.queryKey/phrase.query;未命中则continue跳到下一个框架(因为后续 Pitch 的生成依赖 Query); - 命中 Query 后,计算 Pitch 键查
singingPitchCache,未命中同样跳到下一框架; - 依此类推 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):
- 优先选择播放头位置包含在内的框架;
- 若无,选择播放头之后最近的框架;
- 若无,选择播放头之前最近的框架。
该逻辑由单测 tests/unit/lib/selectPriorPhrase.spec.ts 验证:测试构造 5 个连续框架,将播放头置于第 3 个框架内,断言选择顺序依次为"包含播放头的框架 → 其后的框架(按时间近→远)→ 其前的框架(按时间近→远)",并在空集合时抛错"phraseRanges.size is 0."。这就是文档所述"再生ヘッドに近いフレーズから優先的に処理"的实现。
renderPhrase的四阶段流水线(src/song/songTrackRendering.ts):
| 阶段 | 触发条件 | 引擎 API | 完成后事件 |
|---|---|---|---|
| ① 框架渲染开始 | — | — | PhraseRenderingStartedEvent |
| ② クエリ(音素タイミング)生成 | query未生成 | fetchFrameAudioQuery | QueryGenerationCompleteEvent |
| ③ 歌唱ピッチ生成 | singingPitch未生成 | fetchSingFrameF0 | PitchGenerationCompleteEvent |
| ④ 歌唱ボリューム生成 | singingVolume未生成 | fetchSingFrameVolume | VolumeGenerationCompleteEvent |
| ⑤ 歌声合成 | singingVoice未生成 | frameSynthesis | VoiceSynthesisCompleteEvent |
| ⑥ 框架完成 | — | — | 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 各生成函数的后处理
| 生成函数 | 关键后处理 |
|---|---|
generateQuery | shiftKeyOfNotes(-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 ソングレンダリング 的设计,可以提炼出以下工程要点:
- 快照先行:
SnapshotForRender保证渲染与用户编辑解耦,任何渲染输入都以快照为准; - 框架为单位:音符按休符切分为
PhraseForRender,每个框架独立完成 Query → Pitch → Volume → Voice 四步,天然支持局部失效与增量渲染; - 哈希缓存:四层缓存共用"输入哈希即键"的思路,编辑只使相关框架的缓存失效,其余框架直接命中;
- 播放头优先级:
selectPriorPhrase让最靠近播放头的框架先渲染,保证试听体验; - 事件驱动: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のエディター
相关推荐
OSSU计算机组成:缓存流水线与虚拟内存机制
OSSU计算机组成:缓存流水线与虚拟内存机制 你是否曾疑惑为什么打开大型文件时电脑会卡顿?为什么同时运行多个程序会变得缓慢?本文将深入解析计算机组成中的三大核心
教程文档知识库R2R缓存机制详解:内存缓存设计与实现策略
R2R缓存机制详解:内存缓存设计与实现策略 引言:缓存架构的核心挑战 在现代应用开发中,缓存系统(Cache System)是提升性能的关键组件,尤其对于R2R
人工智能RAGAI Agent后端知识图谱搜索引擎react-admin 缓存机制深度解析:从乐观渲染到 HTTP 缓存与应用级缓存
react admin 缓存机制深度解析:从乐观渲染到 HTTP 缓存与应用级缓存 本篇技术指南围绕 react admin 的官方文档 Caching htt
前端UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考