/** Nari's complete-response WAV endpoint; credentials remain server-side. */ import { mkdir, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { VclawError } from '../errors.js'; import type { TtsBackend } from './types.js'; const MODELS = new Set(['qwen3-tts:free', 'qwen3-tts-fast:free', 'qwen3-tts', 'qwen3-tts-fast']); function failure(message: string, details: Record = {}): VclawError { return new VclawError('tts_failed', `Nari TTS: ${message}`, { backendId: 'nari-tts', ...details }); } /** Validate the documented PCM format and measure delivered audio, not text. */ function wavDuration(bytes: Buffer): number { const invalid = () => failure('Returned invalid or truncated WAV audio.'); if (bytes.length < 44 || bytes.toString('ascii', 0, 4) !== 'RIFF' || bytes.toString('ascii', 8, 12) !== 'WAVE' || bytes.readUInt32LE(4) + 8 !== bytes.length) throw invalid(); let formatFound = false; let dataSize = 0; let dataFound = false; let offset = 12; while (offset + 8 <= bytes.length) { const id = bytes.toString('ascii', offset, offset + 4); const size = bytes.readUInt32LE(offset + 4); const start = offset + 8; if (start + size > bytes.length) throw invalid(); if (id === 'fmt ') { if (formatFound || size < 16 || bytes.readUInt16LE(start) !== 1 || bytes.readUInt16LE(start + 2) !== 1 || bytes.readUInt32LE(start + 4) !== 24000 || bytes.readUInt32LE(start + 8) !== 48000 || bytes.readUInt16LE(start + 12) !== 2 || bytes.readUInt16LE(start + 14) !== 16) throw invalid(); formatFound = true; } if (id === 'data') { if (dataFound || size === 0 || size % 2 !== 0) throw invalid(); dataFound = true; dataSize = size; } offset = start + size + (size % 2); } if (!formatFound || !dataFound || offset !== bytes.length) throw invalid(); return dataSize / 48; } export const nariTts: TtsBackend = { id: 'nari-tts', kind: 'tts', displayName: 'Nari Labs TTS', outputExtension: 'wav', requiredEnv: ['NARI_API_KEY'], summary: 'Nari Labs speech as 24 kHz mono WAV. Requires NARI_API_KEY. --voice is a case-sensitive voice ID (default diana); NARI_TTS_MODEL defaults to qwen3-tts:free. Maximum 2,048 characters per request.', async generate(input) { const text = input.text.trim(); const length = Array.from(text).length; if (length < 1 || length > 2048) { throw failure('Text must contain 1–2,048 Unicode characters after trimming. Split longer scripts at sentence boundaries.'); } const env = input.env ?? process.env; const model = env.NARI_TTS_MODEL?.trim() || 'qwen3-tts:free'; if (!MODELS.has(model)) throw failure('Unsupported NARI_TTS_MODEL. See docs/NARI.md.'); const body = JSON.stringify({ model, input: text, voice: input.voice?.trim() || 'diana', response_format: 'wav', stream: false }); if (Buffer.byteLength(body) > 65536) throw failure('Request exceeds the 64 KiB JSON body limit.'); if (input.dryRun) { await mkdir(dirname(input.outputPath), { recursive: true }); await writeFile(input.outputPath, new Uint8Array(0)); return { path: input.outputPath, durationMs: Math.round(Math.max(1, length / 14) * 1000), backendId: 'nari-tts' }; } const key = env.NARI_API_KEY?.trim(); if (!key) throw failure('NARI_API_KEY is not set. Configure a fresh key in your environment or workspace secrets.', { envVar: 'NARI_API_KEY' }); let bytes: Buffer; try { const response = await (input.fetcher ?? fetch)('https://api.narilabs.com/v1/audio/speech', { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', Accept: 'audio/wav' }, body, signal: AbortSignal.timeout(120_000), redirect: 'error', }); // Do not retry generation automatically: a lost response may still have // consumed allowance. Never include untrusted response bodies in errors. if (!response.ok) { await response.body?.cancel().catch(() => undefined); throw failure(`Request failed with HTTP ${response.status}. Check credentials, access and allowance before retrying.`, { status: response.status }); } if (response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'audio/wav') { await response.body?.cancel().catch(() => undefined); throw failure('Expected an audio/wav response.'); } bytes = Buffer.from(await response.arrayBuffer()); } catch (error) { if (error instanceof VclawError) throw error; throw failure('Network request failed or timed out. No automatic retry was made.'); } const durationMs = wavDuration(bytes); await mkdir(dirname(input.outputPath), { recursive: true }); await writeFile(input.outputPath, bytes); return { path: input.outputPath, durationMs, backendId: 'nari-tts' }; }, };