Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 12 additions & 44 deletions .github/workflows/integration-test-tts-ggml.yml
Original file line number Diff line number Diff line change
Expand Up @@ -184,53 +184,21 @@ jobs:
name: Set Vulkan SDK Path on Windows Server 2025
run: echo "VULKAN_SDK=C:\VulkanSDK" >> $Env:GITHUB_ENV

# Pin to 3.11 (not 3.12) because numba 0.65+ stopped shipping
# darwin-x86_64 wheels for Python 3.12. librosa pulls in numba
# transitively; on 3.12 + macos-15-large pip falls back to building
# llvmlite from source which fails because the runner doesn't have
# an LLVM 15 development install. 3.11 has prebuilt darwin-x64
# wheels for the entire numba/llvmlite/librosa stack and is fine
# for every other converter dependency we use.
- name: Setup Python (HF -> .gguf conversion)
if: matrix.arch == 'arm64' || matrix.platform == 'darwin'
uses: actions/setup-python@v5
- name: Cache TTS GGML GGUF models
id: model-cache
if: startsWith(matrix.runner, 'qvac-')
uses: ./.github/actions/cache-models
with:
python-version: '3.11'

# Cache the Python venv + every Chatterbox + Supertonic GGUF the
# integration suite can exercise. Mirrors the mobile workflow's
# caching pattern (integration-mobile-test-tts-ggml.yml). Without
# this every desktop matrix entry re-pays the multi-GB HF download
# + Torch install + multi-arch GGUF convert (~10-30 min cold) on
# six runners per PR. Each `ensure*Models` helper in
# test/utils/downloadModel.js gracefully skips its test when the
# matching GGUF isn't on disk, so partial cache misses degrade
# rather than abort the whole matrix.
- name: Cache TTS GGML venv + GGUFs
id: cache-tts-ggml-models
uses: actions/cache@v4
with:
path: |
${{ github.workspace }}/packages/tts-ggml/venv
${{ github.workspace }}/packages/tts-ggml/models/chatterbox-t3-turbo.gguf
${{ github.workspace }}/packages/tts-ggml/models/chatterbox-s3gen.gguf
${{ github.workspace }}/packages/tts-ggml/models/chatterbox-t3-mtl.gguf
${{ github.workspace }}/packages/tts-ggml/models/chatterbox-s3gen-mtl.gguf
${{ github.workspace }}/packages/tts-ggml/models/supertonic.gguf
${{ github.workspace }}/packages/tts-ggml/models/supertonic2.gguf
key: tts-ggml-desktop-all-gguf-v1-${{ matrix.platform }}-${{ matrix.arch }}-${{ hashFiles('packages/tts-ggml/scripts/convert-models.sh', 'packages/tts-ggml/scripts/setup-venv.sh', 'packages/tts-ggml/scripts/requirements.txt', 'packages/tts-ggml/scripts/convert-t3-turbo-to-gguf.py', 'packages/tts-ggml/scripts/convert-t3-mtl-to-gguf.py', 'packages/tts-ggml/scripts/convert-s3gen-to-gguf.py', 'packages/tts-ggml/scripts/convert-supertonic2-to-gguf.py') }}
restore-keys: |
tts-ggml-desktop-all-gguf-v1-${{ matrix.platform }}-${{ matrix.arch }}-
# No-op today (key still includes platform/arch because of the
# venv); kept for a follow-up split of venv (OS-keyed) vs
# GGUFs (cross-OS).
enableCrossOsArchive: true

- name: Stage GGUF models (npm run setup-models)
if: steps.cache-tts-ggml-models.outputs.cache-hit != 'true'
package: tts-ggml
paths: packages/tts-ggml/models
hash-files-glob: packages/tts-ggml/scripts/download-tts-ggml-models.js
enable-cross-os: 'true'

- name: Download GGUF models from registry
if: steps.model-cache.outputs.cache-hit != 'true'
shell: bash
working-directory: ${{ github.workspace }}/packages/tts-ggml
run: npm run setup-models
run: npm run download-models:registry

- name: Run integration test
if: ${{ inputs.run_integration_tests != false }}
Expand Down
3 changes: 2 additions & 1 deletion packages/tts-ggml/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
"test:dts": "tsc index.d.ts addonLogging.d.ts --noEmit --lib es2018 --esModuleInterop --skipLibCheck",
"setup:venv": "bash scripts/setup-venv.sh",
"convert-models": "bash scripts/convert-models.sh",
"setup-models": "bash scripts/setup-venv.sh && bash scripts/convert-models.sh"
"setup-models": "bash scripts/setup-venv.sh && bash scripts/convert-models.sh",
"download-models:registry": "node scripts/download-tts-ggml-models.js"
},
"files": [
"addonLogging.js",
Expand Down
155 changes: 155 additions & 0 deletions packages/tts-ggml/scripts/download-tts-ggml-models.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
#!/usr/bin/env node
'use strict'

const fs = require('fs')
const path = require('path')
const { QVACRegistryClient } = require('@qvac/registry-client')

const REGISTRY_SOURCE = 's3'
const REGISTRY_DATE_F16 = '2026-05-08'
const REGISTRY_DATE_Q4_0 = '2026-05-18'
const OUT_DIR = path.resolve(__dirname, '..', 'models')

const GROUPS = {
chatterbox: [
{
name: 'chatterbox-t3-turbo.gguf',
registryPath: `qvac_models_compiled/ggml/chatterbox/${REGISTRY_DATE_Q4_0}/chatterbox-t3-turbo-q4_0.gguf`
},
{
name: 'chatterbox-s3gen.gguf',
registryPath: `qvac_models_compiled/chatterbox/${REGISTRY_DATE_F16}/chatterbox-s3gen.gguf`
}
],
'chatterbox-mtl': [
{
name: 'chatterbox-t3-mtl.gguf',
registryPath: `qvac_models_compiled/ggml/chatterbox/${REGISTRY_DATE_Q4_0}/chatterbox-t3-mtl-q4_0.gguf`
},
{
name: 'chatterbox-s3gen-mtl.gguf',
registryPath: `qvac_models_compiled/chatterbox/${REGISTRY_DATE_F16}/chatterbox-s3gen-mtl.gguf`
}
],
supertonic: [
{
name: 'supertonic.gguf',
registryPath: `qvac_models_compiled/ggml/supertonic/${REGISTRY_DATE_Q4_0}/supertonic-q4_0.gguf`
}
],
supertonic2: [
{
name: 'supertonic2.gguf',
registryPath: `qvac_models_compiled/ggml/supertonic/${REGISTRY_DATE_Q4_0}/supertonic2-q4_0.gguf`
}
]
}

const ALL_GROUP_KEYS = Object.keys(GROUPS)

function parseArgs (argv) {
const args = { groups: ['all'], output: OUT_DIR }
for (let i = 0; i < argv.length; i++) {
const flag = argv[i]
const next = argv[i + 1]
if (flag === '--group' || flag === '-g') {
args.groups = next.split(',').map(s => s.trim()).filter(Boolean)
i++
} else if (flag === '--output' || flag === '-o') {
args.output = path.resolve(next)
i++
} else if (flag === '--help' || flag === '-h') {
args.help = true
} else {
throw new Error(`Unknown argument: ${flag}`)
}
}
return args
}

function printUsage () {
console.log(`Usage: node scripts/download-tts-ggml-models.js [--group <G>] [--output <DIR>]

Download TTS GGML GGUFs from the QVAC model registry into ./models/.
On-disk filenames stay at the historical "<name>.gguf" shape so the
tts-ggml resolver finds them without source changes — the script
rebadges the q4_0 variants accordingly.

Flags:
--group, -g ${ALL_GROUP_KEYS.join('|')}|all (default: all; comma-separated for multiple)
--output, -o <dir> (default: ./models)
--help, -h
`)
}

function selectFiles (groupArgs) {
const wantAll = groupArgs.includes('all')
const keys = wantAll ? ALL_GROUP_KEYS : groupArgs
const files = []
for (const key of keys) {
const group = GROUPS[key]
if (!group) throw new Error(`Unknown group: ${key}`)
for (const f of group) files.push(f)
}
return files
}

async function downloadOne (client, file, outputDir) {
const dest = path.join(outputDir, file.name)
if (fs.existsSync(dest)) {
console.log(` ✓ ${file.name} (already present)`)
return { ok: true, cached: true }
}
console.log(` > ${file.name}`)
console.log(` from: ${REGISTRY_SOURCE}/${file.registryPath}`)
await client.downloadModel(file.registryPath, REGISTRY_SOURCE, {
outputFile: dest,
timeout: 600000
})
return { ok: true, cached: false }
}

async function downloadAll (files, outputDir) {
fs.mkdirSync(outputDir, { recursive: true })
const client = new QVACRegistryClient()
let failures = 0
try {
await client.ready()
for (const file of files) {
try {
await downloadOne(client, file, outputDir)
} catch (err) {
console.error(` ✗ ${file.name}: ${err && err.message ? err.message : String(err)}`)
failures++
}
}
} finally {
try { await client.close() } catch (_) {}
}
return failures
}

async function main () {
const args = parseArgs(process.argv.slice(2))
if (args.help) { printUsage(); return }

const files = selectFiles(args.groups)
if (files.length === 0) {
console.error('Nothing to download for the requested group(s).')
process.exit(2)
}

console.log(`Downloading TTS GGML GGUFs into ${args.output}`)
console.log(`Groups: ${args.groups.join(', ')}`)
const failures = await downloadAll(files, args.output)
if (failures > 0) {
console.error(`${failures} download(s) failed.`)
process.exit(1)
}
console.log('Done.')
}

main().catch(err => {
console.error(err && err.message ? err.message : err)
process.exit(1)
})
10 changes: 5 additions & 5 deletions packages/tts-ggml/test/integration/addon.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ test('Chatterbox TTS (ggml): English synthesis + optional WER verification', { t
const download = await ensureChatterboxModels({ targetDir: modelsDir })
if (!download.success) {
console.log('Chatterbox GGUFs not available locally; see instructions above.')
t.pass('Skipped: Chatterbox GGUFs not available locally')
t.fail('Chatterbox GGUFs not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.')
return
}
t.ok(download.success, 'Chatterbox GGUFs should be available')
Expand Down Expand Up @@ -146,7 +146,7 @@ test('Chatterbox TTS (ggml): synthesizes without referenceAudio using the built-

const download = await ensureChatterboxModels({ targetDir: modelsDir })
if (!download.success) {
t.pass('Skipped: Chatterbox GGUFs not available locally')
t.fail('Chatterbox GGUFs not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.')
return
}

Expand Down Expand Up @@ -195,7 +195,7 @@ test('Chatterbox TTS (ggml): outputSampleRate option is accepted (pass-through f

const download = await ensureChatterboxModels({ targetDir: modelsDir })
if (!download.success) {
t.pass('Skipped: Chatterbox GGUFs not available locally')
t.fail('Chatterbox GGUFs not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.')
return
}

Expand Down Expand Up @@ -238,7 +238,7 @@ test('Chatterbox TTS (ggml): native C++ chunk streaming via streamChunkTokens',

const download = await ensureChatterboxModels({ targetDir: modelsDir })
if (!download.success) {
t.pass('Skipped: Chatterbox GGUFs not available locally')
t.fail('Chatterbox GGUFs not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.')
return
}

Expand Down Expand Up @@ -300,7 +300,7 @@ test('Chatterbox TTS (ggml): streaming input + streaming PCM output (runStreamin
console.log('\n=== Ensuring Chatterbox GGUFs (streaming) ===')
const download = await ensureChatterboxModels({ targetDir: modelsDir })
if (!download.success) {
t.pass('Skipped: Chatterbox GGUFs not available locally')
t.fail('Chatterbox GGUFs not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.')
return
}
t.ok(download.success, 'Chatterbox GGUFs should be available')
Expand Down
4 changes: 2 additions & 2 deletions packages/tts-ggml/test/integration/chatterbox-mtl.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ test('Chatterbox MTL TTS (ggml): synthesizes across es/fr/de/pt with shared engi
const baseDir = getBaseDir()
const download = await ensureChatterboxMtlModels({ targetDir: path.join(baseDir, 'models') })
if (!download.success) {
t.pass('Skipped: Chatterbox MTL GGUFs not available')
t.fail('Chatterbox MTL GGUFs not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.')
return
}

Expand Down Expand Up @@ -108,7 +108,7 @@ test('Chatterbox MTL TTS (ggml): backendDevice + backendId surfaced in stats', {
const baseDir = getBaseDir()
const download = await ensureChatterboxMtlModels({ targetDir: path.join(baseDir, 'models') })
if (!download.success) {
t.pass('Skipped: Chatterbox MTL GGUFs not available')
t.fail('Chatterbox MTL GGUFs not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.')
return
}

Expand Down
6 changes: 3 additions & 3 deletions packages/tts-ggml/test/integration/gpu-smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ test('Chatterbox GPU smoke - useGPU=true must engage the GPU backend on GPU-capa

const download = await ensureChatterboxModels({ targetDir: modelsDir })
if (!download.success) {
t.pass('Skipped: Chatterbox GGUFs not available locally')
t.fail('Chatterbox GGUFs not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.')
return
}

Expand Down Expand Up @@ -199,7 +199,7 @@ test('Chatterbox CPU smoke - useGPU=false must run on the CPU backend', { timeou

const download = await ensureChatterboxModels({ targetDir: modelsDir })
if (!download.success) {
t.pass('Skipped: Chatterbox GGUFs not available locally')
t.fail('Chatterbox GGUFs not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.')
return
}

Expand Down Expand Up @@ -237,7 +237,7 @@ test('Supertonic CPU smoke - useGPU=false must run on the CPU backend', { timeou

const download = await ensureSupertonicModel({ targetDir: modelsDir })
if (!download || !download.success) {
t.pass('Skipped: Supertonic GGUF not available locally')
t.fail('Supertonic GGUF not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.')
return
}

Expand Down
14 changes: 7 additions & 7 deletions packages/tts-ggml/test/integration/multiple-runs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const PHRASES = [
test('Chatterbox: multiple sequential runs reuse the same engine instance', { timeout: 1800000 }, async (t) => {
const baseDir = getBaseDir()
const download = await ensureChatterboxModels({ targetDir: path.join(baseDir, 'models') })
if (!download.success) { t.pass('Skipped: Chatterbox GGUFs not available'); return }
if (!download.success) { t.fail('Chatterbox GGUFs not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.'); return }

// Mobile-aware resolution: on iOS / Android the asset is staged into
// `Library/Caches/jfk.wav` via `global.assetPaths`; on desktop falls
Expand Down Expand Up @@ -98,7 +98,7 @@ test('Chatterbox: multiple sequential runs reuse the same engine instance', { ti
test('Supertonic: multiple sequential runs reuse the same engine instance', { timeout: 1800000 }, async (t) => {
const baseDir = getBaseDir()
const download = await ensureSupertonicModel({ targetDir: path.join(baseDir, 'models') })
if (!download.success) { t.pass('Skipped: Supertonic GGUF not available'); return }
if (!download.success) { t.fail('Supertonic GGUF not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.'); return }

const model = await loadSupertonicTTS({
supertonicModelPath: download.path,
Expand Down Expand Up @@ -138,7 +138,7 @@ test('Supertonic: multiple sequential runs reuse the same engine instance', { ti
test('Chatterbox: fresh instance per run (app-restart simulation)', { timeout: 1800000 }, async (t) => {
const baseDir = getBaseDir()
const download = await ensureChatterboxModels({ targetDir: path.join(baseDir, 'models') })
if (!download.success) { t.pass('Skipped: Chatterbox GGUFs not available'); return }
if (!download.success) { t.fail('Chatterbox GGUFs not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.'); return }

// Mobile-aware resolution: on iOS / Android the asset is staged into
// `Library/Caches/jfk.wav` via `global.assetPaths`; on desktop falls
Expand Down Expand Up @@ -177,7 +177,7 @@ test('Chatterbox: fresh instance per run (app-restart simulation)', { timeout: 1
test('Supertonic: fresh instance per run (app-restart simulation)', { timeout: 1800000 }, async (t) => {
const baseDir = getBaseDir()
const download = await ensureSupertonicModel({ targetDir: path.join(baseDir, 'models') })
if (!download.success) { t.pass('Skipped: Supertonic GGUF not available'); return }
if (!download.success) { t.fail('Supertonic GGUF not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.'); return }

const N = 2
const results = []
Expand Down Expand Up @@ -208,7 +208,7 @@ test('Supertonic: fresh instance per run (app-restart simulation)', { timeout: 1
test('Chatterbox: reload() between runs preserves stability', { timeout: 1800000 }, async (t) => {
const baseDir = getBaseDir()
const download = await ensureChatterboxModels({ targetDir: path.join(baseDir, 'models') })
if (!download.success) { t.pass('Skipped: Chatterbox GGUFs not available'); return }
if (!download.success) { t.fail('Chatterbox GGUFs not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.'); return }

// Mobile-aware resolution: on iOS / Android the asset is staged into
// `Library/Caches/jfk.wav` via `global.assetPaths`; on desktop falls
Expand Down Expand Up @@ -242,7 +242,7 @@ test('Chatterbox: reload() between runs preserves stability', { timeout: 1800000
test('Supertonic: reload() between runs preserves stability', { timeout: 1800000 }, async (t) => {
const baseDir = getBaseDir()
const download = await ensureSupertonicModel({ targetDir: path.join(baseDir, 'models') })
if (!download.success) { t.pass('Skipped: Supertonic GGUF not available'); return }
if (!download.success) { t.fail('Supertonic GGUF not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.'); return }

const model = await loadSupertonicTTS({
supertonicModelPath: download.path,
Expand All @@ -269,7 +269,7 @@ test('Engine swap: chatterbox -> supertonic -> chatterbox in separate instances'
const baseDir = getBaseDir()
const cb = await ensureChatterboxModels({ targetDir: path.join(baseDir, 'models') })
const st = await ensureSupertonicModel({ targetDir: path.join(baseDir, 'models') })
if (!cb.success || !st.success) { t.pass('Skipped: not all engines have models locally'); return }
if (!cb.success || !st.success) { t.fail('Not all engines have models locally - registry fetch failed. Run `npm run download-models:registry` or stage models locally.'); return }

// Mobile-aware resolution: on iOS / Android the asset is staged into
// `Library/Caches/jfk.wav` via `global.assetPaths`; on desktop falls
Expand Down
Loading
Loading