// 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"), AppID: os.Getenv("VOLC_APP_ID"), 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) // ---- 0. 握手探针:分别验 ASR / TTS 端点能否连上(区分"鉴权方案错"还是"某个 resource-id 错")---- if cfg.ASREnabled() { if asr, err := voice.StartASR(context.Background(), cfg, "probe"); err != nil { fmt.Printf("[探针] ASR 握手 ❌ %v\n", err) } else { fmt.Println("[探针] ASR 握手 ✅(鉴权方案 + ASR resource-id 有效)") asr.Close() } } // ---- 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 降到 16k(3 取 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) }