bb9c73a9b7
联调发现 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>
194 lines
5.4 KiB
Go
194 lines
5.4 KiB
Go
package voice
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gorilla/websocket"
|
||
)
|
||
|
||
// 火山引擎 V3 大模型流式语音识别(SeedASR / SAUC)客户端。协议见 voice/frame.go 与 voice-jarvis 记忆。
|
||
// 端点固定;鉴权走**新版 API Key**(X-Api-Access-Key + 固定 App Key + App ID,见 frame.go)。
|
||
|
||
const asrEndpoint = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel"
|
||
|
||
// ASRResult 是一次识别回传。Err 非空表示识别流出错/结束(此后 Results 关闭)。
|
||
type ASRResult struct {
|
||
Text string
|
||
Final bool
|
||
Err error
|
||
}
|
||
|
||
// ASRSession 是一路流式识别会话:PushAudio 喂 PCM、Results 出转写、Finish 收尾、Close 关闭。
|
||
type ASRSession struct {
|
||
conn *websocket.Conn
|
||
results chan ASRResult
|
||
}
|
||
|
||
// ---- 初始配置请求 JSON ----
|
||
|
||
type asrReq struct {
|
||
User asrUser `json:"user"`
|
||
Audio asrAudio `json:"audio"`
|
||
Request asrOptions `json:"request"`
|
||
}
|
||
type asrUser struct {
|
||
UID string `json:"uid"`
|
||
}
|
||
type asrAudio struct {
|
||
Format string `json:"format"`
|
||
Rate int `json:"rate"`
|
||
Bits int `json:"bits"`
|
||
Channel int `json:"channel"`
|
||
Codec string `json:"codec"`
|
||
}
|
||
type asrVAD struct {
|
||
VadEnable bool `json:"vad_enable"`
|
||
EndWindowSize int `json:"end_window_size"`
|
||
}
|
||
type asrOptions struct {
|
||
ModelName string `json:"model_name"`
|
||
Language string `json:"language"`
|
||
EnableITN bool `json:"enable_itn"`
|
||
EnablePunc bool `json:"enable_punc"`
|
||
ResultType string `json:"result_type"`
|
||
VAD asrVAD `json:"vad"`
|
||
}
|
||
|
||
// buildASRRequest 组初始配置 JSON。音频格式与客户端采集一致(PCM 16k 单声道 raw)。
|
||
// format="pcm" 是联调可调点(火山对 raw PCM 也接受 "raw")。
|
||
func buildASRRequest(uid string) []byte {
|
||
if uid == "" {
|
||
uid = "sundynix"
|
||
}
|
||
r := asrReq{
|
||
User: asrUser{UID: uid},
|
||
Audio: asrAudio{Format: "pcm", Rate: AudioSampleRate, Bits: AudioBits, Channel: AudioChannels, Codec: "raw"},
|
||
Request: asrOptions{
|
||
ModelName: "bigmodel", Language: "zh", EnableITN: true, EnablePunc: true,
|
||
ResultType: "0", VAD: asrVAD{VadEnable: true, EndWindowSize: 800},
|
||
},
|
||
}
|
||
b, _ := json.Marshal(r)
|
||
return b
|
||
}
|
||
|
||
func newConnectID() string {
|
||
b := make([]byte, 16)
|
||
_, _ = rand.Read(b)
|
||
return hex.EncodeToString(b)
|
||
}
|
||
|
||
// StartASR 连火山流式识别、发初始配置帧,返回会话。读 goroutine 持续把结果推入 Results。
|
||
func StartASR(ctx context.Context, cfg Config, uid string) (*ASRSession, error) {
|
||
if !cfg.ASREnabled() {
|
||
return nil, fmt.Errorf("ASR 未配置")
|
||
}
|
||
hdr := http.Header{}
|
||
setVolcAuthHeaders(hdr, cfg.APIKey, cfg.AppID, cfg.ASRResourceID)
|
||
|
||
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
|
||
conn, resp, err := dialer.DialContext(ctx, asrEndpoint, hdr)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("连接火山 ASR 失败: %w%s", err, handshakeDetail(resp))
|
||
}
|
||
if err := conn.WriteMessage(websocket.BinaryMessage, jsonFrame(msgFullClientReq, flagNone, buildASRRequest(uid))); err != nil {
|
||
_ = conn.Close()
|
||
return nil, fmt.Errorf("发送 ASR 配置失败: %w", err)
|
||
}
|
||
s := &ASRSession{conn: conn, results: make(chan ASRResult, 16)}
|
||
go s.readLoop()
|
||
return s, nil
|
||
}
|
||
|
||
func (s *ASRSession) readLoop() {
|
||
defer close(s.results)
|
||
for {
|
||
mt, data, err := s.conn.ReadMessage()
|
||
if err != nil {
|
||
s.results <- ASRResult{Err: err}
|
||
return
|
||
}
|
||
if mt != websocket.BinaryMessage {
|
||
continue
|
||
}
|
||
msgType, payload, ok := parseServerFrame(data)
|
||
if !ok || len(payload) == 0 {
|
||
continue
|
||
}
|
||
if msgType == msgServerError {
|
||
s.results <- ASRResult{Err: fmt.Errorf("火山 ASR 错误: %s", strings.TrimSpace(string(payload)))}
|
||
return
|
||
}
|
||
if text, final, ok := parseASRResult(payload); ok {
|
||
s.results <- ASRResult{Text: text, Final: final}
|
||
}
|
||
}
|
||
}
|
||
|
||
// PushAudio 喂一帧 PCM。
|
||
func (s *ASRSession) PushAudio(pcm []byte) error {
|
||
return s.conn.WriteMessage(websocket.BinaryMessage, audioFrame(pcm, false))
|
||
}
|
||
|
||
// Finish 发结束标记(空音频 + 最终帧),告知火山本轮说完。
|
||
func (s *ASRSession) Finish() error {
|
||
return s.conn.WriteMessage(websocket.BinaryMessage, audioFrame(nil, true))
|
||
}
|
||
|
||
// Results 返回识别结果流(部分/最终;出错或结束时推一条 Err 后关闭)。
|
||
func (s *ASRSession) Results() <-chan ASRResult { return s.results }
|
||
|
||
// Close 关闭底层连接(读 goroutine 随之退出)。
|
||
func (s *ASRSession) Close() { _ = s.conn.Close() }
|
||
|
||
// parseASRResult 从响应 JSON 提取转写文本 + 是否最终。空文本且非最终时 ok=false。
|
||
func parseASRResult(payload []byte) (text string, final bool, ok bool) {
|
||
var r struct {
|
||
Type string `json:"type"`
|
||
Result json.RawMessage `json:"result"`
|
||
}
|
||
if json.Unmarshal(payload, &r) != nil {
|
||
return "", false, false
|
||
}
|
||
final = r.Type == "final"
|
||
text = extractText(r.Result)
|
||
return text, final, text != "" || final
|
||
}
|
||
|
||
// extractText 从 result 里取转写文本;result 可为 [{text}]/{text}/"string"。
|
||
func extractText(raw json.RawMessage) string {
|
||
if len(raw) == 0 {
|
||
return ""
|
||
}
|
||
var arr []struct {
|
||
Text string `json:"text"`
|
||
}
|
||
if json.Unmarshal(raw, &arr) == nil && len(arr) > 0 {
|
||
var sb strings.Builder
|
||
for _, a := range arr {
|
||
sb.WriteString(a.Text)
|
||
}
|
||
if sb.Len() > 0 {
|
||
return sb.String()
|
||
}
|
||
}
|
||
var obj struct {
|
||
Text string `json:"text"`
|
||
}
|
||
if json.Unmarshal(raw, &obj) == nil && obj.Text != "" {
|
||
return obj.Text
|
||
}
|
||
var s string
|
||
if json.Unmarshal(raw, &s) == nil {
|
||
return s
|
||
}
|
||
return ""
|
||
}
|