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.
 
 
 
 
 
 

314 lines
9.8 KiB

package filetrans
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// 两套端点/模型:
// - 工作空间模式(配了 WorkspaceId):qwen-audio-3.0-asr-flash-filetrans,端点带 ws 前缀,27 语种;
// - 兼容模式(没配 WorkspaceId):老的 paraformer-v2 + 公共端点,仅 8 语种。
//
// 请求体/响应体两者完全同构(异步 task_id + 轮询 + diarization_enabled + language_hints),
// 所以只有 URL 和 model 名不同,其余解析逻辑共用。
const (
legacyBaseURL = "https://dashscope.aliyuncs.com"
legacyModel = "paraformer-v2"
workspaceBaseURLFmt = "https://%s.cn-beijing.maas.aliyuncs.com"
workspaceModel = "qwen-audio-3.0-asr-flash-filetrans"
pathTranscription = "/api/v1/services/audio/asr/transcription"
pathTasks = "/api/v1/tasks/"
)
// modelLangs 各模型 language_hints 实际支持的语种(短码,即 toDashScopeLang 的输出)。
// 用于 CreateTask 前置校验——把不支持的语种送进去,阿里不报错而是**静默返回空转写**
// (线上出过「西语送进 paraformer-v2 拿到空结果」的事故),所以这里必须 fail-fast,
// 不能只依赖后台 languages 白名单配对。
var modelLangs = map[string]map[string]bool{
legacyModel: {
"zh": true, "en": true, "ja": true, "yue": true,
"ko": true, "de": true, "fr": true, "ru": true,
},
// qwen-audio-3.0-asr-flash-filetrans 官方语种表(中文含普通话/粤语/吴/闽南/客家等方言)。
// 注意:土耳其语 tr、乌克兰语 uk 只出现在 Qwen-ASR 系列的语种表里,本模型的表中没有,
// 未经实测前不放开——宁可漏识别,也不能静默返回空。
workspaceModel: {
"zh": true, "yue": true, "en": true, "ja": true, "ko": true,
"vi": true, "th": true, "id": true, "ms": true, "fil": true,
"hi": true, "ar": true, "fr": true, "de": true, "es": true,
"pt": true, "ru": true, "it": true, "nl": true, "sv": true,
"da": true, "fi": true, "no": true, "el": true, "pl": true,
"cs": true, "hu": true, "ro": true, "bg": true, "hr": true,
"sk": true,
},
}
// model 返回本实例应使用的模型名
func (this *FileTrans) model() string {
if this.options.WorkspaceId != "" {
return workspaceModel
}
return legacyModel
}
// baseURL 返回本实例应使用的服务端点
func (this *FileTrans) baseURL() string {
if this.options.WorkspaceId != "" {
return fmt.Sprintf(workspaceBaseURLFmt, this.options.WorkspaceId)
}
return legacyBaseURL
}
func newSys(options Options) (sys *FileTrans, err error) {
sys = &FileTrans{
options: options,
client: &http.Client{
Timeout: 30 * time.Second,
},
}
return
}
type FileTrans struct {
options Options
client *http.Client
}
// ==================== 提交任务 ====================
// submitRequest DashScope 提交转写任务请求体
type submitRequest struct {
Model string `json:"model"`
Input submitRequestInput `json:"input"`
Parameters submitRequestParams `json:"parameters"`
}
type submitRequestInput struct {
FileURLs []string `json:"file_urls"`
}
type submitRequestParams struct {
LanguageHints []string `json:"language_hints,omitempty"`
SpeakerCount *int `json:"speaker_count,omitempty"`
DiarizationEnabled bool `json:"diarization_enabled"`
NotifyURL string `json:"notify_url,omitempty"`
}
// submitResponse DashScope 提交任务的响应
type submitResponse struct {
RequestID string `json:"request_id"`
Output struct {
TaskID string `json:"task_id"`
TaskStatus string `json:"task_status"`
} `json:"output"`
Code string `json:"code"`
Message string `json:"message"`
}
// ==================== 查询任务 ====================
// queryResponse DashScope 查询任务的响应
type queryResponse struct {
RequestID string `json:"request_id"`
Output struct {
TaskID string `json:"task_id"`
TaskStatus string `json:"task_status"`
Code string `json:"code"`
Message string `json:"message"`
TaskMetrics struct {
Total int `json:"TOTAL"`
Succeeded int `json:"SUCCEEDED"`
Failed int `json:"FAILED"`
} `json:"task_metrics"`
Results []taskResult `json:"results"`
} `json:"output"`
Code string `json:"code"`
Message string `json:"message"`
}
type taskResult struct {
FileURL string `json:"file_url"`
TranscriptionURL string `json:"transcription_url"`
SubtaskStatus string `json:"subtask_status"`
FailureReason string `json:"failure_reason,omitempty"`
}
// transcriptionResult 转写结果 JSON(从 transcription_url 获取)
type transcriptionResult struct {
FileURL string `json:"file_url"`
Properties struct {
AudioFormat string `json:"audio_format"`
Channels []int `json:"channels"`
OriginalSamplingRate int `json:"original_sampling_rate"`
OriginalDurationInMs int64 `json:"original_duration_in_milliseconds"`
} `json:"properties"`
Transcripts []transcript `json:"transcripts"`
}
type transcript struct {
ChannelID int `json:"channel_id"`
Sentences []sentence `json:"sentences"`
}
type sentence struct {
Text string `json:"text"`
BeginTime int64 `json:"begin_time"`
EndTime int64 `json:"end_time"`
SpeakerID any `json:"speaker_id,omitempty"` // 开启说话人分离时为数字,关闭时为字符串
}
// ==================== 实现 ====================
func (this *FileTrans) CreateTask(audioURL, language string, enableSpeaker bool, callbackURL string) (taskID string, err error) {
model := this.model()
params := submitRequestParams{
DiarizationEnabled: enableSpeaker,
NotifyURL: callbackURL,
}
if language != "" {
// 不支持的语种绝不放行:阿里对此不报错,只会返回空转写,错误会一路藏到用户看到空白纪要
if !modelLangs[model][language] {
return "", fmt.Errorf("模型 %s 不支持语种 %q,请检查后台该服务的「支持识别的语言」配置", model, language)
}
params.LanguageHints = []string{language}
}
reqBody := submitRequest{
Model: model,
Input: submitRequestInput{
FileURLs: []string{audioURL},
},
Parameters: params,
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequest("POST", this.baseURL()+pathTranscription, strings.NewReader(string(bodyBytes)))
if err != nil {
return "", fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+this.options.ApiKey)
req.Header.Set("X-DashScope-Async", "enable")
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)
}
var result submitResponse
if err = json.Unmarshal(data, &result); err != nil {
return "", fmt.Errorf("unmarshal response: %w", err)
}
if result.Code != "" {
return "", fmt.Errorf("create task failed: code=%s, message=%s", result.Code, result.Message)
}
return result.Output.TaskID, nil
}
func (this *FileTrans) QueryTask(taskID string) (status string, contexts []ContextStruct, err error) {
reqURL := this.baseURL() + pathTasks + taskID
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return StatusFailed, nil, fmt.Errorf("new request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+this.options.ApiKey)
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)
}
var result queryResponse
if err = json.Unmarshal(data, &result); err != nil {
return StatusFailed, nil, fmt.Errorf("unmarshal response: %w", err)
}
if result.Code != "" {
return StatusFailed, nil, fmt.Errorf("query failed: code=%s, message=%s", result.Code, result.Message)
}
switch result.Output.TaskStatus {
case "SUCCEEDED":
contexts, err = this.fetchTranscriptionResults(result.Output.Results)
if err != nil {
return StatusFailed, nil, err
}
return StatusSuccess, contexts, nil
case "FAILED":
reason := result.Output.Message
if reason == "" && len(result.Output.Results) > 0 {
reason = result.Output.Results[0].FailureReason
}
if reason == "" {
reason = result.Message
}
return StatusFailed, nil, fmt.Errorf("task failed: %s", reason)
case "PENDING", "RUNNING":
return StatusRunning, nil, nil
default:
// UNKNOWN / 任务已过期(DashScope 任务结果仅保留 24 小时)/ 其他未知状态
return StatusFailed, nil, fmt.Errorf("task unavailable: task_status=%s output.code=%s output.message=%s top.code=%s top.message=%s raw=%s",
result.Output.TaskStatus, result.Output.Code, result.Output.Message, result.Code, result.Message, string(data))
}
}
// fetchTranscriptionResults 从 transcription_url 下载转写结果并解析
func (this *FileTrans) fetchTranscriptionResults(results []taskResult) (contexts []ContextStruct, err error) {
contexts = make([]ContextStruct, 0)
for _, r := range results {
if r.SubtaskStatus != "SUCCEEDED" || r.TranscriptionURL == "" {
continue
}
resp, err := this.client.Get(r.TranscriptionURL)
if err != nil {
return nil, fmt.Errorf("fetch transcription: %w", err)
}
data, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("read transcription: %w", err)
}
var tr transcriptionResult
if err = json.Unmarshal(data, &tr); err != nil {
return nil, fmt.Errorf("unmarshal transcription: %w", err)
}
for _, t := range tr.Transcripts {
for _, s := range t.Sentences {
contexts = append(contexts, ContextStruct{
Content: s.Text,
StartTime: s.BeginTime,
EndTime: s.EndTime,
Speaker: fmt.Sprintf("%v", s.SpeakerID),
})
}
}
}
return
}