feat: feishu voice voxcpm and edge-tts
Browse files
src/commands/feishu/feishu.tsx
CHANGED
|
@@ -25,6 +25,8 @@ type Step =
|
|
| 25 |
| { type: 'edit-allowed-chats' }
|
| 26 |
| { type: 'edit-mention-policy' }
|
| 27 |
| { type: 'edit-tts-settings' }
|
|
|
|
|
|
|
| 28 |
| { type: 'confirm-clear' }
|
| 29 |
| { type: 'scanning' }
|
| 30 |
|
|
@@ -199,10 +201,18 @@ function FeishuDialog({
|
|
| 199 |
label: `TTS 语音回复: ${config?.ttsEnabled ? '开' : '关'}`,
|
| 200 |
action: () => { void toggleTts() },
|
| 201 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
{
|
| 203 |
label: `TTS 语音: ${config?.ttsVoice || 'zh-CN-XiaoxiaoNeural (中文)'}`,
|
| 204 |
action: () => setStep({ type: 'edit-tts-settings' }),
|
| 205 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
{
|
| 207 |
label:
|
| 208 |
serviceState.status === 'running' ? '停止当前飞书 Bot' : '启动当前飞书 Bot',
|
|
@@ -498,6 +508,52 @@ function FeishuDialog({
|
|
| 498 |
[refreshConfig],
|
| 499 |
)
|
| 500 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
const clearConfigAndStop = React.useCallback(async () => {
|
| 502 |
setBusy(true)
|
| 503 |
try {
|
|
@@ -695,6 +751,20 @@ function FeishuDialog({
|
|
| 695 |
)
|
| 696 |
}
|
| 697 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 698 |
if (step.type === 'confirm-clear') {
|
| 699 |
return (
|
| 700 |
<Dialog title="清空飞书配置" onCancel={() => setStep({ type: 'menu' })}>
|
|
|
|
| 25 |
| { type: 'edit-allowed-chats' }
|
| 26 |
| { type: 'edit-mention-policy' }
|
| 27 |
| { type: 'edit-tts-settings' }
|
| 28 |
+
| { type: 'edit-tts-provider' }
|
| 29 |
+
| { type: 'edit-tts-ref-audio' }
|
| 30 |
| { type: 'confirm-clear' }
|
| 31 |
| { type: 'scanning' }
|
| 32 |
|
|
|
|
| 201 |
label: `TTS 语音回复: ${config?.ttsEnabled ? '开' : '关'}`,
|
| 202 |
action: () => { void toggleTts() },
|
| 203 |
},
|
| 204 |
+
{
|
| 205 |
+
label: `TTS 引擎: ${config?.ttsProvider === 'voxcpm' ? 'VoxCPM' : 'Edge TTS'}`,
|
| 206 |
+
action: () => { void toggleTtsProvider() },
|
| 207 |
+
},
|
| 208 |
{
|
| 209 |
label: `TTS 语音: ${config?.ttsVoice || 'zh-CN-XiaoxiaoNeural (中文)'}`,
|
| 210 |
action: () => setStep({ type: 'edit-tts-settings' }),
|
| 211 |
},
|
| 212 |
+
{
|
| 213 |
+
label: `TTS 参考音频: ${config?.ttsReferenceAudio ? '已配置' : '未配置'}`,
|
| 214 |
+
action: () => setStep({ type: 'edit-tts-ref-audio' }),
|
| 215 |
+
},
|
| 216 |
{
|
| 217 |
label:
|
| 218 |
serviceState.status === 'running' ? '停止当前飞书 Bot' : '启动当前飞书 Bot',
|
|
|
|
| 508 |
[refreshConfig],
|
| 509 |
)
|
| 510 |
|
| 511 |
+
const toggleTtsProvider = React.useCallback(
|
| 512 |
+
async () => {
|
| 513 |
+
setBusy(true)
|
| 514 |
+
try {
|
| 515 |
+
const current = getFeishuConfig()
|
| 516 |
+
const next = current.ttsProvider === 'edge' ? 'voxcpm' : 'edge'
|
| 517 |
+
saveFeishuConfig({ ...current, ttsProvider: next })
|
| 518 |
+
refreshConfig()
|
| 519 |
+
setNotice({
|
| 520 |
+
text: `TTS 引擎已切换为 ${next === 'edge' ? 'Edge TTS' : 'VoxCPM'}。`,
|
| 521 |
+
tone: 'success',
|
| 522 |
+
})
|
| 523 |
+
} catch (error) {
|
| 524 |
+
setNotice({
|
| 525 |
+
text: error instanceof Error ? error.message : '保存失败',
|
| 526 |
+
tone: 'error',
|
| 527 |
+
})
|
| 528 |
+
} finally {
|
| 529 |
+
setBusy(false)
|
| 530 |
+
setStep({ type: 'menu' })
|
| 531 |
+
}
|
| 532 |
+
},
|
| 533 |
+
[refreshConfig],
|
| 534 |
+
)
|
| 535 |
+
|
| 536 |
+
const saveTtsRefAudio = React.useCallback(
|
| 537 |
+
async (value: string) => {
|
| 538 |
+
setBusy(true)
|
| 539 |
+
try {
|
| 540 |
+
const current = getFeishuConfig()
|
| 541 |
+
saveFeishuConfig({ ...current, ttsReferenceAudio: value || undefined })
|
| 542 |
+
refreshConfig()
|
| 543 |
+
setNotice({ text: '参考音频路径已保存。', tone: 'success' })
|
| 544 |
+
} catch (error) {
|
| 545 |
+
setNotice({
|
| 546 |
+
text: error instanceof Error ? error.message : '保存失败',
|
| 547 |
+
tone: 'error',
|
| 548 |
+
})
|
| 549 |
+
} finally {
|
| 550 |
+
setBusy(false)
|
| 551 |
+
setStep({ type: 'menu' })
|
| 552 |
+
}
|
| 553 |
+
},
|
| 554 |
+
[refreshConfig],
|
| 555 |
+
)
|
| 556 |
+
|
| 557 |
const clearConfigAndStop = React.useCallback(async () => {
|
| 558 |
setBusy(true)
|
| 559 |
try {
|
|
|
|
| 751 |
)
|
| 752 |
}
|
| 753 |
|
| 754 |
+
if (step.type === 'edit-tts-ref-audio') {
|
| 755 |
+
return (
|
| 756 |
+
<Dialog title="VoxCPM 参考音频" onCancel={() => setStep({ type: 'menu' })}>
|
| 757 |
+
<TextInput
|
| 758 |
+
title="参考音频路径"
|
| 759 |
+
hint="VoxCPM 语音克隆的参考音频文件路径(支持 WAV/MP3)。留空清空配置。"
|
| 760 |
+
initialValue={config?.ttsReferenceAudio}
|
| 761 |
+
onSubmit={value => { void saveTtsRefAudio(value) }}
|
| 762 |
+
onCancel={() => setStep({ type: 'menu' })}
|
| 763 |
+
/>
|
| 764 |
+
</Dialog>
|
| 765 |
+
)
|
| 766 |
+
}
|
| 767 |
+
|
| 768 |
if (step.type === 'confirm-clear') {
|
| 769 |
return (
|
| 770 |
<Dialog title="清空飞书配置" onCancel={() => setStep({ type: 'menu' })}>
|
src/services/feishu/FeishuService.ts
CHANGED
|
@@ -517,43 +517,69 @@ class FeishuService {
|
|
| 517 |
if (!this.channel) return
|
| 518 |
try {
|
| 519 |
const config = getFeishuConfig()
|
| 520 |
-
console.log('[feishu][tts] config.ttsEnabled:', config.ttsEnabled, 'voice:', config.ttsVoice)
|
| 521 |
if (!config.ttsEnabled) return
|
| 522 |
|
| 523 |
const { readFile, unlink } = await import('node:fs/promises')
|
| 524 |
const { spawn } = await import('node:child_process')
|
| 525 |
|
| 526 |
-
const
|
| 527 |
-
const
|
| 528 |
-
const
|
|
|
|
| 529 |
|
| 530 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
'
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
|
|
|
|
|
|
| 545 |
})
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
})
|
| 550 |
-
}
|
|
|
|
|
|
|
|
|
|
| 551 |
|
| 552 |
-
// Convert
|
| 553 |
-
console.log('[feishu][tts] converting
|
| 554 |
await new Promise<void>((resolve, reject) => {
|
| 555 |
const child = spawn('ffmpeg', [
|
| 556 |
-
'-i',
|
| 557 |
'-c:a', 'libopus',
|
| 558 |
'-b:a', '128k',
|
| 559 |
'-y',
|
|
@@ -573,7 +599,7 @@ class FeishuService {
|
|
| 573 |
console.log('[feishu][tts] sent successfully')
|
| 574 |
|
| 575 |
// Clean up temp files
|
| 576 |
-
await unlink(
|
| 577 |
await unlink(oggPath).catch(() => {})
|
| 578 |
} catch (err) {
|
| 579 |
// Don't let TTS failure break the main message flow
|
|
|
|
| 517 |
if (!this.channel) return
|
| 518 |
try {
|
| 519 |
const config = getFeishuConfig()
|
|
|
|
| 520 |
if (!config.ttsEnabled) return
|
| 521 |
|
| 522 |
const { readFile, unlink } = await import('node:fs/promises')
|
| 523 |
const { spawn } = await import('node:child_process')
|
| 524 |
|
| 525 |
+
const provider = config.ttsProvider || 'edge'
|
| 526 |
+
const timestamp = Date.now()
|
| 527 |
+
const rawBase = path.join(os.tmpdir(), `feishu_tts_raw_${timestamp}`)
|
| 528 |
+
const oggPath = path.join(os.tmpdir(), `feishu_tts_${timestamp}.ogg`)
|
| 529 |
|
| 530 |
+
if (provider === 'voxcpm') {
|
| 531 |
+
const refAudio = config.ttsReferenceAudio
|
| 532 |
+
if (!refAudio) {
|
| 533 |
+
console.log('[feishu][tts] voxcpm: no ttsReferenceAudio configured')
|
| 534 |
+
return
|
| 535 |
+
}
|
| 536 |
+
console.log('[feishu][tts] voxcpm: synthesizing with ref:', refAudio, 'output base:', rawBase)
|
| 537 |
|
| 538 |
+
await new Promise<void>((resolve, reject) => {
|
| 539 |
+
const child = spawn(
|
| 540 |
+
'voxcpm', ['clone', '--text', text, '--reference-audio', refAudio, '--denoise', '--output', `${rawBase}.wav`],
|
| 541 |
+
{ shell: false },
|
| 542 |
+
)
|
| 543 |
+
let stdout = ''
|
| 544 |
+
let stderr = ''
|
| 545 |
+
child.stdout?.on('data', c => { stdout += String(c) })
|
| 546 |
+
child.stderr?.on('data', c => { stderr += String(c) })
|
| 547 |
+
child.on('close', code => {
|
| 548 |
+
console.log('[feishu][tts] voxcpm exit code:', code)
|
| 549 |
+
if (code === 0) resolve()
|
| 550 |
+
else reject(new Error(`voxcpm exit ${code}: ${stderr.slice(0, 200)}`))
|
| 551 |
+
})
|
| 552 |
+
child.on('error', reject)
|
| 553 |
})
|
| 554 |
+
} else {
|
| 555 |
+
const voice = config.ttsVoice || 'zh-CN-XiaoxiaoNeural'
|
| 556 |
+
console.log('[feishu][tts] edge-tts: voice:', voice, 'text len:', text.length)
|
| 557 |
+
|
| 558 |
+
await new Promise<void>((resolve, reject) => {
|
| 559 |
+
const child = spawn('edge-tts', [
|
| 560 |
+
'--voice', voice,
|
| 561 |
+
'--text', text,
|
| 562 |
+
'--write-media', rawBase,
|
| 563 |
+
])
|
| 564 |
+
let stderr = ''
|
| 565 |
+
child.stderr?.on('data', c => { stderr += String(c) })
|
| 566 |
+
child.on('close', code => {
|
| 567 |
+
console.log('[feishu][tts] edge-tts exit code:', code)
|
| 568 |
+
if (code === 0) resolve()
|
| 569 |
+
else reject(new Error(`edge-tts exit ${code}: ${stderr.slice(0, 200)}`))
|
| 570 |
+
})
|
| 571 |
+
child.on('error', reject)
|
| 572 |
})
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
const rawPath = provider === 'voxcpm' ? `${rawBase}.wav` : rawBase
|
| 576 |
+
console.log('[feishu][tts] raw audio at:', rawPath)
|
| 577 |
|
| 578 |
+
// Convert raw → OGG/Opus (Feishu voice requires opus format)
|
| 579 |
+
console.log('[feishu][tts] converting to OGG/Opus')
|
| 580 |
await new Promise<void>((resolve, reject) => {
|
| 581 |
const child = spawn('ffmpeg', [
|
| 582 |
+
'-i', rawPath,
|
| 583 |
'-c:a', 'libopus',
|
| 584 |
'-b:a', '128k',
|
| 585 |
'-y',
|
|
|
|
| 599 |
console.log('[feishu][tts] sent successfully')
|
| 600 |
|
| 601 |
// Clean up temp files
|
| 602 |
+
await unlink(rawPath).catch(() => {})
|
| 603 |
await unlink(oggPath).catch(() => {})
|
| 604 |
} catch (err) {
|
| 605 |
// Don't let TTS failure break the main message flow
|
src/services/feishu/feishuConfig.ts
CHANGED
|
@@ -41,8 +41,12 @@ export type FeishuRuntimeConfig = {
|
|
| 41 |
streamingCard?: boolean
|
| 42 |
/** TTS 语音回复开关 — 发送消息时同时发送语音 */
|
| 43 |
ttsEnabled?: boolean
|
|
|
|
|
|
|
| 44 |
/** TTS 语音名称(edge-tts 格式,默认 zh-CN-XiaoxiaoNeural) */
|
| 45 |
ttsVoice?: string
|
|
|
|
|
|
|
| 46 |
}
|
| 47 |
|
| 48 |
function getConfigPath(): string {
|
|
|
|
| 41 |
streamingCard?: boolean
|
| 42 |
/** TTS 语音回复开关 — 发送消息时同时发送语音 */
|
| 43 |
ttsEnabled?: boolean
|
| 44 |
+
/** TTS 引擎: 'edge' | 'voxcpm' */
|
| 45 |
+
ttsProvider?: 'edge' | 'voxcpm'
|
| 46 |
/** TTS 语音名称(edge-tts 格式,默认 zh-CN-XiaoxiaoNeural) */
|
| 47 |
ttsVoice?: string
|
| 48 |
+
/** VoxCPM 参考音频路径(用于 voxtts 模式) */
|
| 49 |
+
ttsReferenceAudio?: string
|
| 50 |
}
|
| 51 |
|
| 52 |
function getConfigPath(): string {
|