// scheduler.js —— createScheduler:逐行剧本调度(并行 / 阻塞编排 + play + interrupt)。 // 移植自原型 play-demo.html 的 playLine / play,落实 T-core §3.4 的并行 / 阻塞硬约定。 // // 这是 core 内**唯一**碰三接口(renderer / audio / tts)的地方——把指令翻译成接口调用, // 但自己不实现接口。求值(anim-state / camera)只产状态,scheduler 负责编排时序。 // // §3.4 并行 / 阻塞契约: // · 不阻塞、行首即发:cam / fx / sfx / pose / trigger / emotion / facing / headFacing / // prop / propPlace(见 line-handlers.emitNonBlocking)。 // · 阻塞、必须 await:moveTo(走位补间,时长 = moveDur);say(有 voice 走 // tts.synthesize → audio.play 等 durationMs 并驱动口型 applyFace(talking); // 无 voice 按字数估时长)。 // · 本行总时长 = max(moveTo 时长, say 时长) + wait,多个阻塞项并发 await(Promise.all), // 不串行累加。 // · transition:阻塞,走 renderer.runTransition + 换班底。 // · interrupt():audio.stopAll + 每演员 resetActor + animState.reset + camera.reset, // 并让正在进行的 play() 提前 resolve、不再推进后续行。 import { emitNonBlocking, estimateSayDuration } from './line-handlers.js' const VOICE_PRESET_IDS = { '女声·年轻清亮': 'zh_female_xiaohe_uranus_bigtts', '女声·甜美活泼': 'zh_female_tianmeixiaoyuan_uranus_bigtts', '女声·温柔自然': 'zh_female_qingxinnvsheng_uranus_bigtts', '女声·青春俏皮': 'zh_female_tianmeitaozi_uranus_bigtts', '女声·可爱元气': 'zh_female_shuangkuaisisi_uranus_bigtts', '女声·轻柔少女': 'zh_female_xiaoxue_uranus_bigtts', '男声·阳光青年': 'zh_male_shaonianzixin_uranus_bigtts', '男声·温柔青年': 'zh_male_liufei_uranus_bigtts', '男声·活泼少年': 'zh_male_taocheng_uranus_bigtts', '男声·清爽青年': 'zh_male_ruyayichen_uranus_bigtts', '男声·元气少年': 'zh_male_dayi_uranus_bigtts', '男声·自然青年': 'zh_male_m191_uranus_bigtts', '女声·温柔': 'S_9AB0nUX42', '女声·清丽': 'S_6wA0nUX42', '女声·甜美': 'S_6wA0nUX42', '女声·柔和': 'S_9AB0nUX42', '男声·年长': 'S_aAB0nUX42', '男声·青年': 'S_5wA0nUX42', '男声·深沉': 'S_aAB0nUX42', '男声·磁性': 'S_5wA0nUX42', } const FALLBACK_VOICE_ID = VOICE_PRESET_IDS['男声·青年'] function normalizeVoiceId(value) { const voice = String(value || '').trim() if (!voice) return '' if (/^S_[A-Za-z0-9]+$/.test(voice)) return voice return VOICE_PRESET_IDS[voice] || voice } function normalizeSpeechRate(value) { if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value if (typeof value === 'string') { const n = Number(value) if (Number.isFinite(n) && n > 0) return n } return undefined } /** 默认时钟:真实计时器。测试注入假时钟以确定性推进。 */ const REAL_CLOCK = { /** @param {number} ms @returns {Promise} */ sleep(ms) { return new Promise((res) => setTimeout(res, Math.max(0, ms))) }, } /** * 调度器工厂。 * @param {{ * renderer: import('./types.js').Renderer, * audio: import('./types.js').AudioPort, * tts: import('./types.js').TtsPort, * camera?: (import('./camera.js').Camera|null), * clock?: import('./types.js').Clock * }} deps * @returns {{ * playLine: (line:import('./types.js').ScriptLine, ctx:import('./types.js').SchedulerContext) => Promise, * play: (lines:import('./types.js').ScriptLine[], ctx:import('./types.js').SchedulerContext) => Promise, * interrupt: () => void * }} */ export function createScheduler({ renderer, audio, tts, camera = null, clock = REAL_CLOCK }) { if (!renderer || !audio || !tts) { throw new Error('createScheduler: 必须注入 renderer / audio / tts') } // runToken:每次 play() 自增;interrupt() 也自增。任何 await 醒来后比对 token, // 不一致即说明被打断 / 被新一轮 play 接管,立刻停止推进。移植自原型 runToken 机制。 let runToken = 0 /** @param {import('./types.js').SchedulerContext} ctx */ function actorsOf(ctx) { return ctx.actors && typeof ctx.actors === 'object' ? Object.keys(ctx.actors) : [] } /** * say 阻塞分支:有 voice 走 TTS → audio.play 等 durationMs,并驱动口型; * 缺 voice 时自动回退到默认音色,避免后半段新角色转场出现静音。 * @param {import('./types.js').ScriptLine} line * @param {import('./types.js').SchedulerContext} ctx * @param {number} my * @param {{current:(import('./types.js').PlayingAudio|null)}} audioRef * @returns {Promise} */ async function runSay(line, ctx, my, audioRef) { const alias = line.who if (!alias || !line.say) return const anim = ctx.animStateOf ? ctx.animStateOf(alias) : null const cv = (ctx.castVoice && ctx.castVoice(alias)) || {} const voice = normalizeVoiceId(line.voice) || normalizeVoiceId(cv.voice) || FALLBACK_VOICE_ID // 口型 + 头部说话动作 if (anim) anim.setAnimation('head', 'head.talk_shake', { mix: 120 }) renderer.applyFace(alias, line.emotion || 'normal', true) let durMs = estimateSayDuration(line.say) const speechRate = normalizeSpeechRate( Object.prototype.hasOwnProperty.call(line, 'speechRate') && line.speechRate !== undefined ? line.speechRate : cv && cv.speechRate, ) if (voice) { let result = null try { result = await tts.synthesize({ text: line.say, speaker: voice, instruction: line.instruction, emotion: line.emotion, speechRate, lang: cv.lang, }) } catch { result = null // TTS 失败:回落估算时长,不中断本行 } if (my !== runToken) { audio.stopAll() return } if (result) { if (typeof audio.unlock === 'function') { try { await audio.unlock() } catch { // 某些浏览器会在转场/静默后把 AudioContext 置为 suspended;解锁失败不应阻塞本行。 } } const handle = audio.play(result.audio, { volume: 1 }) audioRef.current = handle const reported = result.durationMs ?? (await handle.durationMs) durMs = reported && isFinite(reported) ? reported : estimateSayDuration(line.say) } } // 把本行台词目标时长暴露给宿主,供打字机字幕铺字(移植自 OLD typewriter,per=(dur-150)/len)。 if (ctx.onSay) ctx.onSay(alias, line.say, durMs, line.emotion || 'normal') await clock.sleep(durMs) if (my !== runToken) { audio.stopAll() return } if (audioRef.current) { audioRef.current.stop() audioRef.current = null } renderer.applyFace(alias, line.emotion || 'normal', false) if (anim) anim.setAnimation('head', 'head.idle', { mix: 150 }) } /** * moveTo 阻塞分支:朝行进方向、走路动画、等走位补间结束(时长 = moveDur),到点切站立。 * @param {import('./types.js').ScriptLine} line * @param {import('./types.js').SchedulerContext} ctx * @param {number} my * @returns {Promise} */ async function runMove(line, ctx, my) { const alias = line.who if (!alias) return const anim = ctx.animStateOf ? ctx.animStateOf(alias) : null const target = typeof line.moveTo === 'string' ? ctx.resolvePoint && ctx.resolvePoint(line.moveTo) : line.moveTo if (!target) return const from = ctx.getActorWorldPos && ctx.getActorWorldPos(alias) if (from) renderer.applyFacing(alias, target.x < from.x ? 'left' : 'right', null) if (anim) anim.setAnimation('lower_body', 'leg.walk', { mix: 150 }) const dur = line.moveDur || 1500 // 走位补间(lerp),而非瞬移到终点(移植自 OLD play-demo.html:197 `a.moveTo(p.x,p.y,dur,easing)`)。 // renderer 缺 moveActor(如假 renderer)时回落到瞬移,保证契约不破。 if (typeof renderer.moveActor === 'function') { renderer.moveActor(alias, target.x, target.y, dur, line.moveEasing || 'linear') } else { renderer.setActorWorldPos(alias, target.x, target.y) } await clock.sleep(dur) if (my !== runToken) return if (anim) anim.setAnimation('lower_body', 'leg.stand_together', { mix: 200 }) } /** * 执行一行(§3.4 并行 / 阻塞语义)。 * @param {import('./types.js').ScriptLine} line * @param {import('./types.js').SchedulerContext} ctx * @returns {Promise} */ async function playLine(line, ctx) { const my = runToken const cam = camera || ctx.camera || null // transition:阻塞,走黑场切换 + 换班底。 // 把同行新班底连同 transition 一起交给 runTransition,使渲染实现可在黑场期间换班底 // (移植自 OLD transitionTo:play-demo.html:103 spawnCast 在淡黑后、淡回前,避免淡回后 pop-in)。 // 渲染实现未消费 __applyCast 时,由 scheduler 在 runTransition 后兜底换班底(契约不破)。 if (line.transition) { let castApplied = false const applyCast = () => { if (!castApplied && line.cast && ctx.applyCast) { castApplied = true ctx.applyCast(line.cast) } } const t = { ...line.transition, __applyCast: applyCast } await renderer.runTransition(t) if (my !== runToken) return applyCast() // 兜底:renderer 未在黑场调 __applyCast 时这里补上 } // 行首即发的全部非阻塞副作用 /** @type {import('./types.js').LineSfxState} */ const sfxState = { untilLineEnd: null } emitNonBlocking(line, { renderer, audio, camera: cam }, ctx, sfxState) // 阻塞项:moveTo 与 say 并发 await(Promise.all),本行时长取 max /** @type {{current:(import('./types.js').PlayingAudio|null)}} */ const audioRef = { current: null } const blocking = [] if (line.who && line.moveTo) blocking.push(runMove(line, ctx, my)) if (line.who && line.say) blocking.push(runSay(line, ctx, my, audioRef)) if (blocking.length) await Promise.all(blocking) if (my !== runToken) return // wait 叠加在阻塞时长之上 if (line.wait) { await clock.sleep(line.wait) if (my !== runToken) return } // 本行专属 BGM:行结束即停 if (sfxState.untilLineEnd) sfxState.untilLineEnd.stop() } /** * 顺序播全部行。被 interrupt() 或新一轮 play() 接管时提前 resolve。 * @param {import('./types.js').ScriptLine[]} lines * @param {import('./types.js').SchedulerContext} ctx * @returns {Promise} */ async function play(lines, ctx) { const my = ++runToken for (const line of lines) { if (my !== runToken) return await playLine(line, ctx) if (my !== runToken) return } } /** * 打断 / 复位:停当前 say 音频、复位每演员动画与 renderer、复位相机,并中止后续行。 * 需通过 ctx 拿到演员集 / animState;为支持无 ctx 调用,最近一次 play 的 ctx 被记住。 */ /** @type {import('./types.js').SchedulerContext|null} */ let lastCtx = null const origPlay = play /** @param {import('./types.js').ScriptLine[]} lines @param {import('./types.js').SchedulerContext} ctx */ async function playTracked(lines, ctx) { lastCtx = ctx return origPlay(lines, ctx) } function interrupt() { runToken++ // 使所有进行中的 await 醒来后比对失败,停止推进 audio.stopAll() const ctx = lastCtx if (ctx) { for (const alias of actorsOf(ctx)) { renderer.resetActor(alias) const anim = ctx.animStateOf && ctx.animStateOf(alias) if (anim) anim.reset() } } const cam = camera || (ctx && ctx.camera) if (cam) cam.reset() } return { playLine, play: playTracked, interrupt } }