benchgo-plugin 0.0.0-stage → 0.0.2

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 (180) hide show
  1. package/DEEPSEEK-LICENSE +21 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +17 -0
  4. package/PASEO-LICENSE +211 -0
  5. package/README.md +256 -2
  6. package/TOOL-CONFIG.md +33 -0
  7. package/VERIFICATION.md +297 -0
  8. package/assets/fonts/README.md +13 -0
  9. package/assets/fonts/manifest.json +437 -0
  10. package/assets/fonts/web/fangyuan-latin-238bc49130b1.woff2 +0 -0
  11. package/assets/fonts/web/fangyuan-u30-0fe8d99641c3.woff2 +0 -0
  12. package/assets/fonts/web/fangyuan-u32-202a573131a1.woff2 +0 -0
  13. package/assets/fonts/web/fangyuan-u4e-cc2f8b20be49.woff2 +0 -0
  14. package/assets/fonts/web/fangyuan-u4f-9189b5f2d321.woff2 +0 -0
  15. package/assets/fonts/web/fangyuan-u50-e2560e003872.woff2 +0 -0
  16. package/assets/fonts/web/fangyuan-u51-a9c5e7a8c7c4.woff2 +0 -0
  17. package/assets/fonts/web/fangyuan-u52-0cfd00b473c8.woff2 +0 -0
  18. package/assets/fonts/web/fangyuan-u53-18122a07f60a.woff2 +0 -0
  19. package/assets/fonts/web/fangyuan-u54-8ca41aa9cff2.woff2 +0 -0
  20. package/assets/fonts/web/fangyuan-u55-7bd55e14647e.woff2 +0 -0
  21. package/assets/fonts/web/fangyuan-u56-26f093e8a810.woff2 +0 -0
  22. package/assets/fonts/web/fangyuan-u57-36bde83a8378.woff2 +0 -0
  23. package/assets/fonts/web/fangyuan-u58-d1df14217098.woff2 +0 -0
  24. package/assets/fonts/web/fangyuan-u59-4e41ab22c51a.woff2 +0 -0
  25. package/assets/fonts/web/fangyuan-u5a-746a367174e6.woff2 +0 -0
  26. package/assets/fonts/web/fangyuan-u5b-3d1dd404d70c.woff2 +0 -0
  27. package/assets/fonts/web/fangyuan-u5c-fe0a273f69e7.woff2 +0 -0
  28. package/assets/fonts/web/fangyuan-u5d-af1f0a1c8d2d.woff2 +0 -0
  29. package/assets/fonts/web/fangyuan-u5e-598c65420e17.woff2 +0 -0
  30. package/assets/fonts/web/fangyuan-u5f-a92eaaa5265a.woff2 +0 -0
  31. package/assets/fonts/web/fangyuan-u60-48ee3d26462d.woff2 +0 -0
  32. package/assets/fonts/web/fangyuan-u61-7a57f35bc077.woff2 +0 -0
  33. package/assets/fonts/web/fangyuan-u62-d2d0b7b36631.woff2 +0 -0
  34. package/assets/fonts/web/fangyuan-u63-cade7c6635e6.woff2 +0 -0
  35. package/assets/fonts/web/fangyuan-u64-ed34e40d7f65.woff2 +0 -0
  36. package/assets/fonts/web/fangyuan-u65-e13e83e69c38.woff2 +0 -0
  37. package/assets/fonts/web/fangyuan-u66-1dbcfa5da147.woff2 +0 -0
  38. package/assets/fonts/web/fangyuan-u67-6b0a26b82da6.woff2 +0 -0
  39. package/assets/fonts/web/fangyuan-u68-1b8217cfceed.woff2 +0 -0
  40. package/assets/fonts/web/fangyuan-u69-673e63627790.woff2 +0 -0
  41. package/assets/fonts/web/fangyuan-u6a-3c94008a4640.woff2 +0 -0
  42. package/assets/fonts/web/fangyuan-u6b-44572c410fa0.woff2 +0 -0
  43. package/assets/fonts/web/fangyuan-u6c-1b323b366e88.woff2 +0 -0
  44. package/assets/fonts/web/fangyuan-u6d-cbc376d6fe3f.woff2 +0 -0
  45. package/assets/fonts/web/fangyuan-u6e-7f7e211e2fee.woff2 +0 -0
  46. package/assets/fonts/web/fangyuan-u6f-ff280ce052fa.woff2 +0 -0
  47. package/assets/fonts/web/fangyuan-u70-cb727eaf73b6.woff2 +0 -0
  48. package/assets/fonts/web/fangyuan-u71-0bb1747439fe.woff2 +0 -0
  49. package/assets/fonts/web/fangyuan-u72-cccd39590d69.woff2 +0 -0
  50. package/assets/fonts/web/fangyuan-u73-e67705133584.woff2 +0 -0
  51. package/assets/fonts/web/fangyuan-u74-03a4e7ac9df5.woff2 +0 -0
  52. package/assets/fonts/web/fangyuan-u75-a2d30b327e65.woff2 +0 -0
  53. package/assets/fonts/web/fangyuan-u76-c08fe029c988.woff2 +0 -0
  54. package/assets/fonts/web/fangyuan-u77-76f03be1f4cb.woff2 +0 -0
  55. package/assets/fonts/web/fangyuan-u78-5b721deaf582.woff2 +0 -0
  56. package/assets/fonts/web/fangyuan-u79-db7713c2eda4.woff2 +0 -0
  57. package/assets/fonts/web/fangyuan-u7a-3e40d3726a09.woff2 +0 -0
  58. package/assets/fonts/web/fangyuan-u7b-a8204d1c9dba.woff2 +0 -0
  59. package/assets/fonts/web/fangyuan-u7c-89bcdae046de.woff2 +0 -0
  60. package/assets/fonts/web/fangyuan-u7d-bcc45fa6351a.woff2 +0 -0
  61. package/assets/fonts/web/fangyuan-u7e-49fab96d9269.woff2 +0 -0
  62. package/assets/fonts/web/fangyuan-u7f-dc110e1ecbf3.woff2 +0 -0
  63. package/assets/fonts/web/fangyuan-u80-c5ce9be0335e.woff2 +0 -0
  64. package/assets/fonts/web/fangyuan-u81-b026091b3779.woff2 +0 -0
  65. package/assets/fonts/web/fangyuan-u82-a7c6960e875d.woff2 +0 -0
  66. package/assets/fonts/web/fangyuan-u83-4b7d1c2c50d4.woff2 +0 -0
  67. package/assets/fonts/web/fangyuan-u84-4119128100c8.woff2 +0 -0
  68. package/assets/fonts/web/fangyuan-u85-ca43dd4c5411.woff2 +0 -0
  69. package/assets/fonts/web/fangyuan-u86-54fc0e3158fb.woff2 +0 -0
  70. package/assets/fonts/web/fangyuan-u87-b64f5eb55720.woff2 +0 -0
  71. package/assets/fonts/web/fangyuan-u88-00feb1251f08.woff2 +0 -0
  72. package/assets/fonts/web/fangyuan-u89-db419435b316.woff2 +0 -0
  73. package/assets/fonts/web/fangyuan-u8a-06c11d45fb87.woff2 +0 -0
  74. package/assets/fonts/web/fangyuan-u8b-5c2f6fda436a.woff2 +0 -0
  75. package/assets/fonts/web/fangyuan-u8c-2b46d2d1f4cc.woff2 +0 -0
  76. package/assets/fonts/web/fangyuan-u8d-1a67d064000d.woff2 +0 -0
  77. package/assets/fonts/web/fangyuan-u8e-eb87baf0091c.woff2 +0 -0
  78. package/assets/fonts/web/fangyuan-u8f-96c1f0632da6.woff2 +0 -0
  79. package/assets/fonts/web/fangyuan-u90-86752dba8cbe.woff2 +0 -0
  80. package/assets/fonts/web/fangyuan-u91-0573b8548552.woff2 +0 -0
  81. package/assets/fonts/web/fangyuan-u92-f85ecf735974.woff2 +0 -0
  82. package/assets/fonts/web/fangyuan-u93-e7e0451735db.woff2 +0 -0
  83. package/assets/fonts/web/fangyuan-u94-f3957ff98bcd.woff2 +0 -0
  84. package/assets/fonts/web/fangyuan-u95-b31129d29013.woff2 +0 -0
  85. package/assets/fonts/web/fangyuan-u96-ca7f779c2c13.woff2 +0 -0
  86. package/assets/fonts/web/fangyuan-u97-100a25198b2c.woff2 +0 -0
  87. package/assets/fonts/web/fangyuan-u98-59e4b784a94f.woff2 +0 -0
  88. package/assets/fonts/web/fangyuan-u99-d35af6e30868.woff2 +0 -0
  89. package/assets/fonts/web/fangyuan-u9a-1eb6f7cdfcfe.woff2 +0 -0
  90. package/assets/fonts/web/fangyuan-u9b-8f428d81dcbb.woff2 +0 -0
  91. package/assets/fonts/web/fangyuan-u9c-e10f7f8f0274.woff2 +0 -0
  92. package/assets/fonts/web/fangyuan-u9e-2be65003b759.woff2 +0 -0
  93. package/assets/fonts/web/fangyuan-u9f-02c3d5224fdb.woff2 +0 -0
  94. package/assets/fonts/web/fangyuan-ufb-3eaee54bc476.woff2 +0 -0
  95. package/assets/fonts/web/fangyuan-ufe-f64da8a3acce.woff2 +0 -0
  96. package/assets/fonts/web/fangyuan-uff-0a6dcfb689fd.woff2 +0 -0
  97. package/client/assets/iconsax/LICENSE +16 -0
  98. package/client/assets/iconsax/SOURCE.md +14 -0
  99. package/config/routergo-deepseek.example.yml +25 -0
  100. package/config.example.json +24 -0
  101. package/cordis.patch.yml +5 -0
  102. package/lib/LINK-THIRD-PARTY-LICENSES.txt +1197 -0
  103. package/lib/client.js +3577 -0
  104. package/lib/link-web/app.css +92 -0
  105. package/lib/link-web/app.js +599 -0
  106. package/lib/link-web/index.html +1 -0
  107. package/lib/link-web/workbench.js +118 -0
  108. package/lib/link.mjs +244 -0
  109. package/lib/relay/ip-certificate.mjs +559 -0
  110. package/lib/relay/relay.example.json +8 -0
  111. package/lib/relay/run-relay-server.cjs +4100 -0
  112. package/lib/server/bootstrap.mjs +147 -0
  113. package/lib/server/bootstrap.sh +39 -0
  114. package/lib/server/manifest.json +8 -0
  115. package/lib/server/server-seed.tar.gz +0 -0
  116. package/locale/en.json +6 -0
  117. package/locale/zh-CN.json +6 -0
  118. package/openapi.json +629 -0
  119. package/package.json +104 -4
  120. package/shared/plugin-install.mjs +198 -0
  121. package/shared/tool-quick-settings.mjs +70 -0
  122. package/shared/update-feed.mjs +191 -0
  123. package/shared/update-public-key.pem +3 -0
  124. package/src/cli.mjs +102 -0
  125. package/src/commands.mjs +17 -0
  126. package/src/delegation.mjs +47 -0
  127. package/src/fonts.mjs +17 -0
  128. package/src/harness-adapter.mjs +228 -0
  129. package/src/harness-commands.mjs +39 -0
  130. package/src/harness-selection.mjs +5 -0
  131. package/src/harness-usage.mjs +31 -0
  132. package/src/isolated-launch.mjs +22 -0
  133. package/src/line-process.mjs +114 -0
  134. package/src/link-plugin.mjs +21 -0
  135. package/src/mcp.mjs +21 -0
  136. package/src/model-providers.mjs +125 -0
  137. package/src/models.mjs +37 -0
  138. package/src/native-auth.mjs +32 -0
  139. package/src/picker-catalog.mjs +82 -0
  140. package/src/plugin.mjs +138 -0
  141. package/src/projects.mjs +96 -0
  142. package/src/provider-access.mjs +11 -0
  143. package/src/providers/acp.mjs +138 -0
  144. package/src/providers/claude.mjs +115 -0
  145. package/src/providers/codex.mjs +134 -0
  146. package/src/providers/opencode.mjs +138 -0
  147. package/src/providers/pi-permissions.ts +24 -0
  148. package/src/providers/pi.mjs +97 -0
  149. package/src/questions.mjs +34 -0
  150. package/src/recent-workspace.mjs +36 -0
  151. package/src/runtime.mjs +421 -0
  152. package/src/session-routes.mjs +89 -0
  153. package/src/store.mjs +98 -0
  154. package/src/tool-catalog.mjs +22 -0
  155. package/src/tool-config.mjs +94 -0
  156. package/src/tool-install.mjs +31 -0
  157. package/src/tool-profiles.mjs +114 -0
  158. package/src/tooling.mjs +90 -0
  159. package/src/updates.mjs +156 -0
  160. package/src/usage-import-parsers.mjs +37 -0
  161. package/src/usage-import.mjs +114 -0
  162. package/src/usage-timeline.mjs +36 -0
  163. package/src/usage.mjs +142 -0
  164. package/src/web.mjs +274 -0
  165. package/voice/README.md +49 -0
  166. package/voice/STREAMING.md +141 -0
  167. package/voice/VERIFICATION.md +36 -0
  168. package/voice/client/audio-worklet.js +35 -0
  169. package/voice/locale/en.json +1 -0
  170. package/voice/locale/zh-CN.json +1 -0
  171. package/voice/src/asr.mjs +44 -0
  172. package/voice/src/calls.mjs +37 -0
  173. package/voice/src/config.mjs +139 -0
  174. package/voice/src/media.mjs +70 -0
  175. package/voice/src/plugin.mjs +10 -0
  176. package/voice/src/realtime-protocols.mjs +30 -0
  177. package/voice/src/realtime-socket.mjs +86 -0
  178. package/voice/src/realtime.mjs +130 -0
  179. package/voice/src/tts.mjs +16 -0
  180. package/voice/src/web.mjs +171 -0
@@ -0,0 +1,37 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { VoiceError } from './config.mjs';
3
+
4
+ const TTL = 45000, MAX_DURATION = 30 * 60000;
5
+ const identity = (value, name) => { if (typeof value !== 'string' || !value || value.length > 200 || /[\u0000-\u001f]/u.test(value)) throw new VoiceError(`Invalid ${name}`); return value; };
6
+ /** Process-local ownership only. Media cancellation never grants/revokes Agent permissions. */
7
+ export class CallLeases {
8
+ constructor({ now = Date.now } = {}) { this.now = now; this.sessions = new Map(); }
9
+ public(lease) { const { timer, controller, ...value } = lease; return value; }
10
+ remove(lease) { clearTimeout(lease.timer); lease.controller.abort(); this.sessions.delete(lease.sessionId); }
11
+ schedule(lease) {
12
+ clearTimeout(lease.timer);
13
+ lease.timer = setTimeout(() => this.remove(lease), Math.max(0, Math.min(lease.expiresAt, lease.maxEndsAt) - this.now()));
14
+ lease.timer.unref?.();
15
+ }
16
+ purge() { for (const lease of this.sessions.values()) if (Math.min(lease.expiresAt, lease.maxEndsAt) <= this.now()) this.remove(lease); }
17
+ start(input) {
18
+ this.purge(); const sessionId = identity(input.sessionId, 'sessionId'), deviceId = identity(input.deviceId, 'deviceId');
19
+ const existing = this.sessions.get(sessionId);
20
+ if (existing) { if (existing.deviceId !== deviceId) throw new VoiceError('This conversation already has an active call on another device', 409); return this.public(existing); }
21
+ if (this.sessions.size >= 16) throw new VoiceError('Active call limit reached', 429);
22
+ const startedAt = this.now();
23
+ const lease = { callId: randomUUID(), sessionId, deviceId, startedAt, expiresAt: startedAt + TTL, maxEndsAt: startedAt + MAX_DURATION, controller: new AbortController() };
24
+ this.sessions.set(sessionId, lease); this.schedule(lease); return this.public(lease);
25
+ }
26
+ owned(input) {
27
+ this.purge();
28
+ const sessionId = identity(input.sessionId, 'sessionId'), deviceId = identity(input.deviceId, 'deviceId'), callId = identity(input.callId, 'callId');
29
+ const lease = this.sessions.get(sessionId);
30
+ if (!lease || lease.deviceId !== deviceId || lease.callId !== callId) throw new VoiceError('Voice call expired or belongs to another device', 409);
31
+ return lease;
32
+ }
33
+ heartbeat(input) { const lease = this.owned(input); lease.expiresAt = Math.min(this.now() + TTL, lease.maxEndsAt); this.schedule(lease); return this.public(lease); }
34
+ end(input) { const lease = this.owned(input); this.remove(lease); return { ended: true }; }
35
+ media(callId) { this.purge(); const lease = [...this.sessions.values()].find(value => value.callId === callId); if (!lease) throw new VoiceError('Voice call has expired', 409); return lease.controller.signal; }
36
+ clear() { for (const lease of this.sessions.values()) this.remove(lease); }
37
+ }
@@ -0,0 +1,139 @@
1
+ import { mkdir, readFile, writeFile, rename, rm, chmod } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { randomUUID } from 'node:crypto';
5
+
6
+ export class VoiceError extends Error {
7
+ constructor(message, status = 400, code) { super(message); this.status = status; if (code) this.code = code; }
8
+ }
9
+ const object = value => value && typeof value === 'object' && !Array.isArray(value);
10
+ // Portable multipart transcription is the default; native protocols are explicit.
11
+ export const DEFAULT_TTS_SPEED = 1.2;
12
+ const TTS_SPEED_MIN = 0.25, TTS_SPEED_MAX = 4;
13
+ const defaults = () => ({ dictation: false, readAloud: false, call: false, asr: { baseURL: '', apiKey: '', model: 'stepaudio-2.5-asr', protocol: 'transcriptions-sse' }, tts: { baseURL: '', apiKey: '', model: 'stepaudio-2.5-tts', voice: '', speed: DEFAULT_TTS_SPEED } });
14
+ const string = (value, label, limit) => {
15
+ if (typeof value !== 'string' || value.length > limit || /[\u0000-\u001f\u007f]/u.test(value)) throw new VoiceError(`Invalid ${label}`);
16
+ return value.trim();
17
+ };
18
+ function endpoint(value) {
19
+ value = string(value, 'baseURL', 2048);
20
+ if (!value) return '';
21
+ let url; try { url = new URL(value); } catch { throw new VoiceError('Invalid baseURL'); }
22
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new VoiceError('baseURL must be an HTTP(S) API base without credentials, query or fragment');
23
+ return url.href.replace(/\/+$/u, '');
24
+ }
25
+ const sensitiveQuery = /api.?key|authorization|token|secret|password|credential|^key$|^bearer$/iu;
26
+ /** Public routing metadata only: credentials belong exclusively in server headers. */
27
+ export function validateRealtime(value, apiKey = '') {
28
+ if (!object(value) || Object.keys(value).some(key => !['url', 'query'].includes(key))) throw new VoiceError('Invalid realtime ASR configuration');
29
+ const result = { url: '', query: {} };
30
+ const parameter = (key, val) => {
31
+ if (!/^[a-zA-Z0-9_.~-]{1,80}$/u.test(key) || sensitiveQuery.test(key) || ['__proto__', 'prototype', 'constructor'].includes(key)) throw new VoiceError('Invalid realtime ASR query key');
32
+ const clean = string(val, 'realtime ASR query', 512);
33
+ if (apiKey && clean.includes(apiKey)) throw new VoiceError('Realtime ASR routing must not contain API credentials');
34
+ return clean;
35
+ };
36
+ if (value.url) {
37
+ const raw = string(value.url, 'realtime ASR URL', 2048);
38
+ let url; try { url = new URL(raw); } catch { throw new VoiceError('Invalid realtime ASR URL'); }
39
+ if (!['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol) || url.username || url.password || url.hash) throw new VoiceError('Realtime ASR URL must be HTTP(S) or WS(S), without credentials or fragment');
40
+ for (const [key, val] of url.searchParams) parameter(key, val);
41
+ if (apiKey && decodeURIComponent(url.href).includes(apiKey)) throw new VoiceError('Realtime ASR routing must not contain API credentials');
42
+ result.url = url.href;
43
+ } else if (Object.hasOwn(value, 'url') && typeof value.url !== 'string') throw new VoiceError('Invalid realtime ASR URL');
44
+ if (Object.hasOwn(value, 'query')) {
45
+ if (!object(value.query) || Object.keys(value.query).length > 32) throw new VoiceError('Invalid realtime ASR query');
46
+ for (const [key, val] of Object.entries(value.query)) result.query[key] = parameter(key, val);
47
+ }
48
+ return result;
49
+ }
50
+ // Model IDs belong to the selected provider; an apparent alias may be a distinct route.
51
+ export function normalizeTtsModel(model) {
52
+ return String(model || '').trim();
53
+ }
54
+ export function normalizeAsrModel(model) {
55
+ return String(model || '').trim();
56
+ }
57
+ export function isStepTtsModel(model) {
58
+ const value = normalizeTtsModel(model).toLowerCase();
59
+ return /^(?:stepaudio-(?:2\.5|3)-tts|stepfun-2\.5-tts|step-tts-(?:2|mini))$/u.test(value);
60
+ }
61
+ export function normalizeTtsSpeed(value) {
62
+ if (value === undefined) return DEFAULT_TTS_SPEED;
63
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < TTS_SPEED_MIN || value > TTS_SPEED_MAX || Math.round(value * 100) !== value * 100) throw new VoiceError('Invalid TTS speed');
64
+ return value;
65
+ }
66
+ export function updateConfig(current, patch) {
67
+ if (!object(patch)) throw new VoiceError('Expected config object');
68
+ if (Object.keys(patch).some(key => !['dictation', 'readAloud', 'call', 'asr', 'tts'].includes(key))) throw new VoiceError('Unknown config field');
69
+ const next = structuredClone(current);
70
+ if (next.tts && !Object.hasOwn(next.tts, 'speed')) next.tts.speed = DEFAULT_TTS_SPEED;
71
+ for (const key of ['dictation', 'readAloud', 'call']) if (Object.hasOwn(patch, key)) {
72
+ if (typeof patch[key] !== 'boolean') throw new VoiceError(`Invalid ${key}`);
73
+ next[key] = patch[key];
74
+ }
75
+ for (const kind of ['asr', 'tts']) if (Object.hasOwn(patch, kind)) {
76
+ if (!object(patch[kind])) throw new VoiceError(`Invalid ${kind}`);
77
+ for (const [key, value] of Object.entries(patch[kind])) {
78
+ // Read-only metadata may be round-tripped by settings forms.
79
+ if (['configured', 'apiKeyConfigured', 'apiKeyMasked'].includes(key)) continue;
80
+ if (!['providerId', 'baseURL', 'apiKey', 'model', 'protocol', ...(kind === 'tts' ? ['voice', 'speed'] : ['realtime'])].includes(key)) throw new VoiceError(`Unknown ${kind} field`);
81
+ if (key === 'realtime') { next.asr.realtime = validateRealtime(value); continue; }
82
+ if (kind === 'tts' && key === 'speed') { next.tts.speed = normalizeTtsSpeed(value); continue; }
83
+ if (key === 'protocol' && !(kind === 'tts' ? ['speech', 'step-speech'] : ['transcriptions', 'transcriptions-sse', 'mimo-sse', 'step-sse', 'step-websocket', 'openai-websocket']).includes(value)) throw new VoiceError(`Unknown ${kind.toUpperCase()} protocol`);
84
+ const normalized = key === 'baseURL' ? endpoint(value) : string(value, key, key === 'apiKey' ? 4096 : 200);
85
+ next[kind][key] = key === 'model' ? (kind === 'asr' ? normalizeAsrModel(normalized) : normalizeTtsModel(normalized)) : normalized;
86
+ }
87
+ const changedProvider = Object.hasOwn(patch[kind], 'providerId') && (current[kind].providerId || '') !== next[kind].providerId || Object.hasOwn(patch[kind], 'baseURL') && !next[kind].providerId && current[kind].baseURL !== next[kind].baseURL;
88
+ if (changedProvider) {
89
+ if (kind === 'asr' && !Object.hasOwn(patch.asr, 'realtime')) delete next.asr.realtime;
90
+ if (!Object.hasOwn(patch[kind], 'protocol')) next[kind].protocol = kind === 'asr' ? 'transcriptions-sse' : 'speech';
91
+ if (kind === 'tts' && !Object.hasOwn(patch.tts, 'voice')) next.tts.voice = '';
92
+ }
93
+ if (next[kind].providerId) { next[kind].apiKey = ''; next[kind].baseURL = ''; }
94
+ if (kind === 'asr' && next.asr.realtime) next.asr.realtime = validateRealtime(next.asr.realtime, next.asr.apiKey);
95
+ }
96
+ return next;
97
+ }
98
+ /** Upgrade pre-protocol settings once, rather than sending known speech models to an incompatible endpoint. */
99
+ export function migrateLegacyConfig(value) {
100
+ const next = structuredClone(value);
101
+ if (next.asr?.model) next.asr.model = normalizeAsrModel(next.asr.model);
102
+ if (next.tts?.model) next.tts.model = normalizeTtsModel(next.tts.model);
103
+ if (next.asr && !Object.hasOwn(next.asr, 'protocol')) {
104
+ const model = String(next.asr.model || '').toLowerCase();
105
+ next.asr.protocol = /^stepaudio-.*-asr-stream$/u.test(model) ? 'step-websocket' : /^(?:mimo|stepaudio)-.*-asr(?:-max)?$/u.test(model) ? 'transcriptions-sse' : 'transcriptions';
106
+ }
107
+ if (next.tts && !Object.hasOwn(next.tts, 'protocol')) next.tts.protocol = isStepTtsModel(next.tts.model) ? 'step-speech' : 'speech';
108
+ return next;
109
+ }
110
+ export function publicConfig(config) {
111
+ return Object.fromEntries(Object.entries(config).map(([key, value]) => {
112
+ if (!['asr', 'tts'].includes(key)) return [key, value];
113
+ const { apiKey, ...safe } = value;
114
+ return [key, { ...safe, configured: !!((value.providerId || value.baseURL) && value.model), apiKeyConfigured: !!apiKey, apiKeyMasked: apiKey ? '••••••••' : '' }];
115
+ }));
116
+ }
117
+ export class VoiceConfig {
118
+ constructor({ dataDir = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'voice') } = {}) {
119
+ this.directory = dataDir; this.path = join(dataDir, 'config.json'); this.value = defaults(); this.writes = Promise.resolve(); this.listeners = new Set();
120
+ }
121
+ async open() {
122
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
123
+ try { this.value = updateConfig(defaults(), migrateLegacyConfig(JSON.parse(await readFile(this.path, 'utf8')))); await chmod(this.path, 0o600); }
124
+ catch (error) { if (error.code !== 'ENOENT') throw new VoiceError('Voice configuration could not be loaded', 500); }
125
+ return this;
126
+ }
127
+ snapshot() { return publicConfig(this.value); }
128
+ subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
129
+ configure(patch) {
130
+ const operation = this.writes.then(async () => {
131
+ const next = updateConfig(this.value, patch);
132
+ const temp = `${this.path}.${randomUUID()}.tmp`;
133
+ try { await writeFile(temp, JSON.stringify(next, null, 2), { mode: 0o600 }); await rename(temp, this.path); }
134
+ finally { await rm(temp, { force: true }); }
135
+ this.value = next; for (const listener of this.listeners) listener(); return this.snapshot();
136
+ });
137
+ this.writes = operation.catch(() => {}); return operation;
138
+ }
139
+ }
@@ -0,0 +1,70 @@
1
+ import { VoiceError, normalizeAsrModel } from './config.mjs';
2
+
3
+ export const LIMITS = Object.freeze({ audioBytes: 2 * 1024 * 1024, audioSeconds: 15, jsonBytes: 32768, speechCharacters: 4000, speechBytes: 12 * 1024 * 1024, requestTimeout: 90000 });
4
+ export async function readLimited(request, limit, signal = request.signal) {
5
+ if (Number(request.headers.get('content-length')) > limit) throw new VoiceError('Request is too large', 413);
6
+ if (!request.body) return new Uint8Array();
7
+ const reader = request.body.getReader(), chunks = []; let size = 0;
8
+ const abort = () => { reader.cancel().catch(() => {}); };
9
+ signal.addEventListener('abort', abort, { once: true }); if (signal.aborted) abort();
10
+ try {
11
+ while (true) {
12
+ signal.throwIfAborted();
13
+ const { done, value } = await reader.read(); signal.throwIfAborted(); if (done) break;
14
+ size += value.byteLength;
15
+ if (size > limit) { await reader.cancel(); throw new VoiceError('Request is too large', 413); }
16
+ chunks.push(value);
17
+ }
18
+ } finally { signal.removeEventListener('abort', abort); reader.releaseLock(); }
19
+ const output = new Uint8Array(size); let offset = 0;
20
+ for (const chunk of chunks) { output.set(chunk, offset); offset += chunk.byteLength; }
21
+ return output;
22
+ }
23
+ export async function readJson(request) {
24
+ if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) throw new VoiceError('Expected application/json', 415);
25
+ let value; try { value = JSON.parse(new TextDecoder().decode(await readLimited(request, LIMITS.jsonBytes))); }
26
+ catch (error) { if (error instanceof VoiceError || request.signal.aborted) throw error; throw new VoiceError('Invalid JSON'); }
27
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new VoiceError('Expected JSON object');
28
+ return value;
29
+ }
30
+ export async function upstreamFailure(response, kind, signal) {
31
+ let code = '';
32
+ try {
33
+ if (response.headers.get('content-type')?.includes('application/json')) {
34
+ const raw = await readLimited(new Request('http://voice.internal', { method: 'POST', body: response.body, duplex: 'half', signal }), 8192);
35
+ const body = JSON.parse(new TextDecoder().decode(raw));
36
+ // Only a bounded machine code is safe to expose. Upstream messages may echo credentials or audio.
37
+ const candidate = body.error?.code; if (typeof candidate === 'string' && /^[a-zA-Z0-9_.-]{1,80}$/u.test(candidate)) code = candidate;
38
+ }
39
+ } catch {} finally { await response.body?.cancel().catch(() => {}); }
40
+ return new VoiceError(kind + ' service rejected the request (' + response.status + ')' + (code ? '; ' + code : ''), 502, code || kind.toLowerCase() + '_http_' + response.status);
41
+ }
42
+ export function validateWav(bytes) {
43
+ const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
44
+ if (buffer.length < 44 || buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WAVE' || buffer.readUInt32LE(4) + 8 !== buffer.length) throw new VoiceError('Expected a complete PCM WAV file');
45
+ let format, data;
46
+ for (let offset = 12; offset + 8 <= buffer.length;) {
47
+ const id = buffer.toString('ascii', offset, offset + 4), length = buffer.readUInt32LE(offset + 4), start = offset + 8;
48
+ if (start + length > buffer.length) throw new VoiceError('Malformed WAV chunk');
49
+ if (id === 'fmt ') {
50
+ if (length < 16) throw new VoiceError('Malformed WAV format');
51
+ format = { codec: buffer.readUInt16LE(start), channels: buffer.readUInt16LE(start + 2), rate: buffer.readUInt32LE(start + 4), alignment: buffer.readUInt16LE(start + 12), bits: buffer.readUInt16LE(start + 14) };
52
+ }
53
+ if (id === 'data') data = length;
54
+ offset = start + length + (length % 2);
55
+ }
56
+ if (!format || !data || format.codec !== 1 || format.channels !== 1 || format.bits !== 16 || format.alignment !== 2 || ![16000, 22050, 24000, 44100, 48000].includes(format.rate) || data % 2) throw new VoiceError('Audio must be mono PCM16 WAV (16–48 kHz)');
57
+ if (data / (format.rate * 2) > LIMITS.audioSeconds) throw new VoiceError('Audio segment exceeds 15 seconds', 413);
58
+ return format;
59
+ }
60
+ export async function transcribe(fetcher, config, bytes, signal) {
61
+ const body = new FormData();
62
+ body.set('file', new Blob([bytes], { type: 'audio/wav' }), 'segment.wav'); body.set('model', normalizeAsrModel(config.model)); body.set('response_format', 'json');
63
+ const upstream = await fetcher(`${config.baseURL}/audio/transcriptions`, { method: 'POST', headers: config.apiKey ? { authorization: `Bearer ${config.apiKey}` } : {}, body, signal, redirect: 'error' });
64
+ if (!upstream.ok) throw await upstreamFailure(upstream, 'ASR', signal);
65
+ const raw = await readLimited(new Request('http://voice.internal', { method: 'POST', body: upstream.body, duplex: 'half', signal }), 65536);
66
+ let result; try { result = JSON.parse(new TextDecoder().decode(raw)); } catch { throw new VoiceError('ASR service returned invalid JSON', 502); }
67
+ if (typeof result.text !== 'string' || result.text.length > 20000) throw new VoiceError('ASR service returned invalid transcription', 502);
68
+ signal.throwIfAborted();
69
+ return { text: result.text.trim() };
70
+ }
@@ -0,0 +1,10 @@
1
+ import { VoiceConfig } from './config.mjs';
2
+ import { mountWeb } from './web.mjs';
3
+
4
+ export const name = 'voice';
5
+ export async function apply(ctx, config = {}) {
6
+ const store = await new VoiceConfig(config).open();
7
+ ctx.provide('voice', store);
8
+ ctx.effect(() => () => store.writes);
9
+ ctx.inject(['connection'], child => { mountWeb(child, store, { providers: config.providers }); });
10
+ }
@@ -0,0 +1,30 @@
1
+ import { VoiceError } from './config.mjs';
2
+
3
+ // Wire formats depend on the selected protocol, never a provider identity.
4
+ const protocols = {
5
+ 'step-websocket': {
6
+ path: '/realtime/asr/stream', query: {}, requiresItemId: false,
7
+ ready: type => type === 'session.created' || type === 'session.updated',
8
+ session: model => ({ audio: { input: {
9
+ format: { type: 'pcm', codec: 'pcm_s16le', rate: 24000, bits: 16, channel: 1 },
10
+ transcription: { model, language: 'zh', full_rerun_on_commit: true, enable_itn: true },
11
+ turn_detection: null,
12
+ } } }),
13
+ partial: (event, previous) => typeof event.text === 'string' ? event.text : previous,
14
+ },
15
+ 'openai-websocket': {
16
+ path: '/realtime', query: { intent: 'transcription' }, requiresItemId: true,
17
+ // Initial session.created does not acknowledge our transcription settings.
18
+ ready: type => type === 'session.updated',
19
+ session: model => ({ type: 'transcription', audio: { input: {
20
+ format: { type: 'audio/pcm', rate: 24000 }, transcription: { model }, turn_detection: null,
21
+ } } }),
22
+ partial: (event, previous) => typeof event.delta === 'string' ? previous + event.delta : previous,
23
+ },
24
+ };
25
+
26
+ export function realtimeProtocol(protocol = 'openai-websocket') {
27
+ const adapter = Object.hasOwn(protocols, protocol) ? protocols[protocol] : undefined;
28
+ if (!adapter) throw new VoiceError('Select a supported native realtime ASR protocol', 400);
29
+ return adapter;
30
+ }
@@ -0,0 +1,86 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { VoiceError } from './config.mjs';
3
+
4
+ const integer = value => Number.isSafeInteger(value) && value >= 0 && value <= 0xffffffff;
5
+ const reason = error => error instanceof VoiceError ? error.message : 'Realtime voice transport failed';
6
+ /** Carrier auth admits the owner/device; a one-use ticket binds its ASR lease. */
7
+ export function createRealtimeSockets(realtime) {
8
+ const tickets = new Map(), sockets = new Set();
9
+ let disposed = false;
10
+ const issue = (id, callId) => {
11
+ for (const [key, value] of tickets) if (value.expires < Date.now()) tickets.delete(key);
12
+ const ticket = randomBytes(32).toString('base64url');
13
+ tickets.set(ticket, { id, callId, expires: Date.now() + 10000 });
14
+ return ticket;
15
+ };
16
+ const accept = ws => {
17
+ if (disposed) { ws.terminate(); return; }
18
+ sockets.add(ws);
19
+ let session, reader, epoch = 0, resetting = false, ended = false;
20
+ const send = value => {
21
+ if (ended || ws.readyState !== 1) return;
22
+ if (ws.bufferedAmount > 65536) { ws.terminate(); return; }
23
+ ws.send(JSON.stringify(value));
24
+ };
25
+ const stop = () => {
26
+ if (ended) return; ended = true; clearTimeout(attachTimer); sockets.delete(ws);
27
+ void reader?.cancel().catch(() => {});
28
+ if (session) realtime.close(session.id);
29
+ };
30
+ const fail = error => { send({ type: 'error', error: reason(error) }); ws.close(1008, 'Voice transport rejected'); stop(); };
31
+ const attachTimer = setTimeout(() => { ws.terminate(); stop(); }, 10000); attachTimer.unref?.();
32
+ ws.on('error', stop); ws.on('close', stop);
33
+ ws.on('message', (raw, binary) => {
34
+ try {
35
+ if (ended) return;
36
+ if (binary) {
37
+ if (!session || raw.length < 14 || raw.length > 972 || raw.length % 2) throw new VoiceError('Invalid PCM voice frame');
38
+ const generation = raw.readUInt32LE(0), utteranceId = raw.readUInt32LE(4), sequence = raw.readUInt32LE(8);
39
+ if (generation !== epoch || resetting) return;
40
+ const accepted = realtime.append(session.id, session.callId, raw.subarray(12), utteranceId);
41
+ send({ type: 'ack', epoch, sequence, accepted: accepted !== false });
42
+ return;
43
+ }
44
+ if (raw.length > 2048) throw new VoiceError('Voice control message too large');
45
+ const input = JSON.parse(String(raw));
46
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new VoiceError('Invalid voice control');
47
+ if (!session) {
48
+ if (input.type !== 'attach' || typeof input.ticket !== 'string') throw new VoiceError('Attach the voice session first');
49
+ const ticket = tickets.get(input.ticket); tickets.delete(input.ticket);
50
+ if (!ticket || ticket.expires < Date.now() || ticket.id !== input.id || ticket.callId !== (input.callId || '')) throw new VoiceError('Voice connection ticket expired', 401);
51
+ session = realtime.get(ticket.id, ticket.callId);
52
+ const emit = session.emit; session.emit = event => emit({ ...event, epoch });
53
+ reader = realtime.events(session.id, session.callId).getReader(); clearTimeout(attachTimer);
54
+ const previous = session.onClose;
55
+ session.onClose = () => { previous?.(); if (!ended) { send({ type: 'closed' }); ws.close(1000); stop(); } };
56
+ send({ type: 'ready', epoch });
57
+ void (async () => {
58
+ try {
59
+ while (!ended) {
60
+ const { value, done } = await reader.read(); if (done) break;
61
+ const event = JSON.parse(new TextDecoder().decode(value));
62
+ if (!resetting && event.epoch === epoch) send(event);
63
+ }
64
+ } catch (error) { if (!ended) fail(error); }
65
+ })();
66
+ return;
67
+ }
68
+ if (!integer(input.epoch)) throw new VoiceError('Invalid voice generation');
69
+ if (input.type === 'clear') {
70
+ if (input.epoch <= epoch) return;
71
+ if (resetting) throw new VoiceError('Voice reset already pending');
72
+ epoch = input.epoch; resetting = true;
73
+ void realtime.reset(session.id, session.callId).then(() => { resetting = false; send({ type: 'ready', epoch }); }, error => { if (!ended) fail(error); });
74
+ return;
75
+ }
76
+ if (input.epoch !== epoch) return;
77
+ if (resetting) throw new VoiceError('Voice reset is not ready');
78
+ if (input.type !== 'commit' || !integer(input.utteranceId) || !input.utteranceId) throw new VoiceError('Invalid voice control');
79
+ if (!session.bytes) { send({ type: 'final', epoch, utteranceId: input.utteranceId, text: '' }); return; }
80
+ realtime.commit(session.id, session.callId, input.utteranceId);
81
+ } catch (error) { fail(error); }
82
+ });
83
+ };
84
+ const clear = () => { tickets.clear(); for (const ws of sockets) ws.terminate(); sockets.clear(); };
85
+ return { issue, accept, clear, dispose() { disposed = true; clear(); } };
86
+ }
@@ -0,0 +1,130 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import WebSocket from 'ws';
3
+ import { VoiceError, validateRealtime } from './config.mjs';
4
+ import { realtimeProtocol } from './realtime-protocols.mjs';
5
+
6
+ export function realtimeEndpoint(config) {
7
+ const adapter = realtimeProtocol(config.protocol);
8
+ const settings = validateRealtime(config.realtime || {}, config.apiKey || '');
9
+ let endpoint;
10
+ try { endpoint = new URL(settings.url || String(config.baseURL || '').replace(/\/+$/u, '') + adapter.path); }
11
+ catch { throw new VoiceError('Invalid realtime ASR endpoint'); }
12
+ if (!['https:', 'http:', 'wss:', 'ws:'].includes(endpoint.protocol)) throw new VoiceError('Invalid realtime ASR endpoint');
13
+ endpoint.protocol = endpoint.protocol === 'https:' ? 'wss:' : endpoint.protocol === 'http:' ? 'ws:' : endpoint.protocol;
14
+ const supplied = new URLSearchParams(endpoint.search); endpoint.search = '';
15
+ // A complete explicit URL owns its query string; protocol defaults apply only
16
+ // when the endpoint is derived from the provider base URL.
17
+ if (!settings.url) for (const [key, value] of Object.entries(adapter.query)) endpoint.searchParams.set(key, value);
18
+ for (const [key, value] of supplied) endpoint.searchParams.set(key, value);
19
+ for (const [key, value] of Object.entries(settings.query)) endpoint.searchParams.set(key, value.replaceAll('{model}', String(config.model || '')));
20
+ // Validate expanded values too, so credentials never escape into URL logs.
21
+ validateRealtime({ url: endpoint.href, query: {} }, config.apiKey || '');
22
+ return endpoint;
23
+ }
24
+
25
+ /** One authenticated media socket owns each native ASR session. */
26
+ export class RealtimeASR {
27
+ constructor({ socket = (url, options) => new WebSocket(url, options) } = {}) { this.socket = socket; this.sessions = new Map(); }
28
+ async connect(s) {
29
+ const epoch = ++s.socketEpoch;
30
+ s.ready = false; s.bytes = 0; s.utterance = 0; s.committing = false; s.text = ''; s.itemId = ''; s.retiredItems = new Set();
31
+ const adapter = realtimeProtocol(s.config.protocol);
32
+ const endpoint = realtimeEndpoint(s.config);
33
+ const ws = this.socket(endpoint.href, { headers: s.config.apiKey ? { authorization: 'Bearer ' + s.config.apiKey } : {}, followRedirects: false, maxPayload: 131072, handshakeTimeout: 10000 });
34
+ s.ws = ws; const current = () => !s.finished && s.socketEpoch === epoch;
35
+ await new Promise((resolve, reject) => {
36
+ let settled = false;
37
+ const timer = setTimeout(() => finish(new VoiceError('Realtime ASR handshake timed out', 502)), 12000); timer.unref?.();
38
+ const finish = error => { if (settled) return; settled = true; clearTimeout(timer); s.signal.removeEventListener('abort', abort); if (s.cancelConnect === cancel) s.cancelConnect = null; error ? reject(error) : resolve(); };
39
+ const cancel = () => finish(s.signal.aborted ? new DOMException('Aborted', 'AbortError') : new VoiceError('Realtime ASR connection cancelled', 409)); s.cancelConnect = cancel;
40
+ const abort = () => finish(new DOMException('Aborted', 'AbortError')); s.signal.addEventListener('abort', abort, { once: true });
41
+ ws.on('open', () => {
42
+ if (!current()) return;
43
+ try { ws.send(JSON.stringify({ event_id: randomUUID(), type: 'session.update', session: adapter.session(s.config.model) })); }
44
+ catch { finish(new VoiceError('Realtime ASR session initialization failed', 502)); this.close(s.id); }
45
+ });
46
+ ws.on('message', raw => {
47
+ if (!current()) return;
48
+ let event; try { event = JSON.parse(String(raw)); if (!event || typeof event !== 'object' || Array.isArray(event)) throw Error(); } catch { finish(new VoiceError('Invalid realtime ASR event', 502)); s.emit({ type: 'error', error: 'Invalid realtime ASR event' }); this.close(s.id); return; }
49
+ if (adapter.ready(event.type)) { s.ready = true; finish(); return; }
50
+ if (event.type === 'error') {
51
+ const code = typeof event.error?.code === 'string' && /^[a-zA-Z0-9_.-]{1,80}$/u.test(event.error.code) ? event.error.code : 'upstream_error';
52
+ const error = new VoiceError('Realtime ASR service rejected the session (' + code + ')', 502, code); finish(error); s.emit({ type: 'error', error: error.message, code }); this.close(s.id); return;
53
+ }
54
+ // Epochs fence discarded sockets; item IDs fence delayed same-socket events.
55
+ if (!s.ready || !s.utterance) return;
56
+ const transcriptEvent = ['conversation.item.input_audio_transcription.delta', 'conversation.item.input_audio_transcription.completed', 'conversation.item.input_audio_transcription.failed'].includes(event.type);
57
+ if (event.type !== 'input_audio_buffer.committed' && !transcriptEvent) return;
58
+ if ((event.type === 'input_audio_buffer.committed' || event.type === 'conversation.item.input_audio_transcription.completed') && !s.committing) return;
59
+ const itemId = typeof event.item_id === 'string' && event.item_id.length <= 512 ? event.item_id : '';
60
+ if ((adapter.requiresItemId && !itemId) || (itemId && (s.retiredItems.has(itemId) || (s.itemId && itemId !== s.itemId)))) return;
61
+ if (itemId) s.itemId = itemId;
62
+ if (event.type === 'conversation.item.input_audio_transcription.delta') {
63
+ const text = adapter.partial(event, s.text);
64
+ if (text.length > 20000) { this.close(s.id); return; }
65
+ if (text !== s.text) { s.text = text; s.emit({ type: 'partial', text, utteranceId: s.utterance }); }
66
+ }
67
+ if (event.type === 'conversation.item.input_audio_transcription.completed') {
68
+ if (!s.committing) return;
69
+ if (typeof event.transcript !== 'string' || event.transcript.length > 20000) { this.close(s.id); return; }
70
+ s.emit({ type: 'final', text: event.transcript, utteranceId: s.utterance });
71
+ if (s.itemId) s.retiredItems.add(s.itemId);
72
+ s.committing = false; s.bytes = 0; s.utterance = 0; s.itemId = ''; s.text = '';
73
+ if (s.retiredItems.size > 4096) this.close(s.id);
74
+ }
75
+ if (event.type === 'conversation.item.input_audio_transcription.failed') {
76
+ s.emit({ type: 'error', error: 'Realtime ASR transcription failed', code: 'asr_transcription_failed' }); this.close(s.id);
77
+ }
78
+ });
79
+ ws.on('error', () => { if (!current()) return; finish(new VoiceError('Realtime ASR connection failed', 502)); s.emit({ type: 'error', error: 'Realtime ASR connection failed' }); this.close(s.id); });
80
+ ws.on('unexpected-response', (_request, response) => {
81
+ if (!current()) return; response.resume?.(); const status = Number(response.statusCode) || 502;
82
+ finish(new VoiceError('Realtime ASR handshake rejected (' + status + '); verify the configured realtime URL and protocol', 502, 'asr_handshake_' + status)); this.close(s.id);
83
+ });
84
+ ws.on('close', () => { if (!current()) return; finish(new VoiceError('Realtime ASR connection closed before ready', 502)); s.emit({ type: 'error', error: 'Realtime ASR connection closed' }); this.close(s.id); });
85
+ if (s.signal.aborted) abort();
86
+ });
87
+ s.signal.throwIfAborted();
88
+ }
89
+ async open(config, signal, callId = '') {
90
+ signal.throwIfAborted();
91
+ if (this.sessions.size >= 2) throw new VoiceError('Too many active realtime recognizers', 429);
92
+ const id = randomUUID(), s = { id, config, signal, callId, socketEpoch: 0, queue: [], consumer: null, finished: false, attached: false };
93
+ this.sessions.set(id, s);
94
+ s.emit = event => {
95
+ if (s.finished) return; const data = JSON.stringify(event) + String.fromCharCode(10);
96
+ if (s.consumer) { if (s.consumer.desiredSize <= 0) { this.close(id); return; } try { s.consumer.enqueue(new TextEncoder().encode(data)); } catch { this.close(id); } }
97
+ else { s.queue.push(data); if (s.queue.length > 128) this.close(id); }
98
+ };
99
+ s.abort = () => this.close(id); signal.addEventListener('abort', s.abort, { once: true });
100
+ s.timer = setTimeout(() => this.close(id), 30 * 60 * 1000); s.timer.unref?.();
101
+ try {
102
+ await this.connect(s); s.attachTimer = setTimeout(() => this.close(id), 10000); s.attachTimer.unref?.(); return id;
103
+ } catch (error) { this.close(id); throw error; }
104
+ }
105
+ get(id, callId) { const s = this.sessions.get(id); if (!s || s.finished || s.callId !== callId) throw new VoiceError('Realtime ASR session expired', 409); return s; }
106
+ events(id, callId) {
107
+ const s = this.get(id, callId); if (s.attached) throw new VoiceError('Realtime ASR stream already attached', 409); s.attached = true; clearTimeout(s.attachTimer);
108
+ return new ReadableStream({ start: controller => { s.consumer = controller; for (const data of s.queue.splice(0)) controller.enqueue(new TextEncoder().encode(data)); }, cancel: () => this.close(id) }, { highWaterMark: 64 });
109
+ }
110
+ append(id, callId, bytes, utteranceId) {
111
+ const s = this.get(id, callId); if (!s.ready) throw new VoiceError('Realtime ASR is resetting; wait for ready', 409);
112
+ if (!Number.isSafeInteger(utteranceId) || utteranceId < 1) throw new VoiceError('Invalid utterance');
113
+ if (!bytes.length || bytes.length % 2 || bytes.length > 24000) throw new VoiceError('Expected 1–12000 PCM16 samples');
114
+ if (s.committing || s.bytes && s.utterance !== utteranceId) throw new VoiceError('Previous utterance has not completed', 409);
115
+ if (s.bytes + bytes.length > 1440000) throw new VoiceError('Realtime ASR audio limit exceeded', 429);
116
+ // Congestion drops a frame instead of growing stale upstream audio.
117
+ if (s.ws.bufferedAmount > 12800) return false;
118
+ s.utterance = utteranceId; s.bytes += bytes.length; s.ws.send(JSON.stringify({ event_id: randomUUID(), type: 'input_audio_buffer.append', audio: Buffer.from(bytes).toString('base64') }));
119
+ }
120
+ commit(id, callId, utteranceId) { const s = this.get(id, callId); if (!s.ready || s.utterance !== utteranceId || !s.bytes || s.committing) throw new VoiceError('No pending ASR utterance', 409); s.committing = true; s.ws.send(JSON.stringify({ event_id: randomUUID(), type: 'input_audio_buffer.commit' })); }
121
+ async reset(id, callId) {
122
+ const s = this.get(id, callId); if (s.resetting) return s.resetting;
123
+ // Replace the connection to fence late events without vendor-specific clear acknowledgements.
124
+ ++s.socketEpoch; s.ready = false; const old = s.ws; try { old.close(); } catch {}
125
+ s.queue.length = 0; s.resetting = this.connect(s).catch(error => { s.emit({ type: 'error', error: error instanceof VoiceError ? error.message : 'Realtime ASR reset failed' }); this.close(id); throw error; }).finally(() => { s.resetting = null; });
126
+ return s.resetting;
127
+ }
128
+ close(id) { const s = this.sessions.get(id); if (!s) return; s.finished = true; s.cancelConnect?.(); ++s.socketEpoch; this.sessions.delete(id); clearTimeout(s.timer); clearTimeout(s.attachTimer); s.signal.removeEventListener('abort', s.abort); try { s.consumer?.close(); } catch {} try { s.ws?.close(); } catch {} s.onClose?.(); }
129
+ clear() { for (const id of [...this.sessions.keys()]) this.close(id); }
130
+ }
@@ -0,0 +1,16 @@
1
+ import { VoiceError, isStepTtsModel, normalizeTtsModel, normalizeTtsSpeed } from './config.mjs';
2
+
3
+ /** Keep vendor extensions out of the standard OpenAI speech request. */
4
+ export function speechRequest(config, text) {
5
+ const protocol = config.protocol || (isStepTtsModel(config.model) ? 'step-speech' : 'speech');
6
+ if (!['speech', 'step-speech'].includes(protocol)) throw new VoiceError('Unknown TTS protocol');
7
+ const step = protocol === 'step-speech';
8
+ return {
9
+ model: normalizeTtsModel(config.model),
10
+ voice: config.voice || (step ? 'cixingnansheng' : 'alloy'),
11
+ input: text,
12
+ response_format: 'pcm',
13
+ speed: normalizeTtsSpeed(config.speed),
14
+ ...(step ? { sample_rate: 24000, stream: true } : {}),
15
+ };
16
+ }