package filetrans import ( "encoding/binary" "encoding/json" "fmt" "io" "net/http" "sort" "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"` // ChannelID 要识别的音轨。不传时 DashScope 只转写第 0 轨。 // // ⚠️ 通话录音是客户端录的**双声道 WAV**(左=本端麦克风、右=对端), // 不传这个参数的后果就是「语音纪要只转写出我自己说的话,对方一句都没有」。 // 每多一轨按一份音频计费,所以只在文件确实是多声道时才传(见 probeWavChannels)。 ChannelID []int `json:"channel_id,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 ch := this.probeWavChannels(audioURL); ch >= 2 { params.ChannelID = []int{0, 1} } 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) } // 多音轨(通话录音左右声道)时每轨一份 transcript:说话人直接按音轨给 // (0=本端 1=对端,比分离出来的 speaker_id 可靠得多),并按时间轴合并。 multi := len(tr.Transcripts) > 1 for _, t := range tr.Transcripts { for _, s := range t.Sentences { speaker := fmt.Sprintf("%v", s.SpeakerID) if multi { speaker = fmt.Sprintf("%d", t.ChannelID) } contexts = append(contexts, ContextStruct{ Content: s.Text, StartTime: s.BeginTime, EndTime: s.EndTime, Speaker: speaker, }) } } } sort.SliceStable(contexts, func(i, j int) bool { return contexts[i].StartTime < contexts[j].StartTime }) return } // probeWavChannels 只取文件开头几十个字节,看是不是多声道 WAV。 // 不是 WAV / 取不到 / 解析不出来一律返回 0(按单轨提交,行为与以前一致)。 func (this *FileTrans) probeWavChannels(audioURL string) int { req, err := http.NewRequest(http.MethodGet, audioURL, nil) if err != nil { return 0 } req.Header.Set("Range", "bytes=0-63") client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Do(req) if err != nil { return 0 } defer resp.Body.Close() head, err := io.ReadAll(io.LimitReader(resp.Body, 64)) if err != nil { return 0 } return wavChannelsFromHeader(head) } // wavChannelsFromHeader 从 RIFF/WAVE 头里读 fmt 块的声道数;不是 WAV 或不完整返回 0。 func wavChannelsFromHeader(head []byte) int { if len(head) < 12 || string(head[0:4]) != "RIFF" || string(head[8:12]) != "WAVE" { return 0 } // 从 12 起是若干 chunk:4 字节 id + 4 字节长度 + 数据。fmt 块里偏移 2 处是声道数。 pos := 12 for pos+8 <= len(head) { id := string(head[pos : pos+4]) size := int(binary.LittleEndian.Uint32(head[pos+4 : pos+8])) if id == "fmt " { if pos+8+4 > len(head) { return 0 } return int(binary.LittleEndian.Uint16(head[pos+10 : pos+12])) } pos += 8 + size } return 0 }