Files
picoclaw/pkg/channels/qq/audio_duration.go
T
Hoshina 2c317444c5 fix(qq): send long audio as file
Downgrade outbound QQ audio to file upload when it exceeds the 60 second voice limit or its duration cannot be detected.

Refs #1884
2026-03-22 17:19:11 +08:00

232 lines
4.7 KiB
Go

package qq
import (
"encoding/binary"
"io"
"os"
"path/filepath"
"strings"
"time"
)
const qqVoiceMaxDuration = 60 * time.Second
func qqAudioDuration(localPath, filename, contentType string) (time.Duration, bool, error) {
if localPath == "" {
return 0, false, nil
}
switch qqAudioDurationFormat(localPath, filename, contentType) {
case "wav":
return qqWAVDuration(localPath)
case "ogg":
return qqOggDuration(localPath)
default:
return 0, false, nil
}
}
func qqAudioDurationFormat(localPath, filename, contentType string) string {
contentType = strings.ToLower(contentType)
switch {
case strings.HasPrefix(contentType, "audio/wav"), strings.HasPrefix(contentType, "audio/x-wav"):
return "wav"
case strings.HasPrefix(contentType, "audio/ogg"),
contentType == "application/ogg",
contentType == "application/x-ogg":
return "ogg"
}
switch filepath.Ext(strings.ToLower(filename)) {
case ".wav":
return "wav"
case ".ogg", ".opus":
return "ogg"
}
switch filepath.Ext(strings.ToLower(localPath)) {
case ".wav":
return "wav"
case ".ogg", ".opus":
return "ogg"
}
return ""
}
func qqWAVDuration(localPath string) (time.Duration, bool, error) {
file, err := os.Open(localPath)
if err != nil {
return 0, false, err
}
defer file.Close()
var header [12]byte
if _, err := io.ReadFull(file, header[:]); err != nil {
return 0, false, err
}
var order binary.ByteOrder
switch string(header[:4]) {
case "RIFF":
order = binary.LittleEndian
case "RIFX":
order = binary.BigEndian
default:
return 0, false, nil
}
if string(header[8:12]) != "WAVE" {
return 0, false, nil
}
var byteRate uint32
var dataSize uint32
var foundFmt bool
var foundData bool
for {
var chunkHeader [8]byte
if _, err := io.ReadFull(file, chunkHeader[:]); err != nil {
if err == io.EOF {
break
}
return 0, false, err
}
chunkSize := order.Uint32(chunkHeader[4:8])
switch string(chunkHeader[:4]) {
case "fmt ":
chunkData := make([]byte, chunkSize)
if _, err := io.ReadFull(file, chunkData); err != nil {
return 0, false, err
}
if len(chunkData) >= 12 {
byteRate = order.Uint32(chunkData[8:12])
foundFmt = true
}
case "data":
dataSize = chunkSize
foundData = true
if _, err := io.CopyN(io.Discard, file, int64(chunkSize)); err != nil {
return 0, false, err
}
default:
if _, err := io.CopyN(io.Discard, file, int64(chunkSize)); err != nil {
return 0, false, err
}
}
if chunkSize%2 == 1 {
if _, err := io.CopyN(io.Discard, file, 1); err != nil {
return 0, false, err
}
}
if foundFmt && foundData {
break
}
}
if !foundFmt || !foundData || byteRate == 0 {
return 0, false, nil
}
durationNS := int64(dataSize) * int64(time.Second) / int64(byteRate)
return time.Duration(durationNS), true, nil
}
func qqOggDuration(localPath string) (time.Duration, bool, error) {
file, err := os.Open(localPath)
if err != nil {
return 0, false, err
}
defer file.Close()
var firstPacket []byte
var codec string
var sampleRate uint32
var lastGranule uint64
var haveGranule bool
for {
var header [27]byte
if _, err := io.ReadFull(file, header[:]); err != nil {
if err == io.EOF {
break
}
return 0, false, err
}
if string(header[:4]) != "OggS" {
return 0, false, nil
}
pageSegments := int(header[26])
segments := make([]byte, pageSegments)
if _, err := io.ReadFull(file, segments); err != nil {
return 0, false, err
}
payloadLen := 0
for _, segLen := range segments {
payloadLen += int(segLen)
}
payload := make([]byte, payloadLen)
if _, err := io.ReadFull(file, payload); err != nil {
return 0, false, err
}
granule := binary.LittleEndian.Uint64(header[6:14])
if granule != ^uint64(0) {
lastGranule = granule
haveGranule = true
}
if codec == "" {
offset := 0
for _, segLen := range segments {
firstPacket = append(firstPacket, payload[offset:offset+int(segLen)]...)
offset += int(segLen)
if segLen < 255 {
codec, sampleRate = qqParseOggCodec(firstPacket)
break
}
}
}
}
if !haveGranule || codec == "" {
return 0, false, nil
}
switch codec {
case "opus":
return time.Duration(lastGranule) * time.Second / 48000, true, nil
case "vorbis":
if sampleRate == 0 {
return 0, false, nil
}
return time.Duration(lastGranule) * time.Second / time.Duration(sampleRate), true, nil
default:
return 0, false, nil
}
}
func qqParseOggCodec(packet []byte) (string, uint32) {
if len(packet) >= 8 && string(packet[:8]) == "OpusHead" {
return "opus", 48000
}
if len(packet) >= 16 && packet[0] == 0x01 && string(packet[1:7]) == "vorbis" {
sampleRate := binary.LittleEndian.Uint32(packet[12:16])
if sampleRate > 0 {
return "vorbis", sampleRate
}
}
return "", 0
}