chenbhao commited on
Commit
53e0f9f
·
1 Parent(s): 1caee20

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 voice = config.ttsVoice || 'zh-CN-XiaoxiaoNeural'
527
- const mp3Path = path.join(os.tmpdir(), `feishu_tts_${Date.now()}.mp3`)
528
- const oggPath = mp3Path.replace(/\.mp3$/, '.ogg')
 
529
 
530
- console.log('[feishu][tts] generating MP3 with voice:', voice, 'text len:', text.length)
 
 
 
 
 
 
531
 
532
- // Generate MP3 via edge-tts CLI
533
- await new Promise<void>((resolve, reject) => {
534
- const child = spawn('edge-tts', [
535
- '--voice', voice,
536
- '--text', text,
537
- '--write-media', mp3Path,
538
- ])
539
- let stderr = ''
540
- child.stderr?.on('data', c => { stderr += String(c) })
541
- child.on('close', code => {
542
- console.log('[feishu][tts] edge-tts exit code:', code, 'stderr:', stderr.slice(0, 100))
543
- if (code === 0) resolve()
544
- else reject(new Error(`edge-tts exit ${code}: ${stderr.slice(0, 200)}`))
 
 
545
  })
546
- child.on('error', err => {
547
- console.log('[feishu][tts] edge-tts spawn error:', err.message)
548
- reject(err)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
  })
550
- })
 
 
 
551
 
552
- // Convert MP3 → OGG/Opus (Feishu voice requires opus format)
553
- console.log('[feishu][tts] converting MP3 OGG')
554
  await new Promise<void>((resolve, reject) => {
555
  const child = spawn('ffmpeg', [
556
- '-i', mp3Path,
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(mp3Path).catch(() => {})
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 {