2026-10-18 15:39:09 +02:00
|
|
|
package audio
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/binary"
|
|
|
|
|
"fmt"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// pcmFormat describes raw PCM samples as oto needs them for playback.
|
2026-10-18 15:39:09 +02:00
|
|
|
type pcmFormat struct {
|
|
|
|
|
SampleRate int
|
|
|
|
|
Channels int
|
2026-10-24 11:15:35 +02:00
|
|
|
BitsPerCoded int // 8 (unsigned), 16 (signed LE), 32 (IEEE float only)
|
2026-10-18 15:39:09 +02:00
|
|
|
Float bool
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// decodeWAV splits RIFF/WAVE bytes into format and PCM data.
|
2026-10-18 15:39:09 +02:00
|
|
|
//
|
2026-10-24 11:15:35 +02:00
|
|
|
// Supported: PCM (AudioFormat 1) with 8/16 bit and IEEE float (AudioFormat
|
|
|
|
|
// 3) with 32 bit; mono and stereo. Everything else, compressed formats in
|
|
|
|
|
// particular, is a clear error; the OS TTS paths of osbuiltin deliver
|
|
|
|
|
// 16-bit PCM WAV exclusively.
|
2026-10-18 15:39:09 +02:00
|
|
|
func decodeWAV(data []byte) (pcmFormat, []byte, error) {
|
|
|
|
|
var f pcmFormat
|
|
|
|
|
if len(data) < 12 {
|
2026-10-24 11:15:35 +02:00
|
|
|
return f, nil, fmt.Errorf("wav: too short for a RIFF header (%d bytes)", len(data))
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
|
|
|
|
if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
|
2026-10-24 11:15:35 +02:00
|
|
|
return f, nil, fmt.Errorf("wav: no RIFF/WAVE header")
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var haveFmt bool
|
|
|
|
|
var payload []byte
|
|
|
|
|
pos := 12
|
|
|
|
|
for pos+8 <= len(data) {
|
|
|
|
|
id := string(data[pos : pos+4])
|
|
|
|
|
size := int(binary.LittleEndian.Uint32(data[pos+4 : pos+8]))
|
|
|
|
|
body := pos + 8
|
|
|
|
|
if body+size > len(data) {
|
2026-10-24 11:15:35 +02:00
|
|
|
return f, nil, fmt.Errorf("wav: chunk %q exceeds the data (size %d)", id, size)
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
|
|
|
|
switch id {
|
|
|
|
|
case "fmt ":
|
|
|
|
|
if size < 16 {
|
2026-10-24 11:15:35 +02:00
|
|
|
return f, nil, fmt.Errorf("wav: fmt chunk too short (%d)", size)
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
|
|
|
|
audioFormat := binary.LittleEndian.Uint16(data[body : body+2])
|
|
|
|
|
f.Channels = int(binary.LittleEndian.Uint16(data[body+2 : body+4]))
|
|
|
|
|
f.SampleRate = int(binary.LittleEndian.Uint32(data[body+4 : body+8]))
|
|
|
|
|
f.BitsPerCoded = int(binary.LittleEndian.Uint16(data[body+14 : body+16]))
|
|
|
|
|
switch {
|
|
|
|
|
case audioFormat == 1 && (f.BitsPerCoded == 8 || f.BitsPerCoded == 16):
|
|
|
|
|
case audioFormat == 3 && f.BitsPerCoded == 32:
|
|
|
|
|
f.Float = true
|
|
|
|
|
default:
|
2026-10-24 11:15:35 +02:00
|
|
|
return f, nil, fmt.Errorf("wav: unsupported format (AudioFormat %d, %d bit); allowed: PCM 8/16 bit, float 32 bit",
|
2026-10-18 15:39:09 +02:00
|
|
|
audioFormat, f.BitsPerCoded)
|
|
|
|
|
}
|
|
|
|
|
if f.Channels < 1 || f.Channels > 2 {
|
2026-10-24 11:15:35 +02:00
|
|
|
return f, nil, fmt.Errorf("wav: %d channels (allowed: 1, 2)", f.Channels)
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
|
|
|
|
if f.SampleRate <= 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
return f, nil, fmt.Errorf("wav: sample rate %d", f.SampleRate)
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
|
|
|
|
haveFmt = true
|
|
|
|
|
case "data":
|
|
|
|
|
payload = data[body : body+size]
|
|
|
|
|
}
|
|
|
|
|
pos = body + size
|
2026-10-24 11:15:35 +02:00
|
|
|
if size%2 == 1 { // chunks are word-aligned
|
2026-10-18 15:39:09 +02:00
|
|
|
pos++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !haveFmt {
|
2026-10-24 11:15:35 +02:00
|
|
|
return f, nil, fmt.Errorf("wav: no fmt chunk")
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
|
|
|
|
if payload == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return f, nil, fmt.Errorf("wav: no data chunk")
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
|
|
|
|
return f, payload, nil
|
|
|
|
|
}
|