Files
sundynix-agentix/sundynix-gateway/cmd/voicecheck/main.go
T
Blizzard 5b871bc363 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>
2026-07-22 09:33:55 +08:00

192 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}