feat(voice): 火山协议自检工具 voicecheck(本地端到端验协议,免麦克风免全栈)

不用部署、不用麦克风:本机直连火山公网端点,端到端验证手搓的 ASR/TTS 帧协议。
TTS 合成一句→收音频存 out_tts.wav→降采样 24k→16k 喂 ASR→打印转写。
转写≈原句即证明两套协议都被真火山接受。凭证只走环境变量(VOLC_API_KEY 等),不进 git。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-22 09:33:55 +08:00
parent 9458bf21f3
commit 5b871bc363
2 changed files with 191 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
// voicecheck 是火山语音协议自检工具:不需要麦克风、不需要起全栈,直接从本机连火山公网端点,
// 端到端验证我们手搓的 ASR / TTS 二进制帧协议是否被真火山接受。
//
// 做两件事:
// 1. TTS:合成一句中文 → 收音频(PCM 24k)→ 存 out_tts.wav(可播放试听)。
// 2. 往返:把 TTS 音频降采样到 16k → 喂 ASR → 打印转写。若转写≈原句,则 ASR+TTS 两协议全validated。
//
// 用法(凭证只走环境变量,绝不进代码/git):
//
// export VOLC_API_KEY=<你的APIKey>
// export VOLC_ASR_RESOURCE_ID=volc.seedasr.sauc.duration
// export VOLC_TTS_RESOURCE_ID=seed-tts-2.0
// export VOLC_TTS_VOICE=zh_male_m191_uranus_bigtts
// go run ./cmd/voicecheck # 默认合成并往返一句
// go run ./cmd/voicecheck "自定义要合成的话"
package main
import (
"context"
"encoding/binary"
"fmt"
"os"
"time"
"github.com/sundynix/sundynix-gateway/internal/voice"
)
func main() {
cfg := voice.Config{
APIKey: os.Getenv("VOLC_API_KEY"),
ASRResourceID: os.Getenv("VOLC_ASR_RESOURCE_ID"),
TTSResourceID: os.Getenv("VOLC_TTS_RESOURCE_ID"),
TTSVoiceType: os.Getenv("VOLC_TTS_VOICE"),
}
if cfg.APIKey == "" {
fatal("缺 VOLC_API_KEY(见文件头用法)")
}
text := "北京今天的天气怎么样,需要带伞吗。"
if len(os.Args) > 1 && os.Args[1] != "" {
text = os.Args[1]
}
fmt.Printf("配置:ASR-resource=%q TTS-resource=%q 音色=%q\n", cfg.ASRResourceID, cfg.TTSResourceID, cfg.TTSVoiceType)
// ---- 1. TTS:合成 → 收音频 ----
fmt.Printf("\n[TTS] 合成:%q\n", text)
pcm24k, err := runTTS(cfg, text)
if err != nil {
fatal("TTS 失败:" + err.Error())
}
fmt.Printf("[TTS] ✅ 收到音频 %d 字节(PCM 24k 单声道,%.1f 秒)\n", len(pcm24k), float64(len(pcm24k)/2)/24000)
if err := writeWAV("out_tts.wav", pcm24k, 24000); err != nil {
fmt.Printf("[TTS] ⚠️ 存 WAV 失败:%v\n", err)
} else {
fmt.Println("[TTS] 已存 out_tts.wav —— 可 `afplay out_tts.wav` 试听")
}
// ---- 2. 往返:TTS 音频降采样→16k 喂 ASR ----
if !cfg.ASREnabled() {
fmt.Println("\n[ASR] 跳过(未配 ASR resource-id")
return
}
pcm16k := downsample24kTo16k(pcm24k)
fmt.Printf("\n[ASR] 把合成音频降采样到 16k%d 字节)喂识别…\n", len(pcm16k))
transcript, err := runASR(cfg, pcm16k)
if err != nil {
fatal("ASR 失败:" + err.Error())
}
fmt.Printf("[ASR] ✅ 转写结果:%q\n", transcript)
fmt.Println("\n🎉 若转写与原句大致一致,说明 ASR + TTS 两套协议都已被真火山验证通过。")
}
// runTTS 连火山双向 TTS,推一句文字,收全部音频。
func runTTS(cfg voice.Config, text string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ts, err := voice.StartTTS(ctx, cfg)
if err != nil {
return nil, err
}
defer ts.Close()
if err := ts.Speak(text); err != nil {
return nil, fmt.Errorf("Speak: %w", err)
}
if err := ts.Finish(); err != nil {
return nil, fmt.Errorf("Finish: %w", err)
}
var out []byte
for chunk := range ts.Audio() {
out = append(out, chunk...)
}
if e := ts.Err(); e != nil {
return out, e
}
if len(out) == 0 {
return nil, fmt.Errorf("没收到任何音频(检查音色/resource-id/payload 键名)")
}
return out, nil
}
// runASR 连火山流式识别,喂完整段 PCM,收最终转写。
func runASR(cfg voice.Config, pcm16k []byte) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
asr, err := voice.StartASR(ctx, cfg, "voicecheck")
if err != nil {
return "", err
}
defer asr.Close()
// 分帧推送(模拟流式;每帧 ~100ms = 3200 字节)。
const frame = 3200
go func() {
for i := 0; i < len(pcm16k); i += frame {
end := i + frame
if end > len(pcm16k) {
end = len(pcm16k)
}
_ = asr.PushAudio(pcm16k[i:end])
time.Sleep(80 * time.Millisecond) // 稍慢于实时,贴近真实节奏
}
_ = asr.Finish()
}()
var last string
for r := range asr.Results() {
if r.Err != nil {
if last != "" {
return last, nil // 已有转写,流结束正常
}
return "", r.Err
}
if r.Text != "" {
last = r.Text
}
if r.Final {
return last, nil
}
}
return last, nil
}
// downsample24kTo16k 把 16bit PCM 从 24k 降到 16k3 取 2 的线性抽取)。
func downsample24kTo16k(in []byte) []byte {
n := len(in) / 2
out := make([]byte, 0, n*2*2/3+4)
var buf [2]byte
for i := 0; i < n; i++ {
if i%3 == 2 { // 每 3 个采样丢 1 个 → 24k*2/3=16k
continue
}
binary.LittleEndian.PutUint16(buf[:], uint16(int16(binary.LittleEndian.Uint16(in[i*2:]))))
out = append(out, buf[0], buf[1])
}
return out
}
// writeWAV 把 16bit 单声道 PCM 包成可播放的 WAV。
func writeWAV(path string, pcm []byte, rate int) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
dataLen := len(pcm)
var h []byte
put := func(s string) { h = append(h, s...) }
putU32 := func(v uint32) { b := make([]byte, 4); binary.LittleEndian.PutUint32(b, v); h = append(h, b...) }
putU16 := func(v uint16) { b := make([]byte, 2); binary.LittleEndian.PutUint16(b, v); h = append(h, b...) }
put("RIFF")
putU32(uint32(36 + dataLen))
put("WAVEfmt ")
putU32(16) // fmt chunk size
putU16(1) // PCM
putU16(1) // mono
putU32(uint32(rate)) // sample rate
putU32(uint32(rate*2)) // byte rate = rate * block align
putU16(2) // block align = channels * bits/8
putU16(16) // bits per sample
put("data")
putU32(uint32(dataLen))
if _, err := f.Write(h); err != nil {
return err
}
_, err = f.Write(pcm)
return err
}
func fatal(msg string) {
fmt.Fprintln(os.Stderr, "❌ "+msg)
os.Exit(1)
}
BIN
View File
Binary file not shown.