Files
openclaw-skill/skills/luxtts/handler.ts
Selig 8bacc868bd Add 5 missing skills to repo for sync coverage
github-repo-search, gooddays-calendar, luxtts,
openclaw-tavily-search, skill-vetter — previously only
in workspace, now tracked in Gitea for full sync.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 20:36:30 +08:00

135 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/**
* luxtts skill
* 文字轉語音:透過本機 LuxTTS API 進行 voice clone
*/
import { existsSync, readFileSync } from 'fs';
import { execFileSync } from 'child_process';
const LUXTTS_API = process.env.LUXTTS_API || 'http://127.0.0.1:7861/luxtts/api/tts';
const DEFAULT_REF_AUDIO = process.env.LUXTTS_REF_AUDIO || '/home/selig/.openclaw/workspace/media/refs/ref_from_762.wav';
const OUTPUT_DIR = '/home/selig/.openclaw/workspace/media';
const TRIGGER_WORDS = [
'luxtts', 'lux', '文字轉語音', '語音合成', '唸出來', '說出來', '轉語音', 'voice',
];
const SPEED_MODIFIERS: Record<string, number> = {
'慢速': 0.85,
'slow': 0.85,
'快速': 1.15,
'fast': 1.15,
};
function parseMessage(message: string): { text: string; speed: number } {
let cleaned = message;
let speed = 1.0;
for (const trigger of TRIGGER_WORDS) {
const re = new RegExp(trigger, 'gi');
cleaned = cleaned.replace(re, '');
}
for (const [modifier, value] of Object.entries(SPEED_MODIFIERS)) {
const re = new RegExp(modifier, 'gi');
if (re.test(cleaned)) {
cleaned = cleaned.replace(re, '');
speed = value;
}
}
cleaned = cleaned.replace(/^[\s:,、]+/, '').replace(/[\s:,、]+$/, '').trim();
return { text: cleaned, speed };
}
function ensureDependencies() {
if (!existsSync(DEFAULT_REF_AUDIO)) {
throw new Error(`找不到預設參考音檔:${DEFAULT_REF_AUDIO}`);
}
}
function generateSpeech(text: string, speed: number): string {
const timestamp = Date.now();
const outputPath = `${OUTPUT_DIR}/luxtts_clone_${timestamp}.wav`;
const curlCmd = [
'curl', '-sS', '-o', outputPath,
'-F', `ref_audio=@${DEFAULT_REF_AUDIO}`,
'-F', `text=${text}`,
'-F', 'num_steps=4',
'-F', 't_shift=0.9',
'-F', `speed=${speed}`,
'-F', 'duration=5',
'-F', 'rms=0.01',
LUXTTS_API,
];
execFileSync(curlCmd[0], curlCmd.slice(1), {
timeout: 600000,
stdio: 'pipe',
encoding: 'utf8',
});
if (!existsSync(outputPath)) {
throw new Error('LuxTTS 未產生輸出音檔');
}
const header = readFileSync(outputPath).subarray(0, 16).toString('ascii');
if (!header.includes('RIFF') && !header.includes('WAVE')) {
throw new Error(`LuxTTS 回傳非 WAV 音訊,檔頭:${JSON.stringify(header)}`);
}
return outputPath;
}
export async function handler(ctx: any) {
const message = ctx.message?.text || ctx.message?.content || '';
if (!message.trim()) {
return { reply: '請提供要合成的文字例如「luxtts 这个世界已经改变了」' };
}
const { text, speed } = parseMessage(message);
if (!text) {
return { reply: '請提供要合成的文字例如「luxtts 这个世界已经改变了」' };
}
try {
ensureDependencies();
const outputPath = generateSpeech(text, speed);
return {
reply:
'🔊 luxtts 語音合成完成' +
`\n\n📝 文字:${text}` +
`\n⏩ 語速:${speed}` +
`\n🎙 參考音檔:\`${DEFAULT_REF_AUDIO}\`` +
`\n🌐 API\`${LUXTTS_API}\`` +
`\n📂 檔案:\`${outputPath}\``,
metadata: {
text,
speed,
refAudio: DEFAULT_REF_AUDIO,
output: outputPath,
engine: 'luxtts',
backend: 'luxtts-api',
},
files: [outputPath],
};
} catch (error: any) {
return {
reply:
'❌ luxtts 語音合成失敗,請檢查 luxtts 服務、API 與預設參考音檔是否正常。' +
(error?.message ? `\n\n錯誤${error.message}` : ''),
metadata: {
text,
speed,
refAudio: DEFAULT_REF_AUDIO,
engine: 'luxtts',
backend: 'luxtts-api',
error: error?.message || String(error),
},
};
}
}