@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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tiny JSON message protocol spoken between the parent (host-client) and
|
|
3
|
+
* the forked sherpa sidecar, plus the pure per-message handler the sidecar
|
|
4
|
+
* runs. Kept free of `node:child_process` so BOTH sides can import it (the
|
|
5
|
+
* parent for the types, the child for the handler) without dragging fork
|
|
6
|
+
* plumbing into either bundle.
|
|
7
|
+
*
|
|
8
|
+
* Wire shape (over the fork IPC channel, `serialization: 'advanced'` so the
|
|
9
|
+
* Float32Array round-trips intact):
|
|
10
|
+
* parent → child: SynthesizeRequest
|
|
11
|
+
* child → parent: HostReply
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** One synthesis request. Carries the full model config so the sidecar is
|
|
15
|
+
* stateless-per-message except for a model cache keyed by `voiceKey`. */
|
|
16
|
+
export interface SynthesizeRequest {
|
|
17
|
+
readonly id: number;
|
|
18
|
+
readonly type: 'synthesize';
|
|
19
|
+
/** Cache key for the loaded model (the absolute `.onnx` path). */
|
|
20
|
+
readonly voiceKey: string;
|
|
21
|
+
/** Absolute path to the VITS `.onnx` model. */
|
|
22
|
+
readonly model: string;
|
|
23
|
+
/** Absolute path to `tokens.txt`. */
|
|
24
|
+
readonly tokens: string;
|
|
25
|
+
/** Absolute path to the `espeak-ng-data` directory. */
|
|
26
|
+
readonly dataDir: string;
|
|
27
|
+
/** Inference thread count. */
|
|
28
|
+
readonly numThreads: number;
|
|
29
|
+
/** Compute provider (`cpu`). */
|
|
30
|
+
readonly provider: string;
|
|
31
|
+
/** Text to speak. */
|
|
32
|
+
readonly text: string;
|
|
33
|
+
/** Speaker id within the model (0 for single-speaker Piper voices). */
|
|
34
|
+
readonly sid: number;
|
|
35
|
+
/** Speaking-rate multiplier already clamped by the caller. */
|
|
36
|
+
readonly speed: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type HostRequest = SynthesizeRequest;
|
|
40
|
+
|
|
41
|
+
export type HostErrorKind =
|
|
42
|
+
/** Model failed to load (bad/absent files, unsupported arch, addon dlopen). */
|
|
43
|
+
| 'init'
|
|
44
|
+
/** Synthesis itself threw. */
|
|
45
|
+
| 'runtime';
|
|
46
|
+
|
|
47
|
+
export type HostReply =
|
|
48
|
+
| {
|
|
49
|
+
readonly id: number;
|
|
50
|
+
readonly ok: true;
|
|
51
|
+
readonly sampleRate: number;
|
|
52
|
+
/** Mono PCM in [-1, 1]; structured-cloned intact over advanced IPC. */
|
|
53
|
+
readonly samples: Float32Array;
|
|
54
|
+
}
|
|
55
|
+
| {
|
|
56
|
+
readonly id: number;
|
|
57
|
+
readonly ok: false;
|
|
58
|
+
readonly error: { readonly message: string; readonly kind: HostErrorKind };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The subset of the sherpa-onnx-node surface the sidecar uses. Declared here
|
|
62
|
+
* (rather than leaning on the addon's ambient types) so the handler is
|
|
63
|
+
* unit-testable with a fake module and typechecks without the native addon. */
|
|
64
|
+
export interface SherpaOfflineTts {
|
|
65
|
+
generateAsync(args: {
|
|
66
|
+
text: string;
|
|
67
|
+
sid: number;
|
|
68
|
+
speed: number;
|
|
69
|
+
}): Promise<{ samples: Float32Array; sampleRate: number }>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SherpaModule {
|
|
73
|
+
OfflineTts: new (config: unknown) => SherpaOfflineTts;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Lazily load the native sherpa module (so a dlopen failure surfaces as a
|
|
77
|
+
* classified reply, not a boot crash). */
|
|
78
|
+
export type LoadSherpa = () => SherpaModule;
|
|
79
|
+
|
|
80
|
+
export type HostMessageHandler = (req: HostRequest) => Promise<HostReply>;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build the sidecar's message handler. Loads the sherpa module on first use and
|
|
84
|
+
* caches one `OfflineTts` per `voiceKey` so repeated calls on the same voice
|
|
85
|
+
* reuse the loaded model. Every failure becomes a typed `ok:false` reply —
|
|
86
|
+
* nothing throws out of `handle`.
|
|
87
|
+
*/
|
|
88
|
+
export function createMessageHandler(loadSherpa: LoadSherpa): HostMessageHandler {
|
|
89
|
+
const cache = new Map<string, SherpaOfflineTts>();
|
|
90
|
+
let mod: SherpaModule | null = null;
|
|
91
|
+
|
|
92
|
+
return async function handle(req: HostRequest): Promise<HostReply> {
|
|
93
|
+
let tts = cache.get(req.voiceKey);
|
|
94
|
+
if (!tts) {
|
|
95
|
+
try {
|
|
96
|
+
mod ??= loadSherpa();
|
|
97
|
+
tts = new mod.OfflineTts({
|
|
98
|
+
model: {
|
|
99
|
+
vits: { model: req.model, tokens: req.tokens, dataDir: req.dataDir },
|
|
100
|
+
numThreads: req.numThreads,
|
|
101
|
+
provider: req.provider,
|
|
102
|
+
debug: false,
|
|
103
|
+
},
|
|
104
|
+
maxNumSentences: 1,
|
|
105
|
+
});
|
|
106
|
+
cache.set(req.voiceKey, tts);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return { id: req.id, ok: false, error: { message: errMsg(err), kind: 'init' } };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const out = await tts.generateAsync({ text: req.text, sid: req.sid, speed: req.speed });
|
|
114
|
+
const samples =
|
|
115
|
+
out.samples instanceof Float32Array
|
|
116
|
+
? out.samples
|
|
117
|
+
: Float32Array.from(out.samples as ArrayLike<number>);
|
|
118
|
+
return { id: req.id, ok: true, sampleRate: out.sampleRate, samples };
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return { id: req.id, ok: false, error: { message: errMsg(err), kind: 'runtime' } };
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function errMsg(err: unknown): string {
|
|
126
|
+
return err instanceof Error ? err.message : String(err);
|
|
127
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { definePlugin, defineSynthesizer, type Plugin } from '@moxxy/sdk';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createLocalPiperSynthesizer,
|
|
5
|
+
LOCAL_PIPER_SYNTHESIZER_NAME,
|
|
6
|
+
shutdownLocalTts,
|
|
7
|
+
type LocalPiperOptions,
|
|
8
|
+
} from './local-tts.js';
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
LocalPiperSynthesizer,
|
|
12
|
+
createLocalPiperSynthesizer,
|
|
13
|
+
clampSpeed,
|
|
14
|
+
shutdownLocalTts,
|
|
15
|
+
LOCAL_PIPER_SYNTHESIZER_NAME,
|
|
16
|
+
type LocalPiperOptions,
|
|
17
|
+
type VoiceModelPaths,
|
|
18
|
+
} from './local-tts.js';
|
|
19
|
+
export { encodeWav, WAV_HEADER_BYTES } from './wav.js';
|
|
20
|
+
export {
|
|
21
|
+
VOICE_CATALOG,
|
|
22
|
+
voiceIds,
|
|
23
|
+
findVoice,
|
|
24
|
+
routeVoice,
|
|
25
|
+
requireVoice,
|
|
26
|
+
DEFAULT_VOICE_ID,
|
|
27
|
+
DEFAULT_POLISH_VOICE_ID,
|
|
28
|
+
type VoiceEntry,
|
|
29
|
+
type VoiceLanguage,
|
|
30
|
+
} from './voices.js';
|
|
31
|
+
export { HostClient, type HostClientLike, type ForkLike, type ChildHandle } from './host-client.js';
|
|
32
|
+
export {
|
|
33
|
+
sherpaPlatformPackage,
|
|
34
|
+
resolveSherpaLibDir,
|
|
35
|
+
sherpaEnv,
|
|
36
|
+
libraryPathVar,
|
|
37
|
+
} from './platform.js';
|
|
38
|
+
|
|
39
|
+
export interface BuildLocalTtsPluginOptions {
|
|
40
|
+
/** Build-time defaults / test seams for the synthesizer (injected
|
|
41
|
+
* `ensureModelImpl`, `hostFactory`, `fetchImpl`, `modelsDir`, `log`). */
|
|
42
|
+
readonly defaults?: LocalPiperOptions;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the @moxxy/plugin-tts-local plugin. Registers exactly one synthesizer,
|
|
47
|
+
* `local-piper`, backed by sherpa-onnx Piper voices running in a forked
|
|
48
|
+
* sidecar. Side-effect free at load: no model is downloaded and no process is
|
|
49
|
+
* spawned until the first `synthesize`. Per-activation config
|
|
50
|
+
* (`session.synthesizers.setActive('local-piper', { voice, polishVoice,
|
|
51
|
+
* numThreads })`) overrides the build-time defaults.
|
|
52
|
+
*/
|
|
53
|
+
export function buildLocalTtsPlugin(opts: BuildLocalTtsPluginOptions = {}): Plugin {
|
|
54
|
+
const defaults = opts.defaults ?? {};
|
|
55
|
+
return definePlugin({
|
|
56
|
+
name: '@moxxy/plugin-tts-local',
|
|
57
|
+
version: '0.0.0',
|
|
58
|
+
synthesizers: [
|
|
59
|
+
defineSynthesizer({
|
|
60
|
+
name: LOCAL_PIPER_SYNTHESIZER_NAME,
|
|
61
|
+
displayName: 'Local (Piper — offline, EN+PL)',
|
|
62
|
+
// `create` runs lazily and may re-run (buildOnRead) — keep it cheap and
|
|
63
|
+
// side-effect free; the sidecar + download happen on first synthesize.
|
|
64
|
+
create: (ctx) =>
|
|
65
|
+
createLocalPiperSynthesizer({ ...defaults, ...configToOptions(ctx.config) }),
|
|
66
|
+
}),
|
|
67
|
+
],
|
|
68
|
+
// Kill every spawned sidecar when the session/runner shuts down.
|
|
69
|
+
hooks: { onShutdown: () => shutdownLocalTts() },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Narrow the untrusted per-activation config into typed options — only the
|
|
74
|
+
* known string `voice` / `polishVoice` and a positive-integer `numThreads` are
|
|
75
|
+
* honored so a malformed config can't break `create` or route to a bad voice
|
|
76
|
+
* (unknown ids are caught by `requireVoice` at construction). */
|
|
77
|
+
function configToOptions(config: Record<string, unknown>): Partial<LocalPiperOptions> {
|
|
78
|
+
const out: { -readonly [K in keyof LocalPiperOptions]?: LocalPiperOptions[K] } = {};
|
|
79
|
+
if (typeof config.voice === 'string' && config.voice) out.voice = config.voice;
|
|
80
|
+
if (typeof config.polishVoice === 'string' && config.polishVoice) {
|
|
81
|
+
out.polishVoice = config.polishVoice;
|
|
82
|
+
}
|
|
83
|
+
if (typeof config.numThreads === 'number' && Number.isInteger(config.numThreads) && config.numThreads > 0) {
|
|
84
|
+
out.numThreads = config.numThreads;
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Discovery entry: `createPluginLoader` requires a default Plugin export.
|
|
90
|
+
export default buildLocalTtsPlugin();
|
package/src/live.test.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { createLocalPiperSynthesizer } from './local-tts.js';
|
|
9
|
+
|
|
10
|
+
// vitest runs the TypeScript SOURCE, so `import.meta.url` here is src/. The
|
|
11
|
+
// sidecar must be the COMPILED entry, so point at dist/sidecar.js (build first:
|
|
12
|
+
// `pnpm -F @moxxy/plugin-tts-local build`).
|
|
13
|
+
const SIDECAR_PATH = fileURLToPath(new URL('../dist/sidecar.js', import.meta.url));
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Opt-in end-to-end test that REALLY downloads a voice and runs the native
|
|
17
|
+
* sherpa sidecar — off by default (it fetches ~64 MB and needs the platform
|
|
18
|
+
* binary). Run manually with:
|
|
19
|
+
*
|
|
20
|
+
* MOXXY_TTS_LOCAL_LIVE=1 pnpm -F @moxxy/plugin-tts-local test
|
|
21
|
+
*/
|
|
22
|
+
const LIVE = !!process.env.MOXXY_TTS_LOCAL_LIVE;
|
|
23
|
+
|
|
24
|
+
let modelsDir: string;
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
if (!LIVE) return;
|
|
27
|
+
modelsDir = await mkdtemp(path.join(tmpdir(), 'tts-local-live-'));
|
|
28
|
+
});
|
|
29
|
+
afterAll(async () => {
|
|
30
|
+
if (modelsDir) await rm(modelsDir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe.skipIf(!LIVE)('local TTS (live, real download + native sidecar)', () => {
|
|
34
|
+
it('synthesizes English audio', async () => {
|
|
35
|
+
const synth = createLocalPiperSynthesizer({ modelsDir, sidecarPath: SIDECAR_PATH });
|
|
36
|
+
try {
|
|
37
|
+
const out = await synth.synthesize('Hello from a fully local voice.', { language: 'en' });
|
|
38
|
+
expect(out.mimeType).toBe('audio/wav');
|
|
39
|
+
expect(String.fromCharCode(...out.audio.subarray(0, 4))).toBe('RIFF');
|
|
40
|
+
expect(out.audio.byteLength).toBeGreaterThan(44 + 1000);
|
|
41
|
+
} finally {
|
|
42
|
+
synth.shutdown();
|
|
43
|
+
}
|
|
44
|
+
}, 600_000);
|
|
45
|
+
|
|
46
|
+
it('synthesizes Polish audio', async () => {
|
|
47
|
+
const synth = createLocalPiperSynthesizer({ modelsDir, sidecarPath: SIDECAR_PATH });
|
|
48
|
+
try {
|
|
49
|
+
const out = await synth.synthesize('Dzień dobry, mówię po polsku.', { language: 'pl' });
|
|
50
|
+
expect(out.mimeType).toBe('audio/wav');
|
|
51
|
+
expect(out.audio.byteLength).toBeGreaterThan(44 + 1000);
|
|
52
|
+
} finally {
|
|
53
|
+
synth.shutdown();
|
|
54
|
+
}
|
|
55
|
+
}, 600_000);
|
|
56
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { ensureModel } from '@moxxy/model-fetch';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
clampSpeed,
|
|
8
|
+
createLocalPiperSynthesizer,
|
|
9
|
+
type LocalPiperOptions,
|
|
10
|
+
} from './local-tts.js';
|
|
11
|
+
import { buildLocalTtsPlugin, LOCAL_PIPER_SYNTHESIZER_NAME } from './index.js';
|
|
12
|
+
import type { HostClientLike } from './host-client.js';
|
|
13
|
+
import type { HostRequest } from './host-protocol.js';
|
|
14
|
+
|
|
15
|
+
/** A fake host recording each synthesize request; returns canned samples. */
|
|
16
|
+
function fakeHost(): { host: HostClientLike; reqs: Array<Omit<HostRequest, 'id' | 'type'>>; shutdowns: number } {
|
|
17
|
+
const state = { reqs: [] as Array<Omit<HostRequest, 'id' | 'type'>>, shutdowns: 0 };
|
|
18
|
+
const host: HostClientLike = {
|
|
19
|
+
async synthesize(req) {
|
|
20
|
+
state.reqs.push(req);
|
|
21
|
+
return { samples: new Float32Array([0.1, -0.1, 0.2]), sampleRate: 22050 };
|
|
22
|
+
},
|
|
23
|
+
shutdown() {
|
|
24
|
+
state.shutdowns += 1;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
return { host, get reqs() {
|
|
28
|
+
return state.reqs;
|
|
29
|
+
}, get shutdowns() {
|
|
30
|
+
return state.shutdowns;
|
|
31
|
+
} };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A fake ensureModel recording the urls it was asked to fetch. */
|
|
35
|
+
function fakeEnsure(): { impl: typeof ensureModel; urls: string[] } {
|
|
36
|
+
const urls: string[] = [];
|
|
37
|
+
const impl = (async (opts: Parameters<typeof ensureModel>[0]) => {
|
|
38
|
+
urls.push(opts.url);
|
|
39
|
+
return { dir: opts.dir, skipped: false };
|
|
40
|
+
}) as unknown as typeof ensureModel;
|
|
41
|
+
return { impl, urls };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeSynth(over: Partial<LocalPiperOptions> = {}): {
|
|
45
|
+
synth: ReturnType<typeof createLocalPiperSynthesizer>;
|
|
46
|
+
host: ReturnType<typeof fakeHost>;
|
|
47
|
+
ensure: ReturnType<typeof fakeEnsure>;
|
|
48
|
+
} {
|
|
49
|
+
const host = fakeHost();
|
|
50
|
+
const ensure = fakeEnsure();
|
|
51
|
+
const synth = createLocalPiperSynthesizer({
|
|
52
|
+
modelsDir: '/models/tts',
|
|
53
|
+
ensureModelImpl: ensure.impl,
|
|
54
|
+
hostFactory: () => host.host,
|
|
55
|
+
log: () => {},
|
|
56
|
+
...over,
|
|
57
|
+
});
|
|
58
|
+
return { synth, host, ensure };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('LocalPiperSynthesizer.synthesize', () => {
|
|
62
|
+
it('defaults to the English voice and returns WAV bytes', async () => {
|
|
63
|
+
const { synth, host, ensure } = makeSynth();
|
|
64
|
+
const out = await synth.synthesize('hello world');
|
|
65
|
+
expect(out.mimeType).toBe('audio/wav');
|
|
66
|
+
expect(String.fromCharCode(...out.audio.subarray(0, 4))).toBe('RIFF');
|
|
67
|
+
expect(ensure.urls).toEqual([
|
|
68
|
+
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-amy-medium.tar.bz2',
|
|
69
|
+
]);
|
|
70
|
+
// The host got the en model path + default sid/speed.
|
|
71
|
+
expect(host.reqs[0]!.model).toBe(path.join('/models/tts/en_US-amy-medium/vits-piper-en_US-amy-medium/en_US-amy-medium.onnx'));
|
|
72
|
+
expect(host.reqs[0]!.dataDir).toContain('espeak-ng-data');
|
|
73
|
+
expect(host.reqs[0]!.text).toBe('hello world');
|
|
74
|
+
expect(host.reqs[0]!.speed).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('routes a Polish language hint to the configured Polish voice', async () => {
|
|
78
|
+
const { synth, host, ensure } = makeSynth();
|
|
79
|
+
await synth.synthesize('cześć', { language: 'pl-PL' });
|
|
80
|
+
expect(ensure.urls[0]).toContain('vits-piper-pl_PL-gosia-medium');
|
|
81
|
+
expect(host.reqs[0]!.model).toContain('pl_PL-gosia-medium');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('lets an explicit voice override language routing', async () => {
|
|
85
|
+
const { synth, host } = makeSynth();
|
|
86
|
+
await synth.synthesize('lektor', { language: 'pl', voice: 'pl_PL-darkman-medium' });
|
|
87
|
+
expect(host.reqs[0]!.model).toContain('pl_PL-darkman-medium');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('honors a configured polishVoice default', async () => {
|
|
91
|
+
const { synth, host } = makeSynth({ polishVoice: 'pl_PL-darkman-medium' });
|
|
92
|
+
await synth.synthesize('dzień dobry', { language: 'pl' });
|
|
93
|
+
expect(host.reqs[0]!.model).toContain('pl_PL-darkman-medium');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('rejects an unknown requested voice with a clear error', async () => {
|
|
97
|
+
const { synth } = makeSynth();
|
|
98
|
+
await expect(synth.synthesize('x', { voice: 'no-such-voice' })).rejects.toMatchObject({
|
|
99
|
+
code: 'CONFIG_INVALID',
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('downloads a given voice only once across calls', async () => {
|
|
104
|
+
const { synth, ensure } = makeSynth();
|
|
105
|
+
await synth.synthesize('one');
|
|
106
|
+
await synth.synthesize('two');
|
|
107
|
+
await synth.synthesize('three');
|
|
108
|
+
expect(ensure.urls.length).toBe(1); // en voice ensured once
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('maps and clamps rate onto sherpa speed', async () => {
|
|
112
|
+
const { synth, host } = makeSynth();
|
|
113
|
+
await synth.synthesize('fast', { rate: 5 });
|
|
114
|
+
await synth.synthesize('slow', { rate: 0.1 });
|
|
115
|
+
await synth.synthesize('normal');
|
|
116
|
+
expect(host.reqs.map((r) => r.speed)).toEqual([2.0, 0.5, 1.0]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('respects a pre-aborted signal', async () => {
|
|
120
|
+
const { synth } = makeSynth();
|
|
121
|
+
const ac = new AbortController();
|
|
122
|
+
ac.abort(new Error('stop'));
|
|
123
|
+
await expect(synth.synthesize('x', { signal: ac.signal })).rejects.toThrow(/stop/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('uses numThreads and provider defaults', async () => {
|
|
127
|
+
const { synth, host } = makeSynth({ numThreads: 4 });
|
|
128
|
+
await synth.synthesize('hi');
|
|
129
|
+
expect(host.reqs[0]!.numThreads).toBe(4);
|
|
130
|
+
expect(host.reqs[0]!.provider).toBe('cpu');
|
|
131
|
+
expect(host.reqs[0]!.sid).toBe(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('shutdown tears down the host', async () => {
|
|
135
|
+
const { synth, host } = makeSynth();
|
|
136
|
+
await synth.synthesize('hi');
|
|
137
|
+
synth.shutdown();
|
|
138
|
+
expect(host.shutdowns).toBe(1);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('clampSpeed', () => {
|
|
143
|
+
it('clamps to 0.5–2.0 and defaults absent/non-finite to 1.0', () => {
|
|
144
|
+
expect(clampSpeed(undefined)).toBe(1.0);
|
|
145
|
+
expect(clampSpeed(Number.NaN)).toBe(1.0);
|
|
146
|
+
expect(clampSpeed(1.3)).toBe(1.3);
|
|
147
|
+
expect(clampSpeed(9)).toBe(2.0);
|
|
148
|
+
expect(clampSpeed(0.01)).toBe(0.5);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('constructor validation', () => {
|
|
153
|
+
it('throws CONFIG_INVALID for an unknown configured voice', () => {
|
|
154
|
+
expect(() => createLocalPiperSynthesizer({ voice: 'bogus' })).toThrow(/Unknown local voice/);
|
|
155
|
+
});
|
|
156
|
+
it('throws CONFIG_INVALID for an unknown configured polishVoice', () => {
|
|
157
|
+
expect(() => createLocalPiperSynthesizer({ polishVoice: 'nope' })).toThrow(/Unknown local voice/);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('buildLocalTtsPlugin', () => {
|
|
162
|
+
it('registers exactly one synthesizer named local-piper', () => {
|
|
163
|
+
const plugin = buildLocalTtsPlugin();
|
|
164
|
+
expect(plugin.synthesizers).toHaveLength(1);
|
|
165
|
+
const def = plugin.synthesizers![0]!;
|
|
166
|
+
expect(def.name).toBe(LOCAL_PIPER_SYNTHESIZER_NAME);
|
|
167
|
+
expect(def.displayName).toContain('Local');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('create() flows per-activation voice config through to routing', async () => {
|
|
171
|
+
const host = fakeHost();
|
|
172
|
+
const ensure = fakeEnsure();
|
|
173
|
+
const plugin = buildLocalTtsPlugin({
|
|
174
|
+
defaults: {
|
|
175
|
+
modelsDir: '/models/tts',
|
|
176
|
+
ensureModelImpl: ensure.impl,
|
|
177
|
+
hostFactory: () => host.host,
|
|
178
|
+
log: () => {},
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
const inst = plugin.synthesizers![0]!.create({ config: { voice: 'pl_PL-gosia-medium' } });
|
|
182
|
+
await inst.synthesize('cześć');
|
|
183
|
+
expect(host.reqs[0]!.model).toContain('pl_PL-gosia-medium');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('create() rejects an unknown voice in config at construction', () => {
|
|
187
|
+
const plugin = buildLocalTtsPlugin();
|
|
188
|
+
expect(() => plugin.synthesizers![0]!.create({ config: { voice: 'bad-id' } })).toThrow();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('exposes an onShutdown hook', () => {
|
|
192
|
+
const plugin = buildLocalTtsPlugin();
|
|
193
|
+
expect(typeof plugin.hooks?.onShutdown).toBe('function');
|
|
194
|
+
});
|
|
195
|
+
});
|