277 lines
9.8 KiB
Go
277 lines
9.8 KiB
Go
package audio
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/binary"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
|
||
goaudio "github.com/go-audio/audio"
|
||
wavcodec "github.com/go-audio/wav"
|
||
)
|
||
|
||
const (
|
||
minWAVSampleRate = 8000
|
||
maxWAVSampleRate = 48000
|
||
wavPCMFormat = 1
|
||
)
|
||
|
||
type wavPCMLayout struct {
|
||
format uint16
|
||
channels uint16
|
||
sampleRate uint32
|
||
byteRate uint32
|
||
blockAlign uint16
|
||
bitDepth uint16
|
||
dataOffset int
|
||
dataSize int
|
||
riffEnd int
|
||
}
|
||
|
||
// DecodeWAVToPCM16Mono decodes integer PCM RIFF/WAVE audio and normalizes it to
|
||
// mono PCM16 at targetRate. WAV metadata is validated before the decoder reads
|
||
// sample data so truncated chunks cannot be mistaken for completed audio.
|
||
func DecodeWAVToPCM16Mono(encoded []byte, targetRate, maxAudioBytes int) ([]byte, int, error) {
|
||
if len(encoded) == 0 {
|
||
return nil, 0, errors.New("WAV audio is empty")
|
||
}
|
||
if maxAudioBytes <= 0 || len(encoded) > maxAudioBytes {
|
||
return nil, 0, errors.New("WAV audio exceeds the configured bound")
|
||
}
|
||
if targetRate < minWAVSampleRate || targetRate > maxWAVSampleRate {
|
||
return nil, 0, errors.New("WAV target sample rate is outside the supported 8000–48000 Hz range")
|
||
}
|
||
|
||
layout, err := inspectWAVPCMLayout(encoded)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
decoder := wavcodec.NewDecoder(bytes.NewReader(encoded))
|
||
decoder.ReadInfo()
|
||
if err := decoder.Err(); err != nil {
|
||
return nil, 0, fmt.Errorf("read WAV format: %w", err)
|
||
}
|
||
if decoder.WavAudioFormat != layout.format || decoder.NumChans != layout.channels || decoder.SampleRate != layout.sampleRate || decoder.BitDepth != layout.bitDepth {
|
||
return nil, 0, errors.New("WAV decoder metadata does not match the RIFF header")
|
||
}
|
||
if layout.format != wavPCMFormat {
|
||
return nil, 0, fmt.Errorf("unsupported WAV audio format %d", layout.format)
|
||
}
|
||
if layout.channels < 1 || layout.channels > 2 {
|
||
return nil, 0, fmt.Errorf("unsupported WAV channel count %d", layout.channels)
|
||
}
|
||
if layout.sampleRate < minWAVSampleRate || layout.sampleRate > maxWAVSampleRate {
|
||
return nil, 0, fmt.Errorf("WAV sample rate %d is outside the supported 8000–48000 Hz range", layout.sampleRate)
|
||
}
|
||
bytesPerSample := int(layout.bitDepth / 8)
|
||
if bytesPerSample == 0 || layout.blockAlign != layout.channels*uint16(bytesPerSample) || layout.byteRate != layout.sampleRate*uint32(layout.blockAlign) {
|
||
return nil, 0, errors.New("WAV block alignment does not match its PCM format")
|
||
}
|
||
if layout.bitDepth != 8 && layout.bitDepth != 16 && layout.bitDepth != 24 && layout.bitDepth != 32 {
|
||
return nil, 0, fmt.Errorf("unsupported WAV PCM bit depth %d", layout.bitDepth)
|
||
}
|
||
if layout.dataSize == 0 || layout.dataSize%int(layout.blockAlign) != 0 {
|
||
return nil, 0, errors.New("WAV data chunk contains no complete PCM frames")
|
||
}
|
||
|
||
if err := decoder.FwdToPCM(); err != nil {
|
||
return nil, 0, fmt.Errorf("locate WAV data chunk: %w", err)
|
||
}
|
||
if err := decoder.Err(); err != nil {
|
||
return nil, 0, fmt.Errorf("read WAV data chunk: %w", err)
|
||
}
|
||
if decoder.PCMChunk == nil {
|
||
return nil, 0, errors.New("WAV data chunk is unavailable")
|
||
}
|
||
dataOffset, err := decoder.Seek(0, io.SeekCurrent)
|
||
if err != nil {
|
||
return nil, 0, fmt.Errorf("read WAV data offset: %w", err)
|
||
}
|
||
paddedDataSize := layout.dataSize + (layout.dataSize & 1)
|
||
if dataOffset != int64(layout.dataOffset) || decoder.PCMSize != paddedDataSize || int64(layout.dataOffset)+int64(layout.dataSize) > int64(layout.riffEnd) {
|
||
return nil, 0, errors.New("WAV decoder data boundaries do not match the RIFF chunks")
|
||
}
|
||
|
||
expectedSamples := layout.dataSize / bytesPerSample
|
||
frames := expectedSamples / int(layout.channels)
|
||
outputSamples := int64(frames) * int64(targetRate) / int64(layout.sampleRate)
|
||
if outputSamples <= 0 {
|
||
return nil, 0, errors.New("WAV conversion produced no complete PCM16 samples")
|
||
}
|
||
if outputSamples > int64(maxAudioBytes/2) {
|
||
return nil, 0, errors.New("converted WAV PCM exceeds the configured audio bound")
|
||
}
|
||
|
||
buffer := &goaudio.IntBuffer{Data: make([]int, 4096)}
|
||
mono := make([]byte, 0, frames*2)
|
||
decodedSamples := 0
|
||
for {
|
||
n, err := decoder.PCMBuffer(buffer)
|
||
if err != nil {
|
||
return nil, 0, fmt.Errorf("decode WAV PCM samples: %w", err)
|
||
}
|
||
if n == 0 {
|
||
break
|
||
}
|
||
if n < 0 || n > len(buffer.Data) {
|
||
return nil, 0, errors.New("WAV decoder returned an invalid sample count")
|
||
}
|
||
remaining := expectedSamples - decodedSamples
|
||
if n > remaining {
|
||
extra := n - remaining
|
||
if extra != 1 || layout.dataSize%2 == 0 {
|
||
return nil, 0, errors.New("WAV decoder returned samples outside the data chunk")
|
||
}
|
||
n = remaining
|
||
}
|
||
if n%int(layout.channels) != 0 {
|
||
return nil, 0, errors.New("WAV data ends with an incomplete audio frame")
|
||
}
|
||
for i := 0; i < n; i += int(layout.channels) {
|
||
left, err := wavSampleToPCM16(buffer.Data[i], layout.bitDepth)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
monoSample := left
|
||
if layout.channels == 2 {
|
||
right, err := wavSampleToPCM16(buffer.Data[i+1], layout.bitDepth)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
monoSample = int16((int32(left) + int32(right)) / 2)
|
||
}
|
||
var sample [2]byte
|
||
binary.LittleEndian.PutUint16(sample[:], uint16(monoSample))
|
||
mono = append(mono, sample[:]...)
|
||
}
|
||
decodedSamples += n
|
||
}
|
||
if decodedSamples != expectedSamples {
|
||
return nil, 0, fmt.Errorf("WAV data is incomplete: decoded %d of %d samples", decodedSamples, expectedSamples)
|
||
}
|
||
|
||
pcm16 := ResamplePCM16(mono, int(layout.sampleRate), targetRate)
|
||
if len(pcm16) == 0 || len(pcm16)%2 != 0 || len(pcm16) > maxAudioBytes {
|
||
return nil, 0, errors.New("converted WAV PCM is empty, incomplete or oversized")
|
||
}
|
||
return pcm16, int(layout.sampleRate), nil
|
||
}
|
||
|
||
type wavLengthMismatchError struct {
|
||
riffDeclaredBytes uint64
|
||
receivedBytes uint64
|
||
dataChunkDeclaredBytes uint64
|
||
dataChunkAvailableBytes uint64
|
||
hasDataChunk bool
|
||
}
|
||
|
||
func (e *wavLengthMismatchError) Error() string {
|
||
if e.hasDataChunk {
|
||
return fmt.Sprintf("RIFF/WAVE length invalid or truncated: RIFF declared %d bytes, received %d bytes; data chunk declared %d bytes, available %d bytes", e.riffDeclaredBytes, e.receivedBytes, e.dataChunkDeclaredBytes, e.dataChunkAvailableBytes)
|
||
}
|
||
return fmt.Sprintf("RIFF/WAVE length invalid or truncated: RIFF declared %d bytes, received %d bytes; data chunk size unavailable", e.riffDeclaredBytes, e.receivedBytes)
|
||
}
|
||
|
||
func wavLengthMismatch(encoded []byte, riffDeclaredBytes uint64) error {
|
||
mismatch := &wavLengthMismatchError{
|
||
riffDeclaredBytes: riffDeclaredBytes,
|
||
receivedBytes: uint64(len(encoded)),
|
||
}
|
||
for offset := 12; offset+8 <= len(encoded); {
|
||
chunkID := string(encoded[offset : offset+4])
|
||
chunkSize := uint64(binary.LittleEndian.Uint32(encoded[offset+4 : offset+8]))
|
||
chunkStart := uint64(offset + 8)
|
||
if chunkID == "data" {
|
||
mismatch.hasDataChunk = true
|
||
mismatch.dataChunkDeclaredBytes = chunkSize
|
||
available := uint64(len(encoded)) - chunkStart
|
||
if available > chunkSize {
|
||
available = chunkSize
|
||
}
|
||
mismatch.dataChunkAvailableBytes = available
|
||
return mismatch
|
||
}
|
||
chunkEnd := chunkStart + chunkSize
|
||
if chunkEnd < chunkStart || chunkEnd > uint64(len(encoded)) {
|
||
return mismatch
|
||
}
|
||
paddedEnd := chunkEnd + (chunkSize & 1)
|
||
if paddedEnd > uint64(len(encoded)) {
|
||
return mismatch
|
||
}
|
||
offset = int(paddedEnd)
|
||
}
|
||
return mismatch
|
||
}
|
||
|
||
func inspectWAVPCMLayout(encoded []byte) (wavPCMLayout, error) {
|
||
if len(encoded) < 12 || string(encoded[:4]) != "RIFF" || string(encoded[8:12]) != "WAVE" {
|
||
return wavPCMLayout{}, errors.New("audio is not a RIFF/WAVE file")
|
||
}
|
||
riffEnd64 := uint64(binary.LittleEndian.Uint32(encoded[4:8])) + 8
|
||
if riffEnd64 < 12 {
|
||
return wavPCMLayout{}, errors.New("RIFF/WAVE length is invalid or truncated")
|
||
}
|
||
if riffEnd64 > uint64(len(encoded)) {
|
||
return wavPCMLayout{}, wavLengthMismatch(encoded, riffEnd64)
|
||
}
|
||
layout := wavPCMLayout{dataOffset: -1, riffEnd: int(riffEnd64)}
|
||
var sawFormat bool
|
||
for offset := 12; offset < layout.riffEnd; {
|
||
if layout.riffEnd-offset < 8 {
|
||
return wavPCMLayout{}, errors.New("RIFF/WAVE contains a truncated chunk header")
|
||
}
|
||
chunkID := string(encoded[offset : offset+4])
|
||
chunkSize := uint64(binary.LittleEndian.Uint32(encoded[offset+4 : offset+8]))
|
||
chunkStart := uint64(offset + 8)
|
||
chunkEnd := chunkStart + chunkSize
|
||
if chunkEnd > uint64(layout.riffEnd) {
|
||
return wavPCMLayout{}, errors.New("RIFF/WAVE chunk exceeds the declared file length")
|
||
}
|
||
switch chunkID {
|
||
case "fmt ":
|
||
if sawFormat || chunkSize < 16 {
|
||
return wavPCMLayout{}, errors.New("RIFF/WAVE format chunk is missing or invalid")
|
||
}
|
||
layout.format = binary.LittleEndian.Uint16(encoded[chunkStart : chunkStart+2])
|
||
layout.channels = binary.LittleEndian.Uint16(encoded[chunkStart+2 : chunkStart+4])
|
||
layout.sampleRate = binary.LittleEndian.Uint32(encoded[chunkStart+4 : chunkStart+8])
|
||
layout.byteRate = binary.LittleEndian.Uint32(encoded[chunkStart+8 : chunkStart+12])
|
||
layout.blockAlign = binary.LittleEndian.Uint16(encoded[chunkStart+12 : chunkStart+14])
|
||
layout.bitDepth = binary.LittleEndian.Uint16(encoded[chunkStart+14 : chunkStart+16])
|
||
sawFormat = true
|
||
case "data":
|
||
if !sawFormat || layout.dataOffset >= 0 {
|
||
return wavPCMLayout{}, errors.New("RIFF/WAVE data chunk is missing, duplicated or invalid")
|
||
}
|
||
layout.dataOffset = int(chunkStart)
|
||
layout.dataSize = int(chunkSize)
|
||
}
|
||
paddedEnd := chunkEnd + (chunkSize & 1)
|
||
if paddedEnd > uint64(layout.riffEnd) {
|
||
return wavPCMLayout{}, errors.New("RIFF/WAVE chunk padding is truncated")
|
||
}
|
||
offset = int(paddedEnd)
|
||
}
|
||
if !sawFormat || layout.dataOffset < 0 {
|
||
return wavPCMLayout{}, errors.New("RIFF/WAVE is missing its format or data chunk")
|
||
}
|
||
return layout, nil
|
||
}
|
||
|
||
func wavSampleToPCM16(sample int, bitDepth uint16) (int16, error) {
|
||
switch bitDepth {
|
||
case 8:
|
||
return int16((sample - 128) << 8), nil
|
||
case 16:
|
||
return int16(sample), nil
|
||
case 24:
|
||
return int16(sample >> 8), nil
|
||
case 32:
|
||
return int16(sample >> 16), nil
|
||
default:
|
||
return 0, fmt.Errorf("unsupported WAV PCM bit depth %d", bitDepth)
|
||
}
|
||
}
|