Files
Selig 4c966a3ad2 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.
2026-03-13 10:58:30 +08:00

193 lines
5.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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],
};
}