@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/src/local-tts.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `local-piper` Synthesizer: fully local, on-device text-to-speech.
|
|
3
|
+
*
|
|
4
|
+
* On the first synthesis for a voice it downloads + verifies that voice's Piper
|
|
5
|
+
* model (once, via @moxxy/model-fetch), then hands text to the sherpa sidecar
|
|
6
|
+
* ({@link HostClient}) and wraps the returned samples as WAV. No key, no network
|
|
7
|
+
* at synthesis time. Language routing sends `pl*` requests to the configured
|
|
8
|
+
* Polish voice; an explicit `opts.voice` (a catalog id) overrides everything.
|
|
9
|
+
*
|
|
10
|
+
* Every collaborator is injectable (`ensureModelImpl`, `hostFactory`, `log`,
|
|
11
|
+
* `modelsDir`, `fetchImpl`) so the whole flow is unit-testable without the
|
|
12
|
+
* native addon or a real download.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
import { MoxxyError, type SynthesizeOptions, type SynthesisResult, type Synthesizer } from '@moxxy/sdk';
|
|
19
|
+
import { moxxyPath } from '@moxxy/sdk/server';
|
|
20
|
+
import {
|
|
21
|
+
ensureModel,
|
|
22
|
+
type EnsureModelProgress,
|
|
23
|
+
type EnsureModelResult,
|
|
24
|
+
type FetchLike,
|
|
25
|
+
} from '@moxxy/model-fetch';
|
|
26
|
+
|
|
27
|
+
import { HostClient, type HostClientLike } from './host-client.js';
|
|
28
|
+
import { resolveSherpaLibDir, sherpaEnv, sherpaPlatformPackage } from './platform.js';
|
|
29
|
+
import {
|
|
30
|
+
DEFAULT_POLISH_VOICE_ID,
|
|
31
|
+
DEFAULT_VOICE_ID,
|
|
32
|
+
requireVoice,
|
|
33
|
+
routeVoice,
|
|
34
|
+
type VoiceEntry,
|
|
35
|
+
} from './voices.js';
|
|
36
|
+
import { encodeWav } from './wav.js';
|
|
37
|
+
|
|
38
|
+
/** The single registered synthesizer name — surfaces show this in `set_voice`. */
|
|
39
|
+
export const LOCAL_PIPER_SYNTHESIZER_NAME = 'local-piper';
|
|
40
|
+
|
|
41
|
+
/** Local speaking-rate bounds. Piper distorts badly outside this range. */
|
|
42
|
+
const MIN_SPEED = 0.5;
|
|
43
|
+
const MAX_SPEED = 2.0;
|
|
44
|
+
const DEFAULT_NUM_THREADS = 2;
|
|
45
|
+
|
|
46
|
+
/** Map a `SynthesizeOptions.rate` multiplier onto sherpa's `speed`, clamped.
|
|
47
|
+
* An absent / non-finite rate yields 1.0 (natural speed). */
|
|
48
|
+
export function clampSpeed(rate: number | undefined): number {
|
|
49
|
+
if (rate === undefined || !Number.isFinite(rate)) return 1.0;
|
|
50
|
+
return Math.min(MAX_SPEED, Math.max(MIN_SPEED, rate));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Resolved on-disk paths sherpa needs for one voice. */
|
|
54
|
+
export interface VoiceModelPaths {
|
|
55
|
+
readonly model: string;
|
|
56
|
+
readonly tokens: string;
|
|
57
|
+
readonly dataDir: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface LocalPiperOptions {
|
|
61
|
+
/** Default (non-Polish) voice id. Default `en_US-amy-medium`. */
|
|
62
|
+
readonly voice?: string;
|
|
63
|
+
/** Voice used for `pl*` language requests. Default `pl_PL-gosia-medium`. */
|
|
64
|
+
readonly polishVoice?: string;
|
|
65
|
+
/** Inference threads. Default 2. */
|
|
66
|
+
readonly numThreads?: number;
|
|
67
|
+
/** Root for downloaded voices. Default `~/.moxxy/models/tts` (MOXXY_HOME-aware). */
|
|
68
|
+
readonly modelsDir?: string;
|
|
69
|
+
/** sherpa compute provider. Default `cpu`. */
|
|
70
|
+
readonly provider?: string;
|
|
71
|
+
/** Absolute path to the built sidecar entry. Default: `sidecar.js` beside
|
|
72
|
+
* this module (i.e. `dist/sidecar.js` in a published build). Overridable so
|
|
73
|
+
* the source-run live test can point at the compiled sidecar. */
|
|
74
|
+
readonly sidecarPath?: string;
|
|
75
|
+
/** Injected model-ensure (tests). Defaults to @moxxy/model-fetch `ensureModel`. */
|
|
76
|
+
readonly ensureModelImpl?: typeof ensureModel;
|
|
77
|
+
/** Injected `fetch`, threaded into `ensureModel` (tests). */
|
|
78
|
+
readonly fetchImpl?: FetchLike;
|
|
79
|
+
/** Injected sidecar host factory (tests). Defaults to a real {@link HostClient}. */
|
|
80
|
+
readonly hostFactory?: () => HostClientLike;
|
|
81
|
+
/** Diagnostic log sink (download progress etc.). Defaults to stderr. */
|
|
82
|
+
readonly log?: (msg: string) => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Process-wide set of live synthesizers so the plugin's `onShutdown` hook can
|
|
86
|
+
* kill every sidecar it spawned. */
|
|
87
|
+
const ACTIVE_SYNTHS = new Set<LocalPiperSynthesizer>();
|
|
88
|
+
|
|
89
|
+
/** Shut down every live local synthesizer (kills their sidecars). */
|
|
90
|
+
export function shutdownLocalTts(): void {
|
|
91
|
+
for (const s of ACTIVE_SYNTHS) s.shutdown();
|
|
92
|
+
ACTIVE_SYNTHS.clear();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class LocalPiperSynthesizer implements Synthesizer {
|
|
96
|
+
readonly name = LOCAL_PIPER_SYNTHESIZER_NAME;
|
|
97
|
+
readonly mimeType = 'audio/wav';
|
|
98
|
+
|
|
99
|
+
private readonly voiceId: string;
|
|
100
|
+
private readonly polishVoiceId: string;
|
|
101
|
+
private readonly numThreads: number;
|
|
102
|
+
private readonly modelsDir: string;
|
|
103
|
+
private readonly provider: string;
|
|
104
|
+
private readonly sidecarPath: string | undefined;
|
|
105
|
+
private readonly ensureModelImpl: typeof ensureModel;
|
|
106
|
+
private readonly fetchImpl: FetchLike | undefined;
|
|
107
|
+
private readonly hostFactory: () => HostClientLike;
|
|
108
|
+
private readonly log: (msg: string) => void;
|
|
109
|
+
|
|
110
|
+
private host: HostClientLike | null = null;
|
|
111
|
+
/** In-flight per-voice download promises — de-dupes concurrent first-use. */
|
|
112
|
+
private readonly ensuring = new Map<string, Promise<EnsureModelResult>>();
|
|
113
|
+
|
|
114
|
+
constructor(opts: LocalPiperOptions = {}) {
|
|
115
|
+
// Validate configured voice ids up front so a bad config fails clearly at
|
|
116
|
+
// construction rather than on the first (possibly channel-triggered) call.
|
|
117
|
+
this.voiceId = requireVoice(opts.voice ?? DEFAULT_VOICE_ID, 'voice').id;
|
|
118
|
+
this.polishVoiceId = requireVoice(opts.polishVoice ?? DEFAULT_POLISH_VOICE_ID, 'polishVoice').id;
|
|
119
|
+
this.numThreads =
|
|
120
|
+
opts.numThreads && Number.isInteger(opts.numThreads) && opts.numThreads > 0
|
|
121
|
+
? opts.numThreads
|
|
122
|
+
: DEFAULT_NUM_THREADS;
|
|
123
|
+
this.modelsDir = opts.modelsDir ?? moxxyPath('models', 'tts');
|
|
124
|
+
this.provider = opts.provider ?? 'cpu';
|
|
125
|
+
this.sidecarPath = opts.sidecarPath;
|
|
126
|
+
this.ensureModelImpl = opts.ensureModelImpl ?? ensureModel;
|
|
127
|
+
this.fetchImpl = opts.fetchImpl;
|
|
128
|
+
this.hostFactory = opts.hostFactory ?? (() => this.defaultHost());
|
|
129
|
+
this.log = opts.log ?? ((msg) => process.stderr.write(`${msg}\n`));
|
|
130
|
+
ACTIVE_SYNTHS.add(this);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async synthesize(text: string, opts: SynthesizeOptions = {}): Promise<SynthesisResult> {
|
|
134
|
+
opts.signal?.throwIfAborted();
|
|
135
|
+
const voice = routeVoice({
|
|
136
|
+
...(opts.voice !== undefined ? { requestedVoice: opts.voice } : {}),
|
|
137
|
+
...(opts.language !== undefined ? { language: opts.language } : {}),
|
|
138
|
+
defaultVoice: this.voiceId,
|
|
139
|
+
polishVoice: this.polishVoiceId,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const paths = await this.ensureVoice(voice, opts.signal);
|
|
143
|
+
opts.signal?.throwIfAborted();
|
|
144
|
+
|
|
145
|
+
const host = this.getHost();
|
|
146
|
+
const { samples, sampleRate } = await host.synthesize({
|
|
147
|
+
voiceKey: paths.model,
|
|
148
|
+
model: paths.model,
|
|
149
|
+
tokens: paths.tokens,
|
|
150
|
+
dataDir: paths.dataDir,
|
|
151
|
+
numThreads: this.numThreads,
|
|
152
|
+
provider: this.provider,
|
|
153
|
+
text,
|
|
154
|
+
sid: 0,
|
|
155
|
+
speed: clampSpeed(opts.rate),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!samples || samples.length === 0) {
|
|
159
|
+
throw new MoxxyError({
|
|
160
|
+
code: 'INTERNAL',
|
|
161
|
+
message: 'Local TTS produced no audio samples.',
|
|
162
|
+
context: { voice: voice.id },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return { audio: encodeWav(samples, sampleRate), mimeType: this.mimeType };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Kill this synthesizer's sidecar (if any) and drop registration. */
|
|
169
|
+
shutdown(): void {
|
|
170
|
+
this.host?.shutdown();
|
|
171
|
+
this.host = null;
|
|
172
|
+
ACTIVE_SYNTHS.delete(this);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private getHost(): HostClientLike {
|
|
176
|
+
this.host ??= this.hostFactory();
|
|
177
|
+
return this.host;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Resolve on-disk paths for `voice`, downloading + extracting it once. */
|
|
181
|
+
private async ensureVoice(voice: VoiceEntry, signal?: AbortSignal): Promise<VoiceModelPaths> {
|
|
182
|
+
const dir = path.join(this.modelsDir, voice.id);
|
|
183
|
+
let inflight = this.ensuring.get(voice.id);
|
|
184
|
+
if (!inflight) {
|
|
185
|
+
inflight = this.ensureModelImpl({
|
|
186
|
+
url: voice.url,
|
|
187
|
+
sha256: voice.sha256,
|
|
188
|
+
dir,
|
|
189
|
+
...(this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}),
|
|
190
|
+
...(signal ? { signal } : {}),
|
|
191
|
+
onProgress: this.makeProgressLogger(voice),
|
|
192
|
+
});
|
|
193
|
+
this.ensuring.set(voice.id, inflight);
|
|
194
|
+
// On failure, forget the promise so a later call retries the download.
|
|
195
|
+
inflight.catch(() => this.ensuring.delete(voice.id));
|
|
196
|
+
}
|
|
197
|
+
await inflight;
|
|
198
|
+
return this.voicePaths(voice, dir);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private voicePaths(voice: VoiceEntry, dir: string): VoiceModelPaths {
|
|
202
|
+
const root = path.join(dir, voice.archiveRootDir);
|
|
203
|
+
return {
|
|
204
|
+
model: path.join(root, voice.modelFile),
|
|
205
|
+
tokens: path.join(root, 'tokens.txt'),
|
|
206
|
+
dataDir: path.join(root, 'espeak-ng-data'),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A progress callback that logs a single start line then ~every-10% ticks,
|
|
211
|
+
* and an extraction line — but stays silent when the voice is already
|
|
212
|
+
* present (ensureModel emits only `done`). */
|
|
213
|
+
private makeProgressLogger(voice: VoiceEntry): (p: EnsureModelProgress) => void {
|
|
214
|
+
let started = false;
|
|
215
|
+
let extracting = false;
|
|
216
|
+
let lastPct = -1;
|
|
217
|
+
return (p) => {
|
|
218
|
+
if (p.phase === 'downloading') {
|
|
219
|
+
if (!started) {
|
|
220
|
+
started = true;
|
|
221
|
+
this.log(
|
|
222
|
+
`tts-local: first use — downloading ${voice.id} (~${voice.approxMb} MB) to ${this.modelsDir}, one-time`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (p.totalBytes > 0) {
|
|
226
|
+
const pct = Math.floor((p.receivedBytes / p.totalBytes) * 100);
|
|
227
|
+
if (pct >= lastPct + 10) {
|
|
228
|
+
lastPct = pct - (pct % 10);
|
|
229
|
+
this.log(`tts-local: downloading ${voice.id} … ${lastPct}%`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} else if (p.phase === 'extracting' && !extracting) {
|
|
233
|
+
extracting = true;
|
|
234
|
+
this.log(`tts-local: extracting ${voice.id} …`);
|
|
235
|
+
} else if (p.phase === 'done' && started) {
|
|
236
|
+
this.log(`tts-local: ${voice.id} ready.`);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Build a real sidecar-backed host, resolving the platform loader path.
|
|
242
|
+
* Fails clearly when no sherpa binary exists for this platform/arch. */
|
|
243
|
+
private defaultHost(): HostClientLike {
|
|
244
|
+
const libDir = resolveSherpaLibDir();
|
|
245
|
+
if (!libDir) {
|
|
246
|
+
const pkg = sherpaPlatformPackage();
|
|
247
|
+
throw new MoxxyError({
|
|
248
|
+
code: 'PLUGIN_LOAD_FAILED',
|
|
249
|
+
message: pkg
|
|
250
|
+
? `Local TTS could not load the sherpa-onnx binary (${pkg}). Reinstall @moxxy/plugin-tts-local so its platform dependency is fetched.`
|
|
251
|
+
: `Local TTS has no sherpa-onnx binary for ${process.platform}-${process.arch}.`,
|
|
252
|
+
hint: 'Use the OpenAI or ElevenLabs read-aloud backend instead, or run on a supported platform (macOS/Linux/Windows x64, macOS/Linux arm64).',
|
|
253
|
+
context: { platform: process.platform, arch: process.arch },
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
const hostPath =
|
|
257
|
+
this.sidecarPath ?? path.join(path.dirname(fileURLToPath(import.meta.url)), 'sidecar.js');
|
|
258
|
+
return new HostClient({
|
|
259
|
+
hostPath,
|
|
260
|
+
env: { ...process.env, ...sherpaEnv(libDir) },
|
|
261
|
+
log: this.log,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function createLocalPiperSynthesizer(opts: LocalPiperOptions = {}): LocalPiperSynthesizer {
|
|
267
|
+
return new LocalPiperSynthesizer(opts);
|
|
268
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
libraryPathVar,
|
|
5
|
+
resolveSherpaLibDir,
|
|
6
|
+
sherpaEnv,
|
|
7
|
+
sherpaPlatformPackage,
|
|
8
|
+
} from './platform.js';
|
|
9
|
+
|
|
10
|
+
describe('sherpaPlatformPackage', () => {
|
|
11
|
+
it.each([
|
|
12
|
+
['darwin', 'arm64', 'sherpa-onnx-darwin-arm64'],
|
|
13
|
+
['darwin', 'x64', 'sherpa-onnx-darwin-x64'],
|
|
14
|
+
['linux', 'x64', 'sherpa-onnx-linux-x64'],
|
|
15
|
+
['linux', 'arm64', 'sherpa-onnx-linux-arm64'],
|
|
16
|
+
['win32', 'x64', 'sherpa-onnx-win-x64'],
|
|
17
|
+
] as const)('maps %s-%s to %s', (platform, arch, pkg) => {
|
|
18
|
+
expect(sherpaPlatformPackage(platform as NodeJS.Platform, arch)).toBe(pkg);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns null for an unsupported platform/arch', () => {
|
|
22
|
+
expect(sherpaPlatformPackage('linux', 'ia32')).toBeNull();
|
|
23
|
+
expect(sherpaPlatformPackage('freebsd', 'x64')).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('libraryPathVar', () => {
|
|
28
|
+
it('picks the platform dynamic-loader var, or null on Windows', () => {
|
|
29
|
+
expect(libraryPathVar('darwin')).toBe('DYLD_LIBRARY_PATH');
|
|
30
|
+
expect(libraryPathVar('linux')).toBe('LD_LIBRARY_PATH');
|
|
31
|
+
expect(libraryPathVar('win32')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('sherpaEnv', () => {
|
|
36
|
+
it('sets the loader var to the lib dir when none is present', () => {
|
|
37
|
+
expect(sherpaEnv('/libs', 'darwin', {})).toEqual({ DYLD_LIBRARY_PATH: '/libs' });
|
|
38
|
+
expect(sherpaEnv('/libs', 'linux', {})).toEqual({ LD_LIBRARY_PATH: '/libs' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('prepends the lib dir to an existing loader path', () => {
|
|
42
|
+
expect(sherpaEnv('/libs', 'darwin', { DYLD_LIBRARY_PATH: '/other' })).toEqual({
|
|
43
|
+
DYLD_LIBRARY_PATH: `/libs${path.delimiter}/other`,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns no env on Windows (DLLs resolve next to the addon)', () => {
|
|
48
|
+
expect(sherpaEnv('C:\\libs', 'win32', {})).toEqual({});
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('resolveSherpaLibDir', () => {
|
|
53
|
+
it('returns the dirname of the resolved platform package.json', () => {
|
|
54
|
+
const fakeResolve = (req: string): string => {
|
|
55
|
+
expect(req).toBe('sherpa-onnx-darwin-arm64/package.json');
|
|
56
|
+
return '/store/sherpa-onnx-darwin-arm64/package.json';
|
|
57
|
+
};
|
|
58
|
+
expect(resolveSherpaLibDir('darwin', 'arm64', fakeResolve)).toBe('/store/sherpa-onnx-darwin-arm64');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns null when the platform package cannot be resolved', () => {
|
|
62
|
+
const throwing = (): string => {
|
|
63
|
+
throw new Error('MODULE_NOT_FOUND');
|
|
64
|
+
};
|
|
65
|
+
expect(resolveSherpaLibDir('darwin', 'arm64', throwing)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns null for an unsupported platform without calling resolve', () => {
|
|
69
|
+
let called = false;
|
|
70
|
+
const spy = (): string => {
|
|
71
|
+
called = true;
|
|
72
|
+
return 'x';
|
|
73
|
+
};
|
|
74
|
+
expect(resolveSherpaLibDir('sunos' as NodeJS.Platform, 'x64', spy)).toBeNull();
|
|
75
|
+
expect(called).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
package/src/platform.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the sherpa-onnx platform binary package and the environment the
|
|
3
|
+
* sidecar must be spawned with.
|
|
4
|
+
*
|
|
5
|
+
* CRITICAL native gotcha (scout-verified): sherpa-onnx-node's addon dlopen's
|
|
6
|
+
* sibling shared libs (`libonnxruntime.dylib`, `libsherpa-onnx-c-api.dylib`, …)
|
|
7
|
+
* that live inside the per-platform package dir, and dyld/ld.so read
|
|
8
|
+
* `DYLD_LIBRARY_PATH` / `LD_LIBRARY_PATH` ONCE at process start. So the addon
|
|
9
|
+
* only resolves its libs when that env var already points at the platform
|
|
10
|
+
* package dir when the process launches — which is exactly why the host runs in
|
|
11
|
+
* a freshly-forked child: the PARENT resolves the dir here and sets the var in
|
|
12
|
+
* the fork's env. On Windows the DLLs sit next to the `.node`, so no env var is
|
|
13
|
+
* needed.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
/** `${platform}-${arch}` → npm package that ships the native addon + libs.
|
|
20
|
+
* Note the Windows package is `sherpa-onnx-win-x64` (renamed from win32-x64). */
|
|
21
|
+
const PLATFORM_PACKAGES: Readonly<Record<string, string>> = {
|
|
22
|
+
'darwin-arm64': 'sherpa-onnx-darwin-arm64',
|
|
23
|
+
'darwin-x64': 'sherpa-onnx-darwin-x64',
|
|
24
|
+
'linux-x64': 'sherpa-onnx-linux-x64',
|
|
25
|
+
'linux-arm64': 'sherpa-onnx-linux-arm64',
|
|
26
|
+
'win32-x64': 'sherpa-onnx-win-x64',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** The platform binary package name for a host, or null if unsupported. */
|
|
30
|
+
export function sherpaPlatformPackage(
|
|
31
|
+
platform: NodeJS.Platform = process.platform,
|
|
32
|
+
arch: string = process.arch,
|
|
33
|
+
): string | null {
|
|
34
|
+
return PLATFORM_PACKAGES[`${platform}-${arch}`] ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A `require.resolve`-shaped function (injectable for tests). */
|
|
38
|
+
export type ResolveLike = (request: string) => string;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A `require.resolve` anchored at sherpa-onnx-node itself.
|
|
42
|
+
*
|
|
43
|
+
* Load-bearing: sherpa's per-platform binary packages are its OWN
|
|
44
|
+
* `optionalDependencies`, so under pnpm's strict layout they are visible from
|
|
45
|
+
* sherpa-onnx-node's `node_modules` but NOT hoisted into this plugin's — a
|
|
46
|
+
* resolve anchored here would `MODULE_NOT_FOUND`. So we first resolve
|
|
47
|
+
* sherpa-onnx-node, then resolve the platform package from ITS context. Cached
|
|
48
|
+
* because it walks two package boundaries.
|
|
49
|
+
*/
|
|
50
|
+
let cachedSherpaResolve: ResolveLike | null | undefined;
|
|
51
|
+
function sherpaAnchoredResolve(): ResolveLike | null {
|
|
52
|
+
if (cachedSherpaResolve !== undefined) return cachedSherpaResolve;
|
|
53
|
+
try {
|
|
54
|
+
const pluginRequire = createRequire(import.meta.url);
|
|
55
|
+
const sherpaPkg = pluginRequire.resolve('sherpa-onnx-node/package.json');
|
|
56
|
+
cachedSherpaResolve = createRequire(sherpaPkg).resolve;
|
|
57
|
+
} catch {
|
|
58
|
+
cachedSherpaResolve = null;
|
|
59
|
+
}
|
|
60
|
+
return cachedSherpaResolve;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Absolute directory of the resolved platform package (which holds the `.node`
|
|
65
|
+
* addon and its sibling shared libraries), or null when the package can't be
|
|
66
|
+
* resolved (unsupported platform, or the optionalDependency didn't install).
|
|
67
|
+
* `resolve` is injectable for tests; the default is anchored at sherpa-onnx-node.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveSherpaLibDir(
|
|
70
|
+
platform: NodeJS.Platform = process.platform,
|
|
71
|
+
arch: string = process.arch,
|
|
72
|
+
resolve?: ResolveLike,
|
|
73
|
+
): string | null {
|
|
74
|
+
const pkg = sherpaPlatformPackage(platform, arch);
|
|
75
|
+
if (!pkg) return null;
|
|
76
|
+
const r = resolve ?? sherpaAnchoredResolve();
|
|
77
|
+
if (!r) return null;
|
|
78
|
+
try {
|
|
79
|
+
return path.dirname(r(`${pkg}/package.json`));
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The dynamic-loader env var name for a platform, or null on Windows. */
|
|
86
|
+
export function libraryPathVar(platform: NodeJS.Platform = process.platform): string | null {
|
|
87
|
+
if (platform === 'win32') return null;
|
|
88
|
+
return platform === 'darwin' ? 'DYLD_LIBRARY_PATH' : 'LD_LIBRARY_PATH';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build the env overrides the sidecar must launch with: the platform loader var
|
|
93
|
+
* with `libDir` PREPENDED to any existing value. Returns `{}` on Windows (DLLs
|
|
94
|
+
* resolve next to the addon). `existing` defaults to `process.env`.
|
|
95
|
+
*/
|
|
96
|
+
export function sherpaEnv(
|
|
97
|
+
libDir: string,
|
|
98
|
+
platform: NodeJS.Platform = process.platform,
|
|
99
|
+
existing: NodeJS.ProcessEnv = process.env,
|
|
100
|
+
): NodeJS.ProcessEnv {
|
|
101
|
+
const varName = libraryPathVar(platform);
|
|
102
|
+
if (!varName) return {};
|
|
103
|
+
const prev = existing[varName];
|
|
104
|
+
const value = prev ? `${libDir}${path.delimiter}${prev}` : libDir;
|
|
105
|
+
return { [varName]: value };
|
|
106
|
+
}
|
package/src/sidecar.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sherpa-onnx TTS sidecar — a forked child that owns the native sherpa addon.
|
|
4
|
+
*
|
|
5
|
+
* It exists as a SEPARATE process for one hard reason (see ./platform.ts): the
|
|
6
|
+
* addon's shared libraries only resolve when `DYLD_LIBRARY_PATH` /
|
|
7
|
+
* `LD_LIBRARY_PATH` points at the platform package dir AT PROCESS START, so the
|
|
8
|
+
* parent forks this with that env pre-set. Forking (not `spawn`) gives us a
|
|
9
|
+
* structured IPC channel; the parent uses `serialization: 'advanced'` so the
|
|
10
|
+
* `Float32Array` of samples round-trips without manual byte packing.
|
|
11
|
+
*
|
|
12
|
+
* Protocol: {@link ./host-protocol.ts}. This module is the thin runtime glue —
|
|
13
|
+
* lazily `require`ing the native module (a dlopen failure becomes a classified
|
|
14
|
+
* `init` reply rather than a boot crash), plus lifecycle self-termination so an
|
|
15
|
+
* orphaned sidecar never lingers.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
createMessageHandler,
|
|
23
|
+
type HostReply,
|
|
24
|
+
type HostRequest,
|
|
25
|
+
type SherpaModule,
|
|
26
|
+
} from './host-protocol.js';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
|
|
30
|
+
/** Lazy native load — deferred so a missing/broken addon is a caught, reported
|
|
31
|
+
* `init` error on the first synthesize, not an unhandled boot exception. */
|
|
32
|
+
function loadSherpa(): SherpaModule {
|
|
33
|
+
return require('sherpa-onnx-node') as SherpaModule;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function send(reply: HostReply): void {
|
|
37
|
+
// `process.send` is defined only when spawned with an IPC channel (our fork).
|
|
38
|
+
process.send?.(reply);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isRequest(msg: unknown): msg is HostRequest {
|
|
42
|
+
return (
|
|
43
|
+
typeof msg === 'object' &&
|
|
44
|
+
msg !== null &&
|
|
45
|
+
(msg as { type?: unknown }).type === 'synthesize' &&
|
|
46
|
+
typeof (msg as { id?: unknown }).id === 'number'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Self-terminate if the parent runner disappears. `fork` delivers a
|
|
52
|
+
* `disconnect` event when the IPC channel closes (parent exit / explicit
|
|
53
|
+
* disconnect); belt-and-braces, poll the parent PID too — a hard SIGKILL of the
|
|
54
|
+
* parent may not always close the channel promptly.
|
|
55
|
+
*/
|
|
56
|
+
function armLifecycle(): void {
|
|
57
|
+
process.on('disconnect', () => process.exit(0));
|
|
58
|
+
const parentPid = process.ppid;
|
|
59
|
+
if (!parentPid || parentPid <= 1) return;
|
|
60
|
+
const timer = setInterval(() => {
|
|
61
|
+
try {
|
|
62
|
+
process.kill(parentPid, 0); // existence probe, no signal delivered
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if ((err as NodeJS.ErrnoException).code === 'ESRCH') process.exit(0);
|
|
65
|
+
// EPERM ⇒ the process exists but we can't signal it — still alive.
|
|
66
|
+
}
|
|
67
|
+
}, 3000);
|
|
68
|
+
timer.unref?.();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function main(): void {
|
|
72
|
+
const handle = createMessageHandler(loadSherpa);
|
|
73
|
+
// Serialise requests: sherpa's OfflineTts is not re-entrant, and processing
|
|
74
|
+
// one at a time keeps memory bounded under a burst of read-aloud calls.
|
|
75
|
+
let queue: Promise<void> = Promise.resolve();
|
|
76
|
+
process.on('message', (msg: unknown) => {
|
|
77
|
+
if (!isRequest(msg)) return;
|
|
78
|
+
queue = queue
|
|
79
|
+
.then(async () => {
|
|
80
|
+
const reply = await handle(msg);
|
|
81
|
+
send(reply);
|
|
82
|
+
})
|
|
83
|
+
.catch((err) => {
|
|
84
|
+
send({
|
|
85
|
+
id: msg.id,
|
|
86
|
+
ok: false,
|
|
87
|
+
error: { message: err instanceof Error ? err.message : String(err), kind: 'runtime' },
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
armLifecycle();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Run only when executed as the forked child, never when imported by a test.
|
|
95
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
96
|
+
main();
|
|
97
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_POLISH_VOICE_ID,
|
|
4
|
+
DEFAULT_VOICE_ID,
|
|
5
|
+
findVoice,
|
|
6
|
+
requireVoice,
|
|
7
|
+
routeVoice,
|
|
8
|
+
VOICE_CATALOG,
|
|
9
|
+
voiceIds,
|
|
10
|
+
} from './voices.js';
|
|
11
|
+
|
|
12
|
+
describe('VOICE_CATALOG', () => {
|
|
13
|
+
it('pins the three Piper voices with their sherpa checksum.txt sha256s', () => {
|
|
14
|
+
// These MUST match sherpa-onnx's tts-models/checksum.txt verbatim — the
|
|
15
|
+
// integrity story depends on them not drifting.
|
|
16
|
+
const byId = Object.fromEntries(VOICE_CATALOG.map((v) => [v.id, v]));
|
|
17
|
+
expect(byId['en_US-amy-medium']?.sha256).toBe(
|
|
18
|
+
'9a5d1fc497f85e8022b785bff5f8105203b1e33099ee6265203efc70b0cb0264',
|
|
19
|
+
);
|
|
20
|
+
expect(byId['pl_PL-gosia-medium']?.sha256).toBe(
|
|
21
|
+
'75bd34dcbdc4dd98d763954756b4b34b4208100497c836381542e4d73dcefa9c',
|
|
22
|
+
);
|
|
23
|
+
expect(byId['pl_PL-darkman-medium']?.sha256).toBe(
|
|
24
|
+
'444727aa46eb6db645a2bc88fe73868e4bd7596b7f56ca39fad5ef53c41210d4',
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('derives archive/model paths and language consistently', () => {
|
|
29
|
+
for (const v of VOICE_CATALOG) {
|
|
30
|
+
expect(v.archiveRootDir).toBe(`vits-piper-${v.id}`);
|
|
31
|
+
expect(v.modelFile).toBe(`${v.id}.onnx`);
|
|
32
|
+
expect(v.url).toBe(
|
|
33
|
+
`https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-${v.id}.tar.bz2`,
|
|
34
|
+
);
|
|
35
|
+
expect(/^[0-9a-f]{64}$/.test(v.sha256)).toBe(true);
|
|
36
|
+
}
|
|
37
|
+
expect(VOICE_CATALOG.filter((v) => v.language === 'pl').map((v) => v.id)).toEqual([
|
|
38
|
+
'pl_PL-gosia-medium',
|
|
39
|
+
'pl_PL-darkman-medium',
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('exposes the default voice ids from the catalog', () => {
|
|
44
|
+
expect(findVoice(DEFAULT_VOICE_ID)?.language).toBe('en');
|
|
45
|
+
expect(findVoice(DEFAULT_POLISH_VOICE_ID)?.language).toBe('pl');
|
|
46
|
+
expect(voiceIds()).toContain('en_US-amy-medium');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('routeVoice', () => {
|
|
51
|
+
const cfg = { defaultVoice: DEFAULT_VOICE_ID, polishVoice: DEFAULT_POLISH_VOICE_ID };
|
|
52
|
+
|
|
53
|
+
it('uses the default voice with no hints', () => {
|
|
54
|
+
expect(routeVoice({ ...cfg }).id).toBe('en_US-amy-medium');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('routes pl* language hints to the Polish voice', () => {
|
|
58
|
+
expect(routeVoice({ ...cfg, language: 'pl' }).id).toBe('pl_PL-gosia-medium');
|
|
59
|
+
expect(routeVoice({ ...cfg, language: 'pl-PL' }).id).toBe('pl_PL-gosia-medium');
|
|
60
|
+
expect(routeVoice({ ...cfg, language: 'PL_pl' }).id).toBe('pl_PL-gosia-medium');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('routes a non-pl language to the default voice', () => {
|
|
64
|
+
expect(routeVoice({ ...cfg, language: 'en-US' }).id).toBe('en_US-amy-medium');
|
|
65
|
+
expect(routeVoice({ ...cfg, language: 'de' }).id).toBe('en_US-amy-medium');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('lets an explicit requestedVoice override everything', () => {
|
|
69
|
+
expect(routeVoice({ ...cfg, requestedVoice: 'pl_PL-darkman-medium', language: 'en' }).id).toBe(
|
|
70
|
+
'pl_PL-darkman-medium',
|
|
71
|
+
);
|
|
72
|
+
expect(routeVoice({ ...cfg, requestedVoice: 'en_US-amy-medium', language: 'pl' }).id).toBe(
|
|
73
|
+
'en_US-amy-medium',
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('honors a configured Polish voice override', () => {
|
|
78
|
+
expect(routeVoice({ defaultVoice: DEFAULT_VOICE_ID, polishVoice: 'pl_PL-darkman-medium', language: 'pl' }).id).toBe(
|
|
79
|
+
'pl_PL-darkman-medium',
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('throws a clear CONFIG_INVALID for an unknown requested voice, listing valid ids', () => {
|
|
84
|
+
try {
|
|
85
|
+
routeVoice({ ...cfg, requestedVoice: 'xx_ZZ-nobody' });
|
|
86
|
+
throw new Error('expected a throw');
|
|
87
|
+
} catch (err) {
|
|
88
|
+
expect((err as { code?: string }).code).toBe('CONFIG_INVALID');
|
|
89
|
+
expect((err as Error).message).toContain('xx_ZZ-nobody');
|
|
90
|
+
expect((err as { hint?: string }).hint).toContain('en_US-amy-medium');
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('throws when a configured polishVoice id is unknown', () => {
|
|
95
|
+
expect(() => routeVoice({ defaultVoice: DEFAULT_VOICE_ID, polishVoice: 'bogus', language: 'pl' })).toThrow();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('requireVoice', () => {
|
|
100
|
+
it('returns the entry for a valid id', () => {
|
|
101
|
+
expect(requireVoice('en_US-amy-medium', 'voice').id).toBe('en_US-amy-medium');
|
|
102
|
+
});
|
|
103
|
+
it('throws CONFIG_INVALID naming the field for an unknown id', () => {
|
|
104
|
+
expect(() => requireVoice('nope', 'polishVoice')).toThrow(/Unknown local voice/);
|
|
105
|
+
});
|
|
106
|
+
});
|