diff --git a/skills/tts-voice/handler.ts b/skills/tts-voice/handler.ts index 2a943ae..e34796d 100644 --- a/skills/tts-voice/handler.ts +++ b/skills/tts-voice/handler.ts @@ -8,13 +8,15 @@ * - curl CLI */ +import { randomUUID } from 'crypto'; import { spawnSync } from 'child_process'; import { readFileSync, existsSync, unlinkSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; 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 = [ @@ -38,6 +40,18 @@ interface TtsParams { tShift: number; } +function createCookieJarPath(): string { + return join(tmpdir(), `luxtts-skill-cookie-${randomUUID()}.txt`); +} + +function safeUnlink(path: string): void { + try { + if (existsSync(path)) unlinkSync(path); + } catch { + // ignore cleanup errors + } +} + /** Read credentials from .env */ function loadCredentials(): { user: string; pass: string } { try { @@ -51,10 +65,13 @@ function loadCredentials(): { user: string; pass: string } { } /** Ensure we have a valid session cookie */ -function ensureCookie(): boolean { +function ensureCookie(cookieJar: string): boolean { const { user, pass } = loadCredentials(); if (!pass) return false; + // always start fresh to avoid stale session side effects + safeUnlink(cookieJar); + try { const result = spawnSync( 'curl', @@ -62,7 +79,7 @@ function ensureCookie(): boolean { '-s', '-o', '/dev/null', '-w', '%{http_code}', - '-c', COOKIE_JAR, + '-c', cookieJar, '-X', 'POST', '-d', `username=${user}&password=${pass}`, `${LUXTTS_BASE}/luxtts/login`, @@ -71,7 +88,7 @@ function ensureCookie(): boolean { ); const httpCode = (result.stdout || '').trim(); - return result.status === 0 && httpCode === '200' && existsSync(COOKIE_JAR); + return result.status === 0 && httpCode === '200' && existsSync(cookieJar); } catch { return false; } @@ -120,7 +137,7 @@ function parseMessage(message: string): { text: string; params: TtsParams } { } /** Call LuxTTS API to generate speech */ -function generateSpeech(text: string, params: TtsParams): string | null { +function generateSpeech(text: string, params: TtsParams, cookieJar: string): string | null { const timestamp = Date.now(); const outPath = `/tmp/tts_output_${timestamp}.wav`; @@ -129,7 +146,7 @@ function generateSpeech(text: string, params: TtsParams): string | null { '-s', '-o', outPath, '-w', '%{http_code}', - '-b', COOKIE_JAR, + '-b', cookieJar, '-X', 'POST', `${LUXTTS_BASE}/luxtts/api/tts`, '-F', `ref_audio=@${REF_AUDIO}`, @@ -151,10 +168,10 @@ function generateSpeech(text: string, params: TtsParams): string | null { } // Clean up failed output - if (existsSync(outPath)) unlinkSync(outPath); + safeUnlink(outPath); return null; } catch { - if (existsSync(outPath)) unlinkSync(outPath); + safeUnlink(outPath); return null; } } @@ -180,34 +197,40 @@ export async function handler(ctx: any) { return { reply: '❌ LuxTTS 服務未啟動,請先執行:`systemctl --user start luxtts`' }; } - // Ensure authentication - if (!ensureCookie()) { - return { reply: '❌ LuxTTS 認證失敗,請檢查 ~/LuxTTS/.env 的帳密設定' }; - } + const cookieJar = createCookieJarPath(); - // 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('、')})` : ''; + try { + // Ensure authentication + if (!ensureCookie(cookieJar)) { + return { reply: '❌ LuxTTS 認證失敗,請檢查 ~/LuxTTS/.env 的帳密設定' }; + } - // Generate - const wavPath = generateSpeech(text, params); + // 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, cookieJar); + + if (!wavPath) { + return { + reply: '❌ 語音合成失敗,請稍後再試。', + metadata: { text, params, error: true }, + }; + } - if (!wavPath) { return { - reply: `❌ 語音合成失敗,請稍後再試。`, - metadata: { text, params, error: true }, + reply: `🔊 語音合成完成${paramStr}\n\n📝 文字:${text}\n📂 檔案:\`${wavPath}\``, + metadata: { + text, + params, + output: wavPath, + }, + files: [wavPath], }; + } finally { + safeUnlink(cookieJar); } - - return { - reply: `🔊 語音合成完成${paramStr}\n\n📝 文字:${text}\n📂 檔案:\`${wavPath}\``, - metadata: { - text, - params, - output: wavPath, - }, - files: [wavPath], - }; }