/** * tts-voice skill * 文字轉語音:透過本機 LuxTTS API 合成語音 * * 依賴: * - LuxTTS server running on port 7860 (systemd user service) * - ~/LuxTTS/.env (LUXTTS_USER, LUXTTS_PASS) * - curl CLI */ import { execSync } from 'child_process'; import { readFileSync, existsSync, unlinkSync } from 'fs'; const LUXTTS_BASE = 'http://localhost:7860'; const REF_AUDIO = '/home/selig/LuxTTS/ref_speech.wav'; const ENV_PATH = '/home/selig/LuxTTS/.env'; const COOKIE_JAR = '/tmp/luxtts-skill-cookie'; // Trigger keywords to strip from user message const TRIGGER_WORDS = [ '文字轉語音', '語音合成', '唸出來', '說出來', '轉語音', 'tts', 'voice', ]; // Speed/quality modifiers const MODIFIERS: Record> = { '慢速': { speed: 0.8 }, 'slow': { speed: 0.8 }, '快速': { speed: 1.3 }, 'fast': { speed: 1.3 }, '高品質': { numSteps: 6, tShift: 0.95 }, 'hq': { numSteps: 6, tShift: 0.95 }, }; interface TtsParams { speed: number; numSteps: number; tShift: number; } /** Read credentials from .env */ function loadCredentials(): { user: string; pass: string } { try { const content = readFileSync(ENV_PATH, 'utf-8'); const user = content.match(/^LUXTTS_USER=(.+)$/m)?.[1]?.trim() || 'admin'; const pass = content.match(/^LUXTTS_PASS=(.+)$/m)?.[1]?.trim() || ''; return { user, pass }; } catch { return { user: 'admin', pass: '' }; } } /** Ensure we have a valid session cookie */ function ensureCookie(): boolean { const { user, pass } = loadCredentials(); if (!pass) return false; try { execSync( `curl -s -o /dev/null -w "%{http_code}" -c ${COOKIE_JAR} ` + `-d "username=${user}&password=${pass}" ` + `${LUXTTS_BASE}/luxtts/login`, { timeout: 10000 } ); return existsSync(COOKIE_JAR); } catch { return false; } } /** Check if LuxTTS service is alive */ function healthCheck(): boolean { try { const result = execSync( `curl -s -o /dev/null -w "%{http_code}" ${LUXTTS_BASE}/luxtts/api/health`, { timeout: 5000 } ).toString().trim(); return result === '200'; } catch { return false; } } /** Extract text and modifiers from user message */ function parseMessage(message: string): { text: string; params: TtsParams } { let cleaned = message; const params: TtsParams = { speed: 1.0, numSteps: 4, tShift: 0.9 }; // Remove trigger words for (const trigger of TRIGGER_WORDS) { const re = new RegExp(trigger, 'gi'); cleaned = cleaned.replace(re, ''); } // Extract and apply modifiers for (const [mod, overrides] of Object.entries(MODIFIERS)) { const re = new RegExp(mod, 'gi'); if (re.test(cleaned)) { cleaned = cleaned.replace(re, ''); Object.assign(params, overrides); } } // Clean up cleaned = cleaned.replace(/^[\s::,,、]+/, '').replace(/[\s::,,、]+$/, '').trim(); return { text: cleaned, params }; } /** Call LuxTTS API to generate speech */ function generateSpeech(text: string, params: TtsParams): string | null { const timestamp = Date.now(); const outPath = `/tmp/tts_output_${timestamp}.wav`; try { const httpCode = execSync( `curl -s -o ${outPath} -w "%{http_code}" ` + `-b ${COOKIE_JAR} ` + `-X POST ${LUXTTS_BASE}/luxtts/api/tts ` + `-F "ref_audio=@${REF_AUDIO}" ` + `-F "text=${text.replace(/"/g, '\\"')}" ` + `-F "num_steps=${params.numSteps}" ` + `-F "t_shift=${params.tShift}" ` + `-F "speed=${params.speed}"`, { timeout: 120000 } // 2 min timeout for CPU synthesis ).toString().trim(); if (httpCode === '200' && existsSync(outPath)) { return outPath; } // Clean up failed output if (existsSync(outPath)) unlinkSync(outPath); return null; } catch { if (existsSync(outPath)) unlinkSync(outPath); return null; } } // ─── 主 handler ───────────────────────────────────────────────────────────── export async function handler(ctx: any) { const message = ctx.message?.text || ctx.message?.content || ''; if (!message.trim()) { return { reply: '請提供要合成的文字,例如:「tts 你好,歡迎來到我的頻道」' }; } // Parse user input const { text, params } = parseMessage(message); if (!text) { return { reply: '請提供要合成的文字,例如:「tts 你好,歡迎來到我的頻道」' }; } // Health check if (!healthCheck()) { return { reply: '❌ LuxTTS 服務未啟動,請先執行:`systemctl --user start luxtts`' }; } // Ensure authentication if (!ensureCookie()) { return { reply: '❌ LuxTTS 認證失敗,請檢查 ~/LuxTTS/.env 的帳密設定' }; } // Build parameter description const paramDesc: string[] = []; if (params.speed !== 1.0) paramDesc.push(`語速 ${params.speed}x`); if (params.numSteps !== 4) paramDesc.push(`步數 ${params.numSteps}`); const paramStr = paramDesc.length ? `(${paramDesc.join('、')})` : ''; // Generate const wavPath = generateSpeech(text, params); if (!wavPath) { return { reply: `❌ 語音合成失敗,請稍後再試。`, metadata: { text, params, error: true }, }; } return { reply: `🔊 語音合成完成${paramStr}\n\n📝 文字:${text}\n📂 檔案:\`${wavPath}\``, metadata: { text, params, output: wavPath, }, files: [wavPath], }; }