/** * luxtts skill * 文字轉語音:透過本機 LuxTTS API 進行 voice clone */ import { existsSync, readFileSync } from 'fs'; import { execFileSync } from 'child_process'; const LUXTTS_API = process.env.LUXTTS_API || 'http://127.0.0.1:7861/luxtts/api/tts'; const DEFAULT_REF_AUDIO = process.env.LUXTTS_REF_AUDIO || '/home/selig/.openclaw/workspace/media/refs/ref_from_762.wav'; const OUTPUT_DIR = '/home/selig/.openclaw/workspace/media'; const TRIGGER_WORDS = [ 'luxtts', 'lux', '文字轉語音', '語音合成', '唸出來', '說出來', '轉語音', 'voice', ]; const SPEED_MODIFIERS: Record = { '慢速': 0.85, 'slow': 0.85, '快速': 1.15, 'fast': 1.15, }; function parseMessage(message: string): { text: string; speed: number } { let cleaned = message; let speed = 1.0; for (const trigger of TRIGGER_WORDS) { const re = new RegExp(trigger, 'gi'); cleaned = cleaned.replace(re, ''); } for (const [modifier, value] of Object.entries(SPEED_MODIFIERS)) { const re = new RegExp(modifier, 'gi'); if (re.test(cleaned)) { cleaned = cleaned.replace(re, ''); speed = value; } } cleaned = cleaned.replace(/^[\s::,,、]+/, '').replace(/[\s::,,、]+$/, '').trim(); return { text: cleaned, speed }; } function ensureDependencies() { if (!existsSync(DEFAULT_REF_AUDIO)) { throw new Error(`找不到預設參考音檔:${DEFAULT_REF_AUDIO}`); } } function generateSpeech(text: string, speed: number): string { const timestamp = Date.now(); const outputPath = `${OUTPUT_DIR}/luxtts_clone_${timestamp}.wav`; const curlCmd = [ 'curl', '-sS', '-o', outputPath, '-F', `ref_audio=@${DEFAULT_REF_AUDIO}`, '-F', `text=${text}`, '-F', 'num_steps=4', '-F', 't_shift=0.9', '-F', `speed=${speed}`, '-F', 'duration=5', '-F', 'rms=0.01', LUXTTS_API, ]; execFileSync(curlCmd[0], curlCmd.slice(1), { timeout: 600000, stdio: 'pipe', encoding: 'utf8', }); if (!existsSync(outputPath)) { throw new Error('LuxTTS 未產生輸出音檔'); } const header = readFileSync(outputPath).subarray(0, 16).toString('ascii'); if (!header.includes('RIFF') && !header.includes('WAVE')) { throw new Error(`LuxTTS 回傳非 WAV 音訊,檔頭:${JSON.stringify(header)}`); } return outputPath; } export async function handler(ctx: any) { const message = ctx.message?.text || ctx.message?.content || ''; if (!message.trim()) { return { reply: '請提供要合成的文字,例如:「luxtts 这个世界已经改变了」' }; } const { text, speed } = parseMessage(message); if (!text) { return { reply: '請提供要合成的文字,例如:「luxtts 这个世界已经改变了」' }; } try { ensureDependencies(); const outputPath = generateSpeech(text, speed); return { reply: '🔊 luxtts 語音合成完成' + `\n\n📝 文字:${text}` + `\n⏩ 語速:${speed}` + `\n🎙️ 參考音檔:\`${DEFAULT_REF_AUDIO}\`` + `\n🌐 API:\`${LUXTTS_API}\`` + `\n📂 檔案:\`${outputPath}\``, metadata: { text, speed, refAudio: DEFAULT_REF_AUDIO, output: outputPath, engine: 'luxtts', backend: 'luxtts-api', }, files: [outputPath], }; } catch (error: any) { return { reply: '❌ luxtts 語音合成失敗,請檢查 luxtts 服務、API 與預設參考音檔是否正常。' + (error?.message ? `\n\n錯誤:${error.message}` : ''), metadata: { text, speed, refAudio: DEFAULT_REF_AUDIO, engine: 'luxtts', backend: 'luxtts-api', error: error?.message || String(error), }, }; } }