dsh-live-voice 0.0.1-developing → 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.
- package/DEVELOPMENT.md +70 -0
- package/HISTORY.md +10 -85
- package/PLAN.md +244 -0
- package/README.md +88 -96
- package/cordis.patch.yml +3 -0
- package/lib/client.js +2988 -0
- package/lib/server.js +1006 -0
- package/package.json +69 -6
- package/scripts/build.ts +50 -0
- package/scripts/check-dist.mjs +38 -0
- package/scripts/preview-ui.ts +31 -0
- package/scripts/probe-browser.ts +46 -0
- package/src/client/chat.ts +40 -0
- package/src/client/components.ts +778 -0
- package/src/client/index.ts +430 -0
- package/src/client/qwen-settings.ts +144 -0
- package/src/client/styles.ts +36 -0
- package/src/client/whisper-settings.ts +147 -0
- package/src/core/coordinator.ts +554 -0
- package/src/core/microphone.ts +171 -0
- package/src/core/ownership.ts +31 -0
- package/src/core/settings.ts +113 -0
- package/src/core/transcript.ts +48 -0
- package/src/engines/qwen-http-host.ts +246 -0
- package/src/engines/recognition/browser.ts +291 -0
- package/src/engines/recognition/qwen-http.ts +36 -0
- package/src/engines/recognition/whisper-http-host.ts +210 -0
- package/src/engines/recognition/whisper-http.ts +191 -0
- package/src/engines/speaking/browser.ts +136 -0
- package/src/engines/speaking/qwen-http.ts +119 -0
- package/src/engines/speaking/say-client.ts +96 -0
- package/src/engines/speaking/say.ts +271 -0
- package/src/server.ts +365 -0
- package/AGENTS.md +0 -52
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { voiceDetectionPresets } from '../../core/settings.ts';
|
|
3
|
+
|
|
4
|
+
const ROUTE = '/api/dsh-live-voice/whisper';
|
|
5
|
+
const id = () => globalThis.crypto.randomUUID();
|
|
6
|
+
const abortError = () =>
|
|
7
|
+
Object.assign(new Error('Whisper recognition was cancelled.'), { name: 'AbortError' });
|
|
8
|
+
export function encodeMonoPcm16Wav(samples, inputRate) {
|
|
9
|
+
const ratio = inputRate / 16000,
|
|
10
|
+
length = Math.floor(samples.length / ratio),
|
|
11
|
+
out = new Int16Array(length);
|
|
12
|
+
for (let i = 0; i < length; i++) {
|
|
13
|
+
const start = Math.floor(i * ratio),
|
|
14
|
+
end = Math.max(start + 1, Math.floor((i + 1) * ratio));
|
|
15
|
+
let sum = 0;
|
|
16
|
+
for (let j = start; j < end && j < samples.length; j++) sum += samples[j];
|
|
17
|
+
const value = Math.max(-1, Math.min(1, sum / (end - start)));
|
|
18
|
+
out[i] = value < 0 ? value * 32768 : value * 32767;
|
|
19
|
+
}
|
|
20
|
+
const buffer = new ArrayBuffer(44 + out.byteLength),
|
|
21
|
+
view = new DataView(buffer),
|
|
22
|
+
text = (at, s) => {
|
|
23
|
+
for (let i = 0; i < s.length; i++) view.setUint8(at + i, s.charCodeAt(i));
|
|
24
|
+
};
|
|
25
|
+
text(0, 'RIFF');
|
|
26
|
+
view.setUint32(4, 36 + out.byteLength, true);
|
|
27
|
+
text(8, 'WAVE');
|
|
28
|
+
text(12, 'fmt ');
|
|
29
|
+
view.setUint32(16, 16, true);
|
|
30
|
+
view.setUint16(20, 1, true);
|
|
31
|
+
view.setUint16(22, 1, true);
|
|
32
|
+
view.setUint32(24, 16000, true);
|
|
33
|
+
view.setUint32(28, 32000, true);
|
|
34
|
+
view.setUint16(32, 2, true);
|
|
35
|
+
view.setUint16(34, 16, true);
|
|
36
|
+
text(36, 'data');
|
|
37
|
+
view.setUint32(40, out.byteLength, true);
|
|
38
|
+
new Int16Array(buffer, 44).set(out);
|
|
39
|
+
return buffer;
|
|
40
|
+
}
|
|
41
|
+
export class WhisperHttpRecognitionEngine {
|
|
42
|
+
constructor({ globals = globalThis, meter, voiceDetectionPreset = 'natural' } = {}) {
|
|
43
|
+
this.g = globals;
|
|
44
|
+
this.meter = meter;
|
|
45
|
+
this.session = null;
|
|
46
|
+
this.lang = 'pt-BR';
|
|
47
|
+
this.voiceDetectionPreset = voiceDetectionPreset;
|
|
48
|
+
this.route = ROUTE;
|
|
49
|
+
}
|
|
50
|
+
get segmentation() {
|
|
51
|
+
return voiceDetectionPresets[this.voiceDetectionPreset] || voiceDetectionPresets.natural;
|
|
52
|
+
}
|
|
53
|
+
async capability() {
|
|
54
|
+
try {
|
|
55
|
+
const response = await this.g.fetch(this.route + '/capabilities', {
|
|
56
|
+
credentials: 'same-origin',
|
|
57
|
+
}),
|
|
58
|
+
json = await response.json();
|
|
59
|
+
return json?.ok
|
|
60
|
+
? json.value
|
|
61
|
+
: {
|
|
62
|
+
supported: false,
|
|
63
|
+
local: true,
|
|
64
|
+
streaming: false,
|
|
65
|
+
reason: json?.error?.message || 'Whisper HTTP host is unavailable.',
|
|
66
|
+
};
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return {
|
|
69
|
+
supported: false,
|
|
70
|
+
local: true,
|
|
71
|
+
streaming: false,
|
|
72
|
+
reason: 'Whisper HTTP host connection failed: ' + error.message,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async start({ lang = this.lang, signal, onResult, onActivity, onError } = {}) {
|
|
77
|
+
await this.stop();
|
|
78
|
+
if (signal?.aborted) throw abortError();
|
|
79
|
+
const context = this.meter?.context,
|
|
80
|
+
source = this.meter?.source;
|
|
81
|
+
if (!context || !source || typeof context.createScriptProcessor !== 'function')
|
|
82
|
+
throw new Error('This browser cannot capture PCM audio for Whisper HTTP.');
|
|
83
|
+
const processor = context.createScriptProcessor(4096, 1, 1),
|
|
84
|
+
gain = context.createGain?.();
|
|
85
|
+
if (gain) {
|
|
86
|
+
gain.gain.value = 0;
|
|
87
|
+
processor.connect(gain);
|
|
88
|
+
gain.connect(context.destination);
|
|
89
|
+
} else processor.connect(context.destination);
|
|
90
|
+
const session = {
|
|
91
|
+
operation: id(),
|
|
92
|
+
processor,
|
|
93
|
+
gain,
|
|
94
|
+
chunks: [],
|
|
95
|
+
samples: 0,
|
|
96
|
+
voiced: false,
|
|
97
|
+
silence: 0,
|
|
98
|
+
inflight: new Set(),
|
|
99
|
+
onResult,
|
|
100
|
+
onActivity,
|
|
101
|
+
onError,
|
|
102
|
+
lang,
|
|
103
|
+
signal,
|
|
104
|
+
};
|
|
105
|
+
this.session = session;
|
|
106
|
+
const valid = () => this.session === session && !signal?.aborted;
|
|
107
|
+
const submit = async () => {
|
|
108
|
+
if (!session.voiced || session.samples < context.sampleRate * 0.25) {
|
|
109
|
+
session.chunks = [];
|
|
110
|
+
session.samples = 0;
|
|
111
|
+
session.voiced = false;
|
|
112
|
+
session.silence = 0;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const samples = new Float32Array(session.samples);
|
|
116
|
+
let at = 0;
|
|
117
|
+
for (const chunk of session.chunks) {
|
|
118
|
+
samples.set(chunk, at);
|
|
119
|
+
at += chunk.length;
|
|
120
|
+
}
|
|
121
|
+
session.chunks = [];
|
|
122
|
+
session.samples = 0;
|
|
123
|
+
session.voiced = false;
|
|
124
|
+
session.silence = 0;
|
|
125
|
+
const request = new AbortController();
|
|
126
|
+
session.inflight.add(request);
|
|
127
|
+
try {
|
|
128
|
+
const response = await this.g.fetch(this.route + '/transcribe', {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
credentials: 'same-origin',
|
|
131
|
+
headers: {
|
|
132
|
+
'content-type': 'audio/wav',
|
|
133
|
+
'x-dlv-client-id': session.operation,
|
|
134
|
+
'x-dlv-operation-id': id(),
|
|
135
|
+
'x-dlv-language': lang,
|
|
136
|
+
},
|
|
137
|
+
body: encodeMonoPcm16Wav(samples, context.sampleRate),
|
|
138
|
+
signal: request.signal,
|
|
139
|
+
});
|
|
140
|
+
const json = await response.json();
|
|
141
|
+
if (!response.ok || !json?.ok)
|
|
142
|
+
throw new Error(json?.error?.message || 'HTTP transcription failed.');
|
|
143
|
+
if (valid() && json.value.text) onResult?.({ final: json.value.text, interim: '' });
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error.name !== 'AbortError' && valid()) onError?.(error);
|
|
146
|
+
} finally {
|
|
147
|
+
session.inflight.delete(request);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
processor.onaudioprocess = (event) => {
|
|
151
|
+
if (!valid()) return;
|
|
152
|
+
const data = new Float32Array(event.inputBuffer.getChannelData(0)),
|
|
153
|
+
rms = Math.sqrt(data.reduce((sum, x) => sum + x * x, 0) / data.length);
|
|
154
|
+
if (rms > 0.012) {
|
|
155
|
+
session.voiced = true;
|
|
156
|
+
session.silence = 0;
|
|
157
|
+
onActivity?.(true);
|
|
158
|
+
} else if (session.voiced) {
|
|
159
|
+
session.silence += data.length;
|
|
160
|
+
onActivity?.(false);
|
|
161
|
+
}
|
|
162
|
+
session.chunks.push(data);
|
|
163
|
+
session.samples += data.length;
|
|
164
|
+
if (
|
|
165
|
+
(session.voiced &&
|
|
166
|
+
session.silence > context.sampleRate * (this.segmentation.silenceMs / 1000)) ||
|
|
167
|
+
session.samples > context.sampleRate * 20
|
|
168
|
+
)
|
|
169
|
+
void submit();
|
|
170
|
+
};
|
|
171
|
+
source.connect(processor);
|
|
172
|
+
session.abort = () => this.stop();
|
|
173
|
+
signal?.addEventListener('abort', session.abort, { once: true });
|
|
174
|
+
}
|
|
175
|
+
async stop() {
|
|
176
|
+
const session = this.session;
|
|
177
|
+
if (!session) return;
|
|
178
|
+
this.session = null;
|
|
179
|
+
session.signal?.removeEventListener('abort', session.abort);
|
|
180
|
+
session.processor.onaudioprocess = null;
|
|
181
|
+
try {
|
|
182
|
+
this.meter?.source?.disconnect(session.processor);
|
|
183
|
+
} catch {}
|
|
184
|
+
try {
|
|
185
|
+
session.processor.disconnect();
|
|
186
|
+
session.gain?.disconnect();
|
|
187
|
+
} catch {}
|
|
188
|
+
for (const request of session.inflight) request.abort();
|
|
189
|
+
session.inflight.clear();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
const abortError = () =>
|
|
3
|
+
Object.assign(new Error('Speech playback was cancelled.'), { name: 'AbortError' });
|
|
4
|
+
|
|
5
|
+
/** Local-only Web Speech synthesis. The browser synthesis queue is shared: use one owner.
|
|
6
|
+
* Inject { globals: { speechSynthesis, SpeechSynthesisUtterance } } for tests.
|
|
7
|
+
* capability() is synchronous; voices() returns native local voices (refresh after voiceschanged).
|
|
8
|
+
* speak() replaces prior speech and resolves only on completion. Cancellation rejects AbortError.
|
|
9
|
+
* voice accepts a listed voice object, voiceURI, or name; no remote/default fallback is used.
|
|
10
|
+
*/
|
|
11
|
+
export class BrowserSpeakingEngine {
|
|
12
|
+
constructor({ globals = globalThis, lang = 'pt-BR' } = {}) {
|
|
13
|
+
this.globals = globals;
|
|
14
|
+
this.lang = lang;
|
|
15
|
+
this.current = null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
voices() {
|
|
19
|
+
try {
|
|
20
|
+
return Array.from(this.globals.speechSynthesis?.getVoices?.() ?? []).filter(
|
|
21
|
+
(v) => v.localService === true,
|
|
22
|
+
);
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
capability() {
|
|
29
|
+
const supported =
|
|
30
|
+
typeof this.globals.SpeechSynthesisUtterance === 'function' &&
|
|
31
|
+
typeof this.globals.speechSynthesis?.speak === 'function' &&
|
|
32
|
+
typeof this.globals.speechSynthesis?.cancel === 'function' &&
|
|
33
|
+
this.voices().length > 0;
|
|
34
|
+
return {
|
|
35
|
+
supported,
|
|
36
|
+
local: true,
|
|
37
|
+
voices: this.voices().map((voice) => ({
|
|
38
|
+
name: voice.name,
|
|
39
|
+
voiceURI: voice.voiceURI,
|
|
40
|
+
lang: voice.lang,
|
|
41
|
+
})),
|
|
42
|
+
pause: typeof this.globals.speechSynthesis?.pause === 'function',
|
|
43
|
+
resume: typeof this.globals.speechSynthesis?.resume === 'function',
|
|
44
|
+
...(supported
|
|
45
|
+
? {}
|
|
46
|
+
: {
|
|
47
|
+
reason:
|
|
48
|
+
'Local browser speech synthesis is unavailable. Enable an installed local system voice, refresh the voice list, or choose another local speaking engine. Remote voices are not allowed.',
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async speak(text, { voice, rate = 1, signal } = {}) {
|
|
54
|
+
// No await before replacement: concurrent calls cannot enqueue obsolete utterances.
|
|
55
|
+
this.stop();
|
|
56
|
+
if (signal?.aborted) throw abortError();
|
|
57
|
+
if (typeof text !== 'string') throw new TypeError('Speech text must be a string.');
|
|
58
|
+
if (!Number.isFinite(rate) || rate < 0.1 || rate > 10)
|
|
59
|
+
throw new RangeError('Speech rate must be between 0.1 and 10.');
|
|
60
|
+
const capability = this.capability();
|
|
61
|
+
if (!capability.supported) throw new Error(capability.reason);
|
|
62
|
+
const voices = this.voices();
|
|
63
|
+
const chosen =
|
|
64
|
+
voice == null
|
|
65
|
+
? (voices.find((v) => v.lang?.toLowerCase() === this.lang.toLowerCase()) ?? voices[0])
|
|
66
|
+
: voices.find((v) =>
|
|
67
|
+
typeof voice === 'string' ? v.voiceURI === voice || v.name === voice : v === voice,
|
|
68
|
+
);
|
|
69
|
+
if (!chosen)
|
|
70
|
+
throw new Error(
|
|
71
|
+
'The selected voice is not an available local browser voice. Choose a voice from voices().',
|
|
72
|
+
);
|
|
73
|
+
if (!text.trim()) return;
|
|
74
|
+
const utterance = new this.globals.SpeechSynthesisUtterance(text);
|
|
75
|
+
utterance.voice = chosen;
|
|
76
|
+
utterance.lang = chosen.lang || this.lang;
|
|
77
|
+
utterance.rate = rate;
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const finish = (error) => {
|
|
80
|
+
if (this.current !== job) return;
|
|
81
|
+
this.current = null;
|
|
82
|
+
utterance.onend = null;
|
|
83
|
+
utterance.onerror = null;
|
|
84
|
+
signal?.removeEventListener('abort', cancel);
|
|
85
|
+
error ? reject(error) : resolve();
|
|
86
|
+
};
|
|
87
|
+
const cancel = () => {
|
|
88
|
+
if (this.current === job) this.stop();
|
|
89
|
+
};
|
|
90
|
+
const job = { finish, utterance };
|
|
91
|
+
this.current = job;
|
|
92
|
+
utterance.onend = () => finish();
|
|
93
|
+
utterance.onerror = (event) =>
|
|
94
|
+
finish(
|
|
95
|
+
Object.assign(
|
|
96
|
+
new Error('Browser speech synthesis failed: ' + (event.error || 'unknown error')),
|
|
97
|
+
{ code: event.error },
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
101
|
+
if (signal?.aborted) {
|
|
102
|
+
cancel();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
// cancel() can leave the shared synthesizer paused in some browsers.
|
|
107
|
+
this.globals.speechSynthesis.resume?.();
|
|
108
|
+
this.globals.speechSynthesis.speak(utterance);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
finish(error);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async stop() {
|
|
116
|
+
if (!this.current) return;
|
|
117
|
+
this.current.finish(abortError());
|
|
118
|
+
// Detach first: cancel may synchronously emit end/error.
|
|
119
|
+
try {
|
|
120
|
+
this.globals.speechSynthesis.cancel();
|
|
121
|
+
} catch {
|
|
122
|
+
/* Already released locally. */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
pause() {
|
|
127
|
+
if (!this.current || typeof this.globals.speechSynthesis.pause !== 'function') return false;
|
|
128
|
+
this.globals.speechSynthesis.pause();
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
resume() {
|
|
132
|
+
if (!this.current || typeof this.globals.speechSynthesis.resume !== 'function') return false;
|
|
133
|
+
this.globals.speechSynthesis.resume();
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
const BASE = '/api/dsh-live-voice/qwen';
|
|
3
|
+
const cancelled = () => Object.assign(new Error('Speech was cancelled.'), { name: 'AbortError' });
|
|
4
|
+
|
|
5
|
+
/** Fetches host-local Qwen WAV audio through authenticated DSH and plays it in
|
|
6
|
+
* the user's browser. The resident server owns model lifetime. */
|
|
7
|
+
export class QwenHttpSpeakingEngine {
|
|
8
|
+
constructor({ globals = globalThis, lang = 'pt-BR' } = {}) {
|
|
9
|
+
this.g = globals;
|
|
10
|
+
this.lang = lang;
|
|
11
|
+
this.current = null;
|
|
12
|
+
}
|
|
13
|
+
async capability() {
|
|
14
|
+
try {
|
|
15
|
+
const response = await this.g.fetch(BASE + '/capabilities?kind=tts', {
|
|
16
|
+
credentials: 'same-origin',
|
|
17
|
+
}),
|
|
18
|
+
json = await response.json();
|
|
19
|
+
if (!response.ok || !json?.ok)
|
|
20
|
+
throw new Error(json?.error?.message || 'Qwen capability check failed.');
|
|
21
|
+
return { ...json.value, pause: true, resume: true };
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return {
|
|
24
|
+
supported: false,
|
|
25
|
+
local: true,
|
|
26
|
+
location: 'host',
|
|
27
|
+
pause: true,
|
|
28
|
+
resume: true,
|
|
29
|
+
reason: 'Qwen speech server check failed: ' + (error?.message || error),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async speak(text, { rate = 1, signal, lang = this.lang, voice = 'aiden' } = {}) {
|
|
34
|
+
if (typeof text !== 'string') throw new TypeError('Speech text must be a string.');
|
|
35
|
+
if (!Number.isFinite(rate) || rate < 0.1 || rate > 3)
|
|
36
|
+
throw new RangeError('Speech rate must be between 0.1 and 3.');
|
|
37
|
+
if (signal?.aborted) throw cancelled();
|
|
38
|
+
await this.stop();
|
|
39
|
+
if (!text.trim()) return;
|
|
40
|
+
const operation = { abort: new AbortController(), audio: null, url: null };
|
|
41
|
+
this.current = operation;
|
|
42
|
+
const cancel = () => operation.abort.abort();
|
|
43
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
44
|
+
if (signal?.aborted) cancel();
|
|
45
|
+
try {
|
|
46
|
+
const response = await this.g.fetch(BASE + '/speech', {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
credentials: 'same-origin',
|
|
49
|
+
headers: { 'content-type': 'application/json' },
|
|
50
|
+
body: JSON.stringify({ text, lang, voice }),
|
|
51
|
+
signal: operation.abort.signal,
|
|
52
|
+
});
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
let body;
|
|
55
|
+
try {
|
|
56
|
+
body = await response.json();
|
|
57
|
+
} catch {}
|
|
58
|
+
throw new Error(body?.error?.message || `Qwen synthesis failed (${response.status}).`);
|
|
59
|
+
}
|
|
60
|
+
if (operation.abort.signal.aborted) throw cancelled();
|
|
61
|
+
const blob = await response.blob();
|
|
62
|
+
operation.url = this.g.URL.createObjectURL(blob);
|
|
63
|
+
const audio = (operation.audio = new this.g.Audio(operation.url));
|
|
64
|
+
audio.playbackRate = rate;
|
|
65
|
+
await new Promise((resolve, reject) => {
|
|
66
|
+
const done = () => {
|
|
67
|
+
cleanup();
|
|
68
|
+
resolve();
|
|
69
|
+
},
|
|
70
|
+
failed = () => {
|
|
71
|
+
cleanup();
|
|
72
|
+
reject(new Error('The browser could not play Qwen speech audio.'));
|
|
73
|
+
},
|
|
74
|
+
aborted = () => {
|
|
75
|
+
cleanup();
|
|
76
|
+
audio.pause();
|
|
77
|
+
reject(cancelled());
|
|
78
|
+
},
|
|
79
|
+
cleanup = () => {
|
|
80
|
+
audio.removeEventListener('ended', done);
|
|
81
|
+
audio.removeEventListener('error', failed);
|
|
82
|
+
operation.abort.signal.removeEventListener('abort', aborted);
|
|
83
|
+
};
|
|
84
|
+
audio.addEventListener('ended', done, { once: true });
|
|
85
|
+
audio.addEventListener('error', failed, { once: true });
|
|
86
|
+
operation.abort.signal.addEventListener('abort', aborted, { once: true });
|
|
87
|
+
Promise.resolve(audio.play()).catch(failed);
|
|
88
|
+
});
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (operation.abort.signal.aborted && error?.name !== 'AbortError') throw cancelled();
|
|
91
|
+
throw error;
|
|
92
|
+
} finally {
|
|
93
|
+
signal?.removeEventListener('abort', cancel);
|
|
94
|
+
if (operation.url) this.g.URL.revokeObjectURL(operation.url);
|
|
95
|
+
if (this.current === operation) this.current = null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async stop() {
|
|
99
|
+
const operation = this.current;
|
|
100
|
+
if (!operation) return;
|
|
101
|
+
this.current = null;
|
|
102
|
+
operation.abort.abort();
|
|
103
|
+
operation.audio?.pause();
|
|
104
|
+
if (operation.url) {
|
|
105
|
+
this.g.URL.revokeObjectURL(operation.url);
|
|
106
|
+
operation.url = null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
pause() {
|
|
110
|
+
if (!this.current?.audio || this.current.audio.paused) return false;
|
|
111
|
+
this.current.audio.pause();
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
async resume() {
|
|
115
|
+
if (!this.current?.audio || !this.current.audio.paused) return false;
|
|
116
|
+
await this.current.audio.play();
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
const CHANNEL = '/api';
|
|
3
|
+
const cancelled = () => Object.assign(new Error('Speech was cancelled.'), { name: 'AbortError' });
|
|
4
|
+
|
|
5
|
+
/** Browser transport for host-local macOS say; it never claims browser-local audio.
|
|
6
|
+
* rpc is ctx.connection.rpc. The long-running speak request is intentionally
|
|
7
|
+
* abortable, so a closed browser transport cancels the host process as well.
|
|
8
|
+
*/
|
|
9
|
+
export class SayClientEngine {
|
|
10
|
+
constructor({ rpc }) {
|
|
11
|
+
this.rpc = rpc;
|
|
12
|
+
this.clientId = globalThis.crypto.randomUUID();
|
|
13
|
+
this.current = null;
|
|
14
|
+
}
|
|
15
|
+
async request(endpoint, payload = {}, signal) {
|
|
16
|
+
const result = await this.rpc.call(CHANNEL, `dsh-live-voice/${endpoint}`, payload, signal);
|
|
17
|
+
if (!result?.ok) {
|
|
18
|
+
if (signal?.aborted || result?.error?.code === 'cancelled') throw cancelled();
|
|
19
|
+
throw Object.assign(new Error(result?.error?.message || 'Host speech is unavailable.'), {
|
|
20
|
+
code: result?.error?.code,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return result.value;
|
|
24
|
+
}
|
|
25
|
+
async capability() {
|
|
26
|
+
try {
|
|
27
|
+
return { ...(await this.request('capabilities')), local: true, location: 'host' };
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return {
|
|
30
|
+
supported: false,
|
|
31
|
+
local: true,
|
|
32
|
+
location: 'host',
|
|
33
|
+
reason: `macOS say connection failed: ${error.message}`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async speak(text, { voice, rate = 1, signal } = {}) {
|
|
38
|
+
if (typeof text !== 'string') throw new TypeError('Speech text must be a string.');
|
|
39
|
+
if (!Number.isFinite(rate) || rate < 0.1 || rate > 10)
|
|
40
|
+
throw new RangeError('Speech rate must be between 0.1 and 10.');
|
|
41
|
+
if (signal?.aborted) throw cancelled();
|
|
42
|
+
const previous = this.current;
|
|
43
|
+
previous?.abort.abort();
|
|
44
|
+
const operation = { operationId: globalThis.crypto.randomUUID(), abort: new AbortController() };
|
|
45
|
+
this.current = operation;
|
|
46
|
+
const cancel = () => operation.abort.abort();
|
|
47
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
48
|
+
if (signal?.aborted) cancel();
|
|
49
|
+
try {
|
|
50
|
+
if (!text.trim()) return;
|
|
51
|
+
await this.request(
|
|
52
|
+
'speak',
|
|
53
|
+
{
|
|
54
|
+
clientId: this.clientId,
|
|
55
|
+
operationId: operation.operationId,
|
|
56
|
+
text,
|
|
57
|
+
...(voice === undefined ? {} : { voice }),
|
|
58
|
+
rate: Math.round(rate * 175),
|
|
59
|
+
},
|
|
60
|
+
operation.abort.signal,
|
|
61
|
+
);
|
|
62
|
+
if (operation.abort.signal.aborted) throw cancelled();
|
|
63
|
+
} finally {
|
|
64
|
+
signal?.removeEventListener('abort', cancel);
|
|
65
|
+
if (this.current === operation) this.current = null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async stop() {
|
|
69
|
+
const operation = this.current;
|
|
70
|
+
if (!operation) return;
|
|
71
|
+
this.current = null;
|
|
72
|
+
// Send ownership-scoped stop as well as cancelling the open request. Either
|
|
73
|
+
// arrival order is safe; an obsolete operation cannot stop its successor.
|
|
74
|
+
const stopping = this.request('stop', {
|
|
75
|
+
clientId: this.clientId,
|
|
76
|
+
operationId: operation.operationId,
|
|
77
|
+
});
|
|
78
|
+
operation.abort.abort();
|
|
79
|
+
await stopping;
|
|
80
|
+
}
|
|
81
|
+
async control(endpoint) {
|
|
82
|
+
const operation = this.current;
|
|
83
|
+
if (!operation) return false;
|
|
84
|
+
const value = await this.request(endpoint, {
|
|
85
|
+
clientId: this.clientId,
|
|
86
|
+
operationId: operation.operationId,
|
|
87
|
+
});
|
|
88
|
+
return value.applied;
|
|
89
|
+
}
|
|
90
|
+
pause() {
|
|
91
|
+
return this.control('pause');
|
|
92
|
+
}
|
|
93
|
+
resume() {
|
|
94
|
+
return this.control('resume');
|
|
95
|
+
}
|
|
96
|
+
}
|