@moxxy/plugin-tts-local 0.28.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.
- package/LICENSE +21 -0
- package/dist/host-client.d.ts +76 -0
- package/dist/host-client.d.ts.map +1 -0
- package/dist/host-client.js +154 -0
- package/dist/host-client.js.map +1 -0
- package/dist/host-protocol.d.ts +84 -0
- package/dist/host-protocol.d.ts.map +1 -0
- package/dist/host-protocol.js +57 -0
- package/dist/host-protocol.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/local-tts.d.ts +87 -0
- package/dist/local-tts.d.ts.map +1 -0
- package/dist/local-tts.js +204 -0
- package/dist/local-tts.js.map +1 -0
- package/dist/platform.d.ts +34 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +93 -0
- package/dist/platform.js.map +1 -0
- package/dist/sidecar.d.ts +18 -0
- package/dist/sidecar.d.ts.map +1 -0
- package/dist/sidecar.js +86 -0
- package/dist/sidecar.js.map +1 -0
- package/dist/voices.d.ts +57 -0
- package/dist/voices.d.ts.map +1 -0
- package/dist/voices.js +71 -0
- package/dist/voices.js.map +1 -0
- package/dist/wav.d.ts +19 -0
- package/dist/wav.d.ts.map +1 -0
- package/dist/wav.js +62 -0
- package/dist/wav.js.map +1 -0
- package/package.json +67 -0
- package/src/host-client.test.ts +196 -0
- package/src/host-client.ts +205 -0
- package/src/host-protocol.test.ts +128 -0
- package/src/host-protocol.ts +127 -0
- package/src/index.ts +90 -0
- package/src/live.test.ts +56 -0
- package/src/local-tts.test.ts +195 -0
- package/src/local-tts.ts +268 -0
- package/src/platform.test.ts +77 -0
- package/src/platform.ts +106 -0
- package/src/sidecar.ts +97 -0
- package/src/voices.test.ts +106 -0
- package/src/voices.ts +135 -0
- package/src/wav.test.ts +57 -0
- package/src/wav.ts +65 -0
package/dist/wav.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal float32 → PCM16 mono WAV encoder.
|
|
3
|
+
*
|
|
4
|
+
* sherpa-onnx hands back `{ samples: Float32Array in [-1, 1], sampleRate }`.
|
|
5
|
+
* Read-aloud surfaces and channel voice replies want playable container bytes,
|
|
6
|
+
* so we wrap the samples in a canonical 44-byte RIFF/WAVE header + 16-bit PCM
|
|
7
|
+
* little-endian data. Kept dependency-free and self-contained (deliberately NOT
|
|
8
|
+
* imported from plugin-stt-whisper) and covered by golden tests — the header
|
|
9
|
+
* offsets are load-bearing.
|
|
10
|
+
*/
|
|
11
|
+
/** Bytes in the RIFF/WAVE header preceding the PCM data. */
|
|
12
|
+
export const WAV_HEADER_BYTES = 44;
|
|
13
|
+
function writeAscii(view, offset, text) {
|
|
14
|
+
for (let i = 0; i < text.length; i += 1)
|
|
15
|
+
view.setUint8(offset + i, text.charCodeAt(i));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Encode float samples as a mono 16-bit PCM WAV. `sampleRate` must be a
|
|
19
|
+
* positive integer (sherpa reports e.g. 22050). Samples are clamped to
|
|
20
|
+
* [-1, 1]; non-finite samples encode as silence.
|
|
21
|
+
*/
|
|
22
|
+
export function encodeWav(samples, sampleRate) {
|
|
23
|
+
if (!Number.isFinite(sampleRate) || sampleRate <= 0) {
|
|
24
|
+
throw new Error(`encodeWav: invalid sampleRate ${sampleRate}`);
|
|
25
|
+
}
|
|
26
|
+
const rate = Math.round(sampleRate);
|
|
27
|
+
const numSamples = samples.length;
|
|
28
|
+
const bytesPerSample = 2; // PCM16
|
|
29
|
+
const blockAlign = bytesPerSample; // mono
|
|
30
|
+
const byteRate = rate * blockAlign;
|
|
31
|
+
const dataSize = numSamples * bytesPerSample;
|
|
32
|
+
const buffer = new ArrayBuffer(WAV_HEADER_BYTES + dataSize);
|
|
33
|
+
const view = new DataView(buffer);
|
|
34
|
+
// RIFF chunk descriptor
|
|
35
|
+
writeAscii(view, 0, 'RIFF');
|
|
36
|
+
view.setUint32(4, 36 + dataSize, true); // ChunkSize = 4 + (8 + Subchunk1) + (8 + data)
|
|
37
|
+
writeAscii(view, 8, 'WAVE');
|
|
38
|
+
// fmt subchunk
|
|
39
|
+
writeAscii(view, 12, 'fmt ');
|
|
40
|
+
view.setUint32(16, 16, true); // Subchunk1Size for PCM
|
|
41
|
+
view.setUint16(20, 1, true); // AudioFormat = 1 (PCM)
|
|
42
|
+
view.setUint16(22, 1, true); // NumChannels = 1 (mono)
|
|
43
|
+
view.setUint32(24, rate, true); // SampleRate
|
|
44
|
+
view.setUint32(28, byteRate, true); // ByteRate
|
|
45
|
+
view.setUint16(32, blockAlign, true); // BlockAlign
|
|
46
|
+
view.setUint16(34, 16, true); // BitsPerSample
|
|
47
|
+
// data subchunk
|
|
48
|
+
writeAscii(view, 36, 'data');
|
|
49
|
+
view.setUint32(40, dataSize, true);
|
|
50
|
+
let offset = WAV_HEADER_BYTES;
|
|
51
|
+
for (let i = 0; i < numSamples; i += 1) {
|
|
52
|
+
const raw = samples[i];
|
|
53
|
+
const s = Number.isFinite(raw) ? Math.max(-1, Math.min(1, raw)) : 0;
|
|
54
|
+
// Asymmetric scaling so full-scale positive/negative map to the int16 range
|
|
55
|
+
// edges without overflow.
|
|
56
|
+
const int16 = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7fff);
|
|
57
|
+
view.setInt16(offset, int16, true);
|
|
58
|
+
offset += 2;
|
|
59
|
+
}
|
|
60
|
+
return new Uint8Array(buffer);
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=wav.js.map
|
package/dist/wav.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wav.js","sourceRoot":"","sources":["../src/wav.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@moxxy/plugin-tts-local",
|
|
3
|
+
"version": "0.28.0",
|
|
4
|
+
"description": "Fully local, on-device text-to-speech Synthesizer for moxxy. Runs Piper voices (English + Polish) via sherpa-onnx in a forked sidecar process; voice models download once on first use from sherpa-onnx's pinned releases (sha256-verified). No API key, no network at synthesis time. Plugs into the session SynthesizerRegistry — desktop read-aloud, the TUI, and channel voice replies consume it transparently.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"moxxy",
|
|
7
|
+
"agent",
|
|
8
|
+
"tts",
|
|
9
|
+
"text-to-speech",
|
|
10
|
+
"offline",
|
|
11
|
+
"on-device",
|
|
12
|
+
"piper",
|
|
13
|
+
"sherpa-onnx",
|
|
14
|
+
"synthesizer"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://moxxy.ai",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/moxxy-ai/moxxy/issues"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/moxxy-ai/moxxy.git",
|
|
23
|
+
"directory": "packages/plugin-tts-local"
|
|
24
|
+
},
|
|
25
|
+
"author": "Michal Makowski <michal.makowski97@gmail.com>",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"type": "module",
|
|
31
|
+
"main": "./dist/index.js",
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"import": "./dist/index.js"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"src"
|
|
42
|
+
],
|
|
43
|
+
"moxxy": {
|
|
44
|
+
"plugin": {
|
|
45
|
+
"entry": "./dist/index.js",
|
|
46
|
+
"kind": "synthesizer"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"sherpa-onnx-node": "^1.13.3",
|
|
51
|
+
"@moxxy/model-fetch": "0.1.0",
|
|
52
|
+
"@moxxy/sdk": "0.28.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/node": "^22.10.0",
|
|
56
|
+
"typescript": "^5.7.3",
|
|
57
|
+
"vitest": "^2.1.8",
|
|
58
|
+
"@moxxy/tsconfig": "0.0.0",
|
|
59
|
+
"@moxxy/vitest-preset": "0.0.0"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "tsc -p tsconfig.json",
|
|
63
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
64
|
+
"test": "vitest run",
|
|
65
|
+
"clean": "rm -rf dist .turbo"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { HostClient, type ChildHandle, type ForkLike } from './host-client.js';
|
|
3
|
+
import type { HostReply, HostRequest } from './host-protocol.js';
|
|
4
|
+
|
|
5
|
+
/** A controllable fake sidecar: records sent requests + kill, and lets the test
|
|
6
|
+
* drive `message`/`exit`/`error` back to the client. */
|
|
7
|
+
class FakeChild implements ChildHandle {
|
|
8
|
+
readonly sent: HostRequest[] = [];
|
|
9
|
+
killed = false;
|
|
10
|
+
private readonly listeners: Record<string, Array<(...a: unknown[]) => void>> = {
|
|
11
|
+
message: [],
|
|
12
|
+
exit: [],
|
|
13
|
+
error: [],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
send(message: unknown): boolean {
|
|
17
|
+
this.sent.push(message as HostRequest);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
on(event: string, listener: (...args: unknown[]) => void): this {
|
|
21
|
+
this.listeners[event]!.push(listener);
|
|
22
|
+
return this;
|
|
23
|
+
}
|
|
24
|
+
kill(): boolean {
|
|
25
|
+
this.killed = true;
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private emit(event: string, ...args: unknown[]): void {
|
|
30
|
+
for (const cb of [...this.listeners[event]!]) cb(...args);
|
|
31
|
+
}
|
|
32
|
+
/** Reply to the last request the client sent. */
|
|
33
|
+
replyOk(samples: number[], sampleRate = 22050): void {
|
|
34
|
+
this.replyOkTo(this.sent.at(-1)!.id, samples, sampleRate);
|
|
35
|
+
}
|
|
36
|
+
/** Reply to a specific request id (drives out-of-order correlation tests). */
|
|
37
|
+
replyOkTo(id: number, samples: number[], sampleRate = 22050): void {
|
|
38
|
+
this.emit('message', { id, ok: true, samples: new Float32Array(samples), sampleRate } satisfies HostReply);
|
|
39
|
+
}
|
|
40
|
+
replyErr(kind: 'init' | 'runtime', message: string): void {
|
|
41
|
+
const id = this.sent.at(-1)!.id;
|
|
42
|
+
this.emit('message', { id, ok: false, error: { kind, message } } satisfies HostReply);
|
|
43
|
+
}
|
|
44
|
+
crash(code = 1): void {
|
|
45
|
+
this.emit('exit', code, null);
|
|
46
|
+
}
|
|
47
|
+
errorOut(message: string): void {
|
|
48
|
+
this.emit('error', new Error(message));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A fork factory handing out a fixed list of children, tracking call count. */
|
|
53
|
+
function forkFactory(children: FakeChild[]): { fork: ForkLike; calls: () => number } {
|
|
54
|
+
let n = 0;
|
|
55
|
+
const fork: ForkLike = () => {
|
|
56
|
+
const child = children[n];
|
|
57
|
+
n += 1;
|
|
58
|
+
if (!child) throw new Error(`unexpected fork #${n}`);
|
|
59
|
+
return child;
|
|
60
|
+
};
|
|
61
|
+
return { fork, calls: () => n };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const REQ = {
|
|
65
|
+
voiceKey: '/m/model.onnx',
|
|
66
|
+
model: '/m/model.onnx',
|
|
67
|
+
tokens: '/m/tokens.txt',
|
|
68
|
+
dataDir: '/m/espeak-ng-data',
|
|
69
|
+
numThreads: 2,
|
|
70
|
+
provider: 'cpu',
|
|
71
|
+
text: 'hello',
|
|
72
|
+
sid: 0,
|
|
73
|
+
speed: 1,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
|
|
77
|
+
|
|
78
|
+
describe('HostClient', () => {
|
|
79
|
+
it('does not fork until the first synthesize (lazy spawn)', async () => {
|
|
80
|
+
const child = new FakeChild();
|
|
81
|
+
const { fork, calls } = forkFactory([child]);
|
|
82
|
+
const host = new HostClient({ hostPath: '/x/sidecar.js', env: {}, forkImpl: fork });
|
|
83
|
+
expect(calls()).toBe(0);
|
|
84
|
+
const p = host.synthesize(REQ);
|
|
85
|
+
await tick();
|
|
86
|
+
expect(calls()).toBe(1);
|
|
87
|
+
child.replyOk([0.5, -0.5], 16000);
|
|
88
|
+
await expect(p).resolves.toEqual({ samples: new Float32Array([0.5, -0.5]), sampleRate: 16000 });
|
|
89
|
+
host.shutdown();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('correlates replies by id and reuses one child across calls', async () => {
|
|
93
|
+
const child = new FakeChild();
|
|
94
|
+
const { fork, calls } = forkFactory([child]);
|
|
95
|
+
const host = new HostClient({ hostPath: '/x/sidecar.js', env: {}, forkImpl: fork });
|
|
96
|
+
|
|
97
|
+
const p1 = host.synthesize({ ...REQ, text: 'one' });
|
|
98
|
+
await tick();
|
|
99
|
+
const p2 = host.synthesize({ ...REQ, text: 'two' });
|
|
100
|
+
await tick();
|
|
101
|
+
// Two distinct ids were sent to the same child.
|
|
102
|
+
expect(child.sent.map((m) => m.id)).toEqual([1, 2]);
|
|
103
|
+
expect(calls()).toBe(1);
|
|
104
|
+
// Reply out of order — id correlation must still settle each promise.
|
|
105
|
+
child.replyOkTo(2, [2]);
|
|
106
|
+
child.replyOkTo(1, [1]);
|
|
107
|
+
await expect(p1).resolves.toMatchObject({ samples: new Float32Array([1]) });
|
|
108
|
+
await expect(p2).resolves.toMatchObject({ samples: new Float32Array([2]) });
|
|
109
|
+
host.shutdown();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('rejects a synthesis error reply', async () => {
|
|
113
|
+
const child = new FakeChild();
|
|
114
|
+
const { fork } = forkFactory([child]);
|
|
115
|
+
const host = new HostClient({ hostPath: '/x/sidecar.js', env: {}, forkImpl: fork });
|
|
116
|
+
const p = host.synthesize(REQ);
|
|
117
|
+
await tick();
|
|
118
|
+
child.replyErr('runtime', 'boom');
|
|
119
|
+
await expect(p).rejects.toThrow(/runtime error: boom/);
|
|
120
|
+
host.shutdown();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('restarts the sidecar once on a crash and completes on the fresh child', async () => {
|
|
124
|
+
const first = new FakeChild();
|
|
125
|
+
const second = new FakeChild();
|
|
126
|
+
const { fork, calls } = forkFactory([first, second]);
|
|
127
|
+
const host = new HostClient({ hostPath: '/x/sidecar.js', env: {}, forkImpl: fork });
|
|
128
|
+
|
|
129
|
+
const p = host.synthesize(REQ);
|
|
130
|
+
await tick();
|
|
131
|
+
expect(calls()).toBe(1);
|
|
132
|
+
first.crash(139); // dies with the request in flight
|
|
133
|
+
await tick();
|
|
134
|
+
// A fresh child was spawned for the retry.
|
|
135
|
+
expect(calls()).toBe(2);
|
|
136
|
+
second.replyOk([0.25], 8000);
|
|
137
|
+
await expect(p).resolves.toEqual({ samples: new Float32Array([0.25]), sampleRate: 8000 });
|
|
138
|
+
host.shutdown();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('gives up after a second consecutive crash', async () => {
|
|
142
|
+
const first = new FakeChild();
|
|
143
|
+
const second = new FakeChild();
|
|
144
|
+
const { fork } = forkFactory([first, second]);
|
|
145
|
+
const host = new HostClient({ hostPath: '/x/sidecar.js', env: {}, forkImpl: fork });
|
|
146
|
+
const p = host.synthesize(REQ);
|
|
147
|
+
await tick();
|
|
148
|
+
first.crash();
|
|
149
|
+
await tick();
|
|
150
|
+
second.crash();
|
|
151
|
+
await expect(p).rejects.toThrow(/sidecar exited/);
|
|
152
|
+
host.shutdown();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('treats a spawn error as a crash', async () => {
|
|
156
|
+
const child = new FakeChild();
|
|
157
|
+
const second = new FakeChild();
|
|
158
|
+
const { fork } = forkFactory([child, second]);
|
|
159
|
+
const host = new HostClient({ hostPath: '/x/sidecar.js', env: {}, forkImpl: fork });
|
|
160
|
+
const p = host.synthesize(REQ);
|
|
161
|
+
await tick();
|
|
162
|
+
child.errorOut('ENOENT node');
|
|
163
|
+
await tick();
|
|
164
|
+
second.replyOk([1]);
|
|
165
|
+
await expect(p).resolves.toMatchObject({ samples: new Float32Array([1]) });
|
|
166
|
+
host.shutdown();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('times out a hung synthesis and kills the child', async () => {
|
|
170
|
+
const child = new FakeChild();
|
|
171
|
+
const { fork } = forkFactory([child]);
|
|
172
|
+
const host = new HostClient({
|
|
173
|
+
hostPath: '/x/sidecar.js',
|
|
174
|
+
env: {},
|
|
175
|
+
forkImpl: fork,
|
|
176
|
+
requestTimeoutMs: 10,
|
|
177
|
+
});
|
|
178
|
+
const p = host.synthesize(REQ);
|
|
179
|
+
await expect(p).rejects.toThrow(/timed out/);
|
|
180
|
+
expect(child.killed).toBe(true);
|
|
181
|
+
host.shutdown();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('shutdown kills the child and rejects in-flight requests', async () => {
|
|
185
|
+
const child = new FakeChild();
|
|
186
|
+
const { fork } = forkFactory([child]);
|
|
187
|
+
const host = new HostClient({ hostPath: '/x/sidecar.js', env: {}, forkImpl: fork });
|
|
188
|
+
const p = host.synthesize(REQ);
|
|
189
|
+
await tick();
|
|
190
|
+
host.shutdown();
|
|
191
|
+
expect(child.killed).toBe(true);
|
|
192
|
+
await expect(p).rejects.toThrow(/shut down/);
|
|
193
|
+
// Further calls fail fast.
|
|
194
|
+
await expect(host.synthesize(REQ)).rejects.toThrow(/shut down/);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parent-side manager for the sherpa sidecar: lazy-spawn on first synthesize,
|
|
3
|
+
* a correlated request/reply protocol over the fork IPC channel, restart-once
|
|
4
|
+
* on a crash, and a clean shutdown. The `fork` itself is injectable (a
|
|
5
|
+
* {@link ForkLike}) so tests drive the whole protocol against a fake child with
|
|
6
|
+
* no real process.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { fork } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
import type { HostReply, HostRequest } from './host-protocol.js';
|
|
12
|
+
|
|
13
|
+
/** The minimal child-process surface the client drives — satisfied by a real
|
|
14
|
+
* `ChildProcess` and by a test fake. */
|
|
15
|
+
export interface ChildHandle {
|
|
16
|
+
send(message: unknown): boolean;
|
|
17
|
+
on(event: 'message', listener: (message: unknown) => void): this;
|
|
18
|
+
on(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
|
|
19
|
+
on(event: 'error', listener: (err: Error) => void): this;
|
|
20
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Fork the sidecar with the given env overrides. Injectable for tests. */
|
|
24
|
+
export type ForkLike = (modulePath: string, env: NodeJS.ProcessEnv) => ChildHandle;
|
|
25
|
+
|
|
26
|
+
/** Default fork: advanced serialization (Float32Array round-trip), an IPC
|
|
27
|
+
* channel, and inherited std streams so sherpa's own diagnostics reach the
|
|
28
|
+
* runner log. `env` already carries the platform loader-path var. */
|
|
29
|
+
export const defaultFork: ForkLike = (modulePath, env) =>
|
|
30
|
+
fork(modulePath, [], {
|
|
31
|
+
serialization: 'advanced',
|
|
32
|
+
env,
|
|
33
|
+
execArgv: [],
|
|
34
|
+
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
|
35
|
+
}) as unknown as ChildHandle;
|
|
36
|
+
|
|
37
|
+
export interface HostClientOptions {
|
|
38
|
+
/** Absolute path to the built sidecar (`dist/sidecar.js`). */
|
|
39
|
+
readonly hostPath: string;
|
|
40
|
+
/** Env the child is forked with (platform loader path merged over process.env). */
|
|
41
|
+
readonly env: NodeJS.ProcessEnv;
|
|
42
|
+
/** Injected fork (tests). Defaults to {@link defaultFork}. */
|
|
43
|
+
readonly forkImpl?: ForkLike;
|
|
44
|
+
/** Per-request deadline. Default 120s (a long read-aloud on CPU). */
|
|
45
|
+
readonly requestTimeoutMs?: number;
|
|
46
|
+
/** Optional diagnostic sink. */
|
|
47
|
+
readonly log?: (msg: string) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The slice of a host the synthesizer depends on — lets tests swap a fake. */
|
|
51
|
+
export interface HostClientLike {
|
|
52
|
+
synthesize(
|
|
53
|
+
req: Omit<HostRequest, 'id' | 'type'>,
|
|
54
|
+
): Promise<{ samples: Float32Array; sampleRate: number }>;
|
|
55
|
+
shutdown(): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Raised when the sidecar dies with a request in flight; drives the one retry. */
|
|
59
|
+
export class HostCrashError extends Error {
|
|
60
|
+
constructor(message: string) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = 'HostCrashError';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Pending {
|
|
67
|
+
readonly resolve: (r: { samples: Float32Array; sampleRate: number }) => void;
|
|
68
|
+
readonly reject: (err: Error) => void;
|
|
69
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
73
|
+
|
|
74
|
+
export class HostClient implements HostClientLike {
|
|
75
|
+
private readonly hostPath: string;
|
|
76
|
+
private readonly env: NodeJS.ProcessEnv;
|
|
77
|
+
private readonly forkImpl: ForkLike;
|
|
78
|
+
private readonly timeoutMs: number;
|
|
79
|
+
private readonly log: (msg: string) => void;
|
|
80
|
+
|
|
81
|
+
private child: ChildHandle | null = null;
|
|
82
|
+
private readonly pending = new Map<number, Pending>();
|
|
83
|
+
private nextId = 1;
|
|
84
|
+
private disposed = false;
|
|
85
|
+
|
|
86
|
+
constructor(opts: HostClientOptions) {
|
|
87
|
+
this.hostPath = opts.hostPath;
|
|
88
|
+
this.env = opts.env;
|
|
89
|
+
this.forkImpl = opts.forkImpl ?? defaultFork;
|
|
90
|
+
this.timeoutMs = opts.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
91
|
+
this.log = opts.log ?? (() => {});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Synthesize, restarting the sidecar ONCE if it crashes mid-request. */
|
|
95
|
+
async synthesize(
|
|
96
|
+
req: Omit<HostRequest, 'id' | 'type'>,
|
|
97
|
+
): Promise<{ samples: Float32Array; sampleRate: number }> {
|
|
98
|
+
try {
|
|
99
|
+
return await this.send(req);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err instanceof HostCrashError && !this.disposed) {
|
|
102
|
+
this.log(`tts-local: sherpa sidecar crashed (${err.message}) — restarting once`);
|
|
103
|
+
return await this.send(req); // a second crash propagates
|
|
104
|
+
}
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private send(
|
|
110
|
+
req: Omit<HostRequest, 'id' | 'type'>,
|
|
111
|
+
): Promise<{ samples: Float32Array; sampleRate: number }> {
|
|
112
|
+
if (this.disposed) return Promise.reject(new Error('tts-local host is shut down'));
|
|
113
|
+
const child = this.ensureChild();
|
|
114
|
+
const id = this.nextId++;
|
|
115
|
+
const message: HostRequest = { ...req, id, type: 'synthesize' };
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
const timer = setTimeout(() => {
|
|
118
|
+
this.pending.delete(id);
|
|
119
|
+
// A hung synthesis is unrecoverable for this child — reset it so the
|
|
120
|
+
// next call starts fresh.
|
|
121
|
+
this.killChild();
|
|
122
|
+
reject(new Error(`tts-local synthesis timed out after ${this.timeoutMs} ms`));
|
|
123
|
+
}, this.timeoutMs);
|
|
124
|
+
timer.unref?.();
|
|
125
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
126
|
+
try {
|
|
127
|
+
child.send(message);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
this.settle(id, () =>
|
|
130
|
+
reject(err instanceof Error ? err : new Error(String(err))),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private ensureChild(): ChildHandle {
|
|
137
|
+
if (this.child) return this.child;
|
|
138
|
+
const child = this.forkImpl(this.hostPath, this.env);
|
|
139
|
+
this.child = child;
|
|
140
|
+
child.on('message', (msg: unknown) => this.onMessage(msg));
|
|
141
|
+
child.on('exit', (code, signal) => this.onExit(code, signal));
|
|
142
|
+
child.on('error', (err) => this.onError(err));
|
|
143
|
+
return child;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private onMessage(msg: unknown): void {
|
|
147
|
+
const reply = msg as HostReply;
|
|
148
|
+
if (!reply || typeof reply !== 'object' || typeof reply.id !== 'number') return;
|
|
149
|
+
const p = this.pending.get(reply.id);
|
|
150
|
+
if (!p) return;
|
|
151
|
+
this.settle(reply.id, () => {
|
|
152
|
+
if (reply.ok) p.resolve({ samples: reply.samples, sampleRate: reply.sampleRate });
|
|
153
|
+
else p.reject(new Error(`tts-local sidecar ${reply.error.kind} error: ${reply.error.message}`));
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private onExit(code: number | null, signal: NodeJS.Signals | null): void {
|
|
158
|
+
this.child = null;
|
|
159
|
+
if (this.pending.size === 0) return;
|
|
160
|
+
const why = `sidecar exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})`;
|
|
161
|
+
this.rejectAll(new HostCrashError(why));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private onError(err: Error): void {
|
|
165
|
+
this.child = null;
|
|
166
|
+
this.rejectAll(new HostCrashError(`sidecar spawn/runtime error: ${err.message}`));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Resolve/reject one pending request, clearing its timer and map entry. */
|
|
170
|
+
private settle(id: number, run: () => void): void {
|
|
171
|
+
const p = this.pending.get(id);
|
|
172
|
+
if (!p) return;
|
|
173
|
+
if (p.timer) clearTimeout(p.timer);
|
|
174
|
+
this.pending.delete(id);
|
|
175
|
+
run();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private rejectAll(err: Error): void {
|
|
179
|
+
for (const [id, p] of this.pending) {
|
|
180
|
+
if (p.timer) clearTimeout(p.timer);
|
|
181
|
+
this.pending.delete(id);
|
|
182
|
+
p.reject(err);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private killChild(): void {
|
|
187
|
+
const child = this.child;
|
|
188
|
+
this.child = null;
|
|
189
|
+
if (child) {
|
|
190
|
+
try {
|
|
191
|
+
child.kill('SIGKILL');
|
|
192
|
+
} catch {
|
|
193
|
+
/* already gone */
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Kill the sidecar and fail any in-flight requests. Idempotent. */
|
|
199
|
+
shutdown(): void {
|
|
200
|
+
if (this.disposed) return;
|
|
201
|
+
this.disposed = true;
|
|
202
|
+
this.rejectAll(new Error('tts-local host shut down'));
|
|
203
|
+
this.killChild();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createMessageHandler,
|
|
4
|
+
type HostRequest,
|
|
5
|
+
type SherpaModule,
|
|
6
|
+
type SherpaOfflineTts,
|
|
7
|
+
} from './host-protocol.js';
|
|
8
|
+
|
|
9
|
+
function req(over: Partial<HostRequest> = {}): HostRequest {
|
|
10
|
+
return {
|
|
11
|
+
id: 1,
|
|
12
|
+
type: 'synthesize',
|
|
13
|
+
voiceKey: '/models/en/model.onnx',
|
|
14
|
+
model: '/models/en/model.onnx',
|
|
15
|
+
tokens: '/models/en/tokens.txt',
|
|
16
|
+
dataDir: '/models/en/espeak-ng-data',
|
|
17
|
+
numThreads: 2,
|
|
18
|
+
provider: 'cpu',
|
|
19
|
+
text: 'hello',
|
|
20
|
+
sid: 0,
|
|
21
|
+
speed: 1,
|
|
22
|
+
...over,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A fake sherpa module recording how many OfflineTts instances + generate
|
|
27
|
+
* calls it saw, with configurable behaviour. */
|
|
28
|
+
function fakeSherpa(opts: {
|
|
29
|
+
onGenerate?: (args: { text: string; sid: number; speed: number }) => {
|
|
30
|
+
samples: Float32Array;
|
|
31
|
+
sampleRate: number;
|
|
32
|
+
};
|
|
33
|
+
throwOnConstruct?: boolean;
|
|
34
|
+
throwOnGenerate?: boolean;
|
|
35
|
+
} = {}): { module: SherpaModule; constructed: number; generated: number; lastConfig: unknown } {
|
|
36
|
+
const state = { constructed: 0, generated: 0, lastConfig: undefined as unknown };
|
|
37
|
+
class FakeTts implements SherpaOfflineTts {
|
|
38
|
+
constructor(config: unknown) {
|
|
39
|
+
if (opts.throwOnConstruct) throw new Error('addon failed to load libonnxruntime.dylib');
|
|
40
|
+
state.constructed += 1;
|
|
41
|
+
state.lastConfig = config;
|
|
42
|
+
}
|
|
43
|
+
async generateAsync(args: { text: string; sid: number; speed: number }): Promise<{
|
|
44
|
+
samples: Float32Array;
|
|
45
|
+
sampleRate: number;
|
|
46
|
+
}> {
|
|
47
|
+
state.generated += 1;
|
|
48
|
+
if (opts.throwOnGenerate) throw new Error('generate blew up');
|
|
49
|
+
return opts.onGenerate?.(args) ?? { samples: new Float32Array([0.1, 0.2]), sampleRate: 22050 };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
module: { OfflineTts: FakeTts as unknown as SherpaModule['OfflineTts'] },
|
|
54
|
+
get constructed() {
|
|
55
|
+
return state.constructed;
|
|
56
|
+
},
|
|
57
|
+
get generated() {
|
|
58
|
+
return state.generated;
|
|
59
|
+
},
|
|
60
|
+
get lastConfig() {
|
|
61
|
+
return state.lastConfig;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('createMessageHandler', () => {
|
|
67
|
+
it('synthesizes and returns samples + sampleRate', async () => {
|
|
68
|
+
const s = fakeSherpa({ onGenerate: () => ({ samples: new Float32Array([1, -1]), sampleRate: 16000 }) });
|
|
69
|
+
const handle = createMessageHandler(() => s.module);
|
|
70
|
+
const reply = await handle(req({ id: 7, text: 'hi', speed: 1.25 }));
|
|
71
|
+
expect(reply).toMatchObject({ id: 7, ok: true, sampleRate: 16000 });
|
|
72
|
+
if (reply.ok) expect(Array.from(reply.samples)).toEqual([1, -1]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('passes the vits model config through to sherpa', async () => {
|
|
76
|
+
const s = fakeSherpa();
|
|
77
|
+
const handle = createMessageHandler(() => s.module);
|
|
78
|
+
await handle(req());
|
|
79
|
+
expect(s.lastConfig).toMatchObject({
|
|
80
|
+
model: {
|
|
81
|
+
vits: { model: '/models/en/model.onnx', tokens: '/models/en/tokens.txt', dataDir: '/models/en/espeak-ng-data' },
|
|
82
|
+
numThreads: 2,
|
|
83
|
+
provider: 'cpu',
|
|
84
|
+
},
|
|
85
|
+
maxNumSentences: 1,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('caches one OfflineTts per voiceKey (loads the model once)', async () => {
|
|
90
|
+
const s = fakeSherpa();
|
|
91
|
+
let loads = 0;
|
|
92
|
+
const handle = createMessageHandler(() => {
|
|
93
|
+
loads += 1;
|
|
94
|
+
return s.module;
|
|
95
|
+
});
|
|
96
|
+
await handle(req({ id: 1 }));
|
|
97
|
+
await handle(req({ id: 2 }));
|
|
98
|
+
await handle(req({ id: 3, voiceKey: '/models/pl/model.onnx', model: '/models/pl/model.onnx' }));
|
|
99
|
+
expect(loads).toBe(1); // module loaded once
|
|
100
|
+
expect(s.constructed).toBe(2); // one OfflineTts per distinct voiceKey
|
|
101
|
+
expect(s.generated).toBe(3);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('classifies a load/construct failure as an init error', async () => {
|
|
105
|
+
const s = fakeSherpa({ throwOnConstruct: true });
|
|
106
|
+
const handle = createMessageHandler(() => s.module);
|
|
107
|
+
const reply = await handle(req());
|
|
108
|
+
expect(reply.ok).toBe(false);
|
|
109
|
+
if (!reply.ok) expect(reply.error.kind).toBe('init');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('classifies a load-time throw as an init error', async () => {
|
|
113
|
+
const handle = createMessageHandler(() => {
|
|
114
|
+
throw new Error('cannot find module sherpa-onnx-node');
|
|
115
|
+
});
|
|
116
|
+
const reply = await handle(req());
|
|
117
|
+
expect(reply.ok).toBe(false);
|
|
118
|
+
if (!reply.ok) expect(reply.error.kind).toBe('init');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('classifies a synthesis failure as a runtime error', async () => {
|
|
122
|
+
const s = fakeSherpa({ throwOnGenerate: true });
|
|
123
|
+
const handle = createMessageHandler(() => s.module);
|
|
124
|
+
const reply = await handle(req());
|
|
125
|
+
expect(reply.ok).toBe(false);
|
|
126
|
+
if (!reply.ok) expect(reply.error.kind).toBe('runtime');
|
|
127
|
+
});
|
|
128
|
+
});
|