UNPKG

piper-announce

Version:

AI-powered announcement generator using Piper TTS and OpenAI GPT models

481 lines (426 loc) • 13.6 kB
#!/usr/bin/env node import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { createWriteStream } from "fs"; import { pipeline } from "stream"; import { promisify } from "util"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const streamPipeline = promisify(pipeline); // Voice models configuration with download URLs for both .onnx and .json files const VOICE_MODELS = { "en_GB-jenny_dioco-medium": { files: { onnx: { filename: "en_GB-jenny_dioco-medium.onnx", url: "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/jenny_dioco/medium/en_GB-jenny_dioco-medium.onnx", size: "63MB", }, json: { filename: "en_GB-jenny_dioco-medium.onnx.json", url: "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/jenny_dioco/medium/en_GB-jenny_dioco-medium.onnx.json", size: "2KB", }, }, language: "English (GB)", gender: "Female", quality: "Medium", }, "en_GB-alan-low": { files: { onnx: { filename: "en_GB-alan-low.onnx", url: "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/alan/low/en_GB-alan-low.onnx", size: "22MB", }, json: { filename: "en_GB-alan-low.onnx.json", url: "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/alan/low/en_GB-alan-low.onnx.json", size: "2KB", }, }, language: "English (GB)", gender: "Male", quality: "Low", }, "es_ES-mls_10246-low": { files: { onnx: { filename: "es_ES-mls_10246-low.onnx", url: "https://huggingface.co/rhasspy/piper-voices/resolve/main/es/es_ES/mls_10246/low/es_ES-mls_10246-low.onnx", size: "22MB", }, json: { filename: "es_ES-mls_10246-low.onnx.json", url: "https://huggingface.co/rhasspy/piper-voices/resolve/main/es/es_ES/mls_10246/low/es_ES-mls_10246-low.onnx.json", size: "2KB", }, }, language: "Spanish (ES)", gender: "Female", quality: "Low", }, "es_ES-carlfm-x_low": { files: { onnx: { filename: "es_ES-carlfm-x_low.onnx", url: "https://huggingface.co/rhasspy/piper-voices/resolve/main/es/es_ES/carlfm/x_low/es_ES-carlfm-x_low.onnx", size: "9MB", }, json: { filename: "es_ES-carlfm-x_low.onnx.json", url: "https://huggingface.co/rhasspy/piper-voices/resolve/main/es/es_ES/carlfm/x_low/es_ES-carlfm-x_low.onnx.json", size: "1KB", }, }, language: "Spanish (ES)", gender: "Male", quality: "Extra Low", }, "ca_ES-upc_ona-x_low": { files: { onnx: { filename: "ca_ES-upc_ona-x_low.onnx", url: "https://huggingface.co/rhasspy/piper-voices/resolve/main/ca/ca_ES/upc_ona/x_low/ca_ES-upc_ona-x_low.onnx", size: "9MB", }, json: { filename: "ca_ES-upc_ona-x_low.onnx.json", url: "https://huggingface.co/rhasspy/piper-voices/resolve/main/ca/ca_ES/upc_ona/x_low/ca_ES-upc_ona-x_low.onnx.json", size: "1KB", }, }, language: "Catalan (ES)", gender: "Female", quality: "Extra Low", }, "ca_ES-upc_pau-x_low": { files: { onnx: { filename: "ca_ES-upc_pau-x_low.onnx", url: "https://huggingface.co/rhasspy/piper-voices/resolve/main/ca/ca_ES/upc_pau/x_low/ca_ES-upc_pau-x_low.onnx", size: "9MB", }, json: { filename: "ca_ES-upc_pau-x_low.onnx.json", url: "https://huggingface.co/rhasspy/piper-voices/resolve/main/ca/ca_ES/upc_pau/x_low/ca_ES-upc_pau-x_low.onnx.json", size: "1KB", }, }, language: "Catalan (ES)", gender: "Male", quality: "Extra Low", }, }; // Colors for console output const colors = { reset: "\x1b[0m", bright: "\x1b[1m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", blue: "\x1b[34m", magenta: "\x1b[35m", cyan: "\x1b[36m", }; function colorize(text, color) { return `${colors[color]}${text}${colors.reset}`; } function findVoicesDir() { const possibleDirs = [ process.env.VOICES_DIR, path.join(process.cwd(), "voices"), path.join(__dirname, "..", "voices"), path.join(process.env.HOME || "~", ".piper", "voices"), ].filter(Boolean); for (const dir of possibleDirs) { if (fs.existsSync(dir)) return dir; } const defaultDir = path.join(__dirname, "..", "voices"); fs.mkdirSync(defaultDir, { recursive: true }); return defaultDir; } function formatBytes(bytes) { if (bytes === 0) return "0 Bytes"; const k = 1024; const sizes = ["Bytes", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; } function createProgressBar(current, total, width = 40) { const percentage = Math.round((current / total) * 100); const filled = Math.round((current / total) * width); const empty = width - filled; const bar = "ā–ˆ".repeat(filled) + "ā–‘".repeat(empty); return `[${colorize(bar, "cyan")}] ${percentage}%`; } async function downloadFile(url, filepath, filename, expectedSize) { return new Promise(async (resolve, reject) => { try { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const totalSize = parseInt(response.headers.get("content-length") || "0"); let downloadedSize = 0; const fileStream = createWriteStream(filepath); console.log(colorize(`\nšŸ“„ Downloading ${filename}...`, "blue")); const fileType = filename.endsWith(".json") ? "Config" : "Model"; console.log(` Type: ${fileType} | Size: ${expectedSize}`); // Create a transform stream to track progress const progressStream = new (await import("stream")).Transform({ transform(chunk, encoding, callback) { downloadedSize += chunk.length; if (totalSize > 0) { const progressBar = createProgressBar(downloadedSize, totalSize); const downloaded = formatBytes(downloadedSize); const total = formatBytes(totalSize); process.stdout.write(`\r ${progressBar} ${downloaded}/${total}`); } else { process.stdout.write( `\r Downloaded: ${formatBytes(downloadedSize)}` ); } callback(null, chunk); }, }); await streamPipeline(response.body, progressStream, fileStream); console.log( colorize(`\n āœ… Successfully downloaded ${filename}`, "green") ); resolve(); } catch (error) { console.log( colorize( `\n āŒ Failed to download ${filename}: ${error.message}`, "red" ) ); reject(error); } }); } function checkExistingVoices(voicesDir) { const existing = []; const missing = []; for (const [voiceName, voiceData] of Object.entries(VOICE_MODELS)) { const onnxPath = path.join(voicesDir, voiceData.files.onnx.filename); const jsonPath = path.join(voicesDir, voiceData.files.json.filename); const onnxExists = fs.existsSync(onnxPath); const jsonExists = fs.existsSync(jsonPath); if (onnxExists && jsonExists) { existing.push({ voiceName, voiceData, files: { onnx: onnxPath, json: jsonPath, }, }); } else { // Add missing files to download list const missingFiles = []; if (!onnxExists) { missingFiles.push({ type: "onnx", filename: voiceData.files.onnx.filename, filepath: onnxPath, url: voiceData.files.onnx.url, size: voiceData.files.onnx.size, }); } if (!jsonExists) { missingFiles.push({ type: "json", filename: voiceData.files.json.filename, filepath: jsonPath, url: voiceData.files.json.url, size: voiceData.files.json.size, }); } missing.push({ voiceName, voiceData, missingFiles, }); } } return { existing, missing }; } async function downloadVoices(force = false) { console.log(colorize("\nšŸŽµ Piper Announce Voice Downloader", "bright")); console.log(colorize("=".repeat(50), "blue")); const voicesDir = findVoicesDir(); console.log(colorize(`\nVoices directory: ${voicesDir}`, "yellow")); const { existing, missing } = checkExistingVoices(voicesDir); if (existing.length > 0) { console.log( colorize(`\nāœ… Complete voices (${existing.length}):`, "green") ); existing.forEach(({ voiceName, voiceData }) => { console.log( ` • ${voiceName} (${voiceData.language}, ${voiceData.gender})` ); }); } // Count total missing files const totalMissingFiles = missing.reduce( (acc, voice) => acc + voice.missingFiles.length, 0 ); if (totalMissingFiles === 0 && !force) { console.log( colorize( "\nšŸŽ‰ All voice models and configs are already downloaded!", "green" ) ); return; } if (totalMissingFiles > 0) { console.log( colorize(`\nā¬‡ļø Need to download (${totalMissingFiles} files):`, "yellow") ); missing.forEach(({ voiceName, voiceData, missingFiles }) => { console.log( ` šŸ“¢ ${voiceName} (${voiceData.language}, ${voiceData.gender}):` ); missingFiles.forEach((file) => { const icon = file.type === "onnx" ? "🧠" : "āš™ļø"; console.log(` ${icon} ${file.filename} (${file.size})`); }); }); // Calculate total download size let totalSizeMB = 0; missing.forEach(({ missingFiles }) => { missingFiles.forEach((file) => { const sizeNum = parseFloat(file.size); totalSizeMB += file.size.includes("MB") ? sizeNum : sizeNum / 1024; }); }); console.log( colorize(`\nTotal download size: ~${Math.round(totalSizeMB)}MB`, "cyan") ); } // Prepare download list let filesToDownload = []; if (force) { // Download all files Object.entries(VOICE_MODELS).forEach(([voiceName, voiceData]) => { filesToDownload.push({ filename: voiceData.files.onnx.filename, filepath: path.join(voicesDir, voiceData.files.onnx.filename), url: voiceData.files.onnx.url, size: voiceData.files.onnx.size, voiceName, type: "onnx", }); filesToDownload.push({ filename: voiceData.files.json.filename, filepath: path.join(voicesDir, voiceData.files.json.filename), url: voiceData.files.json.url, size: voiceData.files.json.size, voiceName, type: "json", }); }); } else { // Download only missing files missing.forEach(({ missingFiles }) => { filesToDownload.push(...missingFiles); }); } if (filesToDownload.length === 0) { return; } console.log( colorize( `\nšŸš€ Starting download of ${filesToDownload.length} file(s)...`, "blue" ) ); let successCount = 0; let failCount = 0; for (let i = 0; i < filesToDownload.length; i++) { const file = filesToDownload[i]; console.log( colorize( `\n[${i + 1}/${filesToDownload.length}] ${file.voiceName || "Unknown"}`, "magenta" ) ); try { await downloadFile(file.url, file.filepath, file.filename, file.size); successCount++; } catch (error) { failCount++; // Try to clean up partial download try { if (fs.existsSync(file.filepath)) { fs.unlinkSync(file.filepath); } } catch {} } } // Final summary console.log(colorize("\n" + "=".repeat(50), "blue")); console.log(colorize("šŸ“Š Download Summary:", "bright")); if (successCount > 0) { console.log( colorize( ` āœ… Successfully downloaded: ${successCount} file(s)`, "green" ) ); } if (failCount > 0) { console.log( colorize(` āŒ Failed downloads: ${failCount} file(s)`, "red") ); console.log( colorize( " šŸ’” You can retry by running: npm run download-voices", "yellow" ) ); } if (successCount === filesToDownload.length) { console.log( colorize( "\nšŸŽ‰ All voice models and configs downloaded successfully!", "green" ) ); console.log( colorize( " You can now use piper-announce with all supported languages.", "cyan" ) ); } } // Export for programmatic use export { downloadVoices, checkExistingVoices, VOICE_MODELS }; // CLI usage if (import.meta.url === `file://${process.argv[1]}`) { const force = process.argv.includes("--force") || process.argv.includes("-f"); const help = process.argv.includes("--help") || process.argv.includes("-h"); if (help) { console.log(` ${colorize("Piper Announce Voice Downloader", "bright")} Usage: node scripts/download-voices.js [options] Options: -f, --force Re-download all voice models (even if they exist) -h, --help Show this help message Examples: node scripts/download-voices.js # Download missing voices node scripts/download-voices.js --force # Re-download all voices `); process.exit(0); } downloadVoices(force).catch((error) => { console.error(colorize(`\nāŒ Download failed: ${error.message}`, "red")); process.exit(1); }); }