@qvac/tts-ggml 0.14.0 → 0.14.1
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/README.md +33 -1
- package/examples/audio8-streaming.js +74 -0
- package/index.js +3 -0
- package/lib/streamingSupport.js +2 -2
- package/package.json +9 -9
- package/test/integration/audio8-streaming.test.js +66 -0
- package/tts.d.ts +1 -0
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ and registry prerequisites described under [MOSS](#moss).
|
|
|
54
54
|
|
|
55
55
|
- Batch synthesis (`run({ input })` → single PCM buffer).
|
|
56
56
|
- **Sentence-granularity streaming** — `runStreaming(asyncIterable)`:
|
|
57
|
-
yields
|
|
57
|
+
yields audio for each input sentence; Audio8 emits PCM as codec windows finish.
|
|
58
58
|
- **Native per-chunk streaming** — set `streamChunkTokens` and audio
|
|
59
59
|
flows out of the C++ engine chunk-by-chunk as T3 tokens produce
|
|
60
60
|
S3Gen+HiFT output; sub-second first-audio-out inside a single
|
|
@@ -982,6 +982,19 @@ await model.load()
|
|
|
982
982
|
await model.run({ input: 'Hello from a fully on-device pipeline.' })
|
|
983
983
|
```
|
|
984
984
|
|
|
985
|
+
`runStream(text)`, `runStreaming(asyncIterable)` and
|
|
986
|
+
`run({ input: text, streamOutput: true })` emit PCM as codec windows finish,
|
|
987
|
+
before the current text chunk's complete waveform is ready. Ordinary `run()`
|
|
988
|
+
retains batch synthesis. Native end markers have an empty `outputArray`. Use `response.await()`
|
|
989
|
+
for completion and aggregate statistics, including for asynchronous text input. PCM window sizes are fixed by the codec, so
|
|
990
|
+
`streamChunkTokens` and `streamFirstChunkTokens` remain unsupported.
|
|
991
|
+
|
|
992
|
+
The native streaming API is included in the pinned `speech-cpp` dependency.
|
|
993
|
+
Rebuild the addon to use it; an older published prebuild does not gain native
|
|
994
|
+
streaming from a JavaScript update alone.
|
|
995
|
+
Use [audio8-streaming.js](examples/audio8-streaming.js) to measure first PCM
|
|
996
|
+
and completion through the wrapper, excluding model loading.
|
|
997
|
+
|
|
985
998
|
Sampling is **repetition-aware**: a semantic token that repeats one from the
|
|
986
999
|
recent window is re-drawn under a narrower nucleus at a higher temperature,
|
|
987
1000
|
which is what keeps the model out of the babble attractor that pure top-p
|
|
@@ -1005,6 +1018,15 @@ synthesis, the analysis half only to clone a voice or run a dialogue, so a
|
|
|
1005
1018
|
text-only deployment can omit `files.mossCodecEncoder`. The backbones have 8B
|
|
1006
1019
|
parameters, so MOSS targets desktop hosts; mobile is not supported.
|
|
1007
1020
|
|
|
1021
|
+
On Metal, the backbone projects only the text-control logits needed during
|
|
1022
|
+
audio generation and omits text projection during the forced drain. Embedding
|
|
1023
|
+
tables stay on the GPU, and the model weight type is preserved.
|
|
1024
|
+
|
|
1025
|
+
When `dialogueReferences` contains one recording, its encoded codes also serve
|
|
1026
|
+
as continuation, avoiding a duplicate encoder pass during engine creation.
|
|
1027
|
+
Multiple references retain their joined-audio continuation encoding. Prepared
|
|
1028
|
+
reference codes stay cached per instance for batch and streaming synthesis.
|
|
1029
|
+
|
|
1008
1030
|
```js
|
|
1009
1031
|
const model = new TTSGgml({
|
|
1010
1032
|
engine: TTSGgml.ENGINE_MOSS,
|
|
@@ -1182,6 +1204,15 @@ to 30 seconds. It is not a speech model, and it ships as one GGUF. Select it
|
|
|
1182
1204
|
with `engine: TTSGgml.ENGINE_MOSS_SFX` or by staging `moss-sfx-*.gguf` in the
|
|
1183
1205
|
`modelDir`.
|
|
1184
1206
|
|
|
1207
|
+
Native generation reuses fixed cross-attention K/V across diffusion steps and
|
|
1208
|
+
uses Metal DiT FlashAttention when the GPU supports both attention shapes.
|
|
1209
|
+
Memory preflight includes the request cache (360 MiB for guided generation
|
|
1210
|
+
with the released models) alongside the scheduler's retained peak across text
|
|
1211
|
+
encoding, diffusion and VAE decoding, including repeated requests.
|
|
1212
|
+
FlashAttention can slightly change waveform samples. See the
|
|
1213
|
+
[native SoundEffect guide](https://github.com/tetherto/qvac-fabric-speech.cpp/blob/33174cc8693b1c8cdb44b5ff57b0f120bc89e454/engines/tts/docs/moss.md#moss-soundeffect)
|
|
1214
|
+
for precision details and the M1 Ultra measurements.
|
|
1215
|
+
|
|
1185
1216
|
```js
|
|
1186
1217
|
const sfx = new TTSGgml({
|
|
1187
1218
|
engine: TTSGgml.ENGINE_MOSS_SFX,
|
|
@@ -1585,6 +1616,7 @@ Runnable demos under `examples/`:
|
|
|
1585
1616
|
| `parler-enhanced.js` | Parler + LavaSR 48 kHz enhancement. `bare examples/parler-enhanced.js "Hello" Laura happy` |
|
|
1586
1617
|
| `cosyvoice-tts.js` | CosyVoice3 batch synth with the cross-engine emotion option (24 kHz; CPU by default, `--gpu` opts into Metal / desktop Vulkan / Android GPU). `bare examples/cosyvoice-tts.js --gpu "Hello" happy` |
|
|
1587
1618
|
| `cosyvoice-enhanced.js` | CosyVoice3 + LavaSR 48 kHz enhancement (add `--denoise` for the denoiser). `bare examples/cosyvoice-enhanced.js "Hello"` |
|
|
1619
|
+
| `audio8-streaming.js` | Measure Audio8 wrapper batch, streaming, or duplex latency: `bare examples/audio8-streaming.js ./models stream "Hello"`. Modes: `batch`, `stream`, `duplex`. |
|
|
1588
1620
|
| `audio8-tts.js` | Audio8 batch synth, optionally cloning a reference. Set `QVAC_TTS_AUDIO8_GPU=1` for the desktop GPU backend (CUDA or Vulkan on linux, Vulkan on Windows). `bare examples/audio8-tts.js "Hello" voice.wav "What it says."` |
|
|
1589
1621
|
| `moss-tts.js` | MOSS batch synth, optionally cloning a reference. Set `QVAC_TTS_MOSS_STREAM_FRAMES=25` for native chunk streaming, `QVAC_TTS_MOSS_DURATION=38` for a target length and `QVAC_TTS_MOSS_GPU=1` for the GPU backend. `bare examples/moss-tts.js "Hello" voice.wav` |
|
|
1590
1622
|
| `moss-sound-effect.js` | MOSS-SoundEffect text-to-sound-effects at 48 kHz. Set `QVAC_TTS_MOSS_SFX_GPU=1` for the GPU backend and `QVAC_TTS_MOSS_SFX_STEPS` to trade quality for time. `bare examples/moss-sound-effect.js "Rain on a tin roof." 5` |
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const TTSGgml = require('..')
|
|
4
|
+
const process = require('bare-process')
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TEXT = 'The first audio should arrive while the rest of this response is generated.'
|
|
7
|
+
const MODES = ['batch', 'stream', 'duplex']
|
|
8
|
+
|
|
9
|
+
async function* textSource(text) {
|
|
10
|
+
yield text
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function start(model, mode, text) {
|
|
14
|
+
if (mode === 'batch') return model.run({ input: text })
|
|
15
|
+
if (mode === 'stream') return model.runStream(text, { maxChunkScalars: Array.from(text).length })
|
|
16
|
+
return model.runStreaming(textSource(text), { accumulateSentences: false })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function measure(model, mode, text) {
|
|
20
|
+
const started = Date.now()
|
|
21
|
+
let firstAudioMs = null
|
|
22
|
+
let samples = 0
|
|
23
|
+
let sampleRate = 0
|
|
24
|
+
let chunks = 0
|
|
25
|
+
const response = await start(model, mode, text)
|
|
26
|
+
response.onUpdate((data) => {
|
|
27
|
+
if (!data.outputArray?.length) return
|
|
28
|
+
firstAudioMs ??= Date.now() - started
|
|
29
|
+
samples += data.outputArray.length
|
|
30
|
+
sampleRate = data.sampleRate
|
|
31
|
+
chunks++
|
|
32
|
+
})
|
|
33
|
+
await response.await()
|
|
34
|
+
if (firstAudioMs === null || sampleRate <= 0) throw new Error('Synthesis returned no audio')
|
|
35
|
+
console.log(
|
|
36
|
+
JSON.stringify({
|
|
37
|
+
mode,
|
|
38
|
+
firstAudioMs,
|
|
39
|
+
totalMs: Date.now() - started,
|
|
40
|
+
audioSeconds: samples / sampleRate,
|
|
41
|
+
chunks,
|
|
42
|
+
stats: response.stats
|
|
43
|
+
})
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function main() {
|
|
48
|
+
const [, , modelDir, mode = 'stream', text = DEFAULT_TEXT] = process.argv
|
|
49
|
+
if (!modelDir || !MODES.includes(mode)) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
'Usage: bare examples/audio8-streaming.js MODEL_DIR [batch|stream|duplex] [TEXT]'
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
const model = new TTSGgml({
|
|
55
|
+
engine: TTSGgml.ENGINE_AUDIO8,
|
|
56
|
+
files: { modelDir },
|
|
57
|
+
greedy: true,
|
|
58
|
+
seed: 42,
|
|
59
|
+
threads: 4,
|
|
60
|
+
config: { useGPU: process.env.QVAC_TTS_AUDIO8_GPU === '1' },
|
|
61
|
+
opts: { stats: true }
|
|
62
|
+
})
|
|
63
|
+
try {
|
|
64
|
+
await model.load()
|
|
65
|
+
await measure(model, mode, text)
|
|
66
|
+
} finally {
|
|
67
|
+
await model.unload()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
main().catch((error) => {
|
|
72
|
+
console.error(error)
|
|
73
|
+
process.exitCode = 1
|
|
74
|
+
})
|
package/index.js
CHANGED
|
@@ -341,6 +341,9 @@ class TTSGgml {
|
|
|
341
341
|
}
|
|
342
342
|
async _dispatchJob(input) {
|
|
343
343
|
const addon = this._requireAddon();
|
|
344
|
+
if (this._setup.engineType === engineIds_1.ENGINE_AUDIO8 && this._sentenceStream.ctx) {
|
|
345
|
+
return addon.runJob({ ...input, streamOutput: true });
|
|
346
|
+
}
|
|
344
347
|
return this._pocket ? this._pocket.runJob(addon, input) : addon.runJob(input);
|
|
345
348
|
}
|
|
346
349
|
async cancel() {
|
package/lib/streamingSupport.js
CHANGED
|
@@ -31,8 +31,8 @@ function assertChunkStreamingSupport(setup) {
|
|
|
31
31
|
(settings.streamChunkTokens != null ||
|
|
32
32
|
settings.streamFirstChunkTokens != null)) {
|
|
33
33
|
throw new Error("tts-ggml: streamChunkTokens / streamFirstChunkTokens are not " +
|
|
34
|
-
"supported by the audio8 engine, which
|
|
35
|
-
"
|
|
34
|
+
"supported by the audio8 engine, which uses fixed codec windows. " +
|
|
35
|
+
"Use incremental PCM streaming via " +
|
|
36
36
|
"runStream() / runStreaming() / run({ streamOutput: true }).");
|
|
37
37
|
}
|
|
38
38
|
if ((engineType === engineIds_1.ENGINE_MOSS_SFX || engineType === engineIds_1.ENGINE_MOSS_SPEECH) &&
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qvac/tts-ggml",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "Text to Speech (TTS) addon for qvac (ggml backend, wrapping the chatterbox + supertonic + parler + cosyvoice3 + audio8 + moss + moss-sfx + moss-speech + Pocket engines from tts-cpp)",
|
|
5
5
|
"qvac": {
|
|
6
|
-
"ggml-speech": "2026-10-
|
|
6
|
+
"ggml-speech": "2026-10-27"
|
|
7
7
|
},
|
|
8
8
|
"engines": {
|
|
9
9
|
"bare": ">=1.19.0"
|
|
@@ -218,16 +218,16 @@
|
|
|
218
218
|
"bare-url": "^2.4.3"
|
|
219
219
|
},
|
|
220
220
|
"optionalDependencies": {
|
|
221
|
-
"@qvac/tts-ggml-linux-x64": "0.14.
|
|
222
|
-
"@qvac/tts-ggml-linux-arm64": "0.14.
|
|
223
|
-
"@qvac/tts-ggml-darwin-arm64": "0.14.
|
|
224
|
-
"@qvac/tts-ggml-darwin-x64": "0.14.
|
|
225
|
-
"@qvac/tts-ggml-win32-x64": "0.14.
|
|
221
|
+
"@qvac/tts-ggml-linux-x64": "0.14.1",
|
|
222
|
+
"@qvac/tts-ggml-linux-arm64": "0.14.1",
|
|
223
|
+
"@qvac/tts-ggml-darwin-arm64": "0.14.1",
|
|
224
|
+
"@qvac/tts-ggml-darwin-x64": "0.14.1",
|
|
225
|
+
"@qvac/tts-ggml-win32-x64": "0.14.1"
|
|
226
226
|
},
|
|
227
227
|
"types": "index.d.ts",
|
|
228
228
|
"peerDependencies": {
|
|
229
|
-
"@qvac/tts-ggml-android-arm64": "0.14.
|
|
230
|
-
"@qvac/tts-ggml-ios": "0.14.
|
|
229
|
+
"@qvac/tts-ggml-android-arm64": "0.14.1",
|
|
230
|
+
"@qvac/tts-ggml-ios": "0.14.1"
|
|
231
231
|
},
|
|
232
232
|
"peerDependenciesMeta": {
|
|
233
233
|
"@qvac/tts-ggml-android-arm64": {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('brittle')
|
|
4
|
+
const process = require('bare-process')
|
|
5
|
+
const TTSGgml = require('../..')
|
|
6
|
+
|
|
7
|
+
const LM = process.env.QVAC_TEST_AUDIO8_LM_GGUF
|
|
8
|
+
const DECODER = process.env.QVAC_TEST_AUDIO8_DECODER_GGUF
|
|
9
|
+
const FRAME_LIMIT = 48
|
|
10
|
+
const TEXT = 'The listener should hear the first words before the complete response is generated.'
|
|
11
|
+
const TIMEOUT_MS = 180000
|
|
12
|
+
|
|
13
|
+
async function modelFiles() {
|
|
14
|
+
if (LM && DECODER) return { audio8Lm: LM, audio8CodecDecoder: DECODER }
|
|
15
|
+
const { ensureAudio8Models } = require('../utils/downloadModel')
|
|
16
|
+
const models = await ensureAudio8Models({ includeEncoder: false })
|
|
17
|
+
if (!models.success) throw new Error('Audio8 streaming tests require LM and decoder GGUFs')
|
|
18
|
+
return { audio8Lm: models.lmPath, audio8CodecDecoder: models.decoderPath }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function* source() {
|
|
22
|
+
yield TEXT
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function run(model, method) {
|
|
26
|
+
if (method === 'runStream') return model.runStream(TEXT)
|
|
27
|
+
if (method === 'runStreaming') {
|
|
28
|
+
return model.runStreaming(source(), { accumulateSentences: false })
|
|
29
|
+
}
|
|
30
|
+
return model.run({ input: TEXT, streamOutput: true })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const method of ['runStream', 'runStreaming', 'streamOutput']) {
|
|
34
|
+
test(
|
|
35
|
+
`Audio8 ${method} delivers native PCM without replay`,
|
|
36
|
+
{ timeout: TIMEOUT_MS },
|
|
37
|
+
async (t) => {
|
|
38
|
+
const model = new TTSGgml({
|
|
39
|
+
engine: TTSGgml.ENGINE_AUDIO8,
|
|
40
|
+
files: await modelFiles(),
|
|
41
|
+
greedy: true,
|
|
42
|
+
threads: 4,
|
|
43
|
+
maxFrames: FRAME_LIMIT,
|
|
44
|
+
opts: { stats: true }
|
|
45
|
+
})
|
|
46
|
+
try {
|
|
47
|
+
await model.load()
|
|
48
|
+
let chunks = 0
|
|
49
|
+
let samples = 0
|
|
50
|
+
const response = await run(model, method)
|
|
51
|
+
response.onUpdate((chunk) => {
|
|
52
|
+
if (!chunk.outputArray?.length) return
|
|
53
|
+
t.is(chunk.sampleRate, 44100)
|
|
54
|
+
chunks++
|
|
55
|
+
samples += chunk.outputArray.length
|
|
56
|
+
})
|
|
57
|
+
await response.await()
|
|
58
|
+
t.ok(chunks > 1, 'multiple codec windows reached JavaScript')
|
|
59
|
+
t.is(samples, response.stats.totalSamples, 'PCM was delivered exactly once')
|
|
60
|
+
t.is(response.stats.generatedFrames, FRAME_LIMIT)
|
|
61
|
+
} finally {
|
|
62
|
+
await model.unload()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
}
|