@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 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 one audio chunk per input sentence.
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() {
@@ -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 synthesizes a whole " +
35
- "utterance at a time. Use sentence-level streaming via " +
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.0",
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-23"
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.0",
222
- "@qvac/tts-ggml-linux-arm64": "0.14.0",
223
- "@qvac/tts-ggml-darwin-arm64": "0.14.0",
224
- "@qvac/tts-ggml-darwin-x64": "0.14.0",
225
- "@qvac/tts-ggml-win32-x64": "0.14.0"
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.0",
230
- "@qvac/tts-ggml-ios": "0.14.0"
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
+ }
package/tts.d.ts CHANGED
@@ -2,6 +2,7 @@ export interface TTSConfigurationParams {
2
2
  [key: string]: string | number | boolean | string[] | undefined;
3
3
  }
4
4
  export interface TTSJobData {
5
+ streamOutput?: boolean;
5
6
  type: string;
6
7
  input: string;
7
8
  description?: string;