Files
sundynix-agentix/sundynix-gateway/cmd/voicecheck/main.go
T
Blizzard bb9c73a9b7 fix(voice): 修正火山 V3 流式端点鉴权头(真火山联调验通协议格式)
联调发现 Authorization: Bearer 不被 openspeech V3 二进制流端点接受。改用官方规范头:
- X-Api-App-Key: 固定常量 PlgvMymc7f3tQnJ6(所有客户端同值,缺它 400 "app key not found")
- X-Api-Access-Key: 新版 API Key(账号鉴权)
- X-Api-App-ID: 账号 App ID(解析资源授权,缺它 401 "grant not found")
- X-Api-Resource-Id / X-Api-Connect-Id

改动:Config 加 AppID 字段;frame.go setVolcAuthHeaders 统一挂头 + handshakeDetail
榨取握手失败的 HTTP 状态/logid/body(联调可诊断);asr/tts 共用;voicecheck 加握手探针
分别验 ASR/TTS 端点;voiceconfig/voicecheck 读 VOLC_APP_ID。

现状:协议格式已被真火山接受(过了 400 格式错,进到账号/资源授权查询)。剩 App ID +
资源开通确认(账号侧,联调补)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:53:26 +08:00

203 lines
6.2 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"),
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 降到 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)
}