forked from Selig/openclaw-skill
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>
135 lines
3.7 KiB
TypeScript
135 lines
3.7 KiB
TypeScript
/**
|
||
* 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),
|
||
},
|
||
};
|
||
}
|
||
}
|