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.
@@ -0,0 +1,271 @@
1
+ // @ts-nocheck
2
+ import { spawn as nodeSpawn } from 'node:child_process';
3
+ import * as nodeFs from 'node:fs/promises';
4
+ import { constants } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ function failure(message, code, cause) {
9
+ return Object.assign(new Error(message, cause === undefined ? undefined : { cause }), { code });
10
+ }
11
+ function aborted(reason) {
12
+ return Object.assign(
13
+ new Error('Speech cancelled', reason === undefined ? undefined : { cause: reason }),
14
+ {
15
+ name: 'AbortError',
16
+ code: 'ABORT_ERR',
17
+ },
18
+ );
19
+ }
20
+
21
+ /** Local macOS adapter. No transcript is placed in argv or diagnostic messages.
22
+ * fs is the node:fs/promises interface; spawn returns a Node ChildProcess.
23
+ * A replacement cancels all earlier requests, and waits for their cleanup.
24
+ */
25
+ export class SayEngine {
26
+ #spawn;
27
+ #fs;
28
+ #platform;
29
+ #tempRoot;
30
+ #killAfterMs;
31
+ #closeAfterMs;
32
+ #tail = Promise.resolve();
33
+ #requests = new Set();
34
+ #active = null;
35
+ #unclosed = null;
36
+ #state = 'idle';
37
+ #lastError = null;
38
+
39
+ constructor({
40
+ spawn = nodeSpawn,
41
+ fs = nodeFs,
42
+ platform = process.platform,
43
+ tempRoot = tmpdir(),
44
+ killAfterMs = 250,
45
+ closeAfterMs = 1000,
46
+ } = {}) {
47
+ for (const value of [killAfterMs, closeAfterMs]) {
48
+ if (!Number.isFinite(value) || value < 0 || value > 2147483647) {
49
+ throw new TypeError('Cancellation deadlines must be finite nonnegative milliseconds');
50
+ }
51
+ }
52
+ this.#spawn = spawn;
53
+ this.#fs = fs;
54
+ this.#platform = platform;
55
+ this.#tempRoot = tempRoot;
56
+ this.#killAfterMs = killAfterMs;
57
+ this.#closeAfterMs = closeAfterMs;
58
+ }
59
+
60
+ get state() {
61
+ return this.#state;
62
+ }
63
+ get lastError() {
64
+ return this.#lastError;
65
+ }
66
+
67
+ /** Checks the host executable, not browser support or installed voices. */
68
+ async getCapabilities() {
69
+ if (this.#platform !== 'darwin') {
70
+ return { supported: false, pause: false, resume: false, reason: 'unsupported-platform' };
71
+ }
72
+ try {
73
+ await this.#fs.access('/usr/bin/say', constants.X_OK);
74
+ return { supported: true, pause: true, resume: true, reason: null };
75
+ } catch {
76
+ return { supported: false, pause: false, resume: false, reason: 'executable-unavailable' };
77
+ }
78
+ }
79
+
80
+ speak(text, { voice, rate, signal } = {}) {
81
+ if (typeof text !== 'string') return Promise.reject(new TypeError('text must be a string'));
82
+ if (
83
+ voice !== undefined &&
84
+ (typeof voice !== 'string' || !voice.trim() || voice.includes('\0'))
85
+ ) {
86
+ return Promise.reject(new TypeError('voice must be a nonempty string without NUL'));
87
+ }
88
+ if (rate !== undefined && (!Number.isFinite(rate) || rate <= 0)) {
89
+ return Promise.reject(new TypeError('rate must be a positive finite number'));
90
+ }
91
+ if (
92
+ signal !== undefined &&
93
+ (signal === null ||
94
+ typeof signal.addEventListener !== 'function' ||
95
+ typeof signal.removeEventListener !== 'function' ||
96
+ typeof signal.aborted !== 'boolean')
97
+ ) {
98
+ return Promise.reject(new TypeError('signal must be an AbortSignal'));
99
+ }
100
+ for (const request of this.#requests) this.#cancel(request);
101
+ const request = { cancelled: false, reason: undefined, cancelChild: null };
102
+ const onAbort = () => this.#cancel(request, signal.reason);
103
+ this.#requests.add(request);
104
+ signal?.addEventListener('abort', onAbort, { once: true });
105
+ if (signal?.aborted) onAbort();
106
+ const result = this.#tail.then(() => this.#run(request, text, voice, rate));
107
+ const settled = result.finally(() => {
108
+ signal?.removeEventListener('abort', onAbort);
109
+ this.#requests.delete(request);
110
+ });
111
+ this.#tail = settled.catch(() => {});
112
+ return settled;
113
+ }
114
+
115
+ /** Resolves after queued requests and cleanup; rejects teardown failures. */
116
+ async stop() {
117
+ for (const request of this.#requests) this.#cancel(request);
118
+ const pending = this.#tail;
119
+ await pending;
120
+ if (this.#unclosed || this.#state === 'error') throw this.#lastError;
121
+ }
122
+
123
+ pause() {
124
+ return this.#control('speaking', 'paused', 'SIGSTOP');
125
+ }
126
+ resume() {
127
+ return this.#control('paused', 'speaking', 'SIGCONT');
128
+ }
129
+
130
+ #control(from, to, signal) {
131
+ if (
132
+ this.#platform !== 'darwin' ||
133
+ this.#state !== from ||
134
+ !this.#active?.child ||
135
+ this.#active.cancelled
136
+ )
137
+ return false;
138
+ try {
139
+ if (!this.#active.child.kill(signal))
140
+ throw failure('Unable to signal speech process', 'SAY_SIGNAL_FAILED');
141
+ this.#state = to;
142
+ return true;
143
+ } catch (error) {
144
+ this.#lastError = error;
145
+ return false;
146
+ }
147
+ }
148
+
149
+ #cancel(request, reason) {
150
+ if (request.cancelled) return;
151
+ request.cancelled = true;
152
+ request.reason = reason;
153
+ request.cancelChild?.();
154
+ }
155
+
156
+ async #run(request, text, voice, rate) {
157
+ let directory;
158
+ let error;
159
+ const check = () => {
160
+ if (request.cancelled) throw aborted(request.reason);
161
+ };
162
+ try {
163
+ check();
164
+ if (this.#unclosed)
165
+ throw failure('Previous speech process has not closed', 'SAY_PROCESS_UNCLOSED');
166
+ this.#active = request;
167
+ this.#state = 'preparing';
168
+ this.#lastError = null;
169
+ const capability = await this.getCapabilities();
170
+ check();
171
+ if (!capability.supported) throw failure('macOS say is unavailable', 'SAY_UNAVAILABLE');
172
+ directory = await this.#fs.mkdtemp(join(this.#tempRoot, 'dsh-live-voice-say-'));
173
+ check();
174
+ await this.#fs.chmod(directory, 0o700);
175
+ const file = join(directory, 'speech.txt');
176
+ await this.#fs.writeFile(file, text, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
177
+ await this.#fs.chmod(file, 0o600);
178
+ check();
179
+ const args = ['-f', file];
180
+ if (voice !== undefined) args.push('-v', voice);
181
+ if (rate !== undefined) args.push('-r', String(rate));
182
+ const child = this.#spawn('/usr/bin/say', args, { shell: false, stdio: 'ignore' });
183
+ request.child = child;
184
+ this.#state = 'speaking';
185
+ await this.#waitForClose(request, child);
186
+ check();
187
+ } catch (caught) {
188
+ error = caught;
189
+ } finally {
190
+ request.cancelChild = null;
191
+ if (directory) {
192
+ try {
193
+ await this.#fs.rm(directory, { recursive: true, force: true });
194
+ } catch (cleanupError) {
195
+ error = failure(
196
+ 'Speech temporary-file cleanup failed',
197
+ 'SAY_CLEANUP_FAILED',
198
+ error ? new AggregateError([error, cleanupError]) : cleanupError,
199
+ );
200
+ }
201
+ }
202
+ if (!error && request.cancelled) error = aborted(request.reason);
203
+ if (this.#active === request) this.#active = null;
204
+ if (error && error.name !== 'AbortError') {
205
+ this.#state = 'error';
206
+ this.#lastError = error;
207
+ } else if (!this.#unclosed && this.#state !== 'error') this.#state = 'idle';
208
+ }
209
+ if (error) throw error;
210
+ }
211
+
212
+ #waitForClose(request, child) {
213
+ return new Promise((resolve, reject) => {
214
+ let closed = false;
215
+ let processError;
216
+ let killTimer;
217
+ let closeTimer;
218
+ const send = (signal) => {
219
+ try {
220
+ if (!child.kill(signal))
221
+ processError ??= failure('Unable to signal speech process', 'SAY_SIGNAL_FAILED');
222
+ } catch (error) {
223
+ processError ??= error;
224
+ }
225
+ };
226
+ const onError = (error) => {
227
+ processError ??= error;
228
+ };
229
+ child.on('error', onError);
230
+ child.once('close', (code, signal) => {
231
+ closed = true;
232
+ clearTimeout(killTimer);
233
+ clearTimeout(closeTimer);
234
+ child.removeListener('error', onError);
235
+ if (this.#unclosed === child) this.#unclosed = null;
236
+ if (request.cancelled) reject(aborted(request.reason));
237
+ else if (processError) reject(processError);
238
+ else if (code !== 0)
239
+ reject(
240
+ failure('Speech process exited unsuccessfully', 'SAY_EXIT_FAILED', { code, signal }),
241
+ );
242
+ else resolve();
243
+ });
244
+ request.cancelChild = () => {
245
+ if (closed) return;
246
+ const paused = this.#state === 'paused';
247
+ this.#state = 'stopping';
248
+ if (paused) send('SIGCONT');
249
+ send('SIGTERM');
250
+ if (closed) return;
251
+ killTimer = setTimeout(() => {
252
+ send('SIGKILL');
253
+ if (closed) return;
254
+ closeTimer = setTimeout(() => {
255
+ if (closed) return;
256
+ // Never start another child while the timed-out one might still speak.
257
+ this.#unclosed = child;
258
+ reject(
259
+ failure(
260
+ 'Speech process did not close after cancellation',
261
+ 'SAY_STOP_TIMEOUT',
262
+ processError,
263
+ ),
264
+ );
265
+ }, this.#closeAfterMs);
266
+ }, this.#killAfterMs);
267
+ };
268
+ if (request.cancelled) request.cancelChild();
269
+ });
270
+ }
271
+ }
package/src/server.ts ADDED
@@ -0,0 +1,365 @@
1
+ // @ts-nocheck
2
+ import { SayEngine } from './engines/speaking/say.ts';
3
+ import {
4
+ WhisperHttpHost,
5
+ createWhisperConfigStore,
6
+ validateWhisperConfig,
7
+ } from './engines/recognition/whisper-http-host.ts';
8
+ import {
9
+ QwenHttpHost,
10
+ createQwenConfigStore,
11
+ validateQwenConfig,
12
+ } from './engines/qwen-http-host.ts';
13
+
14
+ export const name = 'dsh-live-voice';
15
+ export const inject = ['connection'];
16
+ export const SAY_CHANNEL = '/api/dsh-live-voice';
17
+ const ok = (value) => ({ ok: true, value });
18
+ const fail = (code, message) => ({ ok: false, error: { code, message, details: {} } });
19
+ const identity = (value) => typeof value === 'string' && /^[a-zA-Z0-9_-]{1,128}$/.test(value);
20
+
21
+ /** One local speaker owner. IDs isolate control races, not authenticated users:
22
+ * Connection owns browser authentication; its RPC handler exposes no user identity.
23
+ */
24
+ export function createSayHost({ engine = new SayEngine() } = {}) {
25
+ let active = null;
26
+ let disposed = false;
27
+ async function handle(endpoint, payload, signal) {
28
+ if (disposed) return fail('disposed', 'Speech service is closed.');
29
+ if (signal?.aborted) return fail('cancelled', 'Speech was cancelled.');
30
+ try {
31
+ if (endpoint === 'capabilities') return ok(await engine.getCapabilities());
32
+ if (!['speak', 'stop', 'pause', 'resume'].includes(endpoint))
33
+ return fail('not-found', 'Unknown speech endpoint.');
34
+ if (!payload || !identity(payload.clientId) || !identity(payload.operationId)) {
35
+ return fail('invalid-request', 'Valid client and operation IDs are required.');
36
+ }
37
+ const owns =
38
+ active?.clientId === payload.clientId && active?.operationId === payload.operationId;
39
+ if (endpoint !== 'speak') {
40
+ if (!owns) return ok({ applied: false });
41
+ const operation = active;
42
+ if (endpoint === 'stop') {
43
+ operation.abort.abort();
44
+ // Await this operation, never call a global stop after an await:
45
+ // a new request could already own the speaker by then.
46
+ await operation.done;
47
+ return ok({ applied: true });
48
+ }
49
+ return ok({ applied: Boolean(engine[endpoint]()) });
50
+ }
51
+ if (
52
+ typeof payload.text !== 'string' ||
53
+ !payload.text.trim() ||
54
+ payload.text.length > 100000 ||
55
+ payload.text.includes('\0')
56
+ )
57
+ return fail('invalid-request', 'Speech text must contain 1–100000 characters without NUL.');
58
+ if (
59
+ payload.voice !== undefined &&
60
+ (typeof payload.voice !== 'string' ||
61
+ !payload.voice.trim() ||
62
+ payload.voice.length > 200 ||
63
+ payload.voice.includes('\0'))
64
+ )
65
+ return fail('invalid-request', 'Invalid voice.');
66
+ if (!Number.isFinite(payload.rate) || payload.rate < 18 || payload.rate > 1750)
67
+ return fail('invalid-request', 'Invalid speech rate.');
68
+ if (active && active.clientId !== payload.clientId)
69
+ return fail('busy', 'Another voice client owns the host speaker.');
70
+ if (owns) return fail('duplicate-operation', 'The speech operation is already active.');
71
+ active?.abort.abort();
72
+ const operation = {
73
+ clientId: payload.clientId,
74
+ operationId: payload.operationId,
75
+ abort: new AbortController(),
76
+ done: null,
77
+ };
78
+ active = operation;
79
+ const cancel = () => operation.abort.abort();
80
+ signal?.addEventListener('abort', cancel, { once: true });
81
+ if (signal?.aborted) cancel();
82
+ // Keep the authenticated RPC open until process close and file cleanup.
83
+ // DSH aborts its signal when the browser connection closes prematurely.
84
+ operation.done = Promise.resolve()
85
+ .then(() =>
86
+ engine.speak(payload.text, {
87
+ voice: payload.voice,
88
+ rate: payload.rate,
89
+ signal: operation.abort.signal,
90
+ }),
91
+ )
92
+ .then(
93
+ () => ok({ completed: true }),
94
+ (error) =>
95
+ error?.name === 'AbortError'
96
+ ? fail('cancelled', 'Speech was cancelled.')
97
+ : fail('speech-failed', 'Local speech failed.'),
98
+ );
99
+ try {
100
+ return await operation.done;
101
+ } finally {
102
+ signal?.removeEventListener('abort', cancel);
103
+ if (active === operation) active = null;
104
+ }
105
+ } catch {
106
+ return fail('speech-failed', 'Local speech service failed.');
107
+ }
108
+ }
109
+ async function dispose() {
110
+ disposed = true;
111
+ active?.abort.abort();
112
+ await engine.stop();
113
+ active = null;
114
+ }
115
+ return { handle, dispose };
116
+ }
117
+
118
+ export function apply(
119
+ ctx,
120
+ {
121
+ whisperStore = createWhisperConfigStore(),
122
+ whisperFetch = globalThis.fetch,
123
+ qwenStore = createQwenConfigStore(),
124
+ qwenFetch = globalThis.fetch,
125
+ } = {},
126
+ ) {
127
+ const host = createSayHost();
128
+ const whisper = new WhisperHttpHost({ store: whisperStore, fetchImpl: whisperFetch });
129
+ const qwen = new QwenHttpHost({ store: qwenStore, fetchImpl: qwenFetch });
130
+ for (const endpoint of ['config', 'test']) {
131
+ const dispose = ctx.connection.fetch.register({
132
+ path: SAY_CHANNEL + '/whisper/' + endpoint,
133
+ methods: endpoint === 'config' ? ['GET', 'PUT'] : ['POST'],
134
+ requestBody: 'buffered',
135
+ fetch: async (request) => {
136
+ try {
137
+ if (request.method === 'GET') return Response.json(ok(await whisper.getConfig()));
138
+ let config;
139
+ try {
140
+ config = validateWhisperConfig(await request.json());
141
+ } catch (error) {
142
+ return Response.json(fail('invalid-config', error.message), { status: 400 });
143
+ }
144
+ const value =
145
+ endpoint === 'test'
146
+ ? await whisper.capability(request.signal, config)
147
+ : await whisper.replaceConfig(config);
148
+ return Response.json(ok(value));
149
+ } catch (error) {
150
+ return Response.json(
151
+ fail('config-failed', error.message || 'Whisper settings could not be saved.'),
152
+ { status: 500 },
153
+ );
154
+ }
155
+ },
156
+ });
157
+ ctx.effect(() => () => dispose(), 'dsh-live-voice: remove Whisper ' + endpoint + ' route');
158
+ }
159
+ ctx.effect(() => () => whisper.dispose(), 'dsh-live-voice: cancel Whisper requests');
160
+ const whisperCapability = ctx.connection.fetch.register({
161
+ path: SAY_CHANNEL + '/whisper/capabilities',
162
+ methods: ['GET'],
163
+ requestBody: 'buffered',
164
+ fetch: async (request) => Response.json(ok(await whisper.capability(request.signal))),
165
+ });
166
+ const whisperTranscribe = ctx.connection.fetch.register({
167
+ path: SAY_CHANNEL + '/whisper/transcribe',
168
+ methods: ['POST'],
169
+ requestBody: 'buffered',
170
+ fetch: async (request) => {
171
+ try {
172
+ const clientId = request.headers.get('x-dlv-client-id'),
173
+ operationId = request.headers.get('x-dlv-operation-id');
174
+ if (!identity(clientId) || !identity(operationId))
175
+ return Response.json(
176
+ fail('invalid-request', 'Valid client and operation IDs are required.'),
177
+ { status: 400 },
178
+ );
179
+ if (request.headers.get('content-type')?.split(';')[0] !== 'audio/wav')
180
+ return Response.json(
181
+ fail('invalid-audio', 'A mono 16 kHz PCM WAV recording is required.'),
182
+ { status: 400 },
183
+ );
184
+ const length = Number(request.headers.get('content-length') || 0);
185
+ if (length > whisper.maxBytes)
186
+ return Response.json(
187
+ fail('audio-too-large', 'The recording exceeds the configured Whisper limit.'),
188
+ { status: 413 },
189
+ );
190
+ const value = await whisper.transcribe(await request.arrayBuffer(), {
191
+ lang: request.headers.get('x-dlv-language') || 'auto',
192
+ signal: request.signal,
193
+ });
194
+ return Response.json(ok(value));
195
+ } catch (error) {
196
+ if (error?.name === 'AbortError')
197
+ return Response.json(fail('cancelled', 'Whisper transcription was cancelled.'), {
198
+ status: 499,
199
+ });
200
+ return Response.json(
201
+ fail('transcription-failed', error?.message || 'Whisper transcription failed.'),
202
+ { status: 502 },
203
+ );
204
+ }
205
+ },
206
+ });
207
+ ctx.effect(
208
+ () => async () => {
209
+ await whisperCapability();
210
+ await whisperTranscribe();
211
+ },
212
+ 'dsh-live-voice: remove Whisper routes',
213
+ );
214
+ for (const endpoint of ['config', 'test']) {
215
+ const dispose = ctx.connection.fetch.register({
216
+ path: SAY_CHANNEL + '/qwen/' + endpoint,
217
+ methods: endpoint === 'config' ? ['GET', 'PUT'] : ['POST'],
218
+ requestBody: 'buffered',
219
+ fetch: async (request) => {
220
+ try {
221
+ if (request.method === 'GET') return Response.json(ok(await qwen.getConfig()));
222
+ let config;
223
+ try {
224
+ config = validateQwenConfig(await request.json());
225
+ } catch (error) {
226
+ return Response.json(fail('invalid-config', error.message), { status: 400 });
227
+ }
228
+ const value =
229
+ endpoint === 'test'
230
+ ? await qwen.capability(request.signal, config)
231
+ : await qwen.replaceConfig(config);
232
+ return Response.json(ok(value));
233
+ } catch (error) {
234
+ return Response.json(
235
+ fail('config-failed', error.message || 'Qwen settings could not be saved.'),
236
+ { status: 500 },
237
+ );
238
+ }
239
+ },
240
+ });
241
+ ctx.effect(() => () => dispose(), 'dsh-live-voice: remove Qwen ' + endpoint + ' route');
242
+ }
243
+ ctx.effect(() => () => qwen.dispose(), 'dsh-live-voice: cancel Qwen requests');
244
+ const qwenCapability = ctx.connection.fetch.register({
245
+ path: SAY_CHANNEL + '/qwen/capabilities',
246
+ methods: ['GET'],
247
+ requestBody: 'buffered',
248
+ fetch: async (request) => {
249
+ const kind = new URL(request.url).searchParams.get('kind');
250
+ return Response.json(
251
+ ok(
252
+ await qwen.capability(
253
+ request.signal,
254
+ undefined,
255
+ kind === 'asr' || kind === 'tts' ? kind : 'both',
256
+ ),
257
+ ),
258
+ );
259
+ },
260
+ });
261
+ const qwenTranscribe = ctx.connection.fetch.register({
262
+ path: SAY_CHANNEL + '/qwen/transcribe',
263
+ methods: ['POST'],
264
+ requestBody: 'buffered',
265
+ fetch: async (request) => {
266
+ try {
267
+ const clientId = request.headers.get('x-dlv-client-id'),
268
+ operationId = request.headers.get('x-dlv-operation-id');
269
+ if (!identity(clientId) || !identity(operationId))
270
+ return Response.json(
271
+ fail('invalid-request', 'Valid client and operation IDs are required.'),
272
+ { status: 400 },
273
+ );
274
+ if (request.headers.get('content-type')?.split(';')[0] !== 'audio/wav')
275
+ return Response.json(
276
+ fail('invalid-audio', 'A mono 16 kHz PCM WAV recording is required.'),
277
+ { status: 400 },
278
+ );
279
+ const length = Number(request.headers.get('content-length') || 0);
280
+ if (length > qwen.maxBytes)
281
+ return Response.json(
282
+ fail('audio-too-large', 'The recording exceeds the configured Qwen limit.'),
283
+ { status: 413 },
284
+ );
285
+ return Response.json(
286
+ ok(
287
+ await qwen.transcribe(await request.arrayBuffer(), {
288
+ lang: request.headers.get('x-dlv-language') || 'pt-BR',
289
+ signal: request.signal,
290
+ }),
291
+ ),
292
+ );
293
+ } catch (error) {
294
+ if (error?.name === 'AbortError')
295
+ return Response.json(fail('cancelled', 'Qwen transcription was cancelled.'), {
296
+ status: 499,
297
+ });
298
+ return Response.json(
299
+ fail('transcription-failed', error?.message || 'Qwen transcription failed.'),
300
+ { status: 502 },
301
+ );
302
+ }
303
+ },
304
+ });
305
+ const qwenSpeech = ctx.connection.fetch.register({
306
+ path: SAY_CHANNEL + '/qwen/speech',
307
+ methods: ['POST'],
308
+ requestBody: 'buffered',
309
+ fetch: async (request) => {
310
+ try {
311
+ const body = await request.json();
312
+ const bytes = await qwen.synthesize(body?.text, {
313
+ lang: body?.lang || 'pt-BR',
314
+ voice: body?.voice,
315
+ signal: request.signal,
316
+ });
317
+ return new Response(bytes, {
318
+ status: 200,
319
+ headers: { 'content-type': 'audio/wav', 'cache-control': 'no-store' },
320
+ });
321
+ } catch (error) {
322
+ if (error?.name === 'AbortError')
323
+ return Response.json(fail('cancelled', 'Qwen synthesis was cancelled.'), { status: 499 });
324
+ return Response.json(fail('synthesis-failed', error?.message || 'Qwen synthesis failed.'), {
325
+ status: 502,
326
+ });
327
+ }
328
+ },
329
+ });
330
+ ctx.effect(
331
+ () => async () => {
332
+ await qwenCapability();
333
+ await qwenTranscribe();
334
+ await qwenSpeech();
335
+ },
336
+ 'dsh-live-voice: remove Qwen routes',
337
+ );
338
+ // Existing /api carrier preserves DSH authentication and disconnect signals.
339
+ for (const endpoint of ['capabilities', 'speak', 'stop', 'pause', 'resume']) {
340
+ const dispose = ctx.connection.fetch.register({
341
+ path: `${SAY_CHANNEL}/${endpoint}`,
342
+ methods: ['POST'],
343
+ requestBody: 'buffered',
344
+ fetch: async (request) => {
345
+ let body;
346
+ try {
347
+ body = await request.json();
348
+ } catch {
349
+ return new Response('Invalid JSON', { status: 400 });
350
+ }
351
+ if (
352
+ body?.type !== 'client-request' ||
353
+ typeof body.rpcId !== 'string' ||
354
+ body.rpcId.length > 128 ||
355
+ body.method !== `dsh-live-voice/${endpoint}`
356
+ )
357
+ return new Response('Invalid RPC envelope', { status: 400 });
358
+ const result = await host.handle(endpoint, body.payload, request.signal);
359
+ return Response.json({ type: 'server-response', rpcId: body.rpcId, result });
360
+ },
361
+ });
362
+ ctx.effect(() => () => dispose(), `dsh-live-voice: remove ${endpoint} route`);
363
+ }
364
+ ctx.effect(() => () => host.dispose(), 'dsh-live-voice: stop host speech');
365
+ }
package/AGENTS.md DELETED
@@ -1,52 +0,0 @@
1
- # Agent Instructions
2
-
3
- ## Project identity and current stage
4
-
5
- - Project: **DSH Live Voice**; npm package: `dsh-live-voice`.
6
- - Current version: `0.0.1-developing`. This repository is a documentation-only development placeholder, not a functioning DSH plugin.
7
- - Read `README.md`, `HISTORY.md`, and `package.json` before making project changes.
8
- - Write repository documentation, code comments, and public package metadata in English. Keep the requested filename `HISTORY.md`.
9
-
10
- ## Product priorities
11
-
12
- - Lead with **local-first voice conversations**. Both STT and TTS should be able to run on the user’s machine. External providers are optional.
13
- - Do not claim full offline operation merely because voice processing is local; the DSH language model can be remote.
14
- - Coordinate input and output in one plugin. Preserve separate states for capture/recognition, synthesis/playback, and agent generation.
15
- - Keep pause, resume, and cancel distinct. Do not automatically resume obsolete speech just because the user becomes silent.
16
- - Plan for speaker mode with recognition gating and manual interruption, and headphone mode with open-microphone interruption. Exact policies remain undecided.
17
- - Capability detection must distinguish the DSH host from the browser/device environment. Do not assume OS detection proves feature support or that a browser shortcut is global.
18
- - Make capture, recognition, transmission, playback, and permission states understandable to users. Avoid recording or logging raw audio/transcripts by default.
19
-
20
- ## History is strictly append-only
21
-
22
- **Never edit, delete, reorder, reformat, or replace existing content in `HISTORY.md`.**
23
-
24
- When a history update is requested or a meaningful approved decision needs recording:
25
-
26
- 1. Read the existing file.
27
- 2. Preserve its entire existing content exactly.
28
- 3. Append a new English entry at the end using the actual local date (`YYYY-MM-DD`) and a descriptive heading.
29
- 4. Explain what changed and why; distinguish proposals, decisions, and completed implementation.
30
- 5. If correcting or reversing an earlier statement, reference it in the new entry rather than modifying it.
31
-
32
- Do not invent approvals, dates, completed work, or releases. Do not add secrets or sensitive data. Other documentation may be updated normally to reflect the current state; history preserves the evolution.
33
-
34
- ## Implementation discipline
35
-
36
- - Inspect actual DSH extension APIs before selecting integration points. Do not fabricate hooks, plugin manifests, install commands, or support guarantees.
37
- - Existing STT/TTS plugin reuse versus direct engine integration is unresolved.
38
- - Keep conversation policy separate from provider adapters and UI.
39
- - Do not implement features or add dependencies just to make the placeholder look complete.
40
- - Keep planned capabilities clearly labeled until implemented and validated.
41
- - Future audio work must account for echo, false interruptions, recognition latency, playback position, and stale asynchronous results after cancellation.
42
- - Third-party engines and model weights require their own license and platform checks. This repository uses GPL-3.0-only. Commercial use and paid redistribution are allowed subject to GPL obligations; do not describe the license as non-commercial. Check DSH and dependency license compatibility before integration.
43
-
44
- ## Packaging and authorization
45
-
46
- - Keep public metadata and README aligned with the local-first focus.
47
- - Do not invent author or repository URLs.
48
- - Use explicit `--tag developing` for this prerelease. Never silently promote it to `latest`.
49
- - `npm publish --dry-run --tag developing` validates packaging without publishing; it does not reserve a name or prove registry authorization.
50
- - No automated test suite or runnable plugin exists yet. Report validation honestly; do not describe a package dry run as a runtime test.
51
- - Commit, push, actual npm publication, and secondary package creation require explicit maintainer authorization.
52
- - The maintainer supplied `git@github.com:victorwads/dsh-live-voice.git` and authorized the first commit and push to `main`. npm publication requires separate authorization.