dsh-live-voice 0.0.1-developing → 0.1.0

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.
@@ -0,0 +1,171 @@
1
+ // @ts-nocheck
2
+ /** Local-only audio metering. Audio never leaves this module or reaches speakers.
3
+ * start({signal}={}) returns true when ready, false on cancellation/replacement.
4
+ * Permission prompts cannot be dismissed programmatically: late streams are stopped.
5
+ * stop() invalidates immediately; neither pending permission nor context shutdown blocks it.
6
+ */
7
+ export class MicrophoneMeter {
8
+ constructor(globals = globalThis) {
9
+ this.g = globals;
10
+ this.epoch = 0;
11
+ this.current = null;
12
+ this.stream = this.context = this.source = this.analyser = this.samples = null;
13
+ }
14
+
15
+ async capability() {
16
+ const secure =
17
+ this.g.isSecureContext === true ||
18
+ ['localhost', '127.0.0.1', '::1'].includes(this.g.location?.hostname);
19
+ if (!secure)
20
+ return {
21
+ supported: false,
22
+ permission: 'unavailable',
23
+ reason: 'Microphone capture requires a secure or loopback page.',
24
+ };
25
+ if (typeof this.g.navigator?.mediaDevices?.getUserMedia !== 'function')
26
+ return {
27
+ supported: false,
28
+ permission: 'unavailable',
29
+ reason: 'This browser does not expose microphone capture.',
30
+ };
31
+ const AudioContext = this.g.AudioContext || this.g.webkitAudioContext;
32
+ if (typeof AudioContext !== 'function')
33
+ return {
34
+ supported: false,
35
+ permission: 'unavailable',
36
+ reason: 'This browser does not expose Web Audio for the live waveform.',
37
+ };
38
+ let permission = 'prompt';
39
+ try {
40
+ const status = await this.g.navigator.permissions?.query?.({ name: 'microphone' });
41
+ if (['granted', 'denied', 'prompt'].includes(status?.state)) permission = status.state;
42
+ } catch {
43
+ /* Permission is discovered only when capture is requested. */
44
+ }
45
+ return permission === 'denied'
46
+ ? {
47
+ supported: false,
48
+ permission,
49
+ reason:
50
+ 'Microphone permission is denied. Allow it in browser settings, then refresh availability.',
51
+ }
52
+ : { supported: true, permission };
53
+ }
54
+
55
+ async start({ signal } = {}) {
56
+ this.stop();
57
+ if (signal?.aborted) return false;
58
+ const job = {
59
+ signal,
60
+ stream: null,
61
+ context: null,
62
+ source: null,
63
+ analyser: null,
64
+ samples: null,
65
+ };
66
+ const cancelled = new Promise((resolve) => {
67
+ job.cancelled = resolve;
68
+ });
69
+ job.cancel = () => {
70
+ if (this.current === job) this.stop();
71
+ };
72
+ this.current = job;
73
+ signal?.addEventListener('abort', job.cancel, { once: true });
74
+ if (signal?.aborted) {
75
+ job.cancel();
76
+ return false;
77
+ }
78
+ const valid = () => this.current === job;
79
+ const capture = async () => {
80
+ try {
81
+ const stream = await this.g.navigator.mediaDevices.getUserMedia({
82
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
83
+ });
84
+ job.stream = stream;
85
+ if (!valid()) {
86
+ this._dispose(job);
87
+ return false;
88
+ }
89
+ this.stream = stream;
90
+ const AudioContext = this.g.AudioContext || this.g.webkitAudioContext;
91
+ job.context = new AudioContext();
92
+ job.analyser = job.context.createAnalyser();
93
+ job.analyser.fftSize = 256;
94
+ job.source = job.context.createMediaStreamSource(stream);
95
+ job.source.connect(job.analyser);
96
+ job.samples = new Float32Array(job.analyser.fftSize);
97
+ this.context = job.context;
98
+ this.source = job.source;
99
+ this.analyser = job.analyser;
100
+ this.samples = job.samples;
101
+ await job.context.resume();
102
+ return valid();
103
+ } catch (error) {
104
+ // A rejected obsolete resume/getUserMedia must never release newer capture.
105
+ if (!valid()) {
106
+ this._dispose(job);
107
+ return false;
108
+ }
109
+ this.current = null;
110
+ this._clear();
111
+ this._dispose(job);
112
+ throw error;
113
+ }
114
+ };
115
+ return Promise.race([capture(), cancelled]);
116
+ }
117
+
118
+ level() {
119
+ if (!this.analyser || !this.samples) return 0;
120
+ this.analyser.getFloatTimeDomainData(this.samples);
121
+ return Math.min(
122
+ 1,
123
+ Math.sqrt(this.samples.reduce((s, x) => s + x * x, 0) / this.samples.length) * 5,
124
+ );
125
+ }
126
+
127
+ _clear() {
128
+ this.stream = this.context = this.source = this.analyser = this.samples = null;
129
+ }
130
+
131
+ _dispose(job) {
132
+ job.signal?.removeEventListener('abort', job.cancel);
133
+ const stream = job.stream,
134
+ source = job.source,
135
+ context = job.context;
136
+ job.stream = job.source = job.context = job.analyser = job.samples = null;
137
+ if (stream) {
138
+ for (const track of stream.getTracks()) {
139
+ try {
140
+ track.stop();
141
+ } catch {
142
+ /* Continue releasing remaining tracks. */
143
+ }
144
+ }
145
+ }
146
+ try {
147
+ source?.disconnect();
148
+ } catch {
149
+ /* Already disconnected. */
150
+ }
151
+ try {
152
+ if (context && context.state !== 'closed') Promise.resolve(context.close()).catch(() => {});
153
+ } catch {
154
+ /* Closing an obsolete context must not block future capture. */
155
+ }
156
+ }
157
+
158
+ async release() {
159
+ return this.stop();
160
+ }
161
+
162
+ async stop() {
163
+ ++this.epoch;
164
+ const job = this.current;
165
+ this.current = null;
166
+ this._clear();
167
+ if (!job) return;
168
+ job.cancelled(false);
169
+ this._dispose(job);
170
+ }
171
+ }
@@ -0,0 +1,31 @@
1
+ // @ts-nocheck
2
+ /** Serialize hardware handoffs without holding the lock for speech duration. */
3
+ export class VoiceOwnership {
4
+ constructor() {
5
+ this.epoch = 0;
6
+ this.tail = Promise.resolve();
7
+ this.closed = false;
8
+ }
9
+ run(owner, owners, action) {
10
+ const epoch = ++this.epoch;
11
+ let outcome;
12
+ const acquired = this.tail.then(async () => {
13
+ if (this.closed || epoch !== this.epoch || owner.disposed) return;
14
+ await Promise.all(
15
+ owners.filter((other) => other !== owner).map((other) => other.endConversation()),
16
+ );
17
+ if (this.closed || epoch !== this.epoch || owner.disposed) return;
18
+ outcome = Promise.resolve(action());
19
+ outcome.catch(() => {});
20
+ });
21
+ this.tail = acquired.catch(() => {});
22
+ return acquired.then(() => outcome);
23
+ }
24
+ cancel() {
25
+ ++this.epoch;
26
+ }
27
+ close() {
28
+ this.closed = true;
29
+ this.cancel();
30
+ }
31
+ }
@@ -0,0 +1,113 @@
1
+ // @ts-nocheck
2
+ export const voiceDetectionPresets = Object.freeze({
3
+ short: Object.freeze({
4
+ silenceMs: 900,
5
+ label: 'Short',
6
+ description: 'Send quickly after a short pause.',
7
+ }),
8
+ natural: Object.freeze({
9
+ silenceMs: 1500,
10
+ label: 'Natural',
11
+ description: 'Allow normal pauses between phrases.',
12
+ }),
13
+ long: Object.freeze({
14
+ silenceMs: 2200,
15
+ label: 'Long',
16
+ description: 'Wait through longer thinking pauses.',
17
+ }),
18
+ });
19
+ export const usesPluginVoiceDetection = (engine) => ['whisper-http', 'qwen-http'].includes(engine);
20
+ export const qwenVoices = Object.freeze([
21
+ Object.freeze({ value: 'aiden', label: 'Aiden — male, American English' }),
22
+ Object.freeze({ value: 'ryan', label: 'Ryan — male, English' }),
23
+ Object.freeze({ value: 'uncle_fu', label: 'Uncle Fu — male, Chinese' }),
24
+ Object.freeze({ value: 'dylan', label: 'Dylan — male, Beijing Chinese' }),
25
+ Object.freeze({ value: 'eric', label: 'Eric — male, Sichuan Chinese' }),
26
+ Object.freeze({ value: 'vivian', label: 'Vivian — female, Chinese' }),
27
+ Object.freeze({ value: 'serena', label: 'Serena — female, Chinese' }),
28
+ Object.freeze({ value: 'ono_anna', label: 'Ono Anna — female, Japanese' }),
29
+ Object.freeze({ value: 'sohee', label: 'Sohee — female, Korean' }),
30
+ ]);
31
+ export const defaultQwenVoice = qwenVoices[0].value;
32
+ export const isQwenVoice = (value) => qwenVoices.some((voice) => voice.value === value);
33
+ export const defaultSettings = Object.freeze({
34
+ engine: 'browser',
35
+ recognitionEngine: 'browser',
36
+ recognitionProcessLocally: true,
37
+ recognitionAutoInstall: true,
38
+ voiceDetectionPreset: 'natural',
39
+ announceAssistantMessages: true,
40
+ interruptSpeechOnUserMessage: false,
41
+ sendingMode: 'manual',
42
+ autoSendDelaySeconds: 4,
43
+ assistantSpeechDelaySeconds: 3,
44
+ mode: 'speaker',
45
+ lang: 'pt-BR',
46
+ recognitionLang: 'pt-BR',
47
+ voice: '',
48
+ rate: 1,
49
+ });
50
+ /** Persisted browser preferences are untrusted and may belong to an older version. */
51
+ export function normalizeSettings(value) {
52
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
53
+ return {
54
+ engine: ['browser', 'say', 'qwen-http'].includes(source.engine)
55
+ ? source.engine
56
+ : defaultSettings.engine,
57
+ recognitionEngine: ['browser', 'whisper-http', 'qwen-http'].includes(source.recognitionEngine)
58
+ ? source.recognitionEngine
59
+ : defaultSettings.recognitionEngine,
60
+ recognitionProcessLocally:
61
+ typeof source.recognitionProcessLocally === 'boolean'
62
+ ? source.recognitionProcessLocally
63
+ : defaultSettings.recognitionProcessLocally,
64
+ recognitionAutoInstall:
65
+ typeof source.recognitionAutoInstall === 'boolean'
66
+ ? source.recognitionAutoInstall
67
+ : defaultSettings.recognitionAutoInstall,
68
+ voiceDetectionPreset: Object.hasOwn(voiceDetectionPresets, source.voiceDetectionPreset)
69
+ ? source.voiceDetectionPreset
70
+ : defaultSettings.voiceDetectionPreset,
71
+ announceAssistantMessages:
72
+ typeof source.announceAssistantMessages === 'boolean'
73
+ ? source.announceAssistantMessages
74
+ : defaultSettings.announceAssistantMessages,
75
+ interruptSpeechOnUserMessage:
76
+ typeof source.interruptSpeechOnUserMessage === 'boolean'
77
+ ? source.interruptSpeechOnUserMessage
78
+ : defaultSettings.interruptSpeechOnUserMessage,
79
+ sendingMode: ['manual', 'automatic'].includes(source.sendingMode)
80
+ ? source.sendingMode
81
+ : defaultSettings.sendingMode,
82
+ autoSendDelaySeconds:
83
+ Number.isInteger(source.autoSendDelaySeconds) &&
84
+ source.autoSendDelaySeconds >= 2 &&
85
+ source.autoSendDelaySeconds <= 10
86
+ ? source.autoSendDelaySeconds
87
+ : defaultSettings.autoSendDelaySeconds,
88
+ assistantSpeechDelaySeconds:
89
+ Number.isInteger(source.assistantSpeechDelaySeconds) &&
90
+ source.assistantSpeechDelaySeconds >= 1 &&
91
+ source.assistantSpeechDelaySeconds <= 10
92
+ ? source.assistantSpeechDelaySeconds
93
+ : defaultSettings.assistantSpeechDelaySeconds,
94
+ mode: ['speaker', 'headphones'].includes(source.mode) ? source.mode : defaultSettings.mode,
95
+ lang:
96
+ typeof source.lang === 'string' && /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(source.lang)
97
+ ? source.lang
98
+ : defaultSettings.lang,
99
+ recognitionLang:
100
+ source.recognitionLang === 'auto' ||
101
+ (typeof source.recognitionLang === 'string' &&
102
+ /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(source.recognitionLang))
103
+ ? source.recognitionLang
104
+ : typeof source.lang === 'string' && /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(source.lang)
105
+ ? source.lang
106
+ : defaultSettings.recognitionLang,
107
+ voice:
108
+ typeof source.voice === 'string' && source.voice.length <= 200 && !source.voice.includes('\0')
109
+ ? source.voice
110
+ : '',
111
+ rate: Number.isFinite(source.rate) && source.rate >= 0.1 && source.rate <= 3 ? source.rate : 1,
112
+ };
113
+ }
@@ -0,0 +1,48 @@
1
+ // @ts-nocheck
2
+ /** Own only the current hypothesis; never restore an old snapshot over user edits. */
3
+ export class TranscriptDraft {
4
+ constructor() {
5
+ this.reset();
6
+ }
7
+ reset() {
8
+ this.owned = null;
9
+ this.edited = false;
10
+ }
11
+ update(current, hypothesis, final = false) {
12
+ if (this.edited) {
13
+ if (final) this.edited = false;
14
+ return current;
15
+ }
16
+ let base = current;
17
+ let at = current.length;
18
+ if (this.owned) {
19
+ const { start, text, before, after } = this.owned;
20
+ // Only replace a hypothesis whose surrounding text has not changed.
21
+ if (current === before + text + after) {
22
+ base = before + after;
23
+ at = start;
24
+ } else if (
25
+ text &&
26
+ current.indexOf(text) >= 0 &&
27
+ current.indexOf(text) === current.lastIndexOf(text)
28
+ ) {
29
+ // Edits outside our unchanged, uniquely identifiable hypothesis are safe.
30
+ at = current.indexOf(text);
31
+ base = current.slice(0, at) + current.slice(at + text.length);
32
+ } else {
33
+ // The user edited the draft: relinquish it. Do not duplicate a final
34
+ // hypothesis after an edit; the edited text belongs to the user now.
35
+ this.owned = null;
36
+ this.edited = !final;
37
+ return current;
38
+ }
39
+ }
40
+ const before = base.slice(0, at);
41
+ const after = base.slice(at);
42
+ const separator = hypothesis && before && !/\s$/.test(before) ? ' ' : '';
43
+ const text = separator + hypothesis;
44
+ const result = before + text + after;
45
+ this.owned = final ? null : { start: at, text, before, after };
46
+ return result;
47
+ }
48
+ }
@@ -0,0 +1,240 @@
1
+ // @ts-nocheck
2
+ import { readFile, mkdir, writeFile, rename, rm } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { defaultQwenVoice, isQwenVoice } from '../core/settings.ts';
7
+ import { validateMonoPcm16Wav } from './recognition/whisper-http-host.ts';
8
+
9
+ const clean = (value) => String(value ?? '').trim();
10
+ export function resolveQwenBaseUrl(
11
+ value = process.env.DSH_LIVE_VOICE_QWEN_URL || 'http://127.0.0.1:8080/',
12
+ ) {
13
+ const url = new URL(value);
14
+ if (!['http:', 'https:'].includes(url.protocol))
15
+ throw new Error('Qwen API URL must use HTTP or HTTPS.');
16
+ url.pathname = url.pathname.replace(/\/*$/, '/');
17
+ url.search = '';
18
+ url.hash = '';
19
+ return url;
20
+ }
21
+ export function validateQwenConfig(value) {
22
+ if (!value || typeof value.baseUrl !== 'string')
23
+ throw new Error('Qwen API base URL is required.');
24
+ const baseUrl = resolveQwenBaseUrl(value.baseUrl.trim());
25
+ if (!Number.isInteger(value.timeoutMs) || value.timeoutMs < 1000 || value.timeoutMs > 600000)
26
+ throw new Error('Request timeout must be an integer between 1000 and 600000 ms.');
27
+ return { baseUrl: baseUrl.href, timeoutMs: value.timeoutMs };
28
+ }
29
+ export function createQwenConfigStore(path = join(homedir(), '.dsh', 'dsh-live-voice-qwen.json')) {
30
+ return {
31
+ async load() {
32
+ try {
33
+ return JSON.parse(await readFile(path, 'utf8'));
34
+ } catch (error) {
35
+ if (error.code === 'ENOENT') return null;
36
+ throw new Error('Cannot read persisted Qwen settings: ' + error.message);
37
+ }
38
+ },
39
+ async save(config) {
40
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
41
+ const temporary = path + '.' + randomUUID() + '.tmp';
42
+ try {
43
+ await writeFile(temporary, JSON.stringify(config, null, 2) + '\n', {
44
+ mode: 0o600,
45
+ flag: 'wx',
46
+ });
47
+ await rename(temporary, path);
48
+ } finally {
49
+ await rm(temporary, { force: true });
50
+ }
51
+ },
52
+ };
53
+ }
54
+ const language = (value) =>
55
+ value === 'auto'
56
+ ? undefined
57
+ : value?.toLowerCase().startsWith('pt')
58
+ ? 'portuguese'
59
+ : value?.toLowerCase().startsWith('en')
60
+ ? 'english'
61
+ : value;
62
+ export class QwenHttpHost {
63
+ constructor({
64
+ baseUrl,
65
+ timeoutMs = 300000,
66
+ fetchImpl = globalThis.fetch,
67
+ maxBytes = 2_000_000,
68
+ maxSpeechBytes = 50_000_000,
69
+ store,
70
+ } = {}) {
71
+ this.config = validateQwenConfig({ baseUrl: resolveQwenBaseUrl(baseUrl).href, timeoutMs });
72
+ this.fetch = fetchImpl;
73
+ this.maxBytes = maxBytes;
74
+ this.maxSpeechBytes = maxSpeechBytes;
75
+ this.store = store;
76
+ this.active = new AbortController();
77
+ this.queue = Promise.resolve();
78
+ this.ready = Promise.resolve()
79
+ .then(async () => {
80
+ const saved = await store?.load();
81
+ if (saved) this.config = validateQwenConfig(saved);
82
+ })
83
+ .catch((error) => {
84
+ this.loadError = error;
85
+ });
86
+ }
87
+ async getConfig() {
88
+ await this.ready;
89
+ if (this.loadError) throw this.loadError;
90
+ return { ...this.config };
91
+ }
92
+ replaceConfig(value) {
93
+ const next = validateQwenConfig(value);
94
+ const operation = this.queue.then(async () => {
95
+ await this.ready;
96
+ await this.store?.save(next);
97
+ this.active.abort();
98
+ this.active = new AbortController();
99
+ this.config = next;
100
+ this.loadError = null;
101
+ return { ...next };
102
+ });
103
+ this.queue = operation.catch(() => {});
104
+ return operation;
105
+ }
106
+ dispose() {
107
+ this.active.abort();
108
+ }
109
+ async request(path, options, configuration, consume = (response) => response) {
110
+ const config = configuration ? validateQwenConfig(configuration) : await this.getConfig();
111
+ const timeout = AbortSignal.timeout(config.timeoutMs);
112
+ const signal = AbortSignal.any([
113
+ timeout,
114
+ this.active.signal,
115
+ ...(options.signal ? [options.signal] : []),
116
+ ]);
117
+ const response = await this.fetch(new URL(path, config.baseUrl), {
118
+ ...options,
119
+ signal,
120
+ redirect: 'error',
121
+ });
122
+ const value = await consume(response);
123
+ signal.throwIfAborted();
124
+ return value;
125
+ }
126
+ async serverInfo(signal, configuration) {
127
+ const response = await this.request('health', { signal }, configuration, async (response) => ({
128
+ ok: response.ok,
129
+ status: response.status,
130
+ body: response.ok ? await response.json() : null,
131
+ }));
132
+ return { ...response, ominix: response.body?.service === 'ominix-api' };
133
+ }
134
+ async capability(signal, configuration, kind = 'both') {
135
+ try {
136
+ const config = configuration ? validateQwenConfig(configuration) : await this.getConfig();
137
+ const health = await this.serverInfo(signal, config);
138
+ let models = health.body?.models;
139
+ if (health.ominix && health.ok) {
140
+ const status = await this.request(
141
+ 'v1/models/status',
142
+ { signal },
143
+ config,
144
+ async (response) => ({
145
+ ok: response.ok,
146
+ status: response.status,
147
+ body: response.ok ? await response.json() : null,
148
+ }),
149
+ );
150
+ models = {
151
+ asr: status.body?.models?.asr === 'qwen3-asr',
152
+ tts: status.body?.models?.qwen3_tts === 'customvoice',
153
+ };
154
+ }
155
+ const ready =
156
+ kind === 'asr'
157
+ ? models?.asr === true
158
+ : kind === 'tts'
159
+ ? models?.tts === true
160
+ : models?.asr === true && models?.tts === true;
161
+ return health.ok && ready
162
+ ? { supported: true, local: true, location: 'host', streaming: false, models }
163
+ : {
164
+ supported: false,
165
+ local: true,
166
+ location: 'host',
167
+ streaming: false,
168
+ reason: `Qwen health check did not report ${kind === 'both' ? 'both ASR and TTS' : kind.toUpperCase()} ready (${health.status}).`,
169
+ };
170
+ } catch (error) {
171
+ return {
172
+ supported: false,
173
+ local: true,
174
+ location: 'host',
175
+ streaming: false,
176
+ reason: 'Qwen speech server is unreachable: ' + (error?.message || error),
177
+ };
178
+ }
179
+ }
180
+ async transcribe(input, { lang = 'pt-BR', signal } = {}) {
181
+ const bytes = validateMonoPcm16Wav(input, { maxBytes: this.maxBytes }),
182
+ resolved = language(lang),
183
+ health = await this.serverInfo(signal);
184
+ let body, headers;
185
+ if (health.ominix) {
186
+ headers = { 'content-type': 'application/json' };
187
+ body = JSON.stringify({
188
+ file: Buffer.from(bytes).toString('base64'),
189
+ language: resolved,
190
+ response_format: 'json',
191
+ });
192
+ } else {
193
+ const form = new FormData();
194
+ form.append('file', new Blob([bytes], { type: 'audio/wav' }), 'utterance.wav');
195
+ form.append('response_format', 'json');
196
+ if (resolved) form.append('language', resolved);
197
+ body = form;
198
+ }
199
+ return this.request(
200
+ 'v1/audio/transcriptions',
201
+ { method: 'POST', headers, body, signal },
202
+ undefined,
203
+ async (response) => {
204
+ if (!response.ok) throw new Error('Qwen transcription failed (' + response.status + ').');
205
+ const json = await response.json();
206
+ return { text: clean(json?.text) };
207
+ },
208
+ );
209
+ }
210
+ async synthesize(text, { lang = 'pt-BR', signal, voice = defaultQwenVoice } = {}) {
211
+ if (typeof text !== 'string' || !text.trim() || text.length > 100000 || text.includes('\0'))
212
+ throw new Error('Speech text must contain 1–100000 characters without NUL.');
213
+ if (!isQwenVoice(voice)) throw new Error('Unsupported Qwen voice.');
214
+ return this.request(
215
+ 'v1/audio/speech',
216
+ {
217
+ method: 'POST',
218
+ headers: { 'content-type': 'application/json' },
219
+ body: JSON.stringify({
220
+ model: 'qwen3-tts',
221
+ input: text,
222
+ voice,
223
+ language: language(lang) || 'portuguese',
224
+ response_format: 'wav',
225
+ }),
226
+ signal,
227
+ },
228
+ undefined,
229
+ async (response) => {
230
+ if (!response.ok) throw new Error('Qwen synthesis failed (' + response.status + ').');
231
+ const length = Number(response.headers.get('content-length') || 0);
232
+ if (length > this.maxSpeechBytes) throw new Error('Qwen speech response is too large.');
233
+ const bytes = await response.arrayBuffer();
234
+ if (bytes.byteLength < 44 || bytes.byteLength > this.maxSpeechBytes)
235
+ throw new Error('Qwen returned invalid or oversized speech audio.');
236
+ return bytes;
237
+ },
238
+ );
239
+ }
240
+ }