#!/usr/bin/env bun /** * speak - Convert text to speech using Chatterbox TTS * * Entry point for the speak CLI tool. */ import { Command } from "commander"; import pc from "picocolors"; import { existsSync, writeFileSync } from "fs"; import { loadConfig, CONFIG_PATH, CHATTER_DIR, ensureChatterDir, generateDefaultConfig, expandPath, } from "./core/config.ts"; import type { Config } from "./core/types.ts"; const VERSION = "1.1.0"; // Load configuration at startup const config: Config = loadConfig(); const program = new Command(); program .name("speak") .description("Convert text to speech using Chatterbox TTS") .version(VERSION) .argument("[input...]", "Text to convert or file paths") .option("-c, --clipboard", "Read from system clipboard") .option("-o, --output ", "Output file (.wav) or directory", config.output_dir) .option("-m, --model ", "TTS model", config.model) .option("-t, --temp ", "Temperature (0-1)", String(config.temperature)) .option("-s, --speed ", "Playback speed (0-2)", String(config.speed)) .option("-v, --voice ", "Voice preset or path to .wav", config.voice) .option("--markdown ", "Markdown mode: plain|smart", config.markdown_mode) .option("--code-blocks ", "Code handling: read|skip|placeholder", config.code_blocks) .option("--play", "Play audio after generation") .option("--stream", "Stream audio as it generates") .option("--preview", "Generate first sentence only") .option("--daemon", "Use persistent server for faster calls", config.daemon) .option("--timeout ", "Generation timeout in seconds (0 = no timeout)", "300") .option("--auto-chunk", "Automatically chunk long documents for reliable generation") .option("--chunk-size ", "Max characters per chunk", "6000") .option("--resume ", "Resume from a previous incomplete generation") .option("--keep-chunks", "Keep intermediate chunk files after completion") .option("--output-dir ", "Output directory for batch mode") .option("--skip-existing", "Skip files that already have output") .option("--stop-on-error", "Stop batch processing on first error") .option("--estimate", "Show duration estimate without generating") .option("--dry-run", "Preview what would happen without generating") .option("--verbose", "Show detailed progress") .option("--quiet", "Suppress all output except errors") .action(async (input: string[], options) => { const { initLogger, logger } = await import("./ui/logger.ts"); const { startDaemon, stopDaemon } = await import("./bridge/daemon.ts"); const { generate } = await import("./bridge/client.ts"); const { copyToOutput, playAudio, registerCleanupHandlers, stopAudio, prepareOutputPath } = await import("./core/output.ts"); const { isVenvValid, runSetup } = await import("./python/setup.ts"); const { processMarkdown, isMarkdown, extractFirstSentence } = await import( "./core/markdown.ts" ); // Register cleanup handlers for Ctrl+C registerCleanupHandlers(async () => { if (!options.quiet) { console.log(pc.dim("\n Interrupted, cleaning up...")); } await stopDaemon(); }); initLogger({ logLevel: config.log_level, quiet: options.quiet, verbose: options.verbose, }); // Auto-setup on first run if (!isVenvValid()) { if (!options.quiet) { console.log(pc.cyan("First run detected - setting up Python environment...\n")); } const success = await runSetup({ showProgress: !options.quiet }); if (!success) { console.log(pc.red("\nSetup failed. Please run 'speak setup' manually for details.")); process.exit(1); } if (!options.quiet) { console.log(pc.green("\n✓ Setup complete!\n")); } } // Handle resume mode early (doesn't need input) if (options.resume) { // Jump directly to resume handling (after daemon starts) // We need to start the daemon first const { startDaemon, stopDaemon } = await import("./bridge/daemon.ts"); const started = await startDaemon(); if (!started) { console.log(pc.red("Failed to start TTS server")); process.exit(1); } const { loadManifest, getPendingChunks, updateChunkStatus, saveManifest, isComplete, } = await import("./core/manifest.ts"); const { concatenateWav, cleanupChunkFiles, hasSox } = await import( "./core/concatenate.ts" ); const { copyFileSync, unlinkSync } = await import("fs"); const { generate } = await import("./bridge/client.ts"); // Verify sox is available if (!hasSox()) { console.log(pc.red("Error: sox is required for resume but not found.")); console.log(pc.dim("Install with: brew install sox")); process.exit(1); } const manifest = loadManifest(options.resume); if (!manifest) { console.log(pc.red(`Cannot load manifest: ${options.resume}`)); process.exit(1); } const pending = getPendingChunks(manifest); if (pending.length === 0 && isComplete(manifest)) { if (!options.quiet) { console.log(pc.green("All chunks already complete.")); } // Just do final concatenation const outputPath = manifest.output_file || prepareOutputPath(options.output); const chunkFiles = manifest.chunks.map((c) => c.output); concatenateWav(chunkFiles, outputPath); if (!options.quiet) { console.log(pc.green(`✓ Output: ${outputPath}`)); } // Cleanup unless --keep-chunks if (!options.keepChunks) { cleanupChunkFiles(chunkFiles); try { unlinkSync(options.resume); } catch { // Ignore } } // Stop daemon if not in daemon mode if (!options.daemon) { await stopDaemon(); } process.exit(0); } if (!options.quiet) { console.log( pc.cyan(`Resuming: ${pending.length}/${manifest.chunks.length} chunks remaining`) ); } const timeoutMs = parseInt(options.timeout) * 1000; for (const chunk of pending) { if (!options.quiet) { process.stdout.write( pc.dim(` Chunk ${chunk.index + 1}/${manifest.chunks.length}...`) ); } try { const result = await generate( { text: chunk.text, model: manifest.params.model, temperature: manifest.params.temperature, speed: manifest.params.speed, voice: manifest.params.voice, }, timeoutMs ); copyFileSync(result.audio_path, chunk.output); updateChunkStatus(manifest, chunk.index, "complete", result.duration); saveManifest(manifest, options.resume); if (!options.quiet) { console.log(pc.green(` ✓ ${result.duration.toFixed(1)}s`)); } } catch (error) { updateChunkStatus(manifest, chunk.index, "failed"); saveManifest(manifest, options.resume); const message = error instanceof Error ? error.message : String(error); if (!options.quiet) { console.log(pc.red(` ✗ ${message}`)); console.log(pc.yellow(`Resume with: speak --resume ${options.resume}`)); } // Stop daemon if not in daemon mode if (!options.daemon) { await stopDaemon(); } process.exit(1); } } // All done - concatenate const outputPath = manifest.output_file || prepareOutputPath(options.output); const chunkFiles = manifest.chunks.map((c) => c.output); concatenateWav(chunkFiles, outputPath); // Calculate total duration const totalDuration = manifest.chunks.reduce((sum, c) => sum + (c.duration || 0), 0); if (!options.quiet) { console.log( pc.green( `✓ Generated ${totalDuration.toFixed(1)}s of audio from ${manifest.chunks.length} chunks` ) ); console.log(pc.dim(` Output: ${outputPath}`)); } // Cleanup unless --keep-chunks if (!options.keepChunks) { cleanupChunkFiles(chunkFiles); try { unlinkSync(options.resume); } catch { // Ignore } } // Play audio if requested if (options.play) { const { playAudio } = await import("./core/output.ts"); if (!options.quiet) { console.log(pc.dim(" Playing...")); } await playAudio(outputPath); } // Stop daemon if not in daemon mode if (!options.daemon) { await stopDaemon(); } process.exit(0); } // Get text input let text = ""; let isMarkdownFile = false; if (options.clipboard) { // Read from clipboard const { execSync } = await import("child_process"); try { text = execSync("pbpaste", { encoding: "utf-8" }); } catch { console.log(pc.red("Failed to read clipboard")); process.exit(1); } } else if (input.length > 0) { // Check if input is file paths (batch mode) or text const fs = await import("fs"); const inputFiles = input.filter((p) => fs.existsSync(p)); // Batch mode: multiple files detected if (inputFiles.length > 1 || (inputFiles.length === 1 && options.outputDir)) { const { prepareBatchInputs, validateBatchInputs, summarizeBatch } = await import( "./core/batch.ts" ); const { processMarkdown, isMarkdown } = await import("./core/markdown.ts"); const outputDir = options.outputDir || options.output; const batchInputs = prepareBatchInputs(inputFiles, { outputDir, skipExisting: options.skipExisting || false, }); const validation = validateBatchInputs(batchInputs); if (!validation.valid) { for (const error of validation.errors) { console.log(pc.red(`Error: ${error}`)); } process.exit(1); } // Ensure output directory exists if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } if (!options.quiet) { console.log(pc.cyan(`Processing ${batchInputs.length} files...\n`)); } // Start daemon const { startDaemon, stopDaemon } = await import("./bridge/daemon.ts"); const started = await startDaemon(); if (!started) { console.log(pc.red("Failed to start TTS server")); process.exit(1); } const results: Array<{ inputPath: string; outputPath: string; success: boolean; duration?: number; error?: string; skipped: boolean; }> = []; const timeoutMs = parseInt(options.timeout) * 1000; for (const batchInput of batchInputs) { if (batchInput.skip) { if (!options.quiet) { console.log(pc.dim(` Skip: ${batchInput.inputPath} (output exists)`)); } results.push({ inputPath: batchInput.inputPath, outputPath: batchInput.outputPath, success: true, skipped: true, }); continue; } if (!options.quiet) { process.stdout.write(` ${batchInput.inputPath}...`); } try { let fileText = fs.readFileSync(batchInput.inputPath, "utf-8"); // Process markdown if needed const isMarkdownFile = batchInput.inputPath.endsWith(".md"); if (isMarkdownFile || isMarkdown(fileText)) { fileText = processMarkdown(fileText, { mode: options.markdown as "plain" | "smart", codeBlocks: options.codeBlocks as "read" | "skip" | "placeholder", }); } const result = await generate( { text: fileText, model: options.model, temperature: parseFloat(options.temp), speed: parseFloat(options.speed), voice: options.voice, }, timeoutMs ); fs.copyFileSync(result.audio_path, batchInput.outputPath); if (!options.quiet) { console.log(pc.green(` ✓ ${result.duration.toFixed(1)}s`)); } results.push({ inputPath: batchInput.inputPath, outputPath: batchInput.outputPath, success: true, duration: result.duration, skipped: false, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (!options.quiet) { console.log(pc.red(` ✗ ${message}`)); } results.push({ inputPath: batchInput.inputPath, outputPath: batchInput.outputPath, success: false, error: message, skipped: false, }); if (options.stopOnError) { if (!options.quiet) { console.log(pc.red("\nStopping due to --stop-on-error")); } break; } } } // Print summary const summary = summarizeBatch(results); if (!options.quiet) { console.log(pc.cyan("\n--- Batch Summary ---")); console.log(` Total: ${summary.total}`); console.log(pc.green(` Success: ${summary.success}`)); if (summary.failed > 0) { console.log(pc.red(` Failed: ${summary.failed}`)); } if (summary.skipped > 0) { console.log(pc.yellow(` Skipped: ${summary.skipped}`)); } console.log(pc.dim(` Duration: ${summary.totalDuration.toFixed(1)}s total`)); } // Stop daemon if not in daemon mode if (!options.daemon) { await stopDaemon(); } // Exit with error code if any failed process.exit(summary.failed > 0 ? 1 : 0); } // Single file or text input if (input.length === 1 && fs.existsSync(input[0])) { text = fs.readFileSync(input[0], "utf-8"); isMarkdownFile = input[0].endsWith(".md"); } else { text = input.join(" "); } } else { console.log(pc.yellow("No input provided. Use --help for usage.")); return; } if (!text.trim()) { console.log(pc.yellow("Empty input. Nothing to generate.")); return; } // Process markdown if needed const shouldProcessMarkdown = isMarkdownFile || isMarkdown(text); if (shouldProcessMarkdown) { const originalLength = text.length; text = processMarkdown(text, { mode: options.markdown as "plain" | "smart", codeBlocks: options.codeBlocks as "read" | "skip" | "placeholder", }); if (options.verbose && !options.quiet) { console.log( pc.dim(`Processed markdown: ${originalLength} → ${text.length} characters`) ); } } // Preview mode: extract first sentence only if (options.preview) { text = extractFirstSentence(text); if (!options.quiet) { console.log(pc.dim(`Preview mode: "${text}"`)); } } // Estimate mode: show estimate and exit if (options.estimate) { const { estimateDuration, formatEstimate } = await import("./core/estimate.ts"); const estimate = estimateDuration(text, options.model); console.log(pc.cyan("\nGeneration Estimate\n")); console.log(formatEstimate(estimate)); console.log(); process.exit(0); } // Dry-run mode: show what would happen without generating if (options.dryRun) { const { estimateDuration, formatEstimate } = await import("./core/estimate.ts"); const { chunkText, shouldAutoChunk } = await import("./core/chunker.ts"); const estimate = estimateDuration(text, options.model); const outputPath = prepareOutputPath(options.output); const willChunk = options.autoChunk || shouldAutoChunk(text, parseInt(options.timeout)); console.log(pc.cyan("\nDry Run - No audio will be generated\n")); console.log(formatEstimate(estimate)); console.log(); console.log(pc.bold("Output:")); console.log(` ${outputPath}`); if (willChunk) { const chunks = chunkText(text, { maxChars: parseInt(options.chunkSize), overlapChars: 0, }); console.log(); console.log(pc.bold(`Chunking:`)); console.log(` Will split into ${chunks.length} chunks`); console.log(` Chunk size: ~${options.chunkSize} chars max`); } console.log(); process.exit(0); } if (!options.quiet) { console.log(pc.cyan("speak") + " v" + VERSION); console.log(pc.dim(`Generating audio for ${text.length} characters...`)); } try { // Start daemon const started = await startDaemon(); if (!started) { console.log(pc.red("Failed to start TTS server")); process.exit(1); } // Resume mode - continue from a previous incomplete generation if (options.resume) { const { loadManifest, getPendingChunks, updateChunkStatus, saveManifest, isComplete, } = await import("./core/manifest.ts"); const { concatenateWav, cleanupChunkFiles, hasSox } = await import( "./core/concatenate.ts" ); const { copyFileSync, unlinkSync } = await import("fs"); // Verify sox is available if (!hasSox()) { console.log(pc.red("Error: sox is required for resume but not found.")); console.log(pc.dim("Install with: brew install sox")); process.exit(1); } const manifest = loadManifest(options.resume); if (!manifest) { console.log(pc.red(`Cannot load manifest: ${options.resume}`)); process.exit(1); } const pending = getPendingChunks(manifest); if (pending.length === 0 && isComplete(manifest)) { if (!options.quiet) { console.log(pc.green("All chunks already complete.")); } // Just do final concatenation const outputPath = manifest.output_file || prepareOutputPath(options.output); const chunkFiles = manifest.chunks.map((c) => c.output); concatenateWav(chunkFiles, outputPath); if (!options.quiet) { console.log(pc.green(`✓ Output: ${outputPath}`)); } // Cleanup unless --keep-chunks if (!options.keepChunks) { cleanupChunkFiles(chunkFiles); try { unlinkSync(options.resume); } catch { // Ignore } } // Stop daemon if not in daemon mode if (!options.daemon) { await stopDaemon(); } process.exit(0); } if (!options.quiet) { console.log( pc.cyan(`Resuming: ${pending.length}/${manifest.chunks.length} chunks remaining`) ); } const timeoutMs = parseInt(options.timeout) * 1000; for (const chunk of pending) { if (!options.quiet) { process.stdout.write( pc.dim(` Chunk ${chunk.index + 1}/${manifest.chunks.length}...`) ); } try { const result = await generate( { text: chunk.text, model: manifest.params.model, temperature: manifest.params.temperature, speed: manifest.params.speed, voice: manifest.params.voice, }, timeoutMs ); copyFileSync(result.audio_path, chunk.output); updateChunkStatus(manifest, chunk.index, "complete", result.duration); saveManifest(manifest, options.resume); if (!options.quiet) { console.log(pc.green(` ✓ ${result.duration.toFixed(1)}s`)); } } catch (error) { updateChunkStatus(manifest, chunk.index, "failed"); saveManifest(manifest, options.resume); const message = error instanceof Error ? error.message : String(error); if (!options.quiet) { console.log(pc.red(` ✗ ${message}`)); console.log(pc.yellow(`Resume with: speak --resume ${options.resume}`)); } // Stop daemon if not in daemon mode if (!options.daemon) { await stopDaemon(); } process.exit(1); } } // All done - concatenate const outputPath = manifest.output_file || prepareOutputPath(options.output); const chunkFiles = manifest.chunks.map((c) => c.output); concatenateWav(chunkFiles, outputPath); // Calculate total duration const totalDuration = manifest.chunks.reduce((sum, c) => sum + (c.duration || 0), 0); if (!options.quiet) { console.log( pc.green( `✓ Generated ${totalDuration.toFixed(1)}s of audio from ${manifest.chunks.length} chunks` ) ); console.log(pc.dim(` Output: ${outputPath}`)); } // Cleanup unless --keep-chunks if (!options.keepChunks) { cleanupChunkFiles(chunkFiles); try { unlinkSync(options.resume); } catch { // Ignore } } // Play audio if requested if (options.play) { if (!options.quiet) { console.log(pc.dim(" Playing...")); } await playAudio(outputPath); } // Stop daemon if not in daemon mode if (!options.daemon) { await stopDaemon(); } process.exit(0); } // Auto-chunk mode for long documents if (options.autoChunk) { const { createManifest, saveManifest, updateChunkStatus, } = await import("./core/manifest.ts"); const { concatenateWav, cleanupChunkFiles, hasSox } = await import( "./core/concatenate.ts" ); const { copyFileSync, mkdirSync, unlinkSync } = await import("fs"); const { dirname, join } = await import("path"); // Verify sox is available if (!hasSox()) { console.log(pc.red("Error: sox is required for --auto-chunk but not found.")); console.log(pc.dim("Install with: brew install sox")); process.exit(1); } const outputPath = prepareOutputPath(options.output); const tempDir = dirname(outputPath); const manifestPath = join(tempDir, "manifest.json"); // Ensure temp directory exists mkdirSync(tempDir, { recursive: true }); // Create manifest for resume capability const manifest = createManifest( text, tempDir, { model: options.model, temperature: parseFloat(options.temp), speed: parseFloat(options.speed), voice: options.voice, }, { maxChars: parseInt(options.chunkSize), overlapChars: 0 } ); manifest.output_file = outputPath; saveManifest(manifest, manifestPath); if (!options.quiet) { console.log(pc.cyan(`Processing ${manifest.chunks.length} chunks...`)); } const chunkFiles: string[] = []; let totalDuration = 0; try { const timeoutMs = parseInt(options.timeout) * 1000; for (let i = 0; i < manifest.chunks.length; i++) { const chunkInfo = manifest.chunks[i]; if (!chunkInfo) continue; if (!options.quiet) { process.stdout.write( pc.dim(` Chunk ${i + 1}/${manifest.chunks.length} (${chunkInfo.text.length} chars)...`) ); } const result = await generate( { text: chunkInfo.text, model: options.model, temperature: parseFloat(options.temp), speed: parseFloat(options.speed), voice: options.voice, }, timeoutMs ); // Save chunk copyFileSync(result.audio_path, chunkInfo.output); chunkFiles.push(chunkInfo.output); totalDuration += result.duration; // Update manifest updateChunkStatus(manifest, i, "complete", result.duration); saveManifest(manifest, manifestPath); if (!options.quiet) { console.log(pc.green(` ✓ ${result.duration.toFixed(1)}s`)); } } // Concatenate all chunks concatenateWav(chunkFiles, outputPath); if (!options.quiet) { console.log(pc.green(`✓ Generated ${totalDuration.toFixed(1)}s of audio from ${manifest.chunks.length} chunks`)); console.log(pc.dim(` Output: ${outputPath}`)); } // Cleanup temp chunk files and manifest unless --keep-chunks if (!options.keepChunks) { cleanupChunkFiles(chunkFiles); try { unlinkSync(manifestPath); } catch { // Ignore } } // Play audio if requested if (options.play) { if (!options.quiet) { console.log(pc.dim(" Playing...")); } await playAudio(outputPath); } } catch (error) { // Update manifest with failure status for current chunk const completedCount = chunkFiles.length; if (completedCount < manifest.chunks.length) { updateChunkStatus(manifest, completedCount, "failed"); saveManifest(manifest, manifestPath); } // Report partial progress if (chunkFiles.length > 0) { if (!options.quiet) { console.log( pc.yellow(`\n⚠ Generation interrupted after ${chunkFiles.length}/${manifest.chunks.length} chunks`) ); console.log(pc.yellow(` Resume with: speak --resume ${manifestPath}`)); } } throw error; } // Stop daemon if not in daemon mode if (!options.daemon) { await stopDaemon(); } process.exit(0); } // Streaming mode - uses new binary protocol streaming if (options.stream) { const { StreamOrchestrator } = await import("./streaming/orchestrator.ts"); const orchestrator = new StreamOrchestrator(24000, { initialBufferSeconds: 3.0, minBufferSeconds: 1.0, resumeBufferSeconds: 2.0, }); // Handle Ctrl+C const cancelHandler = () => { orchestrator.cancel("User interrupted"); }; process.once("SIGINT", cancelHandler); if (!options.quiet) { console.log(pc.dim("Streaming audio...")); } const result = await orchestrator.stream({ text, model: options.model, temperature: parseFloat(options.temp), speed: parseFloat(options.speed), voice: options.voice, onProgress: (progress) => { if (!options.quiet && options.verbose) { process.stdout.write( `\r${pc.dim(`State: ${progress.state} | Buffer: ${progress.bufferedSeconds.toFixed(1)}s | Chunks: ${progress.chunksReceived}`)}` ); } }, }); process.off("SIGINT", cancelHandler); if (!options.quiet) { if (result.success) { console.log(pc.green(`\n✓ Streamed ${result.totalChunks} chunks`)); console.log(pc.dim(` Duration: ${result.totalDurationSeconds.toFixed(1)}s`)); if (result.rebufferCount > 0) { console.log(pc.yellow(` Rebuffered: ${result.rebufferCount} time(s)`)); } if (result.underrunCount > 0) { console.log(pc.yellow(` Underruns: ${result.underrunCount} samples`)); } } else { console.log(pc.red(`\n✗ Streaming failed: ${result.error}`)); } } // Don't need daemon cleanup for streaming - orchestrator handles socket process.exit(result.success ? 0 : 1); } else { // Non-streaming mode with progress spinner const { createSpinner } = await import("./ui/progress.ts"); const spinner = createSpinner({ text, showEta: !options.quiet && text.length > 100, quiet: options.quiet, }); spinner.start(); try { const timeoutMs = parseInt(options.timeout) * 1000; const result = await generate( { text, model: options.model, temperature: parseFloat(options.temp), speed: parseFloat(options.speed), voice: options.voice, }, timeoutMs, (progress) => { spinner.updateProgress(progress); }, (status) => { spinner.updateStatus(status); } ); spinner.stop(true); // Copy to output directory const outputPath = copyToOutput(result.audio_path, options.output); if (!options.quiet) { // Check if generation was complete or partial if (result.complete === false) { console.log(pc.yellow(`⚠ Partial generation: ${result.duration.toFixed(1)}s of audio`)); console.log(pc.yellow(` Generated ${result.chunks_generated ?? '?'}/${result.chunks_total ?? '?'} chunks`)); if (result.reason) { console.log(pc.yellow(` Reason: ${result.reason}`)); } } else { console.log(pc.green(`✓ Generated ${result.duration.toFixed(1)}s of audio`)); } console.log(pc.dim(` Output: ${outputPath}`)); console.log(pc.dim(` RTF: ${result.rtf.toFixed(2)}x`)); } // Play audio if requested if (options.play) { if (!options.quiet) { console.log(pc.dim(" Playing...")); } await playAudio(outputPath); } } catch (error) { spinner.stop(false); throw error; } } // Stop daemon if not in daemon mode if (!options.daemon) { await stopDaemon(); } // Exit cleanly - don't leave event loop hanging process.exit(0); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.log(pc.red(`Error: ${message}`)); process.exit(1); } }); // Subcommand: setup program .command("setup") .description("Set up Python environment") .option("--force", "Force reinstall even if environment exists") .option("--quiet", "Hide installation progress") .option("--health", "Only run health check, don't install") .action(async (options) => { const { runSetup, checkPython } = await import("./python/setup.ts"); const { printHealthStatus } = await import("./python/health.ts"); const { initLogger, logger } = await import("./ui/logger.ts"); initLogger({ logLevel: config.log_level }); if (options.health) { const healthy = await printHealthStatus(); process.exit(healthy ? 0 : 1); } console.log(pc.cyan("Setting up Python environment for speak CLI...\n")); const success = await runSetup({ force: options.force, showProgress: !options.quiet, }); if (success) { console.log(pc.green("\n✓ Setup complete! You can now use speak to generate audio.")); console.log(pc.dim(" Example: speak \"Hello, world!\" --play")); } else { console.log(pc.red("\n✗ Setup failed. Check the errors above.")); process.exit(1); } }); // Subcommand: models program .command("models") .description("List available TTS models") .action(async () => { console.log(pc.cyan("Available Chatterbox models:\n")); const models = [ { name: "mlx-community/chatterbox-turbo-8bit", desc: "8-bit quantized, fastest, recommended" }, { name: "mlx-community/chatterbox-turbo-fp16", desc: "Full precision, highest quality" }, { name: "mlx-community/chatterbox-turbo-4bit", desc: "4-bit quantized, smallest memory" }, { name: "mlx-community/chatterbox-turbo-5bit", desc: "5-bit quantized" }, { name: "mlx-community/chatterbox-turbo-6bit", desc: "6-bit quantized" }, ]; for (const model of models) { const isDefault = model.name === config.model; const prefix = isDefault ? pc.green("* ") : " "; const suffix = isDefault ? pc.dim(" (current)") : ""; console.log(prefix + model.name + suffix); console.log(pc.dim(" " + model.desc)); } }); // Subcommand: concat program .command("concat ") .description("Concatenate multiple audio files into one") .option("--out ", "Output file path", "combined.wav") .action(async (files: string[], options) => { const { concatenateWav, hasSox } = await import("./core/concatenate.ts"); const { initLogger } = await import("./ui/logger.ts"); const { existsSync, mkdirSync } = await import("fs"); const { dirname, basename } = await import("path"); const { expandPath } = await import("./core/config.ts"); initLogger({ logLevel: config.log_level }); // Check sox availability if (!hasSox()) { console.log(pc.red("Error: sox is required for concatenation but not found.")); console.log(pc.dim("Install with: brew install sox")); process.exit(1); } // Validate input files const missing = files.filter((f) => !existsSync(f)); if (missing.length > 0) { console.log(pc.red("Error: Files not found:")); for (const f of missing) { console.log(pc.red(` - ${f}`)); } process.exit(1); } // Sort files naturally (for numbered sequences) const sortedFiles = [...files].sort((a, b) => { return a.localeCompare(b, undefined, { numeric: true }); }); console.log(pc.cyan(`Concatenating ${sortedFiles.length} files...`)); for (const f of sortedFiles) { console.log(pc.dim(` - ${basename(f)}`)); } try { const outputPath = expandPath(options.out); // Ensure output directory exists const outputDir = dirname(outputPath); if (!existsSync(outputDir)) { mkdirSync(outputDir, { recursive: true }); } concatenateWav(sortedFiles, outputPath); console.log(pc.green(`✓ Created ${outputPath}`)); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.log(pc.red(`Error: ${message}`)); process.exit(1); } }); // Subcommand: daemon const daemonCmd = program.command("daemon").description("Daemon management"); daemonCmd .command("kill") .description("Stop running daemon") .action(async () => { const { stopDaemon } = await import("./bridge/daemon.ts"); const { initLogger } = await import("./ui/logger.ts"); initLogger({ logLevel: config.log_level }); await stopDaemon(); }); // Subcommand: completions program .command("completions ") .description("Generate shell completions (bash, zsh, fish)") .option("--install", "Show installation instructions") .action(async (shell, options) => { const validShells = ["bash", "zsh", "fish"]; if (!validShells.includes(shell)) { console.log(pc.red(`Invalid shell: ${shell}`)); console.log(pc.dim(`Supported shells: ${validShells.join(", ")}`)); process.exit(1); } const { getCompletions, getInstallInstructions } = await import( "./utils/completions.ts" ); if (options.install) { console.log(pc.cyan(`Installation instructions for ${shell}:\n`)); console.log(getInstallInstructions(shell)); } else { // Output completion script (for eval or redirect) console.log(getCompletions(shell)); } }); // Subcommand: config program .command("config") .description("Show current configuration") .option("--init", "Create default config file") .action(async (options) => { if (options.init) { ensureChatterDir(); if (existsSync(CONFIG_PATH)) { console.log(pc.yellow(`Config file already exists: ${CONFIG_PATH}`)); return; } writeFileSync(CONFIG_PATH, generateDefaultConfig()); console.log(pc.green(`Created config file: ${CONFIG_PATH}`)); return; } console.log(pc.cyan("Current Configuration:\n")); console.log(pc.dim(` Config file: ${CONFIG_PATH}`)); console.log(pc.dim(` File exists: ${existsSync(CONFIG_PATH) ? "yes" : "no (using defaults)"}\n`)); console.log(" " + pc.bold("output_dir") + ": " + config.output_dir); console.log(" " + pc.bold("model") + ": " + config.model); console.log(" " + pc.bold("temperature") + ": " + config.temperature); console.log(" " + pc.bold("speed") + ": " + config.speed); console.log(" " + pc.bold("markdown_mode") + ": " + config.markdown_mode); console.log(" " + pc.bold("code_blocks") + ": " + config.code_blocks); console.log(" " + pc.bold("voice") + ": " + (config.voice || "(none)")); console.log(" " + pc.bold("daemon") + ": " + config.daemon); console.log(" " + pc.bold("log_level") + ": " + config.log_level); }); // Subcommand: health program .command("health") .description("Check system health") .option("--json", "Output as JSON") .action(async (options) => { const { runHealthChecks } = await import("./core/health.ts"); const report = await runHealthChecks(); if (options.json) { console.log(JSON.stringify(report, null, 2)); } else { const statusIcon = { healthy: pc.green("✓"), degraded: pc.yellow("⚠"), unhealthy: pc.red("✗"), }; console.log(`\n${statusIcon[report.overall]} System: ${report.overall.toUpperCase()}\n`); for (const check of report.checks) { const icon = check.status === "pass" ? pc.green("✓") : check.status === "warn" ? pc.yellow("⚠") : pc.red("✗"); console.log(` ${icon} ${check.name}: ${check.message}`); } console.log(); } process.exit(report.overall === "unhealthy" ? 1 : 0); }); // Parse arguments program.parse();