scheduler.js
11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
// 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 }
}