diff --git a/sundynix-gateway/cmd/voicecheck/main.go b/sundynix-gateway/cmd/voicecheck/main.go new file mode 100644 index 0000000..e83bb3b --- /dev/null +++ b/sundynix-gateway/cmd/voicecheck/main.go @@ -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 降到 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) +} diff --git a/sundynix-gateway/voicecheck b/sundynix-gateway/voicecheck new file mode 100755 index 0000000..fb54903 Binary files /dev/null and b/sundynix-gateway/voicecheck differ