936c6d71db
The WAV parser now walks chunk headers to find the data chunk instead of scanning for the four bytes "data". A LIST chunk between fmt and data is common, arecord and ffmpeg both write one, and its payload is free text that can spell the word. A byte scan took that text for a chunk header and read the comment as samples. WAVHeader named WAVFromPCM in its error, so a caller of WAVHeader read the wrong function. internal/capture calls it twice. PCMFromWAV returns PCM that aliases the buffer it was given. That is the right trade for a long recording and it was undocumented, so the doc comment now says so and names the two ways a caller gets it wrong. The TTS stub wrote 16000 three times. It reads the rate and the sample width off audio.PCM16kMono now, so the tone stays in tune with the shape the seam declares, and the sample write goes through binary.LittleEndian. Identify computed the clip length twice to report it once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
137 lines
3.9 KiB
Go
137 lines
3.9 KiB
Go
package audio
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"testing"
|
|
)
|
|
|
|
func TestAudioDuration(t *testing.T) {
|
|
t.Parallel()
|
|
// 16k mono int16 ⇒ 2 bytes/sample ⇒ 32000 bytes/second.
|
|
a := Audio{Format: PCM16kMono, Bytes: make([]byte, 32000)}
|
|
if got, want := a.Duration(), 1.0; got != want {
|
|
t.Fatalf("Duration: got %v, want %v", got, want)
|
|
}
|
|
// 4 seconds
|
|
a.Bytes = make([]byte, 128000)
|
|
if got, want := a.Duration(), 4.0; got != want {
|
|
t.Fatalf("Duration: got %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestAudioDurationEmptyAndBad(t *testing.T) {
|
|
t.Parallel()
|
|
if (Audio{}).Duration() != 0 {
|
|
t.Fatalf("empty audio: Duration should be 0")
|
|
}
|
|
a := Audio{Format: Format{}, Bytes: make([]byte, 32000)}
|
|
if a.Duration() != 0 {
|
|
t.Fatalf("zero format: Duration should be 0")
|
|
}
|
|
a = Audio{Format: Format{SampleRate: 16000, Channels: 1, SampleBits: 16, Encoding: "opus"}, Bytes: make([]byte, 32000)}
|
|
if a.Duration() != 0 {
|
|
t.Fatalf("unsupported encoding: Duration should be 0")
|
|
}
|
|
}
|
|
|
|
func TestFormatIsValid(t *testing.T) {
|
|
t.Parallel()
|
|
if !PCM16kMono.IsValid() {
|
|
t.Fatalf("PCM16kMono should be valid")
|
|
}
|
|
if (Format{SampleRate: 8000, Channels: 1, SampleBits: 16, Encoding: "pcm_s16le"}).IsValid() {
|
|
t.Fatalf("8k should be rejected")
|
|
}
|
|
if (Format{SampleRate: 16000, Channels: 2, SampleBits: 16, Encoding: "pcm_s16le"}).IsValid() {
|
|
t.Fatalf("stereo should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestWAVRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
// 0.5s of square wave (alternating samples) — sndfile/aplay can play it.
|
|
const nsamp = 8000
|
|
pcm := make([]byte, nsamp*2)
|
|
for i := 0; i < nsamp; i++ {
|
|
var v int16 = -16384
|
|
if i%2 == 0 {
|
|
v = 16384
|
|
}
|
|
binary.LittleEndian.PutUint16(pcm[i*2:], uint16(v))
|
|
}
|
|
wav, err := WAVFromPCM(PCM16kMono, pcm)
|
|
if err != nil {
|
|
t.Fatalf("WAVFromPCM: %v", err)
|
|
}
|
|
if len(wav) != 44+len(pcm) {
|
|
t.Fatalf("wav length: got %d, want %d", len(wav), 44+len(pcm))
|
|
}
|
|
if string(wav[0:4]) != "RIFF" || string(wav[8:12]) != "WAVE" {
|
|
t.Fatalf("missing RIFF/WAVE marker: %q", wav[0:12])
|
|
}
|
|
f, pcm2, err := PCMFromWAV(wav)
|
|
if err != nil {
|
|
t.Fatalf("PCMFromWAV: %v", err)
|
|
}
|
|
if !f.IsValid() {
|
|
t.Fatalf("parsed format invalid: %+v", f)
|
|
}
|
|
if !bytes.Equal(pcm, pcm2) {
|
|
t.Fatalf("PCM mismatch after round-trip: in=%d bytes, out=%d bytes", len(pcm), len(pcm2))
|
|
}
|
|
}
|
|
|
|
// A LIST chunk sitting between fmt and data is common (arecord and ffmpeg both
|
|
// write one), and its payload is free text that can spell "data". The parser
|
|
// walks chunk headers, so the text is skipped and the real samples are read.
|
|
func TestPCMFromWAVSkipsLISTChunk(t *testing.T) {
|
|
t.Parallel()
|
|
pcm := []byte{1, 0, 2, 0, 3, 0, 4, 0}
|
|
list := []byte("LIST")
|
|
payload := []byte("INFOICMTdata is not here")
|
|
list = binary.LittleEndian.AppendUint32(list, uint32(len(payload)))
|
|
list = append(list, payload...)
|
|
|
|
plain, err := WAVFromPCM(PCM16kMono, pcm)
|
|
if err != nil {
|
|
t.Fatalf("WAVFromPCM: %v", err)
|
|
}
|
|
wav := append([]byte{}, plain[:36]...)
|
|
wav = append(wav, list...)
|
|
wav = append(wav, plain[36:]...)
|
|
binary.LittleEndian.PutUint32(wav[4:8], uint32(len(wav)-8))
|
|
|
|
f, got, err := PCMFromWAV(wav)
|
|
if err != nil {
|
|
t.Fatalf("PCMFromWAV: %v", err)
|
|
}
|
|
if !f.IsValid() {
|
|
t.Fatalf("parsed format invalid: %+v", f)
|
|
}
|
|
if !bytes.Equal(got, pcm) {
|
|
t.Fatalf("PCM mismatch: got %v, want %v", got, pcm)
|
|
}
|
|
}
|
|
|
|
func TestPCMFromWAVRejectsNonCanonical(t *testing.T) {
|
|
t.Parallel()
|
|
// too short
|
|
if _, _, err := PCMFromWAV([]byte("RIFF")); err == nil {
|
|
t.Fatalf("short input should error")
|
|
}
|
|
// bad RIFF marker
|
|
bad := make([]byte, 44)
|
|
copy(bad[0:4], []byte("RIFF"))
|
|
copy(bad[8:12], []byte("XXXX"))
|
|
if _, _, err := PCMFromWAV(bad); err == nil {
|
|
t.Fatalf("non-WAVE marker should error")
|
|
}
|
|
// format code 3 (float), canonical otherwise
|
|
wav, _ := WAVFromPCM(PCM16kMono, []byte{0, 0})
|
|
binary.LittleEndian.PutUint16(wav[20:22], 3) // IEEE float, not PCM
|
|
if _, _, err := PCMFromWAV(wav); err == nil {
|
|
t.Fatalf("non-PCM format should error")
|
|
}
|
|
}
|