@bittersmilezzz/dsh-asr-voice 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +325 -0
  3. package/cordis.patch.yml +32 -0
  4. package/lib/asr-models.js +83 -0
  5. package/lib/asr-models.js.map +1 -0
  6. package/lib/client.js +6706 -0
  7. package/lib/client.js.map +1 -0
  8. package/lib/http.js +113 -0
  9. package/lib/http.js.map +1 -0
  10. package/lib/index.js +193 -0
  11. package/lib/index.js.map +1 -0
  12. package/lib/key-ref.js +25 -0
  13. package/lib/key-ref.js.map +1 -0
  14. package/lib/optimize.js +168 -0
  15. package/lib/optimize.js.map +1 -0
  16. package/lib/presets.js +98 -0
  17. package/lib/presets.js.map +1 -0
  18. package/lib/realtime-dashscope.js +211 -0
  19. package/lib/realtime-dashscope.js.map +1 -0
  20. package/lib/realtime-host.js +413 -0
  21. package/lib/realtime-host.js.map +1 -0
  22. package/lib/realtime-provider.js +143 -0
  23. package/lib/realtime-provider.js.map +1 -0
  24. package/lib/realtime-tts.js +179 -0
  25. package/lib/realtime-tts.js.map +1 -0
  26. package/lib/settings.js +155 -0
  27. package/lib/settings.js.map +1 -0
  28. package/lib/stats.js +30 -0
  29. package/lib/stats.js.map +1 -0
  30. package/lib/transcribe.js +274 -0
  31. package/lib/transcribe.js.map +1 -0
  32. package/lib/types/asr-models.d.ts +30 -0
  33. package/lib/types/asr-models.d.ts.map +1 -0
  34. package/lib/types/http.d.ts +41 -0
  35. package/lib/types/http.d.ts.map +1 -0
  36. package/lib/types/index.d.ts +75 -0
  37. package/lib/types/index.d.ts.map +1 -0
  38. package/lib/types/key-ref.d.ts +9 -0
  39. package/lib/types/key-ref.d.ts.map +1 -0
  40. package/lib/types/optimize.d.ts +41 -0
  41. package/lib/types/optimize.d.ts.map +1 -0
  42. package/lib/types/presets.d.ts +64 -0
  43. package/lib/types/presets.d.ts.map +1 -0
  44. package/lib/types/realtime-dashscope.d.ts +41 -0
  45. package/lib/types/realtime-dashscope.d.ts.map +1 -0
  46. package/lib/types/realtime-host.d.ts +95 -0
  47. package/lib/types/realtime-host.d.ts.map +1 -0
  48. package/lib/types/realtime-provider.d.ts +76 -0
  49. package/lib/types/realtime-provider.d.ts.map +1 -0
  50. package/lib/types/realtime-tts.d.ts +42 -0
  51. package/lib/types/realtime-tts.d.ts.map +1 -0
  52. package/lib/types/settings.d.ts +143 -0
  53. package/lib/types/settings.d.ts.map +1 -0
  54. package/lib/types/stats.d.ts +29 -0
  55. package/lib/types/stats.d.ts.map +1 -0
  56. package/lib/types/transcribe.d.ts +46 -0
  57. package/lib/types/transcribe.d.ts.map +1 -0
  58. package/lib/types/tsconfig.client.tsbuildinfo +1 -0
  59. package/package.json +146 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"presets.js","sourceRoot":"","sources":["../src/presets.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,211 @@
1
+ /** 建连/关闭兜底超时(毫秒):本机回环远低于此,云端握手一般也在内。 */
2
+ const CONNECT_TIMEOUT_MS = 15_000;
3
+ /** close() 后等 `session.finished` 的最长期限:服务端要先吐完在途 final。 */
4
+ const CLOSE_GRACE_MS = 3_000;
5
+ /** 默认 VAD:官方推荐值(低阈值灵敏度高,400ms 断句响应快)。 */
6
+ const DEFAULT_VAD = { threshold: 0.0, silenceDurationMs: 400 };
7
+ /** 一条与 qwen3-asr-flash-realtime 的实时连接。 */
8
+ class DashscopeRealtimeConnection {
9
+ opts;
10
+ /** CONNECTING 期上行缓冲上限:40ms 帧 ≈ 1.3s 语音;满了丢最旧(保新不保旧)。 */
11
+ static PENDING_AUDIO_CAP = 32;
12
+ ws;
13
+ closed = false;
14
+ sessionUpdateSent = false;
15
+ /** 建连超时:close() 也要清(否则连接在 CONNECTING 中被关,15s 后 fail() 空转一次)。 */
16
+ connectTimer;
17
+ /** CONNECTING 期暂存的待发音频帧(有界;open 后按序冲刷,会话开头的帧不丢)。 */
18
+ pendingAudio = [];
19
+ onEvent = null;
20
+ constructor(url, opts) {
21
+ this.opts = opts;
22
+ this.ws = new WebSocket(url, { headers: { Authorization: `Bearer ${opts.apiKey}` } });
23
+ const fail = (code) => {
24
+ if (this.closed)
25
+ return;
26
+ this.closed = true;
27
+ clearTimeout(this.connectTimer);
28
+ this.pendingAudio.length = 0; // 未送出的缓冲帧随连接作废
29
+ try {
30
+ this.ws.close();
31
+ }
32
+ catch { /* already closed */ }
33
+ // 本地能确定的终态:超时/不可达后连接已废,此后不会再有 final。
34
+ this.onEvent?.({ type: 'error', code, fatal: true });
35
+ };
36
+ this.connectTimer = setTimeout(() => fail('provider-timeout'), opts.connectTimeoutMs ?? CONNECT_TIMEOUT_MS);
37
+ this.ws.onopen = () => {
38
+ clearTimeout(this.connectTimer);
39
+ if (this.closed)
40
+ return;
41
+ this.sendSessionUpdate();
42
+ // 协议要求 session.update 先行:冲刷必须在 sendSessionUpdate 之后。
43
+ try {
44
+ this.flushPendingAudio();
45
+ }
46
+ catch { /* socket died during flush */ }
47
+ };
48
+ this.ws.onerror = () => {
49
+ clearTimeout(this.connectTimer);
50
+ fail('provider-unreachable');
51
+ };
52
+ this.ws.onclose = (ev) => {
53
+ clearTimeout(this.connectTimer);
54
+ // closed 已置位 = 由 close()/fail() 主动收尾(graceful 或已报过错),不重复报。
55
+ // 走到这里 = 对端主动关闭且未报过错,用**规范定义的 wasClean**(关闭握手是否
56
+ // 走完)分辨两种收尾:
57
+ // wasClean=false(code 1006)= 握手没走完 = RST/销毁式断连 → provider-unreachable
58
+ // wasClean=true(code 1000 等)= 对端正常挥手关闭 → provider-closed
59
+ // 不能靠「onerror 有没有先到」来区分:undici 对销毁式断连是否补发 error 随 Node
60
+ // 版本而变(22 只发 close、26 先发 error),同一个异常断连会因此在 22 上报
61
+ // provider-closed、在 26 上报 provider-unreachable——实测两版 wasClean 一致,
62
+ // 才是可依赖的判据。
63
+ if (this.closed)
64
+ return;
65
+ this.closed = true;
66
+ // 对端已关连接 = 终态(fatal):此后不可能再有 final,host 据它收尾会话。
67
+ this.onEvent?.({ type: 'error', code: ev.wasClean === false ? 'provider-unreachable' : 'provider-closed', fatal: true });
68
+ };
69
+ this.ws.onmessage = (msg) => {
70
+ if (this.closed)
71
+ return;
72
+ const ev = this.mapServerEvent(String(msg.data));
73
+ if (ev !== null)
74
+ this.onEvent?.(ev);
75
+ };
76
+ }
77
+ /** 连接建立后第一时间发 session.update(pcm/16000/server_vad)。 */
78
+ sendSessionUpdate() {
79
+ if (this.sessionUpdateSent || this.closed)
80
+ return;
81
+ this.sessionUpdateSent = true;
82
+ const { vad } = this.opts;
83
+ const v = vad === undefined ? DEFAULT_VAD : { ...DEFAULT_VAD, ...vad };
84
+ const session = {
85
+ input_audio_format: 'pcm',
86
+ sample_rate: 16000,
87
+ turn_detection: { type: 'server_vad', threshold: v.threshold, silence_duration_ms: v.silenceDurationMs },
88
+ };
89
+ if (this.opts.language !== undefined && this.opts.language !== '') {
90
+ session.input_audio_transcription = { language: this.opts.language };
91
+ }
92
+ this.sendRaw({ type: 'session.update', session });
93
+ }
94
+ /** 上行一段 int16 LE PCM:base64 后走 input_audio_buffer.append。 */
95
+ send(pcm) {
96
+ if (this.closed || pcm.byteLength === 0)
97
+ return;
98
+ if (this.ws.readyState !== WebSocket.OPEN) {
99
+ // CONNECTING(会话开头 ~100-500ms 握手窗口):帧进有界缓冲,open 后按序冲刷,
100
+ // 不再静默丢弃——否则开麦头几百毫秒的语音直接蒸发。缓冲满丢最旧(实时音频
101
+ // 没有重放价值,保新不保旧);CLOSING/CLOSED 无意义,维持丢弃。
102
+ if (this.ws.readyState !== WebSocket.CONNECTING)
103
+ return;
104
+ if (this.pendingAudio.length >= DashscopeRealtimeConnection.PENDING_AUDIO_CAP)
105
+ this.pendingAudio.shift();
106
+ this.pendingAudio.push(pcm);
107
+ return;
108
+ }
109
+ this.flushPendingAudio();
110
+ const base64 = Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).toString('base64');
111
+ this.sendRaw({ type: 'input_audio_buffer.append', audio: base64 });
112
+ }
113
+ /** 把 CONNECTING 期缓冲的音频帧按序送出(open 后调用;随帧转 base64,不提前编码)。 */
114
+ flushPendingAudio() {
115
+ if (this.pendingAudio.length === 0)
116
+ return;
117
+ const frames = this.pendingAudio.splice(0);
118
+ for (const frame of frames) {
119
+ const base64 = Buffer.from(frame.buffer, frame.byteOffset, frame.byteLength).toString('base64');
120
+ this.sendRaw({ type: 'input_audio_buffer.append', audio: base64 });
121
+ }
122
+ }
123
+ /** 结束会话(幂等):先发 session.finish,等 session.finished 或超时再关 WS。 */
124
+ close() {
125
+ if (this.closed)
126
+ return;
127
+ this.closed = true;
128
+ clearTimeout(this.connectTimer);
129
+ // VAD 模式下必须先发 session.finish 再关连接,否则服务端丢弃在途 final。
130
+ try {
131
+ this.sendRaw({ type: 'session.finish' });
132
+ }
133
+ catch { /* socket gone */ }
134
+ const dispose = () => {
135
+ // 兜底关闭也要摘掉监听:收尾期间加的 onFinished 挂在 ws 上,不摘会随连接
136
+ // 一起滞留到 GC,且 session.finished 迟到时会 clear 一个已触发的 timer(无害
137
+ // 但仍是无效操作)。幂等:ws.close() 对已关连接无副作用。
138
+ this.ws.removeEventListener('message', onFinished);
139
+ try {
140
+ this.ws.close();
141
+ }
142
+ catch { /* already closed */ }
143
+ };
144
+ // 收到 session.finished 提前关;到点兜底强制关。
145
+ const timer = setTimeout(dispose, CLOSE_GRACE_MS);
146
+ const onFinished = (msg) => {
147
+ try {
148
+ const parsed = JSON.parse(String(msg.data));
149
+ if (parsed.type === 'session.finished') {
150
+ clearTimeout(timer);
151
+ this.ws.removeEventListener('message', onFinished);
152
+ dispose();
153
+ }
154
+ }
155
+ catch { /* non-JSON (ping etc.) ignore */ }
156
+ };
157
+ this.ws.addEventListener('message', onFinished);
158
+ }
159
+ /** 发一条客户端事件(JSON 文本帧)。 */
160
+ sendRaw(payload) {
161
+ this.ws.send(JSON.stringify(payload));
162
+ }
163
+ /** 服务端事件 → 接缝事件;无关事件(session.created/updated 等)返回 null。 */
164
+ mapServerEvent(raw) {
165
+ let parsed;
166
+ try {
167
+ parsed = JSON.parse(raw);
168
+ }
169
+ catch {
170
+ return null; // 非 JSON(心跳注释等)忽略
171
+ }
172
+ switch (parsed.type) {
173
+ case 'input_audio_buffer.speech_started':
174
+ return { type: 'speech-started' };
175
+ case 'input_audio_buffer.speech_stopped':
176
+ return { type: 'speech-stopped' };
177
+ case 'conversation.item.input_audio_transcription.text':
178
+ // text=已确认前缀 + stash=仍在处理的草稿后缀:拼接才是当前完整预览。
179
+ return { type: 'partial', text: `${parsed.text ?? ''}${parsed.stash ?? ''}` };
180
+ case 'conversation.item.input_audio_transcription.completed':
181
+ return { type: 'final', text: parsed.transcript ?? '' };
182
+ case 'conversation.item.input_audio_transcription.failed':
183
+ // 单条音频转写失败:连接与后续回合不受影响,标 fatal=false 让 host 别拆会话。
184
+ return { type: 'error', code: 'transcription-failed', fatal: false };
185
+ case 'error':
186
+ // 通用 error(官方文档:含 invalid_request_error 等客户端参数错,示例为 model 名非法)——
187
+ // 文档未声明会话终止,连接可能仍可用,故**不判终态**:拆会话会因一次可恢复错误掐掉
188
+ // 整场对话。是否结束交给客户端既有的连败阈值(CLOUD_FAIL_LIMIT)。
189
+ return { type: 'error', code: parsed.error?.code ?? 'provider-error', fatal: false };
190
+ case 'session.finished':
191
+ // 服务端收尾完成:连接使命结束,本地不必再报错(close() 兜底会关)。
192
+ return null;
193
+ default:
194
+ return null; // session.created/updated/committed/item.created 等不需要
195
+ }
196
+ }
197
+ }
198
+ /** 真云端实时 provider 工厂(I5:host 侧 createProvider 用它)。 */
199
+ export function createDashscopeRealtimeProvider(opts) {
200
+ const wssUrl = (opts.wssUrl ?? 'wss://dashscope.aliyuncs.com/api-ws/v1/realtime').replace(/\/+$/, '');
201
+ const model = opts.model ?? 'qwen3-asr-flash-realtime';
202
+ const url = `${wssUrl}?model=${encodeURIComponent(model)}`;
203
+ return {
204
+ connect: async () => {
205
+ if (!opts.apiKey)
206
+ throw new Error('dashscope realtime: no API key');
207
+ return new DashscopeRealtimeConnection(url, opts);
208
+ },
209
+ };
210
+ }
211
+ //# sourceMappingURL=realtime-dashscope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"realtime-dashscope.js","sourceRoot":"","sources":["../src/realtime-dashscope.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,413 @@
1
+ /** dsh-asr-voice — host 半区:实时转写会话注册表 + SSE 下行(I3 交付)。
2
+ * 纯管道:浏览器 PCM 上行(POST audio)→ RealtimeProvider 接缝 → SSE 下行
3
+ * (GET events)。`sid` 由 **host 铸造**(crypto.randomUUID),客户端只拿到
4
+ * 不透明 token,无法伪造会话;4 条 exact 路由全部过 `isTrusted` 信任围栏。
5
+ * 会话生命周期:
6
+ * - POST /api/asr-voice/realtime/session → { ok, sid }(建会话)
7
+ * - POST /api/asr-voice/realtime/audio → { ok }(PCM 上行,?sid=…)
8
+ * - GET /api/asr-voice/realtime/events → SSE 下行(?sid=…)
9
+ * - POST /api/asr-voice/realtime/close → { ok }(关会话,?sid=…)
10
+ * 4 条路由路径互不相同:webserver 的 register 对重复 (kind, path) 直接抛错,
11
+ * 同一路径挂两个 method 会撞——所以关闭走独立的 /close 路径而不是 /session 的 DELETE。
12
+ * SSE 背压:Node `res.write()` 返回 false 表示内核缓冲已满(下行慢于上行)。
13
+ * 这里不无限缓冲——partial(可丢的中间结果)coalesce 成最新一条,final /
14
+ * speech-stopped(不可丢的回合边界)**必须**最终送达。drain 后按序冲刷。
15
+ * 每会话一条 SSE(浏览器是单一消费者);SSE 断开 / 会话超时都会拆掉整个会话,
16
+ * 防止麦克风数据在 host 侧无人认领地堆积。
17
+ */
18
+ import { randomUUID } from 'node:crypto';
19
+ import { guardRoute, readRawBody, sendJson, statusOfBodyError } from "./http.js";
20
+ /** 单次 PCM 上行体上限(16k int16 ≈ 每 100ms 3200B;40ms 帧 1280B)。 */
21
+ const MAX_PCM_BYTES = 4 * 1024 * 1024;
22
+ /** 会话空闲上限(毫秒):没有数据进来也没有消费者,自动拆会话防泄漏。 */
23
+ const DEFAULT_SESSION_IDLE_MS = 10 * 60 * 1000;
24
+ /** 上游终态报错后的收尾宽限期(毫秒):错误帧入队后给它这段时间送达客户端(SSE 若正
25
+ * 背压则等 drain 冲刷),到点无条件拆会话。设短值即可——客户端收到错误帧后自己会走
26
+ * failNow → /close;这个宽限只是「客户端没反应」时的兜底,不是正常路径。 */
27
+ const ERROR_LINGER_MS = 5_000;
28
+ /** SSE 下行通道:带背压(partial 原位合并 / final 必达)与心跳。 */
29
+ export class SseChannel {
30
+ res;
31
+ backedUp = false;
32
+ /** 背压期间排队待发的事件(有界)。partial 会被更新的 partial 原位替换(coalesce),
33
+ * final/speech-stopped 追加保序——drain 后按序冲刷,**任何 final 都不丢**。
34
+ * 此前单一 coalesce 槽会被后续 final/partial 直接覆盖:背压中连续两句收口时,
35
+ * 第一句的 final 被第二句顶掉,客户端永远只看到 partial(缺句)。 */
36
+ pending = [];
37
+ /** pending 上限:与会话侧 pending 缓冲同量级;极端积压(客户端几乎不读)时丢最旧
38
+ * 事件降级,会话最终由 disconnect / 空闲守卫拆除。 */
39
+ static PENDING_CAP = 64;
40
+ closed = false;
41
+ /** 空闲心跳:防止中间代理把长连接掐掉(部分代理 30s 无数据即断)。 */
42
+ heartbeat = null;
43
+ onDisconnect;
44
+ /** 当前挂着的 drain 监听(close/disconnect 时移除,避免监听随响应滞留到 GC)。 */
45
+ drainHandler = null;
46
+ constructor(res, opts = {}) {
47
+ this.res = res;
48
+ this.onDisconnect = opts.onDisconnect ?? null;
49
+ const hb = opts.heartbeatMs ?? 15_000;
50
+ if (hb > 0) {
51
+ this.heartbeat = setInterval(() => {
52
+ if (!this.closed && !this.backedUp) {
53
+ try {
54
+ this.res.write(': ping\n\n');
55
+ }
56
+ catch { /* socket gone */ }
57
+ }
58
+ }, hb);
59
+ }
60
+ res.on('close', this.disconnect);
61
+ }
62
+ /** 排入一条事件:空闲直写;背压时 partial 原位合并、final/stopped 排队保序。
63
+ * 命中背压的那一条**已经写出**(`write` 返回 false 只是「别再写了」,不是「没写」),
64
+ * 所以队列只兜住背压期间新到的事件,等 drain 恢复后按序送出。 */
65
+ enqueue(ev) {
66
+ if (this.closed)
67
+ return;
68
+ if (ev.type === 'partial') {
69
+ // partial 可丢:只保留同句最新一条。队尾是 partial 就原位替换(不后移,
70
+ // 保持与后续 final 的相对顺序——否则迟到的 partial 会排到 final 之后,
71
+ // 客户端先收 final 再收旧 partial,字幕倒退)。
72
+ const tail = this.pending[this.pending.length - 1];
73
+ if (tail?.type === 'partial') {
74
+ this.pending[this.pending.length - 1] = ev;
75
+ return;
76
+ }
77
+ this.pending.push(ev);
78
+ }
79
+ else {
80
+ // final / speech-stopped 不可丢:先丢队尾的 partial——它们是刚收口这句的
81
+ // 预览,final 到达后冗余(且晚到的 final 若排在 partial 之后会让客户端
82
+ // 字幕倒退);再入队保序,任何 final 都最终送达。
83
+ while (this.pending.length > 0) {
84
+ const tail = this.pending[this.pending.length - 1];
85
+ if (tail === undefined || tail.type !== 'partial')
86
+ break;
87
+ this.pending.pop();
88
+ }
89
+ this.pending.push(ev);
90
+ }
91
+ // 有界:极端积压(客户端几乎不读)时降级防无界增长(会话最终由 disconnect /
92
+ // 空闲守卫拆除)。降级顺序不能破坏「final 必达」契约:先丢队尾 partial(可丢的
93
+ // 中间结果,final 到达后冗余),只有整队都是 final(不可丢的回合边界)才丢最旧
94
+ // 一条——新来的事件绝不顶掉已入队的 final。
95
+ if (this.pending.length > SseChannel.PENDING_CAP) {
96
+ while (this.pending.length > SseChannel.PENDING_CAP) {
97
+ const tail = this.pending[this.pending.length - 1];
98
+ if (tail === undefined || tail.type !== 'partial')
99
+ break;
100
+ this.pending.pop();
101
+ }
102
+ if (this.pending.length > SseChannel.PENDING_CAP)
103
+ this.pending.shift();
104
+ }
105
+ // 空闲(无背压)才立即冲刷:背压中只入队等 drain 恢复后按序送出。
106
+ if (!this.backedUp)
107
+ this.flush();
108
+ }
109
+ /** 按序冲刷排队的事件(final 不丢、partial 保最新);缓冲满则挂 drain 等恢复。
110
+ * **`write()` 返回 false 只表示内核缓冲已超高水位,事件本身已被接受并会送出**——
111
+ * 因此出队与返回值无关:先出队再据返回值置背压标志。早先「返回 false 就不出队」的
112
+ * 写法会让同一条事件在 drain 后被再写一遍(partial 重放无害,final 重放会让客户端
113
+ * 把同一个回合提交两次)。 */
114
+ flush() {
115
+ if (this.closed || this.backedUp)
116
+ return;
117
+ while (this.pending.length > 0) {
118
+ const ev = this.pending[0];
119
+ const payload = `data: ${JSON.stringify(ev)}\n\n`;
120
+ let ok = false;
121
+ try {
122
+ ok = this.res.write(payload);
123
+ }
124
+ catch {
125
+ this.disconnect();
126
+ return;
127
+ }
128
+ this.pending.shift();
129
+ if (!ok) {
130
+ this.backedUp = true;
131
+ this.armDrain();
132
+ return;
133
+ }
134
+ }
135
+ this.backedUp = false;
136
+ }
137
+ /** 挂一次 drain 监听(同一时间只挂一个;close/disconnect 时摘掉)。 */
138
+ armDrain() {
139
+ if (this.drainHandler !== null)
140
+ return;
141
+ this.drainHandler = () => {
142
+ this.drainHandler = null;
143
+ // drain = 内核缓冲已回落到水位之下:先解除背压态,flush 才会真的往下写
144
+ //(flush 自身对背压态早退,避免在背压中反复重试)。
145
+ this.backedUp = false;
146
+ this.flush();
147
+ };
148
+ this.res.once('drain', this.drainHandler);
149
+ }
150
+ /** 结束下行(幂等):清心跳、断 close/drain 监听。 */
151
+ close() {
152
+ if (this.closed)
153
+ return;
154
+ this.closed = true;
155
+ if (this.heartbeat !== null)
156
+ clearInterval(this.heartbeat);
157
+ this.heartbeat = null;
158
+ this.res.removeListener('close', this.disconnect);
159
+ if (this.drainHandler !== null)
160
+ this.res.removeListener('drain', this.drainHandler);
161
+ this.drainHandler = null;
162
+ try {
163
+ this.res.end();
164
+ }
165
+ catch { /* already ended */ }
166
+ }
167
+ disconnect = () => {
168
+ if (this.closed)
169
+ return;
170
+ this.closed = true;
171
+ if (this.heartbeat !== null)
172
+ clearInterval(this.heartbeat);
173
+ this.heartbeat = null;
174
+ this.res.removeListener('close', this.disconnect);
175
+ if (this.drainHandler !== null)
176
+ this.res.removeListener('drain', this.drainHandler);
177
+ this.drainHandler = null;
178
+ this.onDisconnect?.();
179
+ };
180
+ }
181
+ /** 实时转写会话注册表 + 路由。 */
182
+ export class RealtimeHost {
183
+ sessions = new Map();
184
+ opts;
185
+ createProvider;
186
+ constructor(options) {
187
+ this.createProvider = options.createProvider;
188
+ this.opts = {
189
+ idleMs: options.idleMs ?? DEFAULT_SESSION_IDLE_MS,
190
+ heartbeatMs: options.heartbeatMs ?? 15_000,
191
+ errorLingerMs: options.errorLingerMs ?? ERROR_LINGER_MS,
192
+ now: options.now ?? Date.now,
193
+ };
194
+ }
195
+ /** 铸造新会话:host 生成 sid,建 provider 连接。 */
196
+ async createSession() {
197
+ const sid = randomUUID();
198
+ const conn = await this.createProvider();
199
+ const session = { sid, conn, sse: null, pending: [], lastActive: this.opts.now(), idleTimer: null, errored: false };
200
+ // provider 事件统一走同一个收口:无 SSE 时缓冲(有界),挂上后冲刷。
201
+ conn.onEvent = (ev) => {
202
+ const s = this.sessions.get(sid);
203
+ if (s === undefined)
204
+ return;
205
+ if (s.sse !== null) {
206
+ s.sse.enqueue(ev);
207
+ }
208
+ else {
209
+ s.pending.push(ev);
210
+ if (s.pending.length > 64)
211
+ s.pending.shift();
212
+ }
213
+ // 终态报错(连接已死)才收尾:错误帧先入队(无 SSE 时留缓冲,等 SSE 挂上补送),
214
+ // 再进入宽限拆除。非终态错误(如单条转写失败)不动会话——连接还活着,后续回合
215
+ // 照常,拆掉等于用户说错一句就掐掉整场对话。注意这个判定必须在「SSE 已挂」分支
216
+ // 之外:SSE 挂着才是常态,放在分支里等于永不收尾。
217
+ if (ev.type === 'error' && ev.fatal !== false)
218
+ this.armErrorTeardown(sid);
219
+ };
220
+ this.sessions.set(sid, session);
221
+ this.armIdle(sid);
222
+ return { sid };
223
+ }
224
+ /** 上游终态报错后的收尾:标记会话已终结 + 把空闲窗口收紧到 ERROR_LINGER_MS。
225
+ * 修的是「僵尸会话」:此前 provider 报错后 host 不拆会话,客户端仍在按 40ms 一帧
226
+ * 上行 PCM,每帧 `feedAudio` → `refreshIdle` 都把 lastActive 顶到现在——死连接的
227
+ * 会话因此永远等不到空闲过期,SSE 一直挂着、麦克风一直开。 */
228
+ armErrorTeardown(sid) {
229
+ const s = this.sessions.get(sid);
230
+ if (s === undefined || s.errored)
231
+ return;
232
+ s.errored = true;
233
+ s.lastActive = this.opts.now();
234
+ this.armIdle(sid, this.opts.errorLingerMs);
235
+ }
236
+ /** 空闲守卫:到点复查——期间有任何上行/下行活动会走 refreshIdle 重挂, 真正空闲满 windowMs 才拆会话防泄漏(默认 idleMs;上游终态报错后用更短的收尾宽限)。 */
237
+ armIdle(sid, windowMs = this.opts.idleMs) {
238
+ const s = this.sessions.get(sid);
239
+ if (s === undefined)
240
+ return;
241
+ if (s.idleTimer !== null)
242
+ clearTimeout(s.idleTimer);
243
+ s.idleTimer = setTimeout(() => {
244
+ const cur = this.sessions.get(sid);
245
+ if (cur === undefined)
246
+ return;
247
+ const idle = this.opts.now() - cur.lastActive;
248
+ if (idle >= windowMs)
249
+ this.closeSession(sid);
250
+ else
251
+ this.armIdle(sid, windowMs);
252
+ }, windowMs);
253
+ }
254
+ /** 刷新空闲计时(每次上行/下行活动调用)。已终结的会话不续命——否则死连接被
255
+ * 客户端的持续上行"续命",永远到不了过期点。 */
256
+ refreshIdle(sid) {
257
+ const s = this.sessions.get(sid);
258
+ if (s === undefined || s.errored)
259
+ return;
260
+ s.lastActive = this.opts.now();
261
+ this.armIdle(sid);
262
+ }
263
+ /** 上行 PCM(16k int16 LE)到指定会话。会话不存在**或上游已终态报错**返回 false
264
+ * (路由据此回 404,客户端停止上行)。 */
265
+ feedAudio(sid, pcm) {
266
+ const s = this.sessions.get(sid);
267
+ if (s === undefined || s.errored)
268
+ return false;
269
+ this.refreshIdle(sid);
270
+ s.conn.send(pcm);
271
+ return true;
272
+ }
273
+ /** 挂起 SSE 下行(单消费者)。会话不存在 / 已有下行返回 false。 */
274
+ attachSse(sid, res) {
275
+ const s = this.sessions.get(sid);
276
+ if (s === undefined || s.sse !== null)
277
+ return false;
278
+ this.refreshIdle(sid);
279
+ const channel = new SseChannel(res, {
280
+ heartbeatMs: this.opts.heartbeatMs,
281
+ onDisconnect: () => this.closeSession(sid),
282
+ });
283
+ s.sse = channel;
284
+ // 冲刷挂 SSE 前缓冲的上游事件(上行先于下行的部分不丢)。
285
+ if (s.pending.length > 0) {
286
+ for (const ev of s.pending)
287
+ channel.enqueue(ev);
288
+ s.pending = [];
289
+ }
290
+ return true;
291
+ }
292
+ /** 关闭会话:拆 provider、拆 SSE、清定时器(幂等)。 */
293
+ closeSession(sid) {
294
+ const s = this.sessions.get(sid);
295
+ if (s === undefined)
296
+ return;
297
+ this.sessions.delete(sid);
298
+ if (s.idleTimer !== null)
299
+ clearTimeout(s.idleTimer);
300
+ s.idleTimer = null;
301
+ s.sse?.close();
302
+ s.sse = null;
303
+ s.conn.onEvent = null;
304
+ try {
305
+ s.conn.close();
306
+ }
307
+ catch { /* noop */ }
308
+ }
309
+ /** 会话是否存活(供测试/诊断)。 */
310
+ hasSession(sid) {
311
+ return this.sessions.has(sid);
312
+ }
313
+ /** 释放全部会话(插件卸载/热重载时由 fiber disposer 调用):逐个 closeSession
314
+ * (幂等),SSE 心跳、idle timer、provider 连接全部随之释放。 */
315
+ dispose() {
316
+ for (const sid of [...this.sessions.keys()])
317
+ this.closeSession(sid);
318
+ }
319
+ /** 注册 4 条 exact 路由(全部过 isTrusted)。 @returns 全部路由的 disposer(由 ctx.effect 挂载/回收)。 */
320
+ registerRoutes(register) {
321
+ const disposers = [
322
+ register({
323
+ kind: 'exact',
324
+ path: '/api/asr-voice/realtime/session',
325
+ handler: async (req, res) => {
326
+ const denied = guardRoute(req);
327
+ if (denied !== null)
328
+ return sendJson(res, denied.status, denied.payload);
329
+ try {
330
+ const { sid } = await this.createSession();
331
+ return sendJson(res, 200, { ok: true, sid });
332
+ }
333
+ catch (error) {
334
+ return sendJson(res, 502, { ok: false, reason: error instanceof Error ? error.message : String(error) });
335
+ }
336
+ },
337
+ }),
338
+ register({
339
+ kind: 'exact',
340
+ path: '/api/asr-voice/realtime/audio',
341
+ handler: async (req, res) => {
342
+ const denied = guardRoute(req);
343
+ if (denied !== null)
344
+ return sendJson(res, denied.status, denied.payload);
345
+ const sid = sidOf(req);
346
+ if (sid === '')
347
+ return sendJson(res, 400, { ok: false, reason: 'missing sid' });
348
+ try {
349
+ const pcm = await readRawBody(req, MAX_PCM_BYTES);
350
+ if (pcm.length === 0)
351
+ return sendJson(res, 400, { ok: false, reason: 'empty audio body' });
352
+ if (!this.feedAudio(sid, pcm))
353
+ return sendJson(res, 404, { ok: false, reason: 'no such session' });
354
+ return sendJson(res, 200, { ok: true });
355
+ }
356
+ catch (error) {
357
+ // 超限/超时是请求侧问题(413/408),其余(含 sid 不存在)仍按 400 口径。
358
+ return sendJson(res, statusOfBodyError(error, 400), { ok: false, reason: error instanceof Error ? error.message : String(error) });
359
+ }
360
+ },
361
+ }),
362
+ register({
363
+ kind: 'exact',
364
+ path: '/api/asr-voice/realtime/events',
365
+ handler: async (req, res) => {
366
+ const denied = guardRoute(req, ['GET']);
367
+ if (denied !== null)
368
+ return sendJson(res, denied.status, denied.payload);
369
+ const sid = sidOf(req);
370
+ if (sid === '')
371
+ return sendJson(res, 400, { ok: false, reason: 'missing sid' });
372
+ if (!this.hasSession(sid))
373
+ return sendJson(res, 404, { ok: false, reason: 'no such session' });
374
+ // SSE:先把头写出去(背压/断连交给 SseChannel),再挂会话。
375
+ res.writeHead(200, {
376
+ 'content-type': 'text/event-stream; charset=utf-8',
377
+ 'cache-control': 'no-cache, no-store',
378
+ 'connection': 'keep-alive',
379
+ 'x-accel-buffering': 'no',
380
+ });
381
+ res.flushHeaders();
382
+ if (!this.attachSse(sid, res)) {
383
+ // 已有下行消费者(或会话刚被拆):本连接直接收掉。
384
+ res.end();
385
+ }
386
+ },
387
+ }),
388
+ register({
389
+ kind: 'exact',
390
+ path: '/api/asr-voice/realtime/close',
391
+ handler: async (req, res) => {
392
+ const denied = guardRoute(req);
393
+ if (denied !== null)
394
+ return sendJson(res, denied.status, denied.payload);
395
+ const sid = sidOf(req);
396
+ if (sid === '')
397
+ return sendJson(res, 400, { ok: false, reason: 'missing sid' });
398
+ this.closeSession(sid);
399
+ return sendJson(res, 200, { ok: true });
400
+ },
401
+ }),
402
+ ];
403
+ return () => { for (const dispose of disposers)
404
+ dispose(); };
405
+ }
406
+ }
407
+ /** 从查询串取 sid(缺省返回空串)。 */
408
+ function sidOf(req) {
409
+ const url = new URL(req.url ?? '/', 'http://localhost');
410
+ const sid = url.searchParams.get('sid') ?? '';
411
+ return sid.trim();
412
+ }
413
+ //# sourceMappingURL=realtime-host.js.map