5b871bc363
不用部署、不用麦克风:本机直连火山公网端点,端到端验证手搓的 ASR/TTS 帧协议。 TTS 合成一句→收音频存 out_tts.wav→降采样 24k→16k 喂 ASR→打印转写。 转写≈原句即证明两套协议都被真火山接受。凭证只走环境变量(VOLC_API_KEY 等),不进 git。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
192 lines
5.8 KiB
Go
192 lines
5.8 KiB
Go
// 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)
|
||
}
|