|
| 1 | +import { spawn, spawnSync } from 'child_process'; |
| 2 | +import { existsSync, readFileSync } from 'fs'; |
| 3 | +import { join } from 'path'; |
| 4 | +import { platform, release } from 'os'; |
| 5 | +import type { TTSProvider } from './index'; |
| 6 | + |
| 7 | +const IS_WSL = platform() === 'linux' && release().toLowerCase().includes('microsoft'); |
| 8 | + |
| 9 | +interface VoiceConfig { |
| 10 | + model: string; |
| 11 | + speaker: number; |
| 12 | +} |
| 13 | + |
| 14 | +export class Piper implements TTSProvider { |
| 15 | + readonly name = 'piper'; |
| 16 | + private baseDir: string; |
| 17 | + private binary: string; |
| 18 | + private modelsDir: string; |
| 19 | + private voices: Record<string, VoiceConfig> = {}; |
| 20 | + |
| 21 | + constructor(baseDir?: string) { |
| 22 | + this.baseDir = baseDir || join(import.meta.dir, '..'); |
| 23 | + const configPath = join(this.baseDir, 'voices.json'); |
| 24 | + |
| 25 | + if (existsSync(configPath)) { |
| 26 | + const config = JSON.parse(readFileSync(configPath, 'utf-8')); |
| 27 | + this.binary = join(this.baseDir, config.piper?.binary || 'piper-bin/piper/piper'); |
| 28 | + this.modelsDir = join(this.baseDir, config.piper?.models_dir || 'piper-voices'); |
| 29 | + this.voices = config.voices || {}; |
| 30 | + } else { |
| 31 | + this.binary = join(this.baseDir, 'piper-bin/piper/piper'); |
| 32 | + this.modelsDir = join(this.baseDir, 'piper-voices'); |
| 33 | + } |
| 34 | + } |
| 35 | + |
| 36 | + isAvailable(): boolean { |
| 37 | + return existsSync(this.binary); |
| 38 | + } |
| 39 | + |
| 40 | + async speak(text: string, voiceId?: string): Promise<void> { |
| 41 | + const voice = this.voices[voiceId || 'default'] || { model: 'en_US-libritts_r-medium', speaker: 0 }; |
| 42 | + const modelPath = join(this.modelsDir, `${voice.model}.onnx`); |
| 43 | + |
| 44 | + if (!existsSync(modelPath)) { |
| 45 | + throw new Error(`Piper model not found: ${modelPath}`); |
| 46 | + } |
| 47 | + |
| 48 | + const result = spawnSync(this.binary, [ |
| 49 | + '--model', modelPath, |
| 50 | + '--speaker', voice.speaker.toString(), |
| 51 | + '--output-raw' |
| 52 | + ], { input: text, maxBuffer: 10 * 1024 * 1024 }); |
| 53 | + |
| 54 | + if (result.error) throw new Error(`Piper error: ${result.error.message}`); |
| 55 | + if (result.status !== 0) throw new Error(`Piper failed: ${result.stderr?.toString()}`); |
| 56 | + |
| 57 | + const wavBuffer = this.pcmToWav(result.stdout); |
| 58 | + await this.playAudio(wavBuffer); |
| 59 | + } |
| 60 | + |
| 61 | + private pcmToWav(pcm: Buffer): Buffer { |
| 62 | + const header = Buffer.alloc(44); |
| 63 | + header.write('RIFF', 0); |
| 64 | + header.writeUInt32LE(36 + pcm.length, 4); |
| 65 | + header.write('WAVE', 8); |
| 66 | + header.write('fmt ', 12); |
| 67 | + header.writeUInt32LE(16, 16); |
| 68 | + header.writeUInt16LE(1, 20); |
| 69 | + header.writeUInt16LE(1, 22); |
| 70 | + header.writeUInt32LE(22050, 24); |
| 71 | + header.writeUInt32LE(44100, 28); |
| 72 | + header.writeUInt16LE(2, 32); |
| 73 | + header.writeUInt16LE(16, 34); |
| 74 | + header.write('data', 36); |
| 75 | + header.writeUInt32LE(pcm.length, 40); |
| 76 | + return Buffer.concat([header, pcm]); |
| 77 | + } |
| 78 | + |
| 79 | + private async playAudio(wav: Buffer): Promise<void> { |
| 80 | + const tempFile = `/tmp/voice-${Date.now()}.wav`; |
| 81 | + await Bun.write(tempFile, wav); |
| 82 | + |
| 83 | + if (IS_WSL) { |
| 84 | + const winPath = `C:\\Users\\Public\\piper_${Date.now()}.wav`; |
| 85 | + const wslPath = `/mnt/c/Users/Public/piper_${Date.now()}.wav`; |
| 86 | + await Bun.write(wslPath, wav); |
| 87 | + |
| 88 | + return new Promise((resolve, reject) => { |
| 89 | + const proc = spawn('powershell.exe', ['-NoProfile', '-Command', |
| 90 | + `(New-Object Media.SoundPlayer '${winPath}').PlaySync(); Remove-Item '${winPath}'`]); |
| 91 | + proc.on('error', reject); |
| 92 | + proc.on('exit', (code) => { |
| 93 | + spawn('/bin/rm', ['-f', tempFile]); |
| 94 | + code === 0 ? resolve() : reject(new Error(`powershell exited ${code}`)); |
| 95 | + }); |
| 96 | + }); |
| 97 | + } |
| 98 | + |
| 99 | + return new Promise((resolve, reject) => { |
| 100 | + const proc = spawn('aplay', ['-q', tempFile]); |
| 101 | + proc.on('error', reject); |
| 102 | + proc.on('exit', (code) => { |
| 103 | + spawn('/bin/rm', ['-f', tempFile]); |
| 104 | + code === 0 ? resolve() : reject(new Error(`aplay exited ${code}`)); |
| 105 | + }); |
| 106 | + }); |
| 107 | + } |
| 108 | +} |
0 commit comments