You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

381 lines
9.6 KiB

// Package ost 调用科大讯飞 SparkChain SDK 里 RegionMSFT 那条录音文件转写。
//
// SDK 二进制里的引擎名是 iflyrec_voice_mul_cbm_msft_24h,走海外 OST:
// 创建 https://<host>/v2/ost/pro_create ,查询 https://<host>/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 == "<nil>" {
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]
}