Browse Source

services: 通话录音按左右声道分轨转写,说话人直接按音轨给

客户端录的通话音频是双声道 WAV(左=本端麦克风、右=对端),而 DashScope 录音文件识别
不传 channel_id 时只转第 0 轨 —— 语音纪要里只有机主自己说的话,对方一句都没有。

- 提交前先探一次 WAV 头(probeWavChannels),确认是多声道才传 channel_id=[0,1]:
  每多一轨按一份音频计费,单声道文件不该白花钱;
- 多轨结果每轨一份 transcript,说话人直接按音轨定(0=本端 1=对端),
  比让服务商去分离 speaker_id 可靠得多,再按时间轴合并成一条转写。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 3 weeks ago
parent
commit
4d97c99528
  1. 0
      apps/admin/app/components/MeetTemplatePanel.vue
  2. 63
      apps/services/sys/aliyun/filetrans/filetrans.go
  3. 39
      apps/services/sys/aliyun/filetrans/wavheader_test.go

0
apps/admin/app/pages/meettemplates.vue → apps/admin/app/components/MeetTemplatePanel.vue

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

39
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)
}
}
Loading…
Cancel
Save