forked from Selig/openclaw-skill
202 lines
5.6 KiB
TypeScript
202 lines
5.6 KiB
TypeScript
/**
|
||
* tts-voice skill
|
||
* 文字轉語音:透過本機 LuxTTS API 合成語音
|
||
*
|
||
* 依賴:
|
||
* - LuxTTS server running on port 7860 (systemd user service)
|
||
* - ~/LuxTTS/.env (LUXTTS_USER, LUXTTS_PASS)
|
||
* - curl CLI
|
||
*/
|
||
|
||
import { execSync, spawnSync } 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<string, Partial<TtsParams>> = {
|
||
'慢速': { 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 args = [
|
||
'-s',
|
||
'-o', outPath,
|
||
'-w', '%{http_code}',
|
||
'-b', COOKIE_JAR,
|
||
'-X', 'POST',
|
||
`${LUXTTS_BASE}/luxtts/api/tts`,
|
||
'-F', `ref_audio=@${REF_AUDIO}`,
|
||
'-F', `text=${text}`,
|
||
'-F', `num_steps=${params.numSteps}`,
|
||
'-F', `t_shift=${params.tShift}`,
|
||
'-F', `speed=${params.speed}`,
|
||
];
|
||
|
||
const result = spawnSync('curl', args, {
|
||
timeout: 120000, // 2 min timeout for CPU synthesis
|
||
encoding: 'utf-8',
|
||
});
|
||
|
||
const httpCode = (result.stdout || '').trim();
|
||
|
||
if (result.status === 0 && 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],
|
||
};
|
||
}
|