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,314 @@
1
+ // @ts-nocheck
2
+ const abortError = () =>
3
+ Object.assign(new Error('Speech recognition was cancelled.'), { name: 'AbortError' });
4
+ const localReason =
5
+ 'Local browser speech recognition is unavailable for this language. Install the local language pack, or explicitly turn off local-only processing in Settings if you permit the browser recognition service to process audio.';
6
+ const notify = (callback, value) => {
7
+ try {
8
+ callback?.(value);
9
+ } catch {
10
+ /* Consumer errors must not retain microphone ownership. */
11
+ }
12
+ };
13
+
14
+ /** Web Speech recognition with explicit user-selected local-only or browser-service processing.
15
+ * When local-only plus auto-install is selected, the browser's native language-pack install API is used.
16
+ * All browser globals (including optional setTimeout/clearTimeout) are injectable.
17
+ * capability({lang}?) asynchronously returns {supported, local:true, reason?} without starting capture.
18
+ * start({lang,signal,onResult,onActivity,onError}?) resolves when native start() is accepted,
19
+ * not when permission is granted. Native asynchronous errors go to onError(Error).
20
+ * onResult({interim,final}) contains the current interim string and NEW final text only.
21
+ * onActivity(boolean) reports speech activity, not microphone permission/readiness.
22
+ * reset() replaces active native capture so its cumulative result list starts empty.
23
+ * stop() aborts capture, discards pending results, removes handlers and cancels restarts.
24
+ * Consecutive no-progress restarts are bounded; speech or nonempty results reset the budget.
25
+ * Native start acceptance alone is not progress. Restart delay backs off up to 30 seconds.
26
+ */
27
+ export class BrowserRecognitionEngine {
28
+ constructor({
29
+ globals = globalThis,
30
+ lang = 'pt-BR',
31
+ processLocally = true,
32
+ autoInstallLocalPack = true,
33
+ onResult,
34
+ onActivity,
35
+ onError,
36
+ maxRestarts = 3,
37
+ restartDelayMs = 100,
38
+ } = {}) {
39
+ if (!Number.isInteger(maxRestarts) || maxRestarts < 0 || maxRestarts > 100)
40
+ throw new RangeError('maxRestarts must be an integer between 0 and 100.');
41
+ if (!Number.isFinite(restartDelayMs) || restartDelayMs < 0)
42
+ throw new RangeError('restartDelayMs must be nonnegative.');
43
+ Object.assign(this, {
44
+ globals,
45
+ lang,
46
+ processLocally,
47
+ autoInstallLocalPack,
48
+ onResult,
49
+ onActivity,
50
+ onError,
51
+ maxRestarts,
52
+ restartDelayMs,
53
+ });
54
+ this.session = null;
55
+ }
56
+
57
+ get active() {
58
+ return this.session !== null;
59
+ }
60
+ get Recognition() {
61
+ try {
62
+ const standard = this.globals?.SpeechRecognition;
63
+ return typeof standard === 'function' ? standard : this.globals?.webkitSpeechRecognition;
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }
68
+
69
+ async capability({ lang = this.lang, processLocally = this.processLocally } = {}) {
70
+ try {
71
+ const Recognition = this.Recognition;
72
+ if (typeof Recognition !== 'function') throw new Error();
73
+ const probe = new Recognition();
74
+ if (typeof probe.start !== 'function' || typeof probe.abort !== 'function') throw new Error();
75
+ if (!processLocally)
76
+ return {
77
+ supported: true,
78
+ local: false,
79
+ reason: 'Browser recognition service may process audio remotely.',
80
+ };
81
+ if (typeof Recognition.available !== 'function' || !('processLocally' in probe))
82
+ throw new Error();
83
+ probe.processLocally = true;
84
+ if (probe.processLocally !== true) throw new Error();
85
+ let availability = await Recognition.available({ langs: [lang], processLocally: true });
86
+ if (availability === 'downloadable' && this.autoInstallLocalPack) {
87
+ const installed = await Recognition.install?.({ langs: [lang], processLocally: true });
88
+ if (installed === true)
89
+ availability = await Recognition.available({ langs: [lang], processLocally: true });
90
+ else
91
+ return {
92
+ supported: false,
93
+ local: true,
94
+ availability,
95
+ reason: `The browser could not install the local ${lang} language pack. Disable local processing to use the browser recognition service, or try again later.`,
96
+ };
97
+ }
98
+ if (availability !== 'available')
99
+ return {
100
+ supported: false,
101
+ local: true,
102
+ availability,
103
+ reason: `Local recognition for ${lang}: ${availability}. ${availability === 'downloadable' ? 'The language pack is not installed; enable automatic installation or browser-service recognition.' : availability === 'downloading' ? 'The browser is still installing the language pack.' : 'The browser cannot currently provide on-device recognition for this language.'}`,
104
+ };
105
+ return { supported: true, local: true };
106
+ } catch {
107
+ return {
108
+ supported: false,
109
+ local: processLocally,
110
+ reason: processLocally
111
+ ? localReason
112
+ : 'Browser speech recognition is unavailable in this browser.',
113
+ };
114
+ }
115
+ }
116
+ async installLocalPack({ lang = this.lang } = {}) {
117
+ const Recognition = this.Recognition;
118
+ if (typeof Recognition?.install !== 'function')
119
+ throw new Error('This browser cannot install local speech-recognition language packs.');
120
+ const result = await Recognition.install({ langs: [lang], processLocally: true });
121
+ if (result !== true) throw new Error(`Local recognition for ${lang} could not be installed.`);
122
+ return this.capability({ lang, processLocally: true });
123
+ }
124
+
125
+ async start({
126
+ lang = this.lang,
127
+ signal,
128
+ onResult = this.onResult,
129
+ onActivity = this.onActivity,
130
+ onError = this.onError,
131
+ } = {}) {
132
+ this.stop();
133
+ if (signal?.aborted) throw abortError();
134
+ const session = {
135
+ lang,
136
+ signal,
137
+ onResult,
138
+ onActivity,
139
+ onError,
140
+ restarts: 0,
141
+ timer: null,
142
+ recognition: null,
143
+ speaking: false,
144
+ };
145
+ this.session = session;
146
+ const cancelled = new Promise((resolve, reject) => {
147
+ session.rejectCancelled = reject;
148
+ });
149
+ session.cancel = () => {
150
+ if (this.session === session) this.stop();
151
+ };
152
+ signal?.addEventListener('abort', session.cancel, { once: true });
153
+ const capability = await Promise.race([
154
+ this.capability({ lang, processLocally: this.processLocally }),
155
+ cancelled,
156
+ ]);
157
+ if (this.session !== session || signal?.aborted) {
158
+ session.cancel();
159
+ throw abortError();
160
+ }
161
+ if (!capability.supported) {
162
+ this.stop();
163
+ throw new Error(capability.reason);
164
+ }
165
+ try {
166
+ this._begin(session);
167
+ if (this.session !== session) throw abortError();
168
+ } catch (error) {
169
+ if (this.session === session) this.stop();
170
+ throw error;
171
+ }
172
+ }
173
+
174
+ _activity(session, active) {
175
+ if (session.speaking === active) return;
176
+ session.speaking = active;
177
+ notify(session.onActivity, active);
178
+ }
179
+
180
+ _detach(recognition) {
181
+ if (!recognition) return;
182
+ for (const name of ['onresult', 'onerror', 'onend', 'onspeechstart', 'onspeechend'])
183
+ recognition[name] = null;
184
+ }
185
+
186
+ _fail(session, error) {
187
+ if (this.session !== session) return;
188
+ this.stop();
189
+ notify(session.onError, error);
190
+ }
191
+
192
+ _begin(session) {
193
+ if (this.session !== session) return;
194
+ const recognition = new this.Recognition();
195
+ if (this.processLocally) {
196
+ if (!('processLocally' in recognition)) throw new Error(localReason);
197
+ recognition.processLocally = true;
198
+ if (recognition.processLocally !== true) throw new Error(localReason);
199
+ } else if ('processLocally' in recognition) recognition.processLocally = false;
200
+ recognition.lang = session.lang;
201
+ recognition.continuous = true;
202
+ recognition.interimResults = true;
203
+ session.recognition = recognition;
204
+ const finals = new Set();
205
+ const valid = () => this.session === session && session.recognition === recognition;
206
+ recognition.onspeechstart = () => {
207
+ if (!valid()) return;
208
+ session.restarts = 0;
209
+ this._activity(session, true);
210
+ };
211
+ recognition.onspeechend = () => {
212
+ if (valid()) this._activity(session, false);
213
+ };
214
+ recognition.onresult = (event) => {
215
+ if (!valid()) return;
216
+ const interim = [],
217
+ final = [];
218
+ for (let i = 0; i < event.results.length; i++) {
219
+ const result = event.results[i];
220
+ const text = result[0]?.transcript ?? '';
221
+ if (text.trim()) session.restarts = 0;
222
+ if (result.isFinal) {
223
+ if (!finals.has(i)) {
224
+ finals.add(i);
225
+ final.push(text);
226
+ }
227
+ } else interim.push(text);
228
+ }
229
+ notify(session.onResult, { interim: interim.join(' '), final: final.join(' ') });
230
+ };
231
+ recognition.onerror = (event) => {
232
+ if (!valid()) return;
233
+ const error = Object.assign(
234
+ new Error('Local browser recognition failed: ' + (event.error || 'unknown error')),
235
+ { code: event.error },
236
+ );
237
+ // no-speech is recoverable only when the browser subsequently ends this session.
238
+ if (event.error === 'no-speech') return;
239
+ else this._fail(session, error);
240
+ };
241
+ recognition.onend = () => {
242
+ if (!valid()) return;
243
+ this._detach(recognition);
244
+ session.recognition = null;
245
+ this._activity(session, false);
246
+ if (this.session !== session) return;
247
+ if (session.restarts >= this.maxRestarts) {
248
+ this._fail(
249
+ session,
250
+ Object.assign(
251
+ new Error(
252
+ 'Local browser recognition stopped repeatedly. Restart listening manually or choose another local engine.',
253
+ ),
254
+ { code: 'restart-limit' },
255
+ ),
256
+ );
257
+ return;
258
+ }
259
+ session.restarts++;
260
+ session.timer = (this.globals.setTimeout ?? globalThis.setTimeout)(
261
+ () => {
262
+ session.timer = null;
263
+ if (this.session !== session) return;
264
+ try {
265
+ this._begin(session);
266
+ } catch (error) {
267
+ this._fail(session, error);
268
+ }
269
+ },
270
+ Math.min(30_000, this.restartDelayMs * 2 ** Math.min(session.restarts - 1, 20)),
271
+ );
272
+ };
273
+ recognition.start();
274
+ }
275
+
276
+ reset() {
277
+ const session = this.session;
278
+ const recognition = session?.recognition;
279
+ if (!session || !recognition) return false;
280
+ this._detach(recognition);
281
+ session.recognition = null;
282
+ try {
283
+ recognition.abort();
284
+ } catch {
285
+ /* Browser already ended capture. */
286
+ }
287
+ this._activity(session, false);
288
+ if (this.session !== session || session.signal?.aborted) return false;
289
+ try {
290
+ this._begin(session);
291
+ return true;
292
+ } catch (error) {
293
+ this._fail(session, error);
294
+ return false;
295
+ }
296
+ }
297
+
298
+ async stop() {
299
+ const session = this.session;
300
+ if (!session) return;
301
+ this.session = null;
302
+ session.rejectCancelled(abortError());
303
+ session.signal?.removeEventListener('abort', session.cancel);
304
+ if (session.timer !== null)
305
+ (this.globals.clearTimeout ?? globalThis.clearTimeout)(session.timer);
306
+ this._detach(session.recognition);
307
+ try {
308
+ session.recognition?.abort();
309
+ } catch {
310
+ /* Browser already ended capture. */
311
+ }
312
+ this._activity(session, false);
313
+ }
314
+ }
@@ -0,0 +1,36 @@
1
+ // @ts-nocheck
2
+ import { WhisperHttpRecognitionEngine } from './whisper-http.ts';
3
+
4
+ /** Qwen shares the plugin-owned microphone/VAD pipeline with Whisper, but uses
5
+ * its own authenticated DSH route and capability probe. */
6
+ export class QwenHttpRecognitionEngine extends WhisperHttpRecognitionEngine {
7
+ constructor(options = {}) {
8
+ super(options);
9
+ this.route = '/api/dsh-live-voice/qwen';
10
+ }
11
+ async capability() {
12
+ const capture = await this.meter?.capability?.();
13
+ if (capture?.supported === false) return capture;
14
+ try {
15
+ const response = await this.g.fetch(this.route + '/capabilities?kind=asr', {
16
+ credentials: 'same-origin',
17
+ }),
18
+ json = await response.json();
19
+ return response.ok && json?.ok
20
+ ? json.value
21
+ : {
22
+ supported: false,
23
+ local: true,
24
+ location: 'host',
25
+ reason: json?.error?.message || 'Qwen capability check failed.',
26
+ };
27
+ } catch (error) {
28
+ return {
29
+ supported: false,
30
+ local: true,
31
+ location: 'host',
32
+ reason: 'Qwen speech server check failed: ' + (error?.message || error),
33
+ };
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,210 @@
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
+
7
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
8
+ export function validateWhisperConfig(value) {
9
+ if (!value || typeof value.url !== 'string' || typeof value.healthUrl !== 'string')
10
+ throw new Error('Endpoint URL and health URL/path are required.');
11
+ const url = resolveWhisperUrl(value.url.trim());
12
+ const health = value.healthUrl.trim();
13
+ if (!health) throw new Error('Health URL/path is required.');
14
+ resolveWhisperUrl(new URL(health, url).href);
15
+ if (!Number.isInteger(value.timeoutMs) || value.timeoutMs < 100 || value.timeoutMs > 300000)
16
+ throw new Error('Request timeout must be an integer between 100 and 300000 ms.');
17
+ return { url: url.href, healthUrl: health, timeoutMs: value.timeoutMs };
18
+ }
19
+ export function createWhisperConfigStore(
20
+ path = join(homedir(), '.dsh', 'dsh-live-voice-whisper.json'),
21
+ ) {
22
+ return {
23
+ async load() {
24
+ try {
25
+ return JSON.parse(await readFile(path, 'utf8'));
26
+ } catch (error) {
27
+ if (error.code === 'ENOENT') return null;
28
+ throw new Error('Cannot read persisted Whisper settings: ' + error.message);
29
+ }
30
+ },
31
+ async save(config) {
32
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
33
+ const temporary = path + '.' + randomUUID() + '.tmp';
34
+ try {
35
+ await writeFile(temporary, JSON.stringify(config, null, 2) + '\n', {
36
+ mode: 0o600,
37
+ flag: 'wx',
38
+ });
39
+ await rename(temporary, path);
40
+ } finally {
41
+ await rm(temporary, { force: true });
42
+ }
43
+ },
44
+ };
45
+ }
46
+ const clean = (value) => String(value ?? '').trim();
47
+ export function resolveWhisperUrl(
48
+ value = process.env.DSH_LIVE_VOICE_WHISPER_URL || 'http://127.0.0.1:8080/inference',
49
+ ) {
50
+ const url = new URL(value);
51
+ if (
52
+ url.protocol !== 'http:' ||
53
+ !LOOPBACK.has(url.hostname) ||
54
+ url.username ||
55
+ url.password ||
56
+ url.hash
57
+ )
58
+ throw new Error('Whisper HTTP URL must be an unauthenticated loopback http URL.');
59
+ return url;
60
+ }
61
+ export function validateMonoPcm16Wav(input, { maxBytes = 2_000_000 } = {}) {
62
+ const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
63
+ if (bytes.byteLength < 44 || bytes.byteLength > maxBytes)
64
+ throw new Error('Invalid or oversized WAV recording.');
65
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength),
66
+ ascii = (at, n) => String.fromCharCode(...bytes.subarray(at, at + n));
67
+ if (
68
+ ascii(0, 4) !== 'RIFF' ||
69
+ ascii(8, 4) !== 'WAVE' ||
70
+ view.getUint32(4, true) + 8 !== bytes.byteLength
71
+ )
72
+ throw new Error('Invalid WAV container.');
73
+ let at = 12,
74
+ fmt = null,
75
+ data = null;
76
+ while (at + 8 <= bytes.byteLength) {
77
+ const id = ascii(at, 4),
78
+ size = view.getUint32(at + 4, true),
79
+ start = at + 8,
80
+ end = start + size;
81
+ if (end > bytes.byteLength) throw new Error('Invalid WAV chunk.');
82
+ if (id === 'fmt ') fmt = { start, size };
83
+ if (id === 'data') {
84
+ if (data) throw new Error('Multiple WAV data chunks are unsupported.');
85
+ data = { start, size };
86
+ }
87
+ at = end + (size & 1);
88
+ }
89
+ if (!fmt || fmt.size < 16 || !data || at !== bytes.byteLength)
90
+ throw new Error('Incomplete WAV recording.');
91
+ const f = fmt.start;
92
+ if (
93
+ view.getUint16(f, true) !== 1 ||
94
+ view.getUint16(f + 2, true) !== 1 ||
95
+ view.getUint32(f + 4, true) !== 16000 ||
96
+ view.getUint32(f + 8, true) !== 32000 ||
97
+ view.getUint16(f + 12, true) !== 2 ||
98
+ view.getUint16(f + 14, true) !== 16 ||
99
+ data.size < 2 ||
100
+ data.size % 2
101
+ )
102
+ throw new Error('WAV must be mono 16 kHz PCM16.');
103
+ return bytes;
104
+ }
105
+ export class WhisperHttpHost {
106
+ constructor({
107
+ url,
108
+ healthUrl = '/health',
109
+ timeoutMs = 30000,
110
+ fetchImpl = globalThis.fetch,
111
+ maxBytes = 2_000_000,
112
+ store,
113
+ } = {}) {
114
+ this.config = validateWhisperConfig({ url: resolveWhisperUrl(url).href, healthUrl, timeoutMs });
115
+ this.fetch = fetchImpl;
116
+ this.maxBytes = maxBytes;
117
+ this.store = store;
118
+ this.active = new AbortController();
119
+ this.queue = Promise.resolve();
120
+ this.ready = Promise.resolve()
121
+ .then(async () => {
122
+ const saved = await store?.load();
123
+ if (saved) this.config = validateWhisperConfig(saved);
124
+ })
125
+ .catch((error) => {
126
+ this.loadError = error;
127
+ });
128
+ }
129
+ get url() {
130
+ return new URL(this.config.url);
131
+ }
132
+ async getConfig() {
133
+ await this.ready;
134
+ if (this.loadError) throw this.loadError;
135
+ return { ...this.config };
136
+ }
137
+ replaceConfig(value) {
138
+ const next = validateWhisperConfig(value);
139
+ const operation = this.queue.then(async () => {
140
+ await this.ready;
141
+ await this.store?.save(next);
142
+ this.active.abort();
143
+ this.active = new AbortController();
144
+ this.config = next;
145
+ this.loadError = null;
146
+ return { ...next };
147
+ });
148
+ this.queue = operation.catch(() => {});
149
+ return operation;
150
+ }
151
+ dispose() {
152
+ this.active.abort();
153
+ }
154
+ async request(url, options, config, consume = (response) => response) {
155
+ const timeout = AbortSignal.timeout(config.timeoutMs);
156
+ const signal = AbortSignal.any([
157
+ timeout,
158
+ this.active.signal,
159
+ ...(options.signal ? [options.signal] : []),
160
+ ]);
161
+ const response = await this.fetch(url, { ...options, signal, redirect: 'error' });
162
+ const value = await consume(response);
163
+ signal.throwIfAborted();
164
+ return value;
165
+ }
166
+ async capability(signal, configuration) {
167
+ try {
168
+ const config = configuration ? validateWhisperConfig(configuration) : await this.getConfig();
169
+ const response = await this.request(
170
+ new URL(config.healthUrl, config.url),
171
+ { signal },
172
+ config,
173
+ );
174
+ return response.ok
175
+ ? { supported: true, local: true, streaming: false, maxBytes: this.maxBytes }
176
+ : {
177
+ supported: false,
178
+ local: true,
179
+ streaming: false,
180
+ reason: 'Whisper HTTP health check failed (' + response.status + ').',
181
+ };
182
+ } catch (error) {
183
+ return {
184
+ supported: false,
185
+ local: true,
186
+ streaming: false,
187
+ reason: 'Whisper HTTP server is unreachable: ' + (error?.message || error),
188
+ };
189
+ }
190
+ }
191
+ async transcribe(input, { lang = 'auto', signal } = {}) {
192
+ const bytes = validateMonoPcm16Wav(input, { maxBytes: this.maxBytes }),
193
+ form = new FormData();
194
+ form.append('file', new Blob([bytes], { type: 'audio/wav' }), 'utterance.wav');
195
+ form.append('response_format', 'json');
196
+ form.append('language', lang.startsWith('pt') ? 'pt' : lang.startsWith('en') ? 'en' : 'auto');
197
+ const config = await this.getConfig();
198
+ return this.request(
199
+ new URL(config.url),
200
+ { method: 'POST', body: form, signal },
201
+ config,
202
+ async (response) => {
203
+ if (!response.ok)
204
+ throw new Error('Whisper HTTP transcription failed (' + response.status + ').');
205
+ const json = await response.json();
206
+ return { text: clean(json?.text) };
207
+ },
208
+ );
209
+ }
210
+ }