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>
This commit is contained in:
47
skills/luxtts/SKILL.md
Normal file
47
skills/luxtts/SKILL.md
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: luxtts
|
||||
description: 使用本機 LuxTTS 將文字合成為語音,特別適合需要較高品質中文/英文 voice clone 的情況。用於:(1) 使用主人參考音檔做語音克隆,(2) 中英混合朗讀但希望維持主人音色,(3) 比較 LuxTTS 與 Kokoro 的輸出品質,(4) 需要 LuxTTS API-only 本機服務時。
|
||||
---
|
||||
|
||||
# luxtts
|
||||
|
||||
此 skill 提供 **LuxTTS** 文字轉語音能力,底層使用本機 **LuxTTS API**。
|
||||
|
||||
## 目前架構
|
||||
|
||||
- systemd 服務:`luxtts`
|
||||
- Port:`7861`
|
||||
- 綁定:`127.0.0.1`
|
||||
- Root path:`/luxtts`
|
||||
- 健康檢查:`http://127.0.0.1:7861/luxtts/api/health`
|
||||
- Web UI:**關閉**
|
||||
- API:保留
|
||||
|
||||
## 推薦做法
|
||||
|
||||
目前最穩定的整合方式是直接呼叫本機 API:
|
||||
|
||||
```bash
|
||||
curl -sS -o /tmp/luxtts_test.wav \
|
||||
-F "ref_audio=@/path/to/reference.wav" \
|
||||
-F "text=这个世界已经改变了,人工智能AI改变了这个世界的运作方式。" \
|
||||
-F "num_steps=4" \
|
||||
-F "t_shift=0.9" \
|
||||
-F "speed=1.0" \
|
||||
-F "duration=5" \
|
||||
-F "rms=0.01" \
|
||||
http://127.0.0.1:7861/luxtts/api/tts
|
||||
```
|
||||
|
||||
## 注意事項
|
||||
|
||||
- 目前實測:**中文建議先轉簡體再輸入**。
|
||||
- LuxTTS 比較適合:
|
||||
- 主人音色 clone
|
||||
- 中文/英文都希望保持同一個 clone 聲線
|
||||
- 品質優先、速度其次
|
||||
- 若只是快速中文朗讀、且不要求高擬真 clone,通常先考慮 `kokoro`。
|
||||
|
||||
## 命名
|
||||
|
||||
之後對外統一稱呼為 **luxtts**。
|
||||
134
skills/luxtts/handler.ts
Normal file
134
skills/luxtts/handler.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user