diff --git a/apps/admin/app/pages/meettemplates.vue b/apps/admin/app/components/MeetTemplatePanel.vue similarity index 100% rename from apps/admin/app/pages/meettemplates.vue rename to apps/admin/app/components/MeetTemplatePanel.vue diff --git a/apps/services/sys/aliyun/filetrans/filetrans.go b/apps/services/sys/aliyun/filetrans/filetrans.go index 2fb49946..4a86e594 100644 --- a/apps/services/sys/aliyun/filetrans/filetrans.go +++ b/apps/services/sys/aliyun/filetrans/filetrans.go @@ -1,10 +1,12 @@ package filetrans import ( + "encoding/binary" "encoding/json" "fmt" "io" "net/http" + "sort" "strings" "time" ) @@ -98,6 +100,12 @@ type submitRequestParams struct { 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 提交任务的响应 @@ -172,6 +180,9 @@ func (this *FileTrans) CreateTask(audioURL, language string, enableSpeaker bool, DiarizationEnabled: enableSpeaker, NotifyURL: callbackURL, } + if ch := this.probeWavChannels(audioURL); ch >= 2 { + params.ChannelID = []int{0, 1} + } if language != "" { // 不支持的语种绝不放行:阿里对此不报错,只会返回空转写,错误会一路藏到用户看到空白纪要 if !modelLangs[model][language] { @@ -299,16 +310,66 @@ func (this *FileTrans) fetchTranscriptionResults(results []taskResult) (contexts 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: fmt.Sprintf("%v", s.SpeakerID), + 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 +} diff --git a/apps/services/sys/aliyun/filetrans/wavheader_test.go b/apps/services/sys/aliyun/filetrans/wavheader_test.go new file mode 100644 index 00000000..972ba28b --- /dev/null +++ b/apps/services/sys/aliyun/filetrans/wavheader_test.go @@ -0,0 +1,39 @@ +package filetrans + +import ( + "encoding/binary" + "testing" +) + +func wavHeader(channels uint16) []byte { + b := make([]byte, 44) + copy(b[0:4], "RIFF") + binary.LittleEndian.PutUint32(b[4:8], 36) + copy(b[8:12], "WAVE") + copy(b[12:16], "fmt ") + binary.LittleEndian.PutUint32(b[16:20], 16) + binary.LittleEndian.PutUint16(b[20:22], 1) + binary.LittleEndian.PutUint16(b[22:24], channels) + binary.LittleEndian.PutUint32(b[24:28], 16000) + copy(b[36:40], "data") + return b +} + +func TestWavChannelsFromHeader(t *testing.T) { + if got := wavChannelsFromHeader(wavHeader(2)); got != 2 { + t.Fatalf("stereo: got %d", got) + } + if got := wavChannelsFromHeader(wavHeader(1)); got != 1 { + t.Fatalf("mono: got %d", got) + } + // 客户端 WavWriter 的头只有 44 字节,但取 64 字节的 Range 会带上 data 的前 20 字节 + if got := wavChannelsFromHeader(append(wavHeader(2), make([]byte, 20)...)); got != 2 { + t.Fatalf("stereo+data: got %d", got) + } + if got := wavChannelsFromHeader([]byte("OggS\x00\x02")); got != 0 { + t.Fatalf("ogg: got %d", got) + } + if got := wavChannelsFromHeader(wavHeader(2)[:20]); got != 0 { + t.Fatalf("truncated: got %d", got) + } +}