// Package ost 调用科大讯飞 SparkChain SDK 里 RegionMSFT 那条录音文件转写。 // // SDK 二进制里的引擎名是 iflyrec_voice_mul_cbm_msft_24h,走海外 OST: // 创建 https:///v2/ost/pro_create ,查询 https:///v2/ost/query 。 // 音频不经过讯飞上传接口,直接用客户端已经放到 OSS 上的公网地址。 package ost import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/google/uuid" ) const ( defaultHost = "ost-api-sg.xf-yun.com" defaultTaskType = "iflyrec_voice_mul_cbm_msft_24h" createPath = "/v2/ost/create" queryPath = "/v2/ost/query" ) // Segment 一句转写。StartMs/EndMs 是相对音频开头的毫秒。 type Segment struct { Text string StartMs int64 EndMs int64 Speaker string } // Client 讯飞 OST 客户端。 type Client struct { AppID string APIKey string Secret string Host string TaskType string HTTP *http.Client } // New 用 SparkChain 的 appId / apiKey / apiSecret 建客户端。 func New(appID, apiKey, secret, host, taskType string) (*Client, error) { if strings.TrimSpace(appID) == "" || strings.TrimSpace(apiKey) == "" || strings.TrimSpace(secret) == "" { return nil, fmt.Errorf("iflytek ost: app_id/api_key/api_secret 都不能空") } host = strings.TrimSpace(host) if host == "" { host = defaultHost } host = strings.TrimPrefix(strings.TrimPrefix(host, "https://"), "http://") host = strings.TrimRight(host, "/") taskType = strings.TrimSpace(taskType) if taskType == "" { taskType = defaultTaskType } return &Client{ AppID: appID, APIKey: apiKey, Secret: secret, Host: host, TaskType: taskType, HTTP: &http.Client{Timeout: 45 * time.Second}, }, nil } // Create 提交一条公网音频。language 用 BCP-47(zh-CN / en-US),空则按中文。 func (c *Client) Create(audioURL string, speaker bool, language string) (string, error) { enc, format := audioEncoding(audioURL) business := map[string]any{ "request_id": strings.ReplaceAll(uuid.NewString(), "-", ""), "task_type": c.TaskType, "language": ostLanguage(language), } applySpeaker(business, c.TaskType, speaker, enc) body := map[string]any{ "common": map[string]string{"app_id": c.AppID}, "business": business, "data": map[string]any{ "audio_url": audioURL, "audio_src": "http", "format": format, "encoding": enc, }, } var resp struct { Code int `json:"code"` Message string `json:"message"` Data struct { TaskID string `json:"task_id"` } `json:"data"` } if err := c.post(createPath, body, &resp); err != nil { return "", err } if resp.Code != 0 { return "", fmt.Errorf("iflytek ost create: code=%d %s", resp.Code, resp.Message) } if resp.Data.TaskID == "" { return "", fmt.Errorf("iflytek ost create: 没有 task_id") } return resp.Data.TaskID, nil } // msftSpeakerCap 微软文件转写的说话人数上限。speaker_num 合法范围是 1–35, // 不传时文档默认 1,等于所有人合成一个角色。会议人数事先不知道,按 10 交给引擎去分。 const msftSpeakerCap = 10 // applySpeaker 按引擎写角色分离参数。 // 微软通道(task_type 含 msft)认 spkdia,不认讯飞自研的 vspp_on。 // 自研通道的 mp3 官方写明不支持角色分离,硬开会失败。 func applySpeaker(business map[string]any, taskType string, speaker bool, enc string) { if !speaker { return } if strings.Contains(taskType, "msft") { business["spkdia"] = 1 business["speaker_num"] = msftSpeakerCap return } if enc == "lame" { return } business["vspp_on"] = 1 business["speaker_num"] = 0 } // QueryStatus 任务状态。running 表示还要再查,failed 时 err 带原因。 type QueryStatus int const ( StatusRunning QueryStatus = iota StatusSuccess StatusFailed ) // Query 查一次。完成时 segments 是按句切开的文本。 func (c *Client) Query(taskID string) (QueryStatus, []Segment, error) { body := map[string]any{ "common": map[string]string{"app_id": c.AppID}, "business": map[string]string{"task_id": taskID}, } var raw map[string]any if err := c.post(queryPath, body, &raw); err != nil { return StatusFailed, nil, err } code := intFrom(raw["code"]) if code != 0 { return StatusFailed, nil, fmt.Errorf("iflytek ost query: code=%d %v", code, raw["message"]) } data, _ := raw["data"].(map[string]any) if data == nil { return StatusRunning, nil, nil } switch fmt.Sprint(data["task_status"]) { case "1", "2", "": return StatusRunning, nil, nil case "3", "4": return StatusSuccess, parseLattice(data["result"]), nil default: return StatusFailed, nil, fmt.Errorf("iflytek ost query: task_status=%v %v", data["task_status"], raw["message"]) } } func (c *Client) post(path string, payload any, out any) error { raw, err := json.Marshal(payload) if err != nil { return err } endpoint := "https://" + c.Host + path req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(raw)) if err != nil { return err } req.Host = c.Host for k, v := range c.authHeader(path, raw) { if k == "Host" { continue } req.Header.Set(k, v) } client := c.HTTP if client.CheckRedirect == nil { client.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } } resp, err := client.Do(req) if err != nil { return fmt.Errorf("iflytek ost %s: %w", path, err) } defer resp.Body.Close() buf, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) if err != nil { return err } if resp.StatusCode != http.StatusOK { if json.Unmarshal(buf, out) == nil { return nil } return fmt.Errorf("iflytek ost %s: http %d %s", endpoint, resp.StatusCode, truncate(string(buf), 300)) } if err := json.Unmarshal(buf, out); err != nil { return fmt.Errorf("iflytek ost %s: 响应不是 JSON: %s", path, truncate(string(buf), 200)) } return nil } func (c *Client) authHeader(path string, body []byte) map[string]string { sum := sha256.Sum256(body) digest := "SHA-256=" + base64.StdEncoding.EncodeToString(sum[:]) date := time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05 GMT") origin := "host: " + c.Host + "\n" + "date: " + date + "\n" + "POST " + path + " HTTP/1.1\n" + "digest: " + digest mac := hmac.New(sha256.New, []byte(c.Secret)) _, _ = mac.Write([]byte(origin)) signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) auth := fmt.Sprintf(`api_key="%s", algorithm="hmac-sha256", headers="host date request-line digest", signature="%s"`, c.APIKey, signature) return map[string]string{ "Content-Type": "application/json", "Accept": "application/json", "Host": c.Host, "Date": date, "Digest": digest, "Authorization": auth, } } func ostLanguage(code string) string { c := strings.TrimSpace(code) if c == "" { return "zh-CN" } c = strings.ReplaceAll(c, "_", "-") if !strings.Contains(c, "-") { switch strings.ToLower(c) { case "zh": return "zh-CN" case "en": return "en-US" case "ja": return "ja-JP" case "ko": return "ko-KR" } } return c } func audioEncoding(audioURL string) (encoding, format string) { u, err := url.Parse(audioURL) path := audioURL if err == nil && u.Path != "" { path = u.Path } lower := strings.ToLower(path) switch { case strings.HasSuffix(lower, ".mp3"): return "lame", "audio/mpeg" case strings.HasSuffix(lower, ".wav"), strings.HasSuffix(lower, ".pcm"): return "raw", "audio/L16;rate=16000" default: return "raw", "audio/L16;rate=16000" } } // parseLattice 把 OST 的 lattice 收成一句一条。json_1best 有时是对象,有时是字符串。 func parseLattice(result any) []Segment { obj, _ := result.(map[string]any) if obj == nil { if s, ok := result.(string); ok && s != "" { _ = json.Unmarshal([]byte(s), &obj) } } if obj == nil { return nil } lattice, _ := obj["lattice"].([]any) if len(lattice) == 0 { lattice, _ = obj["lattice2"].([]any) } out := make([]Segment, 0, len(lattice)) for _, item := range lattice { m, _ := item.(map[string]any) if m == nil { continue } best := unwrapBest(m["json_1best"]) st, _ := best["st"].(map[string]any) if st == nil { continue } text := wordsOf(st) if text == "" { continue } spk := strings.TrimSpace(fmt.Sprint(m["spk"])) if spk == "" || spk == "" { spk = strings.TrimSpace(fmt.Sprint(st["rl"])) } out = append(out, Segment{ Text: text, StartMs: intFrom(st["bg"]), EndMs: intFrom(st["ed"]), Speaker: spk, }) } return out } func unwrapBest(v any) map[string]any { switch t := v.(type) { case map[string]any: return t case string: var m map[string]any if json.Unmarshal([]byte(t), &m) == nil { return m } } return map[string]any{} } func wordsOf(st map[string]any) string { rts, _ := st["rt"].([]any) var b strings.Builder for _, rt := range rts { rtm, _ := rt.(map[string]any) wss, _ := rtm["ws"].([]any) for _, ws := range wss { wsm, _ := ws.(map[string]any) cws, _ := wsm["cw"].([]any) for _, cw := range cws { cwm, _ := cw.(map[string]any) if fmt.Sprint(cwm["wp"]) == "g" { continue } w, _ := cwm["w"].(string) b.WriteString(w) } } } return strings.TrimSpace(b.String()) } func intFrom(v any) int64 { switch t := v.(type) { case float64: return int64(t) case json.Number: n, _ := t.Int64() return n case string: var n int64 fmt.Sscan(t, &n) return n case int: return int64(t) case int64: return t default: return 0 } } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] }