@qvac/tts-ggml 0.12.0 → 0.14.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.
Files changed (80) hide show
  1. package/README.md +127 -6
  2. package/docs/pocket-tts.md +12 -10
  3. package/examples/moss-sfx-fit.js +14 -7
  4. package/index.d.ts +43 -1130
  5. package/index.js +127 -3042
  6. package/lib/addonEvents.d.ts +26 -0
  7. package/lib/addonEvents.js +51 -0
  8. package/lib/conditioning.d.ts +50 -0
  9. package/lib/conditioning.js +184 -0
  10. package/lib/engineDetection.d.ts +14 -0
  11. package/lib/engineDetection.js +110 -0
  12. package/lib/engineIds.d.ts +15 -0
  13. package/lib/engineIds.js +39 -0
  14. package/lib/engineParams.d.ts +3 -0
  15. package/lib/engineParams.js +34 -0
  16. package/lib/engineSetup.d.ts +153 -0
  17. package/lib/engineSetup.js +2 -0
  18. package/lib/engines/audio8.d.ts +83 -0
  19. package/lib/engines/audio8.js +262 -0
  20. package/lib/engines/chatterbox.d.ts +10 -0
  21. package/lib/engines/chatterbox.js +112 -0
  22. package/lib/engines/cosyvoice.d.ts +87 -0
  23. package/lib/engines/cosyvoice.js +267 -0
  24. package/lib/engines/moss.d.ts +26 -0
  25. package/lib/engines/moss.js +177 -0
  26. package/lib/engines/mossSoundEffect.d.ts +41 -0
  27. package/lib/engines/mossSoundEffect.js +117 -0
  28. package/lib/engines/mossSpeech.d.ts +86 -0
  29. package/lib/engines/mossSpeech.js +228 -0
  30. package/lib/engines/parler.d.ts +71 -0
  31. package/lib/engines/parler.js +287 -0
  32. package/lib/engines/supertonic.d.ts +11 -0
  33. package/lib/engines/supertonic.js +115 -0
  34. package/lib/exclusiveQueue.d.ts +16 -0
  35. package/lib/exclusiveQueue.js +45 -0
  36. package/lib/fit.d.ts +3 -3
  37. package/lib/fit.js +4 -4
  38. package/lib/jobFields.d.ts +20 -0
  39. package/lib/jobFields.js +73 -0
  40. package/lib/modelFiles.d.ts +207 -0
  41. package/lib/modelFiles.js +117 -0
  42. package/lib/nativeParams.d.ts +12 -0
  43. package/lib/nativeParams.js +73 -0
  44. package/lib/optionValidation.d.ts +2 -0
  45. package/lib/optionValidation.js +103 -0
  46. package/lib/options.d.ts +4 -0
  47. package/lib/options.js +17 -0
  48. package/lib/pocketLifecycle.d.ts +67 -0
  49. package/lib/pocketLifecycle.js +170 -0
  50. package/lib/reload.d.ts +12 -0
  51. package/lib/reload.js +76 -0
  52. package/lib/sentenceStream.d.ts +85 -0
  53. package/lib/sentenceStream.js +367 -0
  54. package/lib/setup.d.ts +7 -0
  55. package/lib/setup.js +169 -0
  56. package/lib/streamingSupport.d.ts +8 -0
  57. package/lib/streamingSupport.js +77 -0
  58. package/lib/types.d.ts +465 -0
  59. package/lib/types.js +2 -0
  60. package/lib/voiceControls.d.ts +12 -0
  61. package/lib/voiceControls.js +23 -0
  62. package/package.json +17 -11
  63. package/test/integration/audio8.test.js +6 -12
  64. package/test/integration/fit.test.js +115 -18
  65. package/test/integration/gpu-smoke.test.js +2 -11
  66. package/test/integration/lavasr-enhancer.test.js +2 -15
  67. package/test/integration/moss-fit.test.js +13 -13
  68. package/test/integration/moss-sfx-fit.test.js +14 -14
  69. package/test/integration/moss-sfx.test.js +29 -4
  70. package/test/integration/moss-speech.test.js +19 -5
  71. package/test/integration/moss.test.js +93 -56
  72. package/test/mobile/integration.auto.cjs +10 -0
  73. package/test/mobile/test-groups.json +6 -2
  74. package/test/utils/desktopGpuBackend.js +21 -0
  75. package/test/utils/gpuOnly.js +15 -0
  76. package/test/utils/mossBackend.js +34 -0
  77. package/test/utils/mossGpuValidation.js +70 -0
  78. package/test/utils/perf-helper.js +275 -0
  79. package/test/utils/runTTS.js +1 -1
  80. package/test/utils/testThreads.js +5 -0
@@ -64,7 +64,7 @@ test('a projection is internally consistent', { timeout: TEST_TIMEOUT_MS }, asyn
64
64
  const model = await supertonicPath(t)
65
65
  if (model === null) return
66
66
 
67
- const fit = TTSGgml.assessFit({
67
+ const fit = await TTSGgml.assessFit({
68
68
  engineType: 'supertonic',
69
69
  supertonicModelPath: model,
70
70
  useGPU: true
@@ -84,13 +84,13 @@ test('a weightless copy projects the same', { timeout: TEST_TIMEOUT_MS }, async
84
84
  const model = await supertonicPath(t)
85
85
  if (model === null) return
86
86
 
87
- const full = TTSGgml.assessFit({
87
+ const full = await TTSGgml.assessFit({
88
88
  engineType: 'supertonic',
89
89
  supertonicModelPath: model,
90
90
  useGPU: true
91
91
  })
92
92
  if (!projected(t, full)) return
93
- const stub = TTSGgml.assessFit({
93
+ const stub = await TTSGgml.assessFit({
94
94
  engineType: 'supertonic',
95
95
  supertonicModelPath: weightlessCopy(tempDir(t), model, 1024 * 1024),
96
96
  useGPU: true
@@ -106,12 +106,12 @@ test('the load’s offload keys reach the projection', { timeout: TEST_TIMEOUT_M
106
106
  const model = await supertonicPath(t)
107
107
  if (model === null) return
108
108
 
109
- const cpu = TTSGgml.assessFit({
109
+ const cpu = await TTSGgml.assessFit({
110
110
  engineType: 'supertonic',
111
111
  supertonicModelPath: model,
112
112
  useGPU: false
113
113
  })
114
- const pinned = TTSGgml.assessFit({
114
+ const pinned = await TTSGgml.assessFit({
115
115
  engineType: 'supertonic',
116
116
  supertonicModelPath: model,
117
117
  nGpuLayers: 0
@@ -129,7 +129,7 @@ test('a LavaSR stage is sized beside the projection', { timeout: TEST_TIMEOUT_MS
129
129
  const enhancer = path.join(dir, 'enhancer.gguf')
130
130
  fs.writeFileSync(enhancer, Buffer.alloc(4096, 1))
131
131
 
132
- const fit = TTSGgml.assessFit({
132
+ const fit = await TTSGgml.assessFit({
133
133
  engineType: 'supertonic',
134
134
  supertonicModelPath: model,
135
135
  useGPU: true,
@@ -141,8 +141,97 @@ test('a LavaSR stage is sized beside the projection', { timeout: TEST_TIMEOUT_MS
141
141
  t.ok(fit.lavasrFileBytes < fit.deviceBytes, 'reported beside the projection')
142
142
  })
143
143
 
144
- test('MOSS routes to its fitter when a model cannot be read', (t) => {
145
- const fit = TTSGgml.assessFit({
144
+ test('fitting keeps the event loop responsive', { timeout: TEST_TIMEOUT_MS }, async (t) => {
145
+ const model = await supertonicPath(t)
146
+ if (model === null) return
147
+
148
+ const request = { engineType: 'supertonic', supertonicModelPath: model, useGPU: true }
149
+
150
+ let pending = true
151
+ let turns = 0
152
+ const spin = () => {
153
+ if (!pending) return
154
+ turns++
155
+ setImmediate(spin)
156
+ }
157
+ setImmediate(spin)
158
+
159
+ const first = TTSGgml.assessFit(request)
160
+ t.ok(first instanceof Promise, 'the call returns before the projection')
161
+ const fits = await Promise.all([
162
+ first,
163
+ ...Array.from({ length: 15 }, () => TTSGgml.assessFit(request))
164
+ ])
165
+ pending = false
166
+
167
+ t.comment(`${turns} loop turns before the fits settled`)
168
+ t.ok(turns > 0, 'the loop turned while the fitter ran')
169
+ for (const fit of fits) {
170
+ t.not(fit.reason, 'model-unreadable', 'every queued projection ran')
171
+ }
172
+ })
173
+
174
+ test(
175
+ 'concurrent fits keep their own requests and all settle',
176
+ { timeout: TEST_TIMEOUT_MS },
177
+ async (t) => {
178
+ const model = await supertonicPath(t)
179
+ if (model === null) return
180
+
181
+ const dir = tempDir(t)
182
+ const enhancer = path.join(dir, 'enhancer.gguf')
183
+ fs.writeFileSync(enhancer, Buffer.alloc(4096, 1))
184
+
185
+ const request = {
186
+ engineType: 'supertonic',
187
+ supertonicModelPath: model,
188
+ useGPU: false,
189
+ lavasrEnhancerPath: enhancer
190
+ }
191
+ const first = TTSGgml.assessFit(request)
192
+ request.lavasrEnhancerPath = ''
193
+ const second = TTSGgml.assessFit(request)
194
+ const missing = TTSGgml.assessFit({
195
+ engineType: 'supertonic',
196
+ supertonicModelPath: '/nonexistent/model.gguf'
197
+ })
198
+ const [a, b, c] = await Promise.all([first, second, missing])
199
+
200
+ t.is(a.lavasrFileBytes, 4096, 'the first call owns its original request')
201
+ t.is(b.lavasrFileBytes, 0, 'the second call owns its changed request')
202
+ t.is(c.status, 'error', 'an error outcome does not strand queued callers')
203
+ }
204
+ )
205
+
206
+ test('a fit settles while a loaded model unloads', { timeout: TEST_TIMEOUT_MS }, async (t) => {
207
+ const model = await supertonicPath(t)
208
+ if (model === null) return
209
+
210
+ const voice = new TTSGgml({
211
+ engine: TTSGgml.ENGINE_SUPERTONIC,
212
+ files: { supertonicModel: model },
213
+ voice: 'F1',
214
+ config: { language: 'en', useGPU: false }
215
+ })
216
+ await voice.load()
217
+ try {
218
+ const fit = TTSGgml.assessFit({
219
+ engineType: 'supertonic',
220
+ supertonicModelPath: model,
221
+ useGPU: false
222
+ })
223
+ await voice.unload()
224
+ const result = await fit
225
+ t.ok(result.deviceTotalBytes > 0, 'the projection measured a device')
226
+ } finally {
227
+ try {
228
+ await voice.unload()
229
+ } catch (_e) {}
230
+ }
231
+ })
232
+
233
+ test('MOSS routes to its fitter when a model cannot be read', async (t) => {
234
+ const fit = await TTSGgml.assessFit({
146
235
  engineType: 'moss',
147
236
  mossBackbonePath: '/models/moss-tts-delay.gguf',
148
237
  mossCodecDecoderPath: '/models/moss-codec-decoder.gguf',
@@ -156,8 +245,8 @@ test('MOSS routes to its fitter when a model cannot be read', (t) => {
156
245
  t.is(fit.modelVariant, 'moss')
157
246
  })
158
247
 
159
- test('a model that cannot be read is an outcome, not a throw', (t) => {
160
- const fit = TTSGgml.assessFit({
248
+ test('a model that cannot be read is an outcome, not a rejection', async (t) => {
249
+ const fit = await TTSGgml.assessFit({
161
250
  engineType: 'supertonic',
162
251
  supertonicModelPath: '/nonexistent/model.gguf'
163
252
  })
@@ -165,14 +254,22 @@ test('a model that cannot be read is an outcome, not a throw', (t) => {
165
254
  t.is(fit.status, 'error')
166
255
  })
167
256
 
168
- test('a count that is not a count is refused', (t) => {
169
- t.exception(
170
- () =>
171
- TTSGgml.assessFit({
172
- engineType: 'supertonic',
173
- supertonicModelPath: '/models/supertonic.gguf',
174
- textTokens: -1
175
- }),
257
+ test('a count that is not a count is refused', async (t) => {
258
+ await t.exception(
259
+ TTSGgml.assessFit({
260
+ engineType: 'supertonic',
261
+ supertonicModelPath: '/models/supertonic.gguf',
262
+ textTokens: -1
263
+ }),
176
264
  /non-negative count/
177
265
  )
178
266
  })
267
+
268
+ test('a broken request rejects', async (t) => {
269
+ let promise
270
+ t.execution(() => {
271
+ promise = TTSGgml.assessFit({})
272
+ }, 'the call itself does not throw')
273
+ t.ok(promise instanceof Promise)
274
+ await t.exception(promise, /engineType is required/)
275
+ })
@@ -46,15 +46,11 @@ const {
46
46
  } = require('../utils/downloadModel')
47
47
  const { recordTtsStats } = require('../utils/perf-helper')
48
48
  const { TTS_TEST_THREADS } = require('../utils/testThreads')
49
+ const { assertDesktopGpuBackend } = require('../utils/desktopGpuBackend')
49
50
 
50
51
  const platform = os.platform()
51
52
  const isMobile = platform === 'ios' || platform === 'android'
52
53
  const isApple = platform === 'darwin' || platform === 'ios'
53
- const GPU_BACKEND_IDS = { metal: 1, cuda: 2, vulkan: 3, opencl: 4 }
54
- // CI rows whose prebuild bundles more than one usable GPU backend (the linux
55
- // runners carry CUDA and Vulkan) pin the engine cascade through
56
- // TTS_CPP_GPU_BACKEND; the assertions expect whatever the row pinned and fall
57
- // back to the platform's cascade default when unset.
58
54
  const PINNED_GPU_BACKEND = (proc.env && proc.env.TTS_CPP_GPU_BACKEND) || ''
59
55
  // Parler GPU coverage is validated on Apple, the Android Device Farm, and the
60
56
  // linux CUDA lane. Keep desktop Vulkan out until dedicated runs prove it there.
@@ -153,12 +149,7 @@ function assertGpuBackend(t, engineTag, stats, allowPolicyCpu = false) {
153
149
  if (platform === 'darwin' || platform === 'ios') {
154
150
  t.is(id, 1, `${engineTag}/${platform}: expected Metal backendId=1, got ${name}`)
155
151
  } else if (platform === 'linux' || platform === 'win32') {
156
- const expectedId = GPU_BACKEND_IDS[PINNED_GPU_BACKEND] || GPU_BACKEND_IDS.vulkan
157
- t.is(
158
- id,
159
- expectedId,
160
- `${engineTag}/${platform}: expected ${backendIdToName(expectedId)} backendId=${expectedId}, got ${name}`
161
- )
152
+ assertDesktopGpuBackend(t, `${engineTag}/${platform}`, dev, id, PINNED_GPU_BACKEND)
162
153
  } else if (platform === 'android') {
163
154
  t.ok(
164
155
  id === 3 || id === 4,
@@ -34,6 +34,7 @@ const {
34
34
  const { resolveRefWavPath } = require('../utils/runChatterboxTTS')
35
35
  const { TTS_TEST_THREADS } = require('../utils/testThreads')
36
36
  const { GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
37
+ const { assertDesktopGpuBackend } = require('../utils/desktopGpuBackend')
37
38
 
38
39
  const platform = os.platform()
39
40
  const isMobile = platform === 'ios' || platform === 'android'
@@ -42,11 +43,6 @@ const isMobile = platform === 'ios' || platform === 'android'
42
43
  // fallback from a failure to a warning (e.g. a Linux host with no Vulkan SDK).
43
44
  const NO_GPU = proc.env && proc.env.NO_GPU === 'true'
44
45
  const RELAX = proc.env && proc.env.QVAC_TTS_GPU_SMOKE_RELAX === '1'
45
- const GPU_BACKEND_IDS = { metal: 1, cuda: 2, vulkan: 3, opencl: 4 }
46
- // CI rows whose prebuild bundles more than one usable GPU backend (the linux
47
- // runners carry CUDA and Vulkan) pin the engine cascade through
48
- // TTS_CPP_GPU_BACKEND; the enhancer assertion expects whatever the row pinned
49
- // and falls back to the platform's cascade default when unset.
50
46
  const PINNED_GPU_BACKEND = (proc.env && proc.env.TTS_CPP_GPU_BACKEND) || ''
51
47
 
52
48
  const ENHANCED_RATE = 48000
@@ -92,10 +88,6 @@ function expectsGpu() {
92
88
  )
93
89
  }
94
90
 
95
- function expectedDesktopEnhancerBackendId() {
96
- return GPU_BACKEND_IDS[PINNED_GPU_BACKEND] || GPU_BACKEND_IDS.vulkan
97
- }
98
-
99
91
  // Strict check that the LavaSR *enhancer* network engaged the GPU. Reads the
100
92
  // enhancer-specific backend surfaced by ChatterboxModel/SupertonicModel
101
93
  // runtimeStats (enhancerBackendDevice: -1 none / 0 CPU / 1 GPU;
@@ -134,12 +126,7 @@ function assertEnhancerGpuBackend(t, stats) {
134
126
  if (platform === 'darwin' || platform === 'ios') {
135
127
  t.is(id, 1, `enhancer/${platform}: expected Metal backendId=1, got ${backendIdToName(id)}`)
136
128
  } else if (platform === 'linux' || platform === 'win32') {
137
- const expectedId = expectedDesktopEnhancerBackendId()
138
- t.is(
139
- id,
140
- expectedId,
141
- `enhancer/${platform}: expected ${backendIdToName(expectedId)} backendId=${expectedId}, got ${backendIdToName(id)}`
142
- )
129
+ assertDesktopGpuBackend(t, `enhancer/${platform}`, dev, id, PINNED_GPU_BACKEND)
143
130
  } else if (platform === 'android') {
144
131
  t.ok(
145
132
  id === 3 || id === 4,
@@ -5,7 +5,7 @@ const process = require('bare-process')
5
5
  const test = require('brittle')
6
6
  const TTSGgml = require('@qvac/tts-ggml')
7
7
 
8
- test('MOSS fit requires explicit, representable workload counts', (t) => {
8
+ test('MOSS fit requires explicit, representable workload counts', async (t) => {
9
9
  const request = {
10
10
  engineType: 'moss',
11
11
  mossBackbonePath: '/models/moss-tts-delay.gguf',
@@ -15,18 +15,18 @@ test('MOSS fit requires explicit, representable workload counts', (t) => {
15
15
  streaming: false
16
16
  }
17
17
  for (const invalid of [NaN, Infinity, -1, 0.5, 2147483648]) {
18
- t.exception(() => TTSGgml.assessFit({ ...request, promptRows: invalid }))
19
- t.exception(() => TTSGgml.assessFit({ ...request, referenceSamples: invalid }))
18
+ await t.exception(TTSGgml.assessFit({ ...request, promptRows: invalid }))
19
+ await t.exception(TTSGgml.assessFit({ ...request, referenceSamples: invalid }))
20
20
  }
21
- t.exception(() => TTSGgml.assessFit({ ...request, promptRows: undefined }))
22
- t.exception(() => TTSGgml.assessFit({ ...request, streaming: undefined }))
23
- t.exception(() => TTSGgml.assessFit({ ...request, threads: -1 }))
24
- t.exception(() => TTSGgml.assessFit({ ...request, durationTokens: -1 }))
25
- t.exception(() => TTSGgml.assessFit({ ...request, streamChunkTokens: -1 }))
26
- t.exception(() => TTSGgml.assessFit({ ...request, useGPU: true, nGpuLayers: 0 }))
21
+ await t.exception(TTSGgml.assessFit({ ...request, promptRows: undefined }))
22
+ await t.exception(TTSGgml.assessFit({ ...request, streaming: undefined }))
23
+ await t.exception(TTSGgml.assessFit({ ...request, threads: -1 }))
24
+ await t.exception(TTSGgml.assessFit({ ...request, durationTokens: -1 }))
25
+ await t.exception(TTSGgml.assessFit({ ...request, streamChunkTokens: -1 }))
26
+ await t.exception(TTSGgml.assessFit({ ...request, useGPU: true, nGpuLayers: 0 }))
27
27
  })
28
28
 
29
- test('MOSS metadata-only fit covers batch, streaming and cloning', (t) => {
29
+ test('MOSS metadata-only fit covers batch, streaming and cloning', async (t) => {
30
30
  const modelDir = process.env.QVAC_TEST_MOSS_MODEL_DIR
31
31
  if (!modelDir) {
32
32
  t.comment('Set QVAC_TEST_MOSS_MODEL_DIR to exercise real MOSS GGUFs')
@@ -40,14 +40,14 @@ test('MOSS metadata-only fit covers batch, streaming and cloning', (t) => {
40
40
  referenceSamples: 0,
41
41
  streaming: false
42
42
  }
43
- const batch = TTSGgml.assessFit(request)
43
+ const batch = await TTSGgml.assessFit(request)
44
44
  t.not(batch.status, 'error', batch.report)
45
45
  t.ok(batch.weightsBytes > 0)
46
46
  t.ok(batch.lmComputeBytes > 0)
47
47
  t.ok(batch.codecComputeBytes > 0)
48
- const stream = TTSGgml.assessFit({ ...request, streaming: true })
48
+ const stream = await TTSGgml.assessFit({ ...request, streaming: true })
49
49
  t.not(stream.status, 'error', stream.report)
50
- const cloning = TTSGgml.assessFit({
50
+ const cloning = await TTSGgml.assessFit({
51
51
  ...request,
52
52
  mossCodecEncoderPath: path.join(modelDir, 'moss-codec-encoder-f16.gguf'),
53
53
  referenceSamples: 24000
@@ -15,17 +15,17 @@ function request(overrides = {}) {
15
15
  }
16
16
  }
17
17
 
18
- test('MOSS sound-effect fit rejects missing workload and invalid load controls', (t) => {
19
- t.exception(() => TTSGgml.assessFit(request({ prompt: undefined })))
20
- t.exception(() => TTSGgml.assessFit(request({ seconds: undefined })))
21
- t.exception(() => TTSGgml.assessFit(request({ seconds: NaN })))
22
- t.exception(() => TTSGgml.assessFit(request({ threads: -1 })))
23
- t.exception(() => TTSGgml.assessFit(request({ useGPU: true, nGpuLayers: 0 })))
24
- t.exception(() => TTSGgml.assessFit(request({ marginBytes: -1 })))
18
+ test('MOSS sound-effect fit rejects missing workload and invalid load controls', async (t) => {
19
+ await t.exception(TTSGgml.assessFit(request({ prompt: undefined })))
20
+ await t.exception(TTSGgml.assessFit(request({ seconds: undefined })))
21
+ await t.exception(TTSGgml.assessFit(request({ seconds: NaN })))
22
+ await t.exception(TTSGgml.assessFit(request({ threads: -1 })))
23
+ await t.exception(TTSGgml.assessFit(request({ useGPU: true, nGpuLayers: 0 })))
24
+ await t.exception(TTSGgml.assessFit(request({ marginBytes: -1 })))
25
25
  })
26
26
 
27
- test('MOSS sound-effect fit reports unreadable models', (t) => {
28
- const fit = TTSGgml.assessFit(request())
27
+ test('MOSS sound-effect fit reports unreadable models', async (t) => {
28
+ const fit = await TTSGgml.assessFit(request())
29
29
  t.is(fit.status, 'error')
30
30
  t.is(fit.reason, 'model-unreadable')
31
31
  t.is(fit.modelVariant, 'moss-sfx')
@@ -36,10 +36,10 @@ test(
36
36
  {
37
37
  skip: !process.env.QVAC_TEST_MOSS_SFX_GGUF
38
38
  },
39
- (t) => {
39
+ async (t) => {
40
40
  const model = process.env.QVAC_TEST_MOSS_SFX_GGUF
41
41
  const cfg = { mossSoundEffectPath: model }
42
- const fit = TTSGgml.assessFit(request(cfg))
42
+ const fit = await TTSGgml.assessFit(request(cfg))
43
43
  t.ok(fit.status === 'fits' || fit.status === 'does-not-fit')
44
44
  t.is(fit.modelVariant, 'moss-sfx')
45
45
  t.ok(fit.deviceIsCpu)
@@ -51,12 +51,12 @@ test(
51
51
  fit.deviceBytes,
52
52
  fit.weightsBytes + fit.stateBytes + fit.lmComputeBytes + fit.codecComputeBytes
53
53
  )
54
- const shorter = TTSGgml.assessFit(request({ ...cfg, seconds: 1 }))
54
+ const shorter = await TTSGgml.assessFit(request({ ...cfg, seconds: 1 }))
55
55
  t.is(shorter.weightsBytes, fit.weightsBytes)
56
56
  t.is(shorter.lmComputeBytes, fit.lmComputeBytes)
57
- const noHeadroom = TTSGgml.assessFit(request({ ...cfg, marginBytes: Number.MAX_VALUE }))
57
+ const noHeadroom = await TTSGgml.assessFit(request({ ...cfg, marginBytes: Number.MAX_VALUE }))
58
58
  t.is(noHeadroom.status, 'does-not-fit')
59
- const invalid = TTSGgml.assessFit(request({ ...cfg, seconds: 0 }))
59
+ const invalid = await TTSGgml.assessFit(request({ ...cfg, seconds: 0 }))
60
60
  t.is(invalid.status, 'error')
61
61
  t.is(invalid.reason, 'invalid-arguments')
62
62
  }
@@ -1,10 +1,12 @@
1
1
  'use strict'
2
2
 
3
3
  const path = require('bare-path')
4
+ const fs = require('bare-fs')
4
5
  const proc = require('bare-process')
5
6
  const test = require('brittle')
6
7
 
7
8
  const TTSGgml = require('@qvac/tts-ggml')
9
+ const { assertMossBackend } = require('../utils/mossBackend')
8
10
 
9
11
  const SFX_SAMPLE_RATE = 48000
10
12
  const TENTHS_PER_SECOND = 10
@@ -14,13 +16,35 @@ const MODEL_DIR_ENV = 'QVAC_TEST_MOSS_SFX_MODEL_DIR'
14
16
  const QUICK_STEPS = 2
15
17
  const SHORT_SECONDS = 1.5
16
18
  const LONGER_SECONDS = 2.5
17
- const CPU_DEVICE = 0
18
- const GPU_DEVICE = 1
19
+ const PINNED_GPU_BACKEND = (proc.env && proc.env.TTS_CPP_GPU_BACKEND) || ''
19
20
 
20
21
  const modelDir = (proc.env && proc.env[MODEL_DIR_ENV]) || ''
21
22
  const skipWithoutModels = modelDir === ''
22
23
  const noGpu = proc.env && proc.env.NO_GPU === 'true'
23
24
 
25
+ if (proc.env && proc.env.QVAC_TEST_MOSS_FULL_GPU === 'true') {
26
+ if (noGpu || !['cuda', 'vulkan', 'metal', 'opencl'].includes(PINNED_GPU_BACKEND.toLowerCase())) {
27
+ throw new Error(
28
+ 'MOSS-SFX full GPU validation requires an explicit GPU backend and NO_GPU=false'
29
+ )
30
+ }
31
+ if (
32
+ !modelDir ||
33
+ !fs.existsSync(modelDir) ||
34
+ !fs
35
+ .readdirSync(modelDir)
36
+ .some(
37
+ (name) =>
38
+ /^moss-sfx(-[a-z0-9_]+)*\.gguf$/i.test(name) &&
39
+ fs.statSync(path.join(modelDir, name)).isFile()
40
+ )
41
+ ) {
42
+ throw new Error(
43
+ 'MOSS-SFX full GPU validation requires QVAC_TEST_MOSS_SFX_MODEL_DIR with a GGUF'
44
+ )
45
+ }
46
+ }
47
+
24
48
  function createSfxModel(useGPU = false) {
25
49
  return new TTSGgml({
26
50
  engine: TTSGgml.ENGINE_MOSS_SFX,
@@ -70,7 +94,7 @@ test(
70
94
  try {
71
95
  const result = await generate(model, { seconds: SHORT_SECONDS })
72
96
  assertClip(t, 'cpu clip', result, SHORT_SECONDS)
73
- t.is(result.stats.backendDevice, CPU_DEVICE, 'the clip ran on the CPU')
97
+ assertMossBackend(t, 'cpu clip', result.stats, false, PINNED_GPU_BACKEND)
74
98
  } finally {
75
99
  await model.unload()
76
100
  }
@@ -88,7 +112,8 @@ test(
88
112
  const longer = await generate(model, { seconds: LONGER_SECONDS, negativePrompt: 'music' })
89
113
  assertClip(t, 'short clip', short, SHORT_SECONDS)
90
114
  assertClip(t, 'longer clip', longer, LONGER_SECONDS)
91
- t.is(short.stats.backendDevice, GPU_DEVICE, 'the clips ran on the GPU')
115
+ assertMossBackend(t, 'short clip', short.stats, true, PINNED_GPU_BACKEND)
116
+ assertMossBackend(t, 'longer clip', longer.stats, true, PINNED_GPU_BACKEND)
92
117
  } finally {
93
118
  await model.unload()
94
119
  }
@@ -8,6 +8,9 @@ const TTSGgml = require('@qvac/tts-ggml')
8
8
  const { resolveRefWavPath } = require('../utils/runChatterboxTTS')
9
9
  const { readWavAsFloat32 } = require('../utils/wav-helper')
10
10
  const { TTS_TEST_THREADS } = require('../utils/testThreads')
11
+ const { assertMossBackend } = require('../utils/mossBackend')
12
+ const { createMossGpuValidation } = require('../utils/mossGpuValidation')
13
+ const { GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
11
14
 
12
15
  const SPEECH_SAMPLE_RATE = 24000
13
16
  const TEST_TIMEOUT_MS = 1800000
@@ -15,12 +18,13 @@ const MODEL_DIR_ENV = 'QVAC_TEST_MOSS_SPEECH_MODEL_DIR'
15
18
  const MAX_NEW_TOKENS = 120
16
19
  const CUT_SECONDS = 2
17
20
  const CUT_MARGIN_SECONDS = 0.5
18
- const CPU_DEVICE = 0
19
- const GPU_DEVICE = 1
21
+ const PINNED_GPU_BACKEND = (proc.env && proc.env.TTS_CPP_GPU_BACKEND) || ''
20
22
 
21
23
  const modelDir = (proc.env && proc.env[MODEL_DIR_ENV]) || ''
22
24
  const skipWithoutModels = modelDir === ''
23
25
  const noGpu = proc.env && proc.env.NO_GPU === 'true'
26
+ const sourceAudio = resolveRefWavPath({})
27
+ const validation = createMossGpuValidation(proc.env || {}, sourceAudio, GPU_ONLY_USE_GPU)
24
28
 
25
29
  function createSpeechModel(useGPU = false) {
26
30
  return new TTSGgml({
@@ -34,7 +38,7 @@ function createSpeechModel(useGPU = false) {
34
38
  }
35
39
 
36
40
  function spokenQuestion() {
37
- const wav = readWavAsFloat32(resolveRefWavPath({}))
41
+ const wav = readWavAsFloat32(sourceAudio)
38
42
  return { audio: wav.samples, sampleRate: wav.sampleRate }
39
43
  }
40
44
 
@@ -75,10 +79,11 @@ test(
75
79
  try {
76
80
  const result = await respond(model, spokenQuestion())
77
81
  assertSpokenReply(t, 'cpu reply', result)
78
- t.is(result.stats.backendDevice, CPU_DEVICE, 'the reply ran on the CPU')
82
+ assertMossBackend(t, 'cpu reply', result.stats, false, PINNED_GPU_BACKEND)
79
83
  } finally {
80
84
  await model.unload()
81
85
  }
86
+ validation.completed('CPU spoken reply')
82
87
  }
83
88
  )
84
89
 
@@ -92,6 +97,7 @@ test(
92
97
  const textReply = await respond(model, { ...spokenQuestion(), textReply: true })
93
98
  t.is(textReply.samples, 0, 'a text reply carries no audio')
94
99
  t.ok(textReply.text.trim().length > 0, 'a text reply carries the answer')
100
+ assertMossBackend(t, 'text reply', textReply.stats, true, PINNED_GPU_BACKEND)
95
101
 
96
102
  const cut = await respond(model, { ...spokenQuestion(), maxReplySeconds: CUT_SECONDS })
97
103
  assertSpokenReply(t, 'cut reply', cut)
@@ -99,12 +105,20 @@ test(
99
105
  cut.samples <= (CUT_SECONDS + CUT_MARGIN_SECONDS) * SPEECH_SAMPLE_RATE,
100
106
  'maxReplySeconds bounds the spoken reply'
101
107
  )
102
- t.is(cut.stats.backendDevice, GPU_DEVICE, 'the replies ran on the GPU')
108
+ assertMossBackend(t, 'cut reply', cut.stats, true, PINNED_GPU_BACKEND)
103
109
 
104
110
  const typed = await respond(model, { input: 'What is the capital of France?' })
105
111
  assertSpokenReply(t, 'reply to a typed question', typed)
112
+ assertMossBackend(t, 'typed reply', typed.stats, true, PINNED_GPU_BACKEND)
106
113
  } finally {
107
114
  await model.unload()
108
115
  }
116
+ validation.completed('GPU text/audio replies and reply cuts')
109
117
  }
110
118
  )
119
+
120
+ if (validation.enabled) {
121
+ test('MOSS-Speech: full GPU validation completed every required case', (t) => {
122
+ validation.assertComplete(t, ['CPU spoken reply', 'GPU text/audio replies and reply cuts'])
123
+ })
124
+ }