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
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]
|
|
}
|
|
|