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.
326 lines
10 KiB
326 lines
10 KiB
package speech
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Azure Speech Batch Transcription v3.2
|
|
// Docs: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/batch-transcription
|
|
const (
|
|
apiPath = "/speechtotext/v3.2/transcriptions"
|
|
)
|
|
|
|
type Speech struct {
|
|
options Options
|
|
client *http.Client
|
|
baseURL string
|
|
}
|
|
|
|
func newSys(options Options) (sys *Speech, err error) {
|
|
if options.Key == "" {
|
|
return nil, fmt.Errorf("microsoft speech: key is required")
|
|
}
|
|
endpoint := strings.TrimRight(options.Endpoint, "/")
|
|
if endpoint == "" {
|
|
if options.Region == "" {
|
|
return nil, fmt.Errorf("microsoft speech: endpoint or region is required")
|
|
}
|
|
endpoint = fmt.Sprintf("https://%s.api.cognitive.microsoft.com", options.Region)
|
|
}
|
|
sys = &Speech{
|
|
options: options,
|
|
client: &http.Client{Timeout: 30 * time.Second},
|
|
baseURL: endpoint,
|
|
}
|
|
return
|
|
}
|
|
|
|
// ==================== 创建任务 ====================
|
|
|
|
type createTaskRequest struct {
|
|
DisplayName string `json:"displayName"`
|
|
Locale string `json:"locale"`
|
|
ContentUrls []string `json:"contentUrls"`
|
|
Properties createTaskProperties `json:"properties"`
|
|
}
|
|
|
|
type createTaskProperties struct {
|
|
// 语种自动识别:请求不指定语言时带上候选语种,让 Azure 自己判断(locale 仍必填,作兜底)。
|
|
LanguageIdentification *languageIdentification `json:"languageIdentification,omitempty"`
|
|
DiarizationEnabled bool `json:"diarizationEnabled"`
|
|
WordLevelTimestampsEnabled bool `json:"wordLevelTimestampsEnabled"`
|
|
DisplayFormWordLevelTimestampsEnabled bool `json:"displayFormWordLevelTimestampsEnabled"`
|
|
PunctuationMode string `json:"punctuationMode,omitempty"`
|
|
ProfanityFilterMode string `json:"profanityFilterMode,omitempty"`
|
|
}
|
|
|
|
type languageIdentification struct {
|
|
CandidateLocales []string `json:"candidateLocales"`
|
|
}
|
|
|
|
// autoDetectLocales 不指定语言时交给 Azure 自动识别的候选语种。Azure 批量转写最多接受 10 个。
|
|
var autoDetectLocales = []string{
|
|
"zh-CN", "en-US", "ja-JP", "ko-KR", "fr-FR",
|
|
"de-DE", "es-ES", "pt-BR", "ru-RU", "it-IT",
|
|
}
|
|
|
|
type createTaskResponse struct {
|
|
Self string `json:"self"`
|
|
Status string `json:"status"`
|
|
Code string `json:"code,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
func (this *Speech) CreateTask(audioURL, language string, enableSpeaker bool, callbackURL string) (taskID string, err error) {
|
|
if audioURL == "" {
|
|
return "", fmt.Errorf("audioURL is empty")
|
|
}
|
|
// 空语言 = 自动识别(语音纪要 2026-09-26 起不再让用户选录音语言)
|
|
var lid *languageIdentification
|
|
if language == "" {
|
|
language = "en-US"
|
|
lid = &languageIdentification{CandidateLocales: autoDetectLocales}
|
|
}
|
|
taskID, err = this.createTask(audioURL, language, enableSpeaker, lid)
|
|
if err != nil && lid != nil {
|
|
// 自动识别的参数组合被拒时退回固定语种,宁可识别质量差也别让整个任务失败
|
|
this.options.Log.Warnf("azure speech 自动识别语种建任务失败,退回 %s: %v", language, err)
|
|
taskID, err = this.createTask(audioURL, language, enableSpeaker, nil)
|
|
}
|
|
return
|
|
}
|
|
|
|
func (this *Speech) createTask(audioURL, language string, enableSpeaker bool, lid *languageIdentification) (taskID string, err error) {
|
|
reqBody := createTaskRequest{
|
|
DisplayName: "echomeet-" + time.Now().Format("20060102150405"),
|
|
Locale: language,
|
|
ContentUrls: []string{audioURL},
|
|
Properties: createTaskProperties{
|
|
LanguageIdentification: lid,
|
|
DiarizationEnabled: enableSpeaker,
|
|
WordLevelTimestampsEnabled: true,
|
|
DisplayFormWordLevelTimestampsEnabled: true,
|
|
PunctuationMode: "DictatedAndAutomatic",
|
|
ProfanityFilterMode: "Masked",
|
|
},
|
|
}
|
|
body, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodPost, this.baseURL+apiPath, bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", fmt.Errorf("new request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Ocp-Apim-Subscription-Key", this.options.Key)
|
|
|
|
resp, err := this.client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("do request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read response: %w", err)
|
|
}
|
|
if resp.StatusCode/100 != 2 {
|
|
return "", fmt.Errorf("azure speech create task failed: status=%d body=%s", resp.StatusCode, string(data))
|
|
}
|
|
|
|
var out createTaskResponse
|
|
if err = json.Unmarshal(data, &out); err != nil {
|
|
return "", fmt.Errorf("unmarshal response: %w body=%s", err, string(data))
|
|
}
|
|
// taskID 取自 self 末段 GUID
|
|
taskID = extractTaskID(out.Self)
|
|
if taskID == "" {
|
|
return "", fmt.Errorf("missing taskID in response: %s", string(data))
|
|
}
|
|
return taskID, nil
|
|
}
|
|
|
|
func extractTaskID(self string) string {
|
|
if self == "" {
|
|
return ""
|
|
}
|
|
idx := strings.LastIndex(self, "/")
|
|
if idx < 0 || idx == len(self)-1 {
|
|
return ""
|
|
}
|
|
return self[idx+1:]
|
|
}
|
|
|
|
// ==================== 查询任务 ====================
|
|
|
|
type queryTaskResponse struct {
|
|
Self string `json:"self"`
|
|
Status string `json:"status"` // NotStarted, Running, Succeeded, Failed
|
|
Links struct {
|
|
Files string `json:"files"`
|
|
} `json:"links"`
|
|
Properties struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
} `json:"properties"`
|
|
}
|
|
|
|
type filesResponse struct {
|
|
Values []fileValue `json:"values"`
|
|
}
|
|
|
|
type fileValue struct {
|
|
Kind string `json:"kind"` // Transcription | TranscriptionReport
|
|
Name string `json:"name"`
|
|
Links struct {
|
|
ContentUrl string `json:"contentUrl"`
|
|
} `json:"links"`
|
|
}
|
|
|
|
// transcriptionResult Azure Speech 转写结果文件结构
|
|
type transcriptionResult struct {
|
|
RecognizedPhrases []recognizedPhrase `json:"recognizedPhrases"`
|
|
}
|
|
|
|
type recognizedPhrase struct {
|
|
RecognitionStatus string `json:"recognitionStatus"`
|
|
Speaker int `json:"speaker,omitempty"`
|
|
Channel int `json:"channel"`
|
|
OffsetInTicks float64 `json:"offsetInTicks"`
|
|
DurationInTicks float64 `json:"durationInTicks"`
|
|
NBest []nBest `json:"nBest"`
|
|
}
|
|
|
|
type nBest struct {
|
|
Confidence float64 `json:"confidence"`
|
|
Display string `json:"display"`
|
|
Lexical string `json:"lexical"`
|
|
}
|
|
|
|
func (this *Speech) QueryTask(taskID string) (status string, contexts []ContextStruct, err error) {
|
|
reqURL := fmt.Sprintf("%s%s/%s", this.baseURL, apiPath, taskID)
|
|
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
|
|
if err != nil {
|
|
return StatusFailed, nil, fmt.Errorf("new request: %w", err)
|
|
}
|
|
req.Header.Set("Ocp-Apim-Subscription-Key", this.options.Key)
|
|
|
|
resp, err := this.client.Do(req)
|
|
if err != nil {
|
|
return StatusFailed, nil, fmt.Errorf("do request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return StatusFailed, nil, fmt.Errorf("read response: %w", err)
|
|
}
|
|
if resp.StatusCode/100 != 2 {
|
|
return StatusFailed, nil, fmt.Errorf("azure speech query failed: status=%d body=%s", resp.StatusCode, string(data))
|
|
}
|
|
|
|
var out queryTaskResponse
|
|
if err = json.Unmarshal(data, &out); err != nil {
|
|
return StatusFailed, nil, fmt.Errorf("unmarshal response: %w body=%s", err, string(data))
|
|
}
|
|
|
|
switch out.Status {
|
|
case "Succeeded":
|
|
contexts, err = this.fetchTranscriptionResults(out.Links.Files)
|
|
if err != nil {
|
|
return StatusFailed, nil, err
|
|
}
|
|
return StatusSuccess, contexts, nil
|
|
case "Failed":
|
|
return StatusFailed, nil, fmt.Errorf("task failed: code=%s msg=%s", out.Properties.Error.Code, out.Properties.Error.Message)
|
|
case "NotStarted", "Running":
|
|
return StatusRunning, nil, nil
|
|
default:
|
|
return StatusFailed, nil, fmt.Errorf("unknown status: %s body=%s", out.Status, string(data))
|
|
}
|
|
}
|
|
|
|
// fetchTranscriptionResults 通过 files 列表拉取 Transcription kind 的结果 JSON
|
|
func (this *Speech) fetchTranscriptionResults(filesURL string) (contexts []ContextStruct, err error) {
|
|
if filesURL == "" {
|
|
return nil, fmt.Errorf("files url is empty")
|
|
}
|
|
req, err := http.NewRequest(http.MethodGet, filesURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new files request: %w", err)
|
|
}
|
|
req.Header.Set("Ocp-Apim-Subscription-Key", this.options.Key)
|
|
|
|
resp, err := this.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("do files request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read files: %w", err)
|
|
}
|
|
if resp.StatusCode/100 != 2 {
|
|
return nil, fmt.Errorf("list files failed: status=%d body=%s", resp.StatusCode, string(data))
|
|
}
|
|
|
|
var fl filesResponse
|
|
if err = json.Unmarshal(data, &fl); err != nil {
|
|
return nil, fmt.Errorf("unmarshal files: %w body=%s", err, string(data))
|
|
}
|
|
|
|
contexts = make([]ContextStruct, 0)
|
|
for _, f := range fl.Values {
|
|
if f.Kind != "Transcription" || f.Links.ContentUrl == "" {
|
|
continue
|
|
}
|
|
// 下载结果文件
|
|
resp2, err := this.client.Get(f.Links.ContentUrl)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("download transcription: %w", err)
|
|
}
|
|
body, err := io.ReadAll(resp2.Body)
|
|
resp2.Body.Close()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read transcription: %w", err)
|
|
}
|
|
var tr transcriptionResult
|
|
if err = json.Unmarshal(body, &tr); err != nil {
|
|
return nil, fmt.Errorf("unmarshal transcription: %w", err)
|
|
}
|
|
for _, p := range tr.RecognizedPhrases {
|
|
if p.RecognitionStatus != "Success" || len(p.NBest) == 0 {
|
|
continue
|
|
}
|
|
text := p.NBest[0].Display
|
|
if text == "" {
|
|
text = p.NBest[0].Lexical
|
|
}
|
|
// Azure 用 100ns Tick;转 ms:ticks / 10000
|
|
startMs := int64(p.OffsetInTicks / 10000)
|
|
endMs := startMs + int64(p.DurationInTicks/10000)
|
|
speaker := ""
|
|
if p.Speaker > 0 {
|
|
speaker = fmt.Sprintf("%d", p.Speaker)
|
|
}
|
|
contexts = append(contexts, ContextStruct{
|
|
Content: text,
|
|
StartTime: startMs,
|
|
EndTime: endMs,
|
|
Speaker: speaker,
|
|
})
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|