New: VisionPsy-Nano, a 460M vision model that outperforms models twice its size.
QVAC Logo

Music generation

Generate music and other audio from text, lyrics, and musical controls using ACE-Step or MiniMax-Music3.

Overview

Music generation uses @qvac/audiogen-ggml, with your choice of either ACE-Step 1.5 or MiniMax-Music3 as the inference engine. Load a model with modelType: "audiogen" and select the engine with modelConfig.engine"minimax" for MiniMax-Music3; ACE-Step is the default. Then call audioGen() with a caption and any engine-specific inputs.

Pick ACE-Step when you need lyrics, per-song musical controls (BPM, key, time signature, vocal language), reference-audio timbre conditioning, or cover generation. Pick MiniMax-Music3 when you want a lighter API surface — just a caption and a handful of sampling knobs — on desktop hardware with a capable GPU.

audioGen() returns immediately with:

  • requestId — a synchronous identifier for targeted cancellation.
  • progressStream — stage and step updates while generation runs.
  • audio — a promise resolving to interleaved PCM, its sample rate, channel count, and bitsPerSample.
  • stats — a promise resolving to timing statistics.

Both engines return through this same shape; only the inputs to loadModel() and audioGen() differ.

Platform support: ACE-Step runs on desktop (Linux, macOS, Windows) and mobile (Android arm64, iOS arm64), with GPU acceleration via Metal (Apple) and Vulkan (Android). MiniMax-Music3 runs on desktop only and needs ~22 GB of device memory for its f16 model pair when useGPU: true.

Functions

Use the following sequence:

  1. loadModel()
  2. audioGen()
  3. unloadModel()

For complete signatures, see the SDK API reference.

Enable the plugin

When your project selects plugins explicitly, add the AudioGen plugin:

qvac.config.json
{
  "plugins": ["@qvac/sdk/audiogen-ggml/plugin"]
}

Then rebuild the SDK bundle:

qvac bundle sdk

If plugins is omitted or empty, all built-in plugins are included. See the plugin system for bundle and Bare runtime registration details.

Models

Unlike single-file model families, AudioGen does not use a top-level modelSrc. Supply every model source in modelConfig, and select the engine with modelConfig.engine.

ACE-Step

ACE-Step uses four GGUF files — text encoder, LM, one DiT variant of the three listed, and VAE:

StageSDK model constantPurpose
Text encoderAUDIOGEN_QWEN3_EMBEDDING_0_6B_Q8_0Encodes captions and lyrics
Language modelAUDIOGEN_ACESTEP_5HZ_LM_0_6B_Q8_0Plans the song and musical structure
DiTAUDIOGEN_ACESTEP_V15_TURBO_Q4_K_MFast, lower-memory Turbo generation
DiTAUDIOGEN_ACESTEP_V15_TURBO_Q8_0Higher-precision Turbo generation
DiTAUDIOGEN_ACESTEP_V15_SFT_Q8_0Supervised fine-tuned generation
VAEAUDIOGEN_VAE_BF16Decodes the latent into PCM audio

The first ACE-Step load downloads ~3.3 GB across these four files. On slow or unstable links, set registryStreamTimeoutMs: 600000 and registryDownloadMaxRetries: 10 in qvac.config.json and point QVAC_CONFIG_PATH at that file before loading.

import {
  AUDIOGEN_ACESTEP_5HZ_LM_0_6B_Q8_0,
  AUDIOGEN_ACESTEP_V15_TURBO_Q4_K_M,
  AUDIOGEN_QWEN3_EMBEDDING_0_6B_Q8_0,
  AUDIOGEN_VAE_BF16,
  loadModel,
} from "@qvac/sdk";

const modelId = await loadModel({
  modelType: "audiogen",
  modelConfig: {
    engine: "acestep",
    textEncModelSrc: AUDIOGEN_QWEN3_EMBEDDING_0_6B_Q8_0,
    lmModelSrc: AUDIOGEN_ACESTEP_5HZ_LM_0_6B_Q8_0,
    ditModelSrc: AUDIOGEN_ACESTEP_V15_TURBO_Q4_K_M,
    vaeModelSrc: AUDIOGEN_VAE_BF16,
    useGPU: true,
  },
});

Advanced load-time controls (inferenceSteps, shift, nGpuLayers, threads, backendsDir) are ACE-Step's to tune; omit inferenceSteps and shift unless you need to override the addon's per-variant defaults. backendsDir is needed on arm64, where the CPU backend ships as per-microarch modules.

For more about model constants and sources, see SDK — Models.

MiniMax-Music3

MiniMax-Music3 uses two GGUF files that you supply as local paths — the language model (mm3-lm-*.gguf) and the synthesis model (mm3-synth-*.gguf). The SDK does not download MiniMax-Music3 from the registry, so bring your own converted files and point lmModelSrc and synthModelSrc at them. Weights are governed by the MiniMax-Music3 Community License.

import { loadModel } from "@qvac/sdk";

const modelId = await loadModel({
  modelType: "audiogen",
  modelConfig: {
    engine: "minimax",
    lmModelSrc: "/path/to/mm3-lm-q8.gguf",
    synthModelSrc: "/path/to/mm3-synth-q8.gguf",
    useGPU: true,
  },
});

Generate audio

caption is required and cannot be empty. Everything else is engine-specific.

The SDK returns raw interleaved PCM rather than a WAV file for both engines. Use bitsPerSample when calculating sample counts or constructing a WAV header so consumers remain correct if the addon changes sample width. The complete examples below include a dependency-free WAV writer.

With ACE-Step

Pass optional lyrics and musical controls to steer the LM:

import { audioGen } from "@qvac/sdk";

const run = audioGen({
  modelId,
  caption: "Energetic cumbia with brass stabs and live percussion",
  lyrics: `[verse]
The city wakes beneath the moon

[chorus]
Dance until the morning comes`,
  vocalLanguage: "en",
  bpm: 98,
  keyscale: "A minor",
  timesignature: "4/4",
  duration: 30,
  seed: 42,
});

for await (const progress of run.progressStream) {
  console.log(`${progress.stage}: ${progress.step}/${progress.total}`);
}

const [{ pcm, sampleRate, channels, bitsPerSample }, stats] = await Promise.all([
  run.audio,
  run.stats,
]);

Omit lyrics or use "[Instrumental]" for instrumental output. Omitted musical controls are inferred from the caption.

duration is approximate. ACE-Step rounds the requested length to its latent frame grid, so the generated clip can be shorter or longer than requested. Use stats.audioDurationMs or calculate the duration from the returned PCM frame count and sampleRate; those values describe the actual output.

Sampling and DCW controls (ACE-Step only)

Advanced per-run controls map 1:1 to the ACE-Step engine and are only needed for reproducible quality comparisons; omit them to keep the engine defaults:

FieldPurpose
lmTemperatureLM sampling temperature (default 0.85)
lmTopPLM nucleus-sampling probability (default 0.9)
lmTopKLM top-k cutoff; 0 disables top-k filtering
lmCfgScaleClassifier-free guidance scale used by the LM
lmPhase1Let the LM infer missing metadata before semantic-code generation
dcwEnabledApply the official Haar DCW correction during DiT sampling (default: true)
dcwScalerDCW low-frequency correction strength (default 0.05)
dcwHighScalerDCW high-frequency correction strength (default 0.02)

With MiniMax-Music3

MiniMax-Music3 exposes a smaller input surface. It rejects ACE-Step's musical controls (bpm, keyscale, timesignature, vocalLanguage), the ACE-Step-only LM and DCW knobs above, and every reference/cover field. Pass the caption and, when useful, any of these MiniMax-only controls:

FieldWhen to set it
lyricsVocal lyrics; use "[Instrumental]" for instrumental output.
seedRNG seed for reproducible generation.
durationTarget length in seconds. Internally converted to the model's 25 semantic frames per second. Cannot be combined with maxFrames.
maxFramesExplicit semantic-frame cap when you want direct control over length instead of a seconds-based target. Cannot be combined with duration.
inferenceStepsPer-run flow sampling steps; leave unset (or 0) to use the model default. Raise it to trade generation time for higher quality.
cfgScalePer-run flow classifier-free guidance scale; leave unset (or 0) to use the model default. Raise it to follow the caption more literally, lower it for more freedom.
import { audioGen } from "@qvac/sdk";

const run = audioGen({
  modelId,
  caption: "Warm cinematic piano with gentle strings",
  lyrics: "[Instrumental]",
  seed: 7,
  cfgScale: 1.7,
  inferenceSteps: 8,
});

const [{ pcm, sampleRate, channels, bitsPerSample }, stats] = await Promise.all([
  run.audio,
  run.stats,
]);

Reference audio and covers

Reference and cover generation is ACE-Step only — MiniMax-Music3 rejects referenceAudio, sourceAudio, and taskType.

referenceAudio conditions the generated timbre without changing the text-to-music task. taskType: "cover-nofsq" re-renders sourceAudio with a new caption while keeping its structure, optionally combined with a timbre reference:

import { audioGen } from "@qvac/sdk";

// Timbre reference only.
const styled = audioGen({
  modelId,
  caption: "slow blues with warm electric guitar",
  lyrics: "[Instrumental]",
  referenceAudio: "/path/to/reference.mp3",
});

// Cover: keep the structure of the source song, change the arrangement.
const cover = audioGen({
  modelId,
  caption: "orchestral arrangement with dramatic strings",
  lyrics: "[Instrumental]",
  taskType: "cover-nofsq",
  sourceAudio: "/path/to/source.wav",
  referenceAudio: "/path/to/reference.wav",
  audioCoverStrength: 1,
  coverNoiseStrength: 0.75,
});
  • referenceAudio — Optional timbre reference. Provide it to nudge the generated timbre toward the reference recording; omit it to let the engine pick freely.
  • taskType"text2music" (default) for caption-driven generation, or "cover-nofsq" to re-render sourceAudio with a new caption while keeping its structure.
  • sourceAudio — The source recording for the cover. Required when taskType is "cover-nofsq".
  • audioCoverStrength — Fraction of DiT steps (0..1) that keep the source context. "cover-nofsq" currently requires 1; the SDK rejects any other value, so omit it or pass 1.
  • coverNoiseStrength — Blends the initial noise toward the source latent (0..1). Closer to 0 gives more variation; closer to 1 stays nearer the source. Default 0.

Both referenceAudio and sourceAudio accept either a file path (the SDK decodes .wav, .mp3, .m4a, .ogg, .flac, and .aac server-side; any other extension is read as raw interleaved stereo 48 kHz Float32 LE PCM) or a Buffer / Uint8Array in that same raw PCM layout (see AUDIOGEN_INPUT_SAMPLE_RATE and AUDIOGEN_INPUT_CHANNELS). Clips are capped at 600 s.

Cancellation

The request ID is available before generation starts, so a stop button can cancel exactly that run:

import { audioGen, cancel, InferenceCancelledError } from "@qvac/sdk";

const run = audioGen({ modelId, caption: "Ambient electronic music" });
stopButton.onclick = () => cancel({ requestId: run.requestId });

try {
  await run.audio;
} catch (error) {
  if (!(error instanceof InferenceCancelledError)) throw error;
}

AudioGen supports hard cancellation on both engines: the addon interrupts the active generation. The stream terminates with stopReason: "cancelled", and the audio and stats promises reject with InferenceCancelledError. You can also broad-cancel AudioGen work with cancel({ modelId, kind: "audiogen" }).

See Runtime — Cancellation for cancellation behavior shared across the SDK.

Example

ACE-Step

This example loads the registry-hosted ACE-Step models, reports progress, generates an instrumental, and writes the PCM output as WAV.

The generated Python client exposes the contract-level audio_gen_stream() method. Unlike the TypeScript audioGen() wrapper, it yields progress, base64 PCM chunks, and the terminal stats frame directly; callers decode and concatenate the chunks themselves — see the Python tab.

generate-music.ts
import { writeFileSync } from 'node:fs'
import {
  AUDIOGEN_ACESTEP_5HZ_LM_0_6B_Q8_0,
  AUDIOGEN_ACESTEP_V15_TURBO_Q4_K_M,
  AUDIOGEN_QWEN3_EMBEDDING_0_6B_Q8_0,
  AUDIOGEN_VAE_BF16,
  audioGen,
  loadModel,
  unloadModel,
  type ModelProgressUpdate
} from '@qvac/sdk'

// Usage:
//   bun examples/audiogen/generate-music.ts "lo-fi hip hop, mellow piano" output.wav
const caption =
  process.argv[2] ?? 'Lo-fi hip hop with mellow piano, soft drums, and a warm bass line'
const outputPath = process.argv[3] ?? 'audiogen-output.wav'

let modelId: string | undefined
const lastLoggedPercentageByDownload = new Map<string, number>()
const completedDownloads = new Set<string>()

try {
  console.log('▸ Loading ACE-Step AudioGen models...')
  modelId = await loadModel({
    modelType: 'audiogen',
    modelConfig: {
      textEncModelSrc: AUDIOGEN_QWEN3_EMBEDDING_0_6B_Q8_0,
      lmModelSrc: AUDIOGEN_ACESTEP_5HZ_LM_0_6B_Q8_0,
      ditModelSrc: AUDIOGEN_ACESTEP_V15_TURBO_Q4_K_M,
      vaeModelSrc: AUDIOGEN_VAE_BF16,
      useGPU: true,
      inferenceSteps: 8
    },
    onProgress: (progress: ModelProgressUpdate) => {
      const mb = (bytes: number) => (bytes / 1e6).toFixed(1)
      const label = getDownloadLabel(progress.downloadKey)
      const line =
        `▸ Downloading ${label}: ${progress.percentage.toFixed(0)}% ` +
        `(${mb(progress.downloaded)}/${mb(progress.total)} MB)`

      if (process.stderr.isTTY) {
        process.stderr.write(`\r${line}`)
        if (progress.percentage >= 100 && !completedDownloads.has(progress.downloadKey)) {
          completedDownloads.add(progress.downloadKey)
          process.stderr.write('\n')
        }
        return
      }

      const percentageBucket = Math.floor(progress.percentage / 5) * 5
      const lastLogged = lastLoggedPercentageByDownload.get(progress.downloadKey)
      const isNewCompletion = progress.percentage >= 100 && lastLogged !== 100
      if (lastLogged === undefined || percentageBucket > lastLogged || isNewCompletion) {
        lastLoggedPercentageByDownload.set(progress.downloadKey, percentageBucket)
        process.stderr.write(`${line}\n`)
      }
    }
  })

  console.log(`▸ Model loaded: ${modelId}`)
  console.log(`▸ Generating: ${caption}`)

  const run = audioGen({
    modelId,
    caption,
    lyrics: '[Instrumental]',
    seed: 42,
    duration: 10
  })
  console.log(`▸ requestId: ${run.requestId}`)

  for await (const progress of run.progressStream) {
    const value =
      progress.total > 0 ? `${progress.step}/${progress.total}` : `${progress.step} (indeterminate)`
    console.log(`▸ ${progress.stage}: ${value}`)
  }

  const [audio, stats] = await Promise.all([run.audio, run.stats])
  const wav = createWav(audio.pcm, audio.sampleRate, audio.channels, audio.bitsPerSample)
  writeFileSync(outputPath, wav)

  const samplesPerChannel = audio.pcm.byteLength / (audio.bitsPerSample / 8) / audio.channels
  console.log(
    `▸ Generated ${samplesPerChannel} samples per channel at ` +
      `${audio.sampleRate} Hz (${audio.channels} channels)`
  )
  if (stats) console.log(`▸ Stats: ${JSON.stringify(stats)}`)
  console.log(`▸ Saved ${outputPath}`)

  await unloadModel({ modelId })
  modelId = undefined
  console.log('▸ Model unloaded')
  process.exit(0)
} catch (error) {
  if (modelId !== undefined) {
    try {
      await unloadModel({ modelId })
    } catch {
      // Preserve the generation error as the primary failure.
    }
  }
  console.error('✖', error)
  process.exit(1)
}

function getDownloadLabel(downloadKey: string) {
  return downloadKey.split('/').pop() ?? downloadKey
}

function createWav(pcm: Uint8Array, sampleRate: number, channels: number, bitsPerSample: number) {
  const header = new ArrayBuffer(44)
  const view = new DataView(header)
  const blockAlign = channels * (bitsPerSample / 8)

  writeAscii(view, 0, 'RIFF')
  view.setUint32(4, 36 + pcm.byteLength, true)
  writeAscii(view, 8, 'WAVE')
  writeAscii(view, 12, 'fmt ')
  view.setUint32(16, 16, true)
  view.setUint16(20, 1, true)
  view.setUint16(22, channels, true)
  view.setUint32(24, sampleRate, true)
  view.setUint32(28, sampleRate * blockAlign, true)
  view.setUint16(32, blockAlign, true)
  view.setUint16(34, bitsPerSample, true)
  writeAscii(view, 36, 'data')
  view.setUint32(40, pcm.byteLength, true)

  const wav = new Uint8Array(44 + pcm.byteLength)
  wav.set(new Uint8Array(header))
  wav.set(pcm, 44)
  return wav
}

function writeAscii(view: DataView, offset: number, value: string) {
  for (let index = 0; index < value.length; index++) {
    view.setUint8(offset + index, value.charCodeAt(index))
  }
}

The cover example takes an existing recording (any FFmpeg-decodable file), an optional timbre reference, and a new caption, and writes the cover-nofsq result as WAV:

generate-cover.ts
import { writeFileSync } from 'node:fs'
import {
  AUDIOGEN_ACESTEP_5HZ_LM_0_6B_Q8_0,
  AUDIOGEN_ACESTEP_V15_TURBO_Q4_K_M,
  AUDIOGEN_QWEN3_EMBEDDING_0_6B_Q8_0,
  AUDIOGEN_VAE_BF16,
  audioGen,
  loadModel,
  unloadModel
} from '@qvac/sdk'

// Re-render an existing song with a new caption (ACE-Step "cover-nofsq"),
// optionally conditioning the timbre on a second reference clip.
//
// Usage:
//   bun examples/audiogen/generate-cover.ts <source.wav|mp3|...> "orchestral arrangement" [reference.wav] [output.wav]
//
// Both audio inputs are file paths: the SDK decodes them (any FFmpeg-decodable
// format) to the 48 kHz stereo float PCM the engine expects. Pass raw
// interleaved stereo 48 kHz Float32 LE PCM as a Buffer instead when the audio
// is already in memory.
const sourcePath = process.argv[2]
const caption = process.argv[3] ?? 'Orchestral arrangement with dramatic strings'
const referencePath = process.argv[4]
const outputPath = process.argv[5] ?? 'audiogen-cover.wav'

if (!sourcePath) {
  console.error(
    'Usage: bun examples/audiogen/generate-cover.ts <source-audio> "<caption>" [reference-audio] [output.wav]'
  )
  process.exit(1)
}

let modelId: string | undefined

try {
  console.log('▸ Loading ACE-Step AudioGen models...')
  modelId = await loadModel({
    modelType: 'audiogen',
    modelConfig: {
      textEncModelSrc: AUDIOGEN_QWEN3_EMBEDDING_0_6B_Q8_0,
      lmModelSrc: AUDIOGEN_ACESTEP_5HZ_LM_0_6B_Q8_0,
      ditModelSrc: AUDIOGEN_ACESTEP_V15_TURBO_Q4_K_M,
      vaeModelSrc: AUDIOGEN_VAE_BF16,
      useGPU: true
    }
  })
  console.log(`▸ Model loaded: ${modelId}`)
  console.log(`▸ Covering ${sourcePath} as: ${caption}`)

  const run = audioGen({
    modelId,
    caption,
    lyrics: '[Instrumental]',
    taskType: 'cover-nofsq',
    sourceAudio: sourcePath,
    ...(referencePath !== undefined && { referenceAudio: referencePath }),
    // cover-nofsq keeps the full source context; coverNoiseStrength blends the
    // initial noise toward the source latent (0 = pure noise, 1 ≈ source).
    audioCoverStrength: 1,
    coverNoiseStrength: 0.75,
    seed: 22886
  })
  console.log(`▸ requestId: ${run.requestId}`)

  for await (const progress of run.progressStream) {
    const value =
      progress.total > 0 ? `${progress.step}/${progress.total}` : `${progress.step} (indeterminate)`
    console.log(`▸ ${progress.stage}: ${value}`)
  }

  const [audio, stats] = await Promise.all([run.audio, run.stats])
  writeFileSync(
    outputPath,
    createWav(audio.pcm, audio.sampleRate, audio.channels, audio.bitsPerSample)
  )
  if (stats) console.log(`▸ Stats: ${JSON.stringify(stats)}`)
  console.log(`▸ Saved ${outputPath}`)

  await unloadModel({ modelId })
  modelId = undefined
  process.exit(0)
} catch (error) {
  if (modelId !== undefined) {
    try {
      await unloadModel({ modelId })
    } catch {
      // Preserve the generation error as the primary failure.
    }
  }
  console.error('✖', error)
  process.exit(1)
}

function createWav(pcm: Uint8Array, sampleRate: number, channels: number, bitsPerSample: number) {
  const header = new ArrayBuffer(44)
  const view = new DataView(header)
  const blockAlign = channels * (bitsPerSample / 8)

  writeAscii(view, 0, 'RIFF')
  view.setUint32(4, 36 + pcm.byteLength, true)
  writeAscii(view, 8, 'WAVE')
  writeAscii(view, 12, 'fmt ')
  view.setUint32(16, 16, true)
  view.setUint16(20, 1, true)
  view.setUint16(22, channels, true)
  view.setUint32(24, sampleRate, true)
  view.setUint32(28, sampleRate * blockAlign, true)
  view.setUint16(32, blockAlign, true)
  view.setUint16(34, bitsPerSample, true)
  writeAscii(view, 36, 'data')
  view.setUint32(40, pcm.byteLength, true)

  const wav = new Uint8Array(44 + pcm.byteLength)
  wav.set(new Uint8Array(header))
  wav.set(pcm, 44)
  return wav
}

function writeAscii(view: DataView, offset: number, value: string) {
  for (let index = 0; index < value.length; index++) {
    view.setUint8(offset + index, value.charCodeAt(index))
  }
}

MiniMax-Music3

This example loads a local MiniMax-Music3 pair from the AUDIOGEN_MINIMAX_LM_MODEL and AUDIOGEN_MINIMAX_SYNTH_MODEL environment variables, generates an instrumental, and writes the PCM output as WAV. Set AUDIOGEN_MAX_FRAMES, AUDIOGEN_STEPS, and AUDIOGEN_CFG_SCALE to override the MiniMax-only per-run controls (maxFrames, inferenceSteps, cfgScale).

generate-music-minimax.ts
import { writeFileSync } from 'node:fs'
import { audioGen, loadModel, unloadModel, type ModelProgressUpdate } from '@qvac/sdk'

// Usage:
//   AUDIOGEN_MINIMAX_LM_MODEL=/models/mm3-lm-q8.gguf \
//   AUDIOGEN_MINIMAX_SYNTH_MODEL=/models/mm3-synth-q8.gguf \
//   bun run examples/audiogen/generate-music-minimax.ts "warm cinematic piano" output.wav
const caption = process.argv[2] ?? 'Warm cinematic piano with gentle strings'
const outputPath = process.argv[3] ?? 'minimax-music3-output.wav'

let modelId: string | undefined

try {
  const threads = numberFromEnv('AUDIOGEN_THREADS')
  const maxFrames = numberFromEnv('AUDIOGEN_MAX_FRAMES')
  const inferenceSteps = numberFromEnv('AUDIOGEN_STEPS')

  console.log('▸ Loading MiniMax-Music3 models...')
  modelId = await loadModel({
    modelType: 'audiogen',
    modelConfig: {
      engine: 'minimax',
      lmModelSrc: requiredModelPath('AUDIOGEN_MINIMAX_LM_MODEL'),
      synthModelSrc: requiredModelPath('AUDIOGEN_MINIMAX_SYNTH_MODEL'),
      useGPU: process.env['AUDIOGEN_USE_GPU'] === '1',
      ...(threads !== undefined && { threads })
    },
    onProgress: function (progress: ModelProgressUpdate) {
      logDownloadProgress(progress)
    }
  })

  console.log(`▸ Model loaded: ${modelId}`)
  console.log(`▸ Generating: ${caption}`)
  const run = audioGen({
    modelId,
    caption,
    lyrics: process.env['AUDIOGEN_LYRICS'] ?? '[Instrumental]',
    seed: numberFromEnv('AUDIOGEN_SEED') ?? 7,
    cfgScale: numberFromEnv('AUDIOGEN_CFG_SCALE') ?? 1.7,
    ...(maxFrames !== undefined && { maxFrames }),
    ...(inferenceSteps !== undefined && { inferenceSteps })
  })

  console.log(`▸ requestId: ${run.requestId}`)
  for await (const progress of run.progressStream) {
    const value =
      progress.total > 0 ? `${progress.step}/${progress.total}` : `${progress.step} (indeterminate)`
    console.log(`▸ ${progress.stage}: ${value}`)
  }

  const [audio, stats] = await Promise.all([run.audio, run.stats])
  writeFileSync(
    outputPath,
    createWav(audio.pcm, audio.sampleRate, audio.channels, audio.bitsPerSample)
  )
  if (stats) console.log(`▸ Stats: ${JSON.stringify(stats)}`)
  console.log(`▸ Saved ${outputPath}`)

  await unloadModel({ modelId })
  modelId = undefined
  console.log('▸ Model unloaded')
  process.exit(0)
} catch (error) {
  if (modelId !== undefined) {
    try {
      await unloadModel({ modelId })
    } catch {
      // Preserve the generation error as the primary failure.
    }
  }
  console.error('✖', error)
  process.exit(1)
}

function requiredModelPath(name: string) {
  const value = process.env[name]
  if (!value) throw new Error(`${name} is required`)
  return value
}

function numberFromEnv(name: string) {
  const value = process.env[name]
  if (value === undefined) return undefined
  if (value.trim() === '') throw new Error(`${name} must not be empty`)

  const number = Number(value)
  if (!Number.isFinite(number)) throw new Error(`${name} must be a finite number`)
  return number
}

function logDownloadProgress(progress: ModelProgressUpdate) {
  const downloadedMb = (progress.downloaded / 1e6).toFixed(1)
  const totalMb = (progress.total / 1e6).toFixed(1)
  const line = `▸ Downloading ${progress.percentage.toFixed(0)}% (${downloadedMb}/${totalMb} MB)`
  process.stderr.write(process.stderr.isTTY ? `\r${line}` : `${line}\n`)
  if (process.stderr.isTTY && progress.percentage >= 100) process.stderr.write('\n')
}

function createWav(pcm: Uint8Array, sampleRate: number, channels: number, bitsPerSample: number) {
  const header = new ArrayBuffer(44)
  const view = new DataView(header)
  const blockAlign = channels * (bitsPerSample / 8)

  writeAscii(view, 0, 'RIFF')
  view.setUint32(4, 36 + pcm.byteLength, true)
  writeAscii(view, 8, 'WAVE')
  writeAscii(view, 12, 'fmt ')
  view.setUint32(16, 16, true)
  view.setUint16(20, 1, true)
  view.setUint16(22, channels, true)
  view.setUint32(24, sampleRate, true)
  view.setUint32(28, sampleRate * blockAlign, true)
  view.setUint16(32, blockAlign, true)
  view.setUint16(34, bitsPerSample, true)
  writeAscii(view, 36, 'data')
  view.setUint32(40, pcm.byteLength, true)

  const wav = new Uint8Array(44 + pcm.byteLength)
  wav.set(new Uint8Array(header))
  wav.set(pcm, 44)
  return wav
}

function writeAscii(view: DataView, offset: number, value: string) {
  for (let index = 0; index < value.length; index++) {
    view.setUint8(offset + index, value.charCodeAt(index))
  }
}

Tip: see the JS/TS quickstart or the Python quickstart for setup instructions, and apply the first-run download configuration above on constrained connections.

On this page

Ask anything about QVAC.