forked from Selig/openclaw-skill
Initial commit: OpenClaw Skill Collection
6 custom skills (assign-task, dispatch-webhook, daily-briefing, task-capture, qmd-brain, tts-voice) with technical documentation. Compatible with Claude Code, OpenClaw, Codex CLI, and OpenCode.
This commit is contained in:
192
skills/tts-voice/handler.ts
Normal file
192
skills/tts-voice/handler.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 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<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 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],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user