scheduler.js 11.8 KB
// 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<void>} */
  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<void>,
 *   play: (lines:import('./types.js').ScriptLine[], ctx:import('./types.js').SchedulerContext) => Promise<void>,
 *   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<void>}
   */
  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<void>}
   */
  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<void>}
   */
  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<void>}
   */
  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 }
}