@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/voices.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pinned local voice catalog.
|
|
3
|
+
*
|
|
4
|
+
* Every voice is a Piper VITS model published by the sherpa-onnx project as a
|
|
5
|
+
* `.tar.bz2` under its `tts-models` GitHub release. Each `sha256` is copied
|
|
6
|
+
* VERBATIM from that release's `checksum.txt` (re-verified 2026-07-05) — so a
|
|
7
|
+
* first-use download is content-addressed and tamper-evident, unlike an
|
|
8
|
+
* unverified blob fetch. The archive extracts to a single top directory
|
|
9
|
+
* (`archiveRootDir`) holding `<id>.onnx`, `tokens.txt`, and `espeak-ng-data/`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { MoxxyError } from '@moxxy/sdk';
|
|
13
|
+
|
|
14
|
+
export type VoiceLanguage = 'en' | 'pl';
|
|
15
|
+
|
|
16
|
+
export interface VoiceEntry {
|
|
17
|
+
/** Stable voice id, e.g. `en_US-amy-medium` (also the `set_voice` name). */
|
|
18
|
+
readonly id: string;
|
|
19
|
+
/** BCP-47 primary language subtag used for language routing. */
|
|
20
|
+
readonly language: VoiceLanguage;
|
|
21
|
+
/** Human label for UI surfaces. */
|
|
22
|
+
readonly label: string;
|
|
23
|
+
/** Pinned archive URL (sherpa-onnx GitHub release). */
|
|
24
|
+
readonly url: string;
|
|
25
|
+
/** Pinned hex sha256 of the archive (from the release checksum.txt). */
|
|
26
|
+
readonly sha256: string;
|
|
27
|
+
/** Top directory the archive extracts to (`vits-piper-<id>`). */
|
|
28
|
+
readonly archiveRootDir: string;
|
|
29
|
+
/** The ONNX model filename inside `archiveRootDir` (`<id>.onnx`). */
|
|
30
|
+
readonly modelFile: string;
|
|
31
|
+
/** Approximate download size in MB (for the one-time first-use notice). */
|
|
32
|
+
readonly approxMb: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const RELEASE_BASE = 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models';
|
|
36
|
+
|
|
37
|
+
function piperVoice(
|
|
38
|
+
id: string,
|
|
39
|
+
language: VoiceLanguage,
|
|
40
|
+
label: string,
|
|
41
|
+
sha256: string,
|
|
42
|
+
approxMb: number,
|
|
43
|
+
): VoiceEntry {
|
|
44
|
+
const archiveRootDir = `vits-piper-${id}`;
|
|
45
|
+
return {
|
|
46
|
+
id,
|
|
47
|
+
language,
|
|
48
|
+
label,
|
|
49
|
+
url: `${RELEASE_BASE}/${archiveRootDir}.tar.bz2`,
|
|
50
|
+
sha256,
|
|
51
|
+
archiveRootDir,
|
|
52
|
+
modelFile: `${id}.onnx`,
|
|
53
|
+
approxMb,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const VOICE_CATALOG: readonly VoiceEntry[] = [
|
|
58
|
+
piperVoice(
|
|
59
|
+
'en_US-amy-medium',
|
|
60
|
+
'en',
|
|
61
|
+
'Amy (English, US)',
|
|
62
|
+
'9a5d1fc497f85e8022b785bff5f8105203b1e33099ee6265203efc70b0cb0264',
|
|
63
|
+
63,
|
|
64
|
+
),
|
|
65
|
+
piperVoice(
|
|
66
|
+
'pl_PL-gosia-medium',
|
|
67
|
+
'pl',
|
|
68
|
+
'Gosia (Polish)',
|
|
69
|
+
'75bd34dcbdc4dd98d763954756b4b34b4208100497c836381542e4d73dcefa9c',
|
|
70
|
+
63,
|
|
71
|
+
),
|
|
72
|
+
piperVoice(
|
|
73
|
+
'pl_PL-darkman-medium',
|
|
74
|
+
'pl',
|
|
75
|
+
'Darkman (Polish)',
|
|
76
|
+
'444727aa46eb6db645a2bc88fe73868e4bd7596b7f56ca39fad5ef53c41210d4',
|
|
77
|
+
63,
|
|
78
|
+
),
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
export const DEFAULT_VOICE_ID = 'en_US-amy-medium';
|
|
82
|
+
export const DEFAULT_POLISH_VOICE_ID = 'pl_PL-gosia-medium';
|
|
83
|
+
|
|
84
|
+
/** All valid voice ids, for error messages and validation. */
|
|
85
|
+
export function voiceIds(): string[] {
|
|
86
|
+
return VOICE_CATALOG.map((v) => v.id);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function findVoice(id: string): VoiceEntry | undefined {
|
|
90
|
+
return VOICE_CATALOG.find((v) => v.id === id);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Resolve a voice id to its catalog entry, or throw a clear, actionable error
|
|
94
|
+
* listing the valid ids. `field` names where the bad id came from. */
|
|
95
|
+
export function requireVoice(id: string, field: string): VoiceEntry {
|
|
96
|
+
const entry = findVoice(id);
|
|
97
|
+
if (!entry) {
|
|
98
|
+
throw new MoxxyError({
|
|
99
|
+
code: 'CONFIG_INVALID',
|
|
100
|
+
message: `Unknown local voice ${JSON.stringify(id)} (${field}).`,
|
|
101
|
+
hint: `Valid voices: ${voiceIds().join(', ')}.`,
|
|
102
|
+
context: { field, voice: id },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return entry;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface RouteVoiceInput {
|
|
109
|
+
/** Explicit per-call voice id (`SynthesizeOptions.voice`) — overrides all. */
|
|
110
|
+
readonly requestedVoice?: string;
|
|
111
|
+
/** Per-call BCP-47 language hint (`SynthesizeOptions.language`). */
|
|
112
|
+
readonly language?: string;
|
|
113
|
+
/** Configured default (non-Polish) voice id. */
|
|
114
|
+
readonly defaultVoice: string;
|
|
115
|
+
/** Configured Polish voice id. */
|
|
116
|
+
readonly polishVoice: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Pick the voice for a synthesis call:
|
|
121
|
+
* 1. an explicit `requestedVoice` (a catalog id) wins outright;
|
|
122
|
+
* 2. else a `language` starting with `pl` routes to the Polish voice;
|
|
123
|
+
* 3. else the configured default voice.
|
|
124
|
+
* An unknown id (in any of the three) throws a clear `CONFIG_INVALID` error.
|
|
125
|
+
*/
|
|
126
|
+
export function routeVoice(input: RouteVoiceInput): VoiceEntry {
|
|
127
|
+
if (input.requestedVoice) {
|
|
128
|
+
return requireVoice(input.requestedVoice, 'voice');
|
|
129
|
+
}
|
|
130
|
+
const lang = (input.language ?? '').trim().toLowerCase();
|
|
131
|
+
if (lang.startsWith('pl')) {
|
|
132
|
+
return requireVoice(input.polishVoice, 'polishVoice');
|
|
133
|
+
}
|
|
134
|
+
return requireVoice(input.defaultVoice, 'voice');
|
|
135
|
+
}
|
package/src/wav.test.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { encodeWav, WAV_HEADER_BYTES } from './wav.js';
|
|
3
|
+
|
|
4
|
+
function ascii(bytes: Uint8Array, start: number, len: number): string {
|
|
5
|
+
return String.fromCharCode(...bytes.subarray(start, start + len));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe('encodeWav', () => {
|
|
9
|
+
it('writes a canonical mono PCM16 header (golden)', () => {
|
|
10
|
+
const samples = new Float32Array([0, 1, -1, 0.5]);
|
|
11
|
+
const wav = encodeWav(samples, 8000);
|
|
12
|
+
const view = new DataView(wav.buffer, wav.byteOffset, wav.byteLength);
|
|
13
|
+
const dataSize = samples.length * 2;
|
|
14
|
+
|
|
15
|
+
expect(wav.byteLength).toBe(WAV_HEADER_BYTES + dataSize);
|
|
16
|
+
expect(ascii(wav, 0, 4)).toBe('RIFF');
|
|
17
|
+
expect(view.getUint32(4, true)).toBe(36 + dataSize);
|
|
18
|
+
expect(ascii(wav, 8, 4)).toBe('WAVE');
|
|
19
|
+
expect(ascii(wav, 12, 4)).toBe('fmt ');
|
|
20
|
+
expect(view.getUint32(16, true)).toBe(16); // PCM fmt size
|
|
21
|
+
expect(view.getUint16(20, true)).toBe(1); // PCM
|
|
22
|
+
expect(view.getUint16(22, true)).toBe(1); // mono
|
|
23
|
+
expect(view.getUint32(24, true)).toBe(8000); // sample rate
|
|
24
|
+
expect(view.getUint32(28, true)).toBe(8000 * 2); // byte rate (mono, 2 bytes)
|
|
25
|
+
expect(view.getUint16(32, true)).toBe(2); // block align
|
|
26
|
+
expect(view.getUint16(34, true)).toBe(16); // bits per sample
|
|
27
|
+
expect(ascii(wav, 36, 4)).toBe('data');
|
|
28
|
+
expect(view.getUint32(40, true)).toBe(dataSize);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('maps full-scale samples to the int16 range edges', () => {
|
|
32
|
+
const wav = encodeWav(new Float32Array([0, 1, -1, 0.5]), 8000);
|
|
33
|
+
const view = new DataView(wav.buffer, wav.byteOffset, wav.byteLength);
|
|
34
|
+
expect(view.getInt16(WAV_HEADER_BYTES + 0, true)).toBe(0);
|
|
35
|
+
expect(view.getInt16(WAV_HEADER_BYTES + 2, true)).toBe(32767); // +1.0
|
|
36
|
+
expect(view.getInt16(WAV_HEADER_BYTES + 4, true)).toBe(-32768); // -1.0
|
|
37
|
+
expect(view.getInt16(WAV_HEADER_BYTES + 6, true)).toBe(16384); // +0.5
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('clamps out-of-range samples and encodes NaN as silence', () => {
|
|
41
|
+
const wav = encodeWav(new Float32Array([2, -2, Number.NaN]), 16000);
|
|
42
|
+
const view = new DataView(wav.buffer, wav.byteOffset, wav.byteLength);
|
|
43
|
+
expect(view.getInt16(WAV_HEADER_BYTES + 0, true)).toBe(32767);
|
|
44
|
+
expect(view.getInt16(WAV_HEADER_BYTES + 2, true)).toBe(-32768);
|
|
45
|
+
expect(view.getInt16(WAV_HEADER_BYTES + 4, true)).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('encodes an empty sample array to a bare 44-byte header', () => {
|
|
49
|
+
const wav = encodeWav(new Float32Array([]), 22050);
|
|
50
|
+
expect(wav.byteLength).toBe(WAV_HEADER_BYTES);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('rejects an invalid sample rate', () => {
|
|
54
|
+
expect(() => encodeWav(new Float32Array([0]), 0)).toThrow();
|
|
55
|
+
expect(() => encodeWav(new Float32Array([0]), Number.NaN)).toThrow();
|
|
56
|
+
});
|
|
57
|
+
});
|
package/src/wav.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
|
|
12
|
+
/** Bytes in the RIFF/WAVE header preceding the PCM data. */
|
|
13
|
+
export const WAV_HEADER_BYTES = 44;
|
|
14
|
+
|
|
15
|
+
function writeAscii(view: DataView, offset: number, text: string): void {
|
|
16
|
+
for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Encode float samples as a mono 16-bit PCM WAV. `sampleRate` must be a
|
|
21
|
+
* positive integer (sherpa reports e.g. 22050). Samples are clamped to
|
|
22
|
+
* [-1, 1]; non-finite samples encode as silence.
|
|
23
|
+
*/
|
|
24
|
+
export function encodeWav(samples: Float32Array, sampleRate: number): Uint8Array {
|
|
25
|
+
if (!Number.isFinite(sampleRate) || sampleRate <= 0) {
|
|
26
|
+
throw new Error(`encodeWav: invalid sampleRate ${sampleRate}`);
|
|
27
|
+
}
|
|
28
|
+
const rate = Math.round(sampleRate);
|
|
29
|
+
const numSamples = samples.length;
|
|
30
|
+
const bytesPerSample = 2; // PCM16
|
|
31
|
+
const blockAlign = bytesPerSample; // mono
|
|
32
|
+
const byteRate = rate * blockAlign;
|
|
33
|
+
const dataSize = numSamples * bytesPerSample;
|
|
34
|
+
const buffer = new ArrayBuffer(WAV_HEADER_BYTES + dataSize);
|
|
35
|
+
const view = new DataView(buffer);
|
|
36
|
+
|
|
37
|
+
// RIFF chunk descriptor
|
|
38
|
+
writeAscii(view, 0, 'RIFF');
|
|
39
|
+
view.setUint32(4, 36 + dataSize, true); // ChunkSize = 4 + (8 + Subchunk1) + (8 + data)
|
|
40
|
+
writeAscii(view, 8, 'WAVE');
|
|
41
|
+
// fmt subchunk
|
|
42
|
+
writeAscii(view, 12, 'fmt ');
|
|
43
|
+
view.setUint32(16, 16, true); // Subchunk1Size for PCM
|
|
44
|
+
view.setUint16(20, 1, true); // AudioFormat = 1 (PCM)
|
|
45
|
+
view.setUint16(22, 1, true); // NumChannels = 1 (mono)
|
|
46
|
+
view.setUint32(24, rate, true); // SampleRate
|
|
47
|
+
view.setUint32(28, byteRate, true); // ByteRate
|
|
48
|
+
view.setUint16(32, blockAlign, true); // BlockAlign
|
|
49
|
+
view.setUint16(34, 16, true); // BitsPerSample
|
|
50
|
+
// data subchunk
|
|
51
|
+
writeAscii(view, 36, 'data');
|
|
52
|
+
view.setUint32(40, dataSize, true);
|
|
53
|
+
|
|
54
|
+
let offset = WAV_HEADER_BYTES;
|
|
55
|
+
for (let i = 0; i < numSamples; i += 1) {
|
|
56
|
+
const raw = samples[i];
|
|
57
|
+
const s = Number.isFinite(raw as number) ? Math.max(-1, Math.min(1, raw as number)) : 0;
|
|
58
|
+
// Asymmetric scaling so full-scale positive/negative map to the int16 range
|
|
59
|
+
// edges without overflow.
|
|
60
|
+
const int16 = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7fff);
|
|
61
|
+
view.setInt16(offset, int16, true);
|
|
62
|
+
offset += 2;
|
|
63
|
+
}
|
|
64
|
+
return new Uint8Array(buffer);
|
|
65
|
+
}
|