chenbhao commited on
Commit
588f433
·
1 Parent(s): 07d0081

fix: just rm symbol not include text

Browse files
src/hooks/useFeishuBridge.ts CHANGED
@@ -117,11 +117,29 @@ export function useFeishuBridge({ messages, isLoading }: Props): void {
117
  // Also send TTS voice if enabled
118
  const config = getFeishuConfig()
119
  if (config.ttsEnabled) {
120
- const plainText = completedTurn.responseParts
121
- .join('\n')
122
- .replace(/[#*`_~\[\]()>|\\]/g, '')
123
- .replace(/{[^}]*}/g, '')
124
- .trim()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  if (plainText) {
126
  await feishuService.sendVoice(completedTurn.chatId, plainText)
127
  }
 
117
  // Also send TTS voice if enabled
118
  const config = getFeishuConfig()
119
  if (config.ttsEnabled) {
120
+ const rawText = completedTurn.responseParts.join('\n')
121
+
122
+ // Deep-clean text before sending to TTS
123
+ let plainText = rawText
124
+ // 去掉代码块标记 ``` ,但保留里面内容
125
+ plainText = plainText.replace(/```(\w*)\n?([\s\S]*?)```/g, '$2')
126
+ // 去掉行内代码标记 ` ,但保留内容
127
+ plainText = plainText.replace(/`([^`]+)`/g, '$1')
128
+ // 去掉 markdown 语法符号(# * _ ~ > | 等)
129
+ plainText = plainText.replace(/[#*_~>|^=\-\[\]()>|\\]/g, '')
130
+ // 去掉花括号内容
131
+ plainText = plainText.replace(/\{[^}]*\}/g, '')
132
+ // 去掉 emoji
133
+ plainText = plainText.replace(
134
+ /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{2300}-\u{23FF}\u{2B50}\u{FE0F}]/gu,
135
+ '',
136
+ )
137
+ // 合并多个换行/空行为单个换行
138
+ plainText = plainText.replace(/\n{2,}/g, '\n')
139
+ // 去掉行首尾空白
140
+ plainText = plainText.split('\n').map(l => l.trim()).join('\n')
141
+ plainText = plainText.trim()
142
+
143
  if (plainText) {
144
  await feishuService.sendVoice(completedTurn.chatId, plainText)
145
  }
src/services/feishu/FeishuService.ts CHANGED
@@ -506,7 +506,7 @@ class FeishuService {
506
  const { optimizeMarkdownForFeishu } = await import(
507
  '../../utils/feishuMarkdown.js'
508
  )
509
- const optimized = optimizeMarkdownForFeishu(markdown)
510
  await this.channel.send(chatId, { markdown: optimized })
511
  } catch (err) {
512
  log.fail('send', err, { chatId, markdownLen: markdown.length })
@@ -518,96 +518,126 @@ class FeishuService {
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
- '/home/yuki/Code/Agent/VersperClaw/.venv/bin/voxcpm',
541
- ['clone', '--text', text, '--reference-audio', refAudio, '--denoise', '--output', `${rawBase}.wav`],
542
- { shell: false },
543
- )
544
- let stdout = ''
545
- let stderr = ''
546
- child.stdout?.on('data', c => { stdout += String(c) })
547
- child.stderr?.on('data', c => { stderr += String(c) })
548
- child.on('close', code => {
549
- console.log('[feishu][tts] voxcpm exit code:', code)
550
- if (code === 0) resolve()
551
- else reject(new Error(`voxcpm exit ${code}: ${stderr.slice(0, 200)}`))
552
- })
553
- child.on('error', reject)
554
- })
555
- } else {
556
- const voice = config.ttsVoice || 'zh-CN-XiaoxiaoNeural'
557
- console.log('[feishu][tts] edge-tts: voice:', voice, 'text len:', text.length)
558
 
 
 
559
  await new Promise<void>((resolve, reject) => {
560
- const child = spawn('edge-tts', [
561
- '--voice', voice,
562
- '--text', text,
563
- '--write-media', rawBase,
564
- ])
565
- let stderr = ''
566
- child.stderr?.on('data', c => { stderr += String(c) })
567
- child.on('close', code => {
568
- console.log('[feishu][tts] edge-tts exit code:', code)
569
- if (code === 0) resolve()
570
- else reject(new Error(`edge-tts exit ${code}: ${stderr.slice(0, 200)}`))
571
- })
572
  child.on('error', reject)
573
  })
574
- }
575
 
576
- const rawPath = provider === 'voxcpm' ? `${rawBase}.wav` : rawBase
577
- console.log('[feishu][tts] raw audio at:', rawPath)
 
578
 
579
- // Convert raw → OGG/Opus (Feishu voice requires opus format)
580
- console.log('[feishu][tts] converting to OGG/Opus')
 
 
 
 
 
 
 
 
 
 
581
  await new Promise<void>((resolve, reject) => {
582
- const child = spawn('ffmpeg', [
583
- '-i', rawPath,
584
- '-c:a', 'libopus',
585
- '-b:a', '128k',
586
- '-y',
587
- oggPath,
588
- ])
589
- child.on('close', code => {
590
- console.log('[feishu][tts] ffmpeg exit code:', code)
591
- if (code === 0) resolve()
592
- else reject(new Error(`ffmpeg exit ${code}`))
593
- })
594
  child.on('error', reject)
595
  })
596
-
597
  const buffer = await readFile(oggPath)
598
- console.log('[feishu][tts] sending audio buffer, size:', buffer.length)
599
  await this.channel.send(chatId, { audio: { source: buffer } })
600
- console.log('[feishu][tts] sent successfully')
601
-
602
- // Clean up temp files
603
  await unlink(rawPath).catch(() => {})
604
  await unlink(oggPath).catch(() => {})
605
  } catch (err) {
606
- // Don't let TTS failure break the main message flow
607
  console.log('[feishu][tts] send-voice-failed:', err instanceof Error ? err.message : String(err))
608
  }
609
  }
610
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
611
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
612
  onMessagesChange(_messages: unknown[], _isLoading: boolean): void {
613
  // No-op
 
506
  const { optimizeMarkdownForFeishu } = await import(
507
  '../../utils/feishuMarkdown.js'
508
  )
509
+ const optimized = optimizeMarkdownForFeishu(markdown).replace(/<br\s*\/?>/gi, '\n')
510
  await this.channel.send(chatId, { markdown: optimized })
511
  } catch (err) {
512
  log.fail('send', err, { chatId, markdownLen: markdown.length })
 
518
  try {
519
  const config = getFeishuConfig()
520
  if (!config.ttsEnabled) return
521
+ const { readFile, unlink, writeFile } = await import('node:fs/promises')
 
522
  const { spawn } = await import('node:child_process')
 
523
  const provider = config.ttsProvider || 'edge'
 
 
 
524
 
525
  if (provider === 'voxcpm') {
526
  const refAudio = config.ttsReferenceAudio
527
+ if (!refAudio) return
528
+ const chunks = this.splitTextForVoxCpm(text)
529
+ console.log('[feishu][tts] voxcpm: splitting into', chunks.length, 'chunks')
530
+ const timestamp = Date.now()
531
+ const rawPaths: string[] = []
532
+ const oggPaths: string[] = []
533
+
534
+ // Generate each chunk
535
+ for (let i = 0; i < chunks.length; i++) {
536
+ const chunk = chunks[i]!.trim()
537
+ if (!chunk) continue
538
+ console.log('[feishu][tts] voxcpm: synthesizing chunk', i + 1, '/', chunks.length, 'len:', chunk.length)
539
+ const rawPath = path.join(os.tmpdir(), `feishu_vc_${timestamp}_${i}.wav`)
540
+ const oggPath = path.join(os.tmpdir(), `feishu_vc_${timestamp}_${i}.ogg`)
541
+ rawPaths.push(rawPath)
542
+ oggPaths.push(oggPath)
543
+ try {
544
+ await new Promise<void>((resolve, reject) => {
545
+ const child = spawn(
546
+ '/home/yuki/Code/Agent/VersperClaw/.venv/bin/voxcpm',
547
+ ['clone', '--text', chunk, '--reference-audio', refAudio, '--denoise', '--output', rawPath],
548
+ { shell: false },
549
+ )
550
+ child.on('close', code => { code === 0 ? resolve() : reject(new Error(`voxcpm exit ${code}`)) })
551
+ child.on('error', reject)
552
+ })
553
+ // Convert each chunk WAV → OGG (so concat can mix sample rates properly)
554
+ await new Promise<void>((resolve, reject) => {
555
+ const child = spawn('ffmpeg', ['-i', rawPath, '-c:a', 'libopus', '-b:a', '128k', '-y', oggPath])
556
+ child.on('close', code => { code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}`)) })
557
+ child.on('error', reject)
558
+ })
559
+ } catch (err) {
560
+ console.log('[feishu][tts] voxcpm: chunk', i + 1, 'failed, skipping:', err instanceof Error ? err.message : String(err))
561
+ rawPaths.pop()
562
+ oggPaths.pop()
563
+ }
564
+ }
565
+
566
+ if (rawPaths.length === 0) {
567
+ console.log('[feishu][tts] voxcpm: no chunks succeeded')
568
  return
569
  }
 
570
 
571
+ // Write concat list for ffmpeg
572
+ const listPath = path.join(os.tmpdir(), `feishu_vc_${timestamp}_list.txt`)
573
+ const listContent = oggPaths.map(p => `file '${p}'`).join('\n')
574
+ await writeFile(listPath, listContent)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
575
 
576
+ // Concat all OGG chunks into one
577
+ const mergedOggPath = path.join(os.tmpdir(), `feishu_vc_${timestamp}_merged.ogg`)
578
  await new Promise<void>((resolve, reject) => {
579
+ const child = spawn('ffmpeg', ['-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', '-y', mergedOggPath])
580
+ child.on('close', code => { code === 0 ? resolve() : reject(new Error(`ffmpeg concat exit ${code}`)) })
 
 
 
 
 
 
 
 
 
 
581
  child.on('error', reject)
582
  })
 
583
 
584
+ const buffer = await readFile(mergedOggPath)
585
+ await this.channel.send(chatId, { audio: { source: buffer } })
586
+ console.log('[feishu][tts] voxcpm: merged audio sent, size:', buffer.length)
587
 
588
+ // Clean up
589
+ for (const p of [...rawPaths, ...oggPaths, listPath, mergedOggPath]) {
590
+ await unlink(p).catch(() => {})
591
+ }
592
+ return
593
+ }
594
+
595
+ // Edge TTS — single shot
596
+ const voice = config.ttsVoice || 'zh-CN-XiaoxiaoNeural'
597
+ const timestamp = Date.now()
598
+ const rawPath = path.join(os.tmpdir(), `feishu_tts_${timestamp}`)
599
+ const oggPath = path.join(os.tmpdir(), `feishu_tts_${timestamp}.ogg`)
600
  await new Promise<void>((resolve, reject) => {
601
+ const child = spawn('edge-tts', ['--voice', voice, '--text', text, '--write-media', rawPath])
602
+ child.on('close', code => { code === 0 ? resolve() : reject(new Error(`edge-tts exit ${code}`)) })
603
+ child.on('error', reject)
604
+ })
605
+ await new Promise<void>((resolve, reject) => {
606
+ const child = spawn('ffmpeg', ['-i', rawPath, '-c:a', 'libopus', '-b:a', '128k', '-y', oggPath])
607
+ child.on('close', code => { code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}`)) })
 
 
 
 
 
608
  child.on('error', reject)
609
  })
 
610
  const buffer = await readFile(oggPath)
 
611
  await this.channel.send(chatId, { audio: { source: buffer } })
 
 
 
612
  await unlink(rawPath).catch(() => {})
613
  await unlink(oggPath).catch(() => {})
614
  } catch (err) {
 
615
  console.log('[feishu][tts] send-voice-failed:', err instanceof Error ? err.message : String(err))
616
  }
617
  }
618
 
619
+ /**
620
+ * Split text into VoxCPM-friendly chunks (max ~150 chars each).
621
+ * Split at Chinese/English sentence boundaries to keep grammar intact.
622
+ */
623
+ private splitTextForVoxCpm(text: string): string[] {
624
+ const sentences: string[] = []
625
+ const parts = text.split(/(?<=[。!?…\n])/)
626
+ let current = ''
627
+ for (const p of parts) {
628
+ const trimmed = p.trim()
629
+ if (!trimmed) continue
630
+ if (current.length + trimmed.length <= 150) {
631
+ current += (current ? ' ' : '') + trimmed
632
+ } else {
633
+ if (current) sentences.push(current)
634
+ current = trimmed
635
+ }
636
+ }
637
+ if (current) sentences.push(current)
638
+ return sentences
639
+ }
640
+
641
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
642
  onMessagesChange(_messages: unknown[], _isLoading: boolean): void {
643
  // No-op