| @@ -1,28 +1,43 @@ | |||||
| package audio | package audio | ||||
| // ResampledSource adapts a WAVSource to a different sample rate using | |||||
| // linear interpolation between adjacent samples. | |||||
| type ResampledSource struct { | type ResampledSource struct { | ||||
| src *WAVSource | |||||
| ratio float64 | |||||
| position float64 | |||||
| src *WAVSource | |||||
| ratio float64 // source rate / target rate | |||||
| position float64 // fractional position in source frames | |||||
| } | } | ||||
| // NewResampledSource wraps a WAV source with rate conversion. | |||||
| func NewResampledSource(src *WAVSource, targetSampleRate float64) *ResampledSource { | func NewResampledSource(src *WAVSource, targetSampleRate float64) *ResampledSource { | ||||
| ratio := 1.0 | |||||
| if src != nil && src.SampleRate > 0 && targetSampleRate > 0 { | |||||
| ratio = float64(src.SampleRate) / targetSampleRate | |||||
| } | |||||
| return &ResampledSource{src: src, ratio: ratio} | |||||
| ratio := 1.0 | |||||
| if src != nil && src.SampleRate > 0 && targetSampleRate > 0 { | |||||
| ratio = float64(src.SampleRate) / targetSampleRate | |||||
| } | |||||
| return &ResampledSource{src: src, ratio: ratio} | |||||
| } | } | ||||
| // NextFrame returns the next interpolated stereo frame. | |||||
| func (s *ResampledSource) NextFrame() Frame { | func (s *ResampledSource) NextFrame() Frame { | ||||
| if s.src == nil || len(s.src.frames) == 0 { | |||||
| return NewFrame(0, 0) | |||||
| } | |||||
| idx := int(s.position) % len(s.src.frames) | |||||
| frame := s.src.frames[idx] | |||||
| s.position += s.ratio | |||||
| for s.position >= float64(len(s.src.frames)) { | |||||
| s.position -= float64(len(s.src.frames)) | |||||
| } | |||||
| return frame | |||||
| if s.src == nil || len(s.src.frames) == 0 { | |||||
| return NewFrame(0, 0) | |||||
| } | |||||
| n := len(s.src.frames) | |||||
| idx0 := int(s.position) % n | |||||
| idx1 := (idx0 + 1) % n | |||||
| frac := s.position - float64(int(s.position)) | |||||
| f0 := s.src.frames[idx0] | |||||
| f1 := s.src.frames[idx1] | |||||
| l := float64(f0.L)*(1-frac) + float64(f1.L)*frac | |||||
| r := float64(f0.R)*(1-frac) + float64(f1.R)*frac | |||||
| s.position += s.ratio | |||||
| for s.position >= float64(n) { | |||||
| s.position -= float64(n) | |||||
| } | |||||
| return NewFrame(Sample(l), Sample(r)) | |||||
| } | } | ||||
| @@ -1,13 +1,46 @@ | |||||
| package audio | package audio | ||||
| import "testing" | |||||
| import ( | |||||
| "math" | |||||
| "testing" | |||||
| ) | |||||
| func TestResampledSource(t *testing.T) { | func TestResampledSource(t *testing.T) { | ||||
| src := &WAVSource{frames: []Frame{NewFrame(0.1, 0.1), NewFrame(0.2, 0.2)}, SampleRate: 48000} | |||||
| rs := NewResampledSource(src, 96000) | |||||
| a := rs.NextFrame() | |||||
| b := rs.NextFrame() | |||||
| if a == (Frame{}) || b == (Frame{}) { | |||||
| t.Fatal("expected frames") | |||||
| } | |||||
| src := &WAVSource{frames: []Frame{NewFrame(0.1, 0.1), NewFrame(0.2, 0.2)}, SampleRate: 48000} | |||||
| rs := NewResampledSource(src, 96000) | |||||
| a := rs.NextFrame() | |||||
| b := rs.NextFrame() | |||||
| if a == (Frame{}) || b == (Frame{}) { | |||||
| t.Fatal("expected non-zero frames") | |||||
| } | |||||
| } | |||||
| func TestResampledSourceInterpolation(t *testing.T) { | |||||
| // 2 samples at 48k, target at 96k -> ratio=0.5, so we should | |||||
| // get interpolated values between the two source frames. | |||||
| src := &WAVSource{ | |||||
| frames: []Frame{NewFrame(0, 0), NewFrame(1, 1)}, | |||||
| SampleRate: 48000, | |||||
| } | |||||
| rs := NewResampledSource(src, 96000) | |||||
| // First sample: position=0.0, exact frame[0] -> (0,0) | |||||
| f0 := rs.NextFrame() | |||||
| if math.Abs(float64(f0.L)) > 1e-9 { | |||||
| t.Fatalf("expected L=0 at pos 0, got %.6f", f0.L) | |||||
| } | |||||
| // Second sample: position=0.5, interpolated -> (0.5, 0.5) | |||||
| f1 := rs.NextFrame() | |||||
| if math.Abs(float64(f1.L)-0.5) > 1e-9 { | |||||
| t.Fatalf("expected L=0.5 at pos 0.5, got %.6f", f1.L) | |||||
| } | |||||
| } | |||||
| func TestResampledSourceNilSrc(t *testing.T) { | |||||
| rs := NewResampledSource(nil, 48000) | |||||
| f := rs.NextFrame() | |||||
| if f.L != 0 || f.R != 0 { | |||||
| t.Fatal("expected zero frame for nil source") | |||||
| } | |||||
| } | } | ||||
| @@ -1,88 +1,152 @@ | |||||
| package audio | package audio | ||||
| import ( | import ( | ||||
| "encoding/binary" | |||||
| "fmt" | |||||
| "io" | |||||
| "os" | |||||
| "encoding/binary" | |||||
| "fmt" | |||||
| "io" | |||||
| "os" | |||||
| ) | ) | ||||
| // WAVSource loads a PCM WAV file into memory and provides frame-by-frame access. | |||||
| type WAVSource struct { | type WAVSource struct { | ||||
| frames []Frame | |||||
| index int | |||||
| SampleRate int | |||||
| Channels int | |||||
| frames []Frame | |||||
| index int | |||||
| SampleRate int | |||||
| Channels int | |||||
| } | } | ||||
| // LoadWAVSource reads and decodes a WAV file. It properly scans for the "fmt " | |||||
| // and "data" chunks, handling files with extra metadata chunks (LIST, INFO, | |||||
| // bext, etc.) that appear between headers. | |||||
| func LoadWAVSource(path string) (*WAVSource, error) { | func LoadWAVSource(path string) (*WAVSource, error) { | ||||
| f, err := os.Open(path) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| defer f.Close() | |||||
| f, err := os.Open(path) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| defer f.Close() | |||||
| header := make([]byte, 44) | |||||
| if _, err := io.ReadFull(f, header); err != nil { | |||||
| return nil, fmt.Errorf("read wav header: %w", err) | |||||
| } | |||||
| if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" { | |||||
| return nil, fmt.Errorf("unsupported wav header") | |||||
| } | |||||
| // Read RIFF header (12 bytes) | |||||
| riffHeader := make([]byte, 12) | |||||
| if _, err := io.ReadFull(f, riffHeader); err != nil { | |||||
| return nil, fmt.Errorf("read riff header: %w", err) | |||||
| } | |||||
| if string(riffHeader[0:4]) != "RIFF" || string(riffHeader[8:12]) != "WAVE" { | |||||
| return nil, fmt.Errorf("not a RIFF/WAVE file") | |||||
| } | |||||
| audioFormat := binary.LittleEndian.Uint16(header[20:22]) | |||||
| channels := binary.LittleEndian.Uint16(header[22:24]) | |||||
| sampleRate := binary.LittleEndian.Uint32(header[24:28]) | |||||
| bitsPerSample := binary.LittleEndian.Uint16(header[34:36]) | |||||
| dataSize := binary.LittleEndian.Uint32(header[40:44]) | |||||
| var ( | |||||
| audioFormat uint16 | |||||
| channels uint16 | |||||
| sampleRate uint32 | |||||
| bitsPerSample uint16 | |||||
| dataBytes []byte | |||||
| fmtFound bool | |||||
| dataFound bool | |||||
| ) | |||||
| if audioFormat != 1 { | |||||
| return nil, fmt.Errorf("only PCM wav supported") | |||||
| } | |||||
| if bitsPerSample != 16 { | |||||
| return nil, fmt.Errorf("only 16-bit PCM wav supported") | |||||
| } | |||||
| if channels != 1 && channels != 2 { | |||||
| return nil, fmt.Errorf("only mono/stereo wav supported") | |||||
| } | |||||
| if sampleRate == 0 { | |||||
| return nil, fmt.Errorf("invalid wav sample rate") | |||||
| } | |||||
| // Scan chunks | |||||
| for { | |||||
| var chunkID [4]byte | |||||
| var chunkSize uint32 | |||||
| if _, err := io.ReadFull(f, chunkID[:]); err != nil { | |||||
| if err == io.EOF || err == io.ErrUnexpectedEOF { | |||||
| break | |||||
| } | |||||
| return nil, fmt.Errorf("read chunk id: %w", err) | |||||
| } | |||||
| if err := binary.Read(f, binary.LittleEndian, &chunkSize); err != nil { | |||||
| return nil, fmt.Errorf("read chunk size: %w", err) | |||||
| } | |||||
| raw := make([]byte, dataSize) | |||||
| if _, err := io.ReadFull(f, raw); err != nil { | |||||
| return nil, fmt.Errorf("read wav data: %w", err) | |||||
| } | |||||
| switch string(chunkID[:]) { | |||||
| case "fmt ": | |||||
| if chunkSize < 16 { | |||||
| return nil, fmt.Errorf("fmt chunk too small: %d", chunkSize) | |||||
| } | |||||
| fmtData := make([]byte, chunkSize) | |||||
| if _, err := io.ReadFull(f, fmtData); err != nil { | |||||
| return nil, fmt.Errorf("read fmt chunk: %w", err) | |||||
| } | |||||
| audioFormat = binary.LittleEndian.Uint16(fmtData[0:2]) | |||||
| channels = binary.LittleEndian.Uint16(fmtData[2:4]) | |||||
| sampleRate = binary.LittleEndian.Uint32(fmtData[4:8]) | |||||
| bitsPerSample = binary.LittleEndian.Uint16(fmtData[14:16]) | |||||
| fmtFound = true | |||||
| step := int(channels) * 2 | |||||
| frames := make([]Frame, 0, len(raw)/step) | |||||
| for i := 0; i+step <= len(raw); i += step { | |||||
| l := pcm16ToSample(int16(binary.LittleEndian.Uint16(raw[i : i+2]))) | |||||
| r := l | |||||
| if channels == 2 { | |||||
| r = pcm16ToSample(int16(binary.LittleEndian.Uint16(raw[i+2 : i+4]))) | |||||
| } | |||||
| frames = append(frames, NewFrame(l, r)) | |||||
| } | |||||
| case "data": | |||||
| dataBytes = make([]byte, chunkSize) | |||||
| if _, err := io.ReadFull(f, dataBytes); err != nil { | |||||
| return nil, fmt.Errorf("read data chunk: %w", err) | |||||
| } | |||||
| dataFound = true | |||||
| return &WAVSource{ | |||||
| frames: frames, | |||||
| SampleRate: int(sampleRate), | |||||
| Channels: int(channels), | |||||
| }, nil | |||||
| default: | |||||
| // Skip unknown chunks, respecting RIFF padding (chunks are word-aligned) | |||||
| skip := int64(chunkSize) | |||||
| if chunkSize%2 != 0 { | |||||
| skip++ | |||||
| } | |||||
| if _, err := io.CopyN(io.Discard, f, skip); err != nil { | |||||
| // Could be EOF if this is the last chunk | |||||
| break | |||||
| } | |||||
| } | |||||
| if fmtFound && dataFound { | |||||
| break | |||||
| } | |||||
| } | |||||
| if !fmtFound { | |||||
| return nil, fmt.Errorf("no fmt chunk found") | |||||
| } | |||||
| if !dataFound { | |||||
| return nil, fmt.Errorf("no data chunk found") | |||||
| } | |||||
| if audioFormat != 1 { | |||||
| return nil, fmt.Errorf("only PCM wav supported (format=%d)", audioFormat) | |||||
| } | |||||
| if bitsPerSample != 16 { | |||||
| return nil, fmt.Errorf("only 16-bit PCM wav supported (bits=%d)", bitsPerSample) | |||||
| } | |||||
| if channels != 1 && channels != 2 { | |||||
| return nil, fmt.Errorf("only mono/stereo wav supported (channels=%d)", channels) | |||||
| } | |||||
| if sampleRate == 0 { | |||||
| return nil, fmt.Errorf("invalid wav sample rate") | |||||
| } | |||||
| step := int(channels) * 2 | |||||
| frames := make([]Frame, 0, len(dataBytes)/step) | |||||
| for i := 0; i+step <= len(dataBytes); i += step { | |||||
| l := pcm16ToSample(int16(binary.LittleEndian.Uint16(dataBytes[i : i+2]))) | |||||
| r := l | |||||
| if channels == 2 { | |||||
| r = pcm16ToSample(int16(binary.LittleEndian.Uint16(dataBytes[i+2 : i+4]))) | |||||
| } | |||||
| frames = append(frames, NewFrame(l, r)) | |||||
| } | |||||
| return &WAVSource{ | |||||
| frames: frames, | |||||
| SampleRate: int(sampleRate), | |||||
| Channels: int(channels), | |||||
| }, nil | |||||
| } | } | ||||
| // NextFrame returns the next audio frame, looping at the end. | |||||
| func (s *WAVSource) NextFrame() Frame { | func (s *WAVSource) NextFrame() Frame { | ||||
| if len(s.frames) == 0 { | |||||
| return NewFrame(0, 0) | |||||
| } | |||||
| frame := s.frames[s.index] | |||||
| s.index++ | |||||
| if s.index >= len(s.frames) { | |||||
| s.index = 0 | |||||
| } | |||||
| return frame | |||||
| if len(s.frames) == 0 { | |||||
| return NewFrame(0, 0) | |||||
| } | |||||
| frame := s.frames[s.index] | |||||
| s.index++ | |||||
| if s.index >= len(s.frames) { | |||||
| s.index = 0 | |||||
| } | |||||
| return frame | |||||
| } | } | ||||
| func pcm16ToSample(v int16) Sample { | func pcm16ToSample(v int16) Sample { | ||||
| return Sample(float64(v) / 32768.0).Clamp() | |||||
| return Sample(float64(v) / 32768.0).Clamp() | |||||
| } | } | ||||
| @@ -1,52 +1,155 @@ | |||||
| package audio | package audio | ||||
| import ( | import ( | ||||
| "os" | |||||
| "path/filepath" | |||||
| "testing" | |||||
| "encoding/binary" | |||||
| "os" | |||||
| "path/filepath" | |||||
| "testing" | |||||
| ) | ) | ||||
| func TestPCM16ToSample(t *testing.T) { | func TestPCM16ToSample(t *testing.T) { | ||||
| if pcm16ToSample(32767) <= 0 { | |||||
| t.Fatal("expected positive sample") | |||||
| } | |||||
| if pcm16ToSample(-32768) < -1.0 { | |||||
| t.Fatal("expected clamped lower bound") | |||||
| } | |||||
| if pcm16ToSample(32767) <= 0 { | |||||
| t.Fatal("expected positive sample") | |||||
| } | |||||
| if pcm16ToSample(-32768) < -1.0 { | |||||
| t.Fatal("expected clamped lower bound") | |||||
| } | |||||
| } | } | ||||
| func TestLoadWAVSource(t *testing.T) { | func TestLoadWAVSource(t *testing.T) { | ||||
| dir := t.TempDir() | |||||
| path := filepath.Join(dir, "test.wav") | |||||
| wav := []byte{ | |||||
| 'R','I','F','F', 52,0,0,0, 'W','A','V','E', | |||||
| 'f','m','t',' ', 16,0,0,0, 1,0, 1,0, 0x80,0xbb,0x00,0x00, 0x00,0x77,0x01,0x00, 2,0, 16,0, | |||||
| 'd','a','t','a', 8,0,0,0, | |||||
| 0,0, 255,127, 0,128, 0,0, | |||||
| } | |||||
| if err := os.WriteFile(path, wav, 0o644); err != nil { | |||||
| t.Fatalf("write wav: %v", err) | |||||
| } | |||||
| src, err := LoadWAVSource(path) | |||||
| if err != nil { | |||||
| t.Fatalf("LoadWAVSource failed: %v", err) | |||||
| } | |||||
| if src.SampleRate != 48000 { | |||||
| t.Fatalf("unexpected sample rate: %d", src.SampleRate) | |||||
| } | |||||
| if src.Channels != 1 { | |||||
| t.Fatalf("unexpected channels: %d", src.Channels) | |||||
| } | |||||
| _ = src.NextFrame() | |||||
| dir := t.TempDir() | |||||
| path := filepath.Join(dir, "test.wav") | |||||
| wav := buildMinimalWAV(48000, 1, []int16{0, 32767, -32768, 0}) | |||||
| if err := os.WriteFile(path, wav, 0o644); err != nil { | |||||
| t.Fatalf("write wav: %v", err) | |||||
| } | |||||
| src, err := LoadWAVSource(path) | |||||
| if err != nil { | |||||
| t.Fatalf("LoadWAVSource failed: %v", err) | |||||
| } | |||||
| if src.SampleRate != 48000 { | |||||
| t.Fatalf("unexpected sample rate: %d", src.SampleRate) | |||||
| } | |||||
| if src.Channels != 1 { | |||||
| t.Fatalf("unexpected channels: %d", src.Channels) | |||||
| } | |||||
| _ = src.NextFrame() | |||||
| } | |||||
| func TestLoadWAVWithExtraChunks(t *testing.T) { | |||||
| dir := t.TempDir() | |||||
| path := filepath.Join(dir, "extra.wav") | |||||
| // Build a WAV with a LIST chunk between fmt and data | |||||
| wav := buildWAVWithExtraChunks(44100, 1, []int16{100, -100}) | |||||
| if err := os.WriteFile(path, wav, 0o644); err != nil { | |||||
| t.Fatalf("write wav: %v", err) | |||||
| } | |||||
| src, err := LoadWAVSource(path) | |||||
| if err != nil { | |||||
| t.Fatalf("LoadWAVSource with extra chunks failed: %v", err) | |||||
| } | |||||
| if src.SampleRate != 44100 { | |||||
| t.Fatalf("unexpected sample rate: %d", src.SampleRate) | |||||
| } | |||||
| if len(src.frames) != 2 { | |||||
| t.Fatalf("expected 2 frames, got %d", len(src.frames)) | |||||
| } | |||||
| } | } | ||||
| func TestRejectInvalidWAV(t *testing.T) { | func TestRejectInvalidWAV(t *testing.T) { | ||||
| dir := t.TempDir() | |||||
| path := filepath.Join(dir, "bad.wav") | |||||
| if err := os.WriteFile(path, []byte("nope"), 0o644); err != nil { | |||||
| t.Fatalf("write wav: %v", err) | |||||
| } | |||||
| if _, err := LoadWAVSource(path); err == nil { | |||||
| t.Fatal("expected wav load error") | |||||
| } | |||||
| dir := t.TempDir() | |||||
| path := filepath.Join(dir, "bad.wav") | |||||
| if err := os.WriteFile(path, []byte("nope"), 0o644); err != nil { | |||||
| t.Fatalf("write wav: %v", err) | |||||
| } | |||||
| if _, err := LoadWAVSource(path); err == nil { | |||||
| t.Fatal("expected wav load error") | |||||
| } | |||||
| } | |||||
| func TestStereoWAV(t *testing.T) { | |||||
| dir := t.TempDir() | |||||
| path := filepath.Join(dir, "stereo.wav") | |||||
| wav := buildMinimalWAV(48000, 2, []int16{1000, -1000, 2000, -2000}) | |||||
| if err := os.WriteFile(path, wav, 0o644); err != nil { | |||||
| t.Fatalf("write wav: %v", err) | |||||
| } | |||||
| src, err := LoadWAVSource(path) | |||||
| if err != nil { | |||||
| t.Fatalf("LoadWAVSource stereo failed: %v", err) | |||||
| } | |||||
| if src.Channels != 2 { | |||||
| t.Fatalf("expected 2 channels, got %d", src.Channels) | |||||
| } | |||||
| if len(src.frames) != 2 { | |||||
| t.Fatalf("expected 2 frames, got %d", len(src.frames)) | |||||
| } | |||||
| } | |||||
| // -- helpers -- | |||||
| func buildMinimalWAV(sampleRate int, channels int, samples []int16) []byte { | |||||
| dataSize := len(samples) * 2 | |||||
| fileSize := 36 + dataSize | |||||
| buf := make([]byte, 0, 44+dataSize) | |||||
| buf = append(buf, []byte("RIFF")...) | |||||
| buf = binary.LittleEndian.AppendUint32(buf, uint32(fileSize)) | |||||
| buf = append(buf, []byte("WAVE")...) | |||||
| // fmt chunk | |||||
| buf = append(buf, []byte("fmt ")...) | |||||
| buf = binary.LittleEndian.AppendUint32(buf, 16) | |||||
| buf = binary.LittleEndian.AppendUint16(buf, 1) // PCM | |||||
| buf = binary.LittleEndian.AppendUint16(buf, uint16(channels)) | |||||
| buf = binary.LittleEndian.AppendUint32(buf, uint32(sampleRate)) | |||||
| buf = binary.LittleEndian.AppendUint32(buf, uint32(sampleRate*channels*2)) // byte rate | |||||
| buf = binary.LittleEndian.AppendUint16(buf, uint16(channels*2)) // block align | |||||
| buf = binary.LittleEndian.AppendUint16(buf, 16) // bits per sample | |||||
| // data chunk | |||||
| buf = append(buf, []byte("data")...) | |||||
| buf = binary.LittleEndian.AppendUint32(buf, uint32(dataSize)) | |||||
| for _, s := range samples { | |||||
| buf = binary.LittleEndian.AppendUint16(buf, uint16(s)) | |||||
| } | |||||
| return buf | |||||
| } | |||||
| func buildWAVWithExtraChunks(sampleRate int, channels int, samples []int16) []byte { | |||||
| dataSize := len(samples) * 2 | |||||
| // Add a fake LIST chunk of 12 bytes between fmt and data | |||||
| listChunkData := []byte("INFOtest") // 8 bytes | |||||
| listChunkSize := uint32(len(listChunkData)) | |||||
| totalExtraChunk := 8 + len(listChunkData) // "LIST" + size + data | |||||
| fileSize := 36 + totalExtraChunk + dataSize | |||||
| buf := make([]byte, 0, 44+totalExtraChunk+dataSize) | |||||
| buf = append(buf, []byte("RIFF")...) | |||||
| buf = binary.LittleEndian.AppendUint32(buf, uint32(fileSize)) | |||||
| buf = append(buf, []byte("WAVE")...) | |||||
| // fmt chunk | |||||
| buf = append(buf, []byte("fmt ")...) | |||||
| buf = binary.LittleEndian.AppendUint32(buf, 16) | |||||
| buf = binary.LittleEndian.AppendUint16(buf, 1) | |||||
| buf = binary.LittleEndian.AppendUint16(buf, uint16(channels)) | |||||
| buf = binary.LittleEndian.AppendUint32(buf, uint32(sampleRate)) | |||||
| buf = binary.LittleEndian.AppendUint32(buf, uint32(sampleRate*channels*2)) | |||||
| buf = binary.LittleEndian.AppendUint16(buf, uint16(channels*2)) | |||||
| buf = binary.LittleEndian.AppendUint16(buf, 16) | |||||
| // LIST chunk (extra, should be skipped) | |||||
| buf = append(buf, []byte("LIST")...) | |||||
| buf = binary.LittleEndian.AppendUint32(buf, listChunkSize) | |||||
| buf = append(buf, listChunkData...) | |||||
| // data chunk | |||||
| buf = append(buf, []byte("data")...) | |||||
| buf = binary.LittleEndian.AppendUint32(buf, uint32(dataSize)) | |||||
| for _, s := range samples { | |||||
| buf = binary.LittleEndian.AppendUint16(buf, uint16(s)) | |||||
| } | |||||
| return buf | |||||
| } | } | ||||