Files
homeclaw/src/tools/tts.ts
T
2026-05-05 17:46:17 +09:00

84 lines
2.5 KiB
TypeScript

import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs';
import os from 'os';
import { getConfig } from '../config/config.js';
interface TTSConfig {
piperPath: string;
modelPath: string;
configPath?: string;
ffmpegPath: string;
tempDir: string;
}
function getTTSConfig(): TTSConfig | null {
const cfg = getConfig().getConfig() as any;
const voice = cfg?.voice;
if (!voice?.tts) return null;
return {
piperPath: voice.tts.piperPath || 'piper',
modelPath: voice.tts.modelPath || process.env.PIPER_MODEL_PATH || '',
configPath: voice.tts.configPath || process.env.PIPER_CONFIG_PATH,
ffmpegPath: voice.ffmpegPath || 'ffmpeg',
tempDir: voice.tempDir || os.tmpdir(),
};
}
export function isTTSAvailable(): boolean {
return getTTSConfig() !== null;
}
export async function synthesizeSpeech(text: string): Promise<Buffer> {
const config = getTTSConfig();
if (!config) throw new Error('TTS not configured');
const truncatedText = text.slice(0, 4000);
const tempDir = config.tempDir;
const wavPath = path.join(tempDir, `tts_output_${Date.now()}.wav`);
const oggPath = path.join(tempDir, `tts_output_${Date.now()}.ogg`);
try {
// Generate WAV via Piper
const piperArgs = ['--model', config.modelPath, '--output_file', wavPath];
if (config.configPath) {
piperArgs.push('--config', config.configPath);
}
await new Promise<void>((resolve, reject) => {
const piper = spawn(config.piperPath, piperArgs);
let stderr = '';
piper.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
piper.stdin.write(truncatedText);
piper.stdin.end();
piper.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`Piper exited ${code}: ${stderr}`));
});
piper.on('error', reject);
});
// Convert WAV to OGG/Opus for Telegram sendVoice
await new Promise<void>((resolve, reject) => {
const ffmpeg = spawn(config.ffmpegPath, [
'-y', '-i', wavPath,
'-c:a', 'libopus', '-b:a', '48k', '-vbr', 'on',
'-compression_level', '10',
oggPath,
]);
let stderr = '';
ffmpeg.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
ffmpeg.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`ffmpeg exited ${code}: ${stderr}`));
});
ffmpeg.on('error', reject);
});
return fs.readFileSync(oggPath);
} finally {
try { fs.unlinkSync(wavPath); } catch {}
try { fs.unlinkSync(oggPath); } catch {}
}
}