XU7FTYK3YAM5TADBGEJZ44UJ6LNJ7YVABSFEXKOBGUHXBFHVLR2AC ZTOMARKZZPKZZ3ESL73THF6W74SK7BC7BKWXY5WRDYCISTT5GMQQC N57PNZPFM6QU5FK4SHC3473IV6IN3HRVKSPZSFIJJ5LLCAXICNIAC SJN7IKIVTAZX3ACEWPLFVUT7P2TLB3RQBD4PKC6PEQQ33ECXFJRQC KZKLAINJJWZ64T5MUZT34LJVQIKBTKZ6EJGD7C7TTSSDGCHEDPMAC LBWQJEDHCNUNMEJWXILGBGYZUKQI7CDAMH2BD44HULM77SVH5UYQC FCCJNYCVGOW6WVHYUUQ3RHHKB4QU3S4LU4AINB4VKWBOXLCPXILQC DS22DKV35FHPKEBSCUCE5VYYUSYBFN46YUP2JZCN72YX7SKQRLVQC GVOVKH5R27K75VXGSZCP3X62FGNCSMDVFEKLR3LFXERFB54CHTUQC HYCZTLSZ5WVJFMP4EPVHAVWRYSYNCIBJ62LLBNO4IRVEBY7WJI6QC WKQ7LFTPDGWTPJKRWB6DH5PUCX2HF34UCGJDIPYC5PTDX4MCZJXAC 2HAQZPV377VV26SMPSXSZR6CL7SS2GTNPR5COIAPN47NLJILRQGAC E27ZWCDPESXDEHYZONCAKYL2U4K4ZLVXWX4453ICWSH4TGMQI4KQC GPQSOVBPY7VTPHD75R6VWSNITPOL3AECF4DHJB32MF5Z72NV7YMQC LQLC7S3ADBR4O2JYVUSQJD65U3HG4ADOQBGB4F7KQCXUMNKMNEKAC NS4TDPLNAWJYJN37PZDYXMG6OJSAWZCMTPSPKX73JCLZZAMY25BAC RUVJ3V4N5V4Z3HSH2YYESKQF5G7RIHBFB5TLV2IPDWXSGJDRD54AC KLUEQ6X5CXVBV3KLJKEHWQYHIU6AYPP2WT4PWKM2QZJ7SNACCJ6QC JZRF7OBJNERB4NIB37RSAF3ZK2A4RBWSCFV5OCRXZYVGPSNOWKTAC QVIGQOQZIEXLFMMAA7RTL7MQWI4MC3CH22R6YO6J7LGLHWLCSD4AC 3ETJ6KPIYI23DLXSKISNJSY3DUGHOACE6CPCPF6V7KJK4EXIADQAC YVFPP5VJJSR4EVGOMB5565IZFYAVFNRH37L6AXEUFU5AP6CL7DJAC 3DVPQOKB6BX63XSBIYYCPWBL2RBG3LXZS3XPQBANJP2FWVRAOVZQC NQPVZ3PPQG6EPTTAEHXOXXGK27HZCISHZCOZU6K6RKWTRTOHMY6QC O45G7VX2XBX2JSKKVK42BLU4UG5K77WPZOD2IYLKK5MOTSPXFFMAC package wavimport ("os""path/filepath""testing")func TestWriteWAVFile(t *testing.T) {t.Run("writes valid samples", func(t *testing.T) {path := filepath.Join(t.TempDir(), "test.wav")samples := []float64{0.5, -0.5, 1.5, -1.5} // includes out of bounds for clampingerr := WriteWAVFile(path, samples, 8000)if err != nil {t.Fatalf("unexpected error: %v", err)}info, err := os.Stat(path)if err != nil || info.Size() == 0 {t.Error("expected file to be written with data")}})t.Run("fails on empty samples", func(t *testing.T) {path := filepath.Join(t.TempDir(), "empty.wav")err := WriteWAVFile(path, []float64{}, 8000)if err == nil {t.Error("expected error for empty samples")}})}
package wavimport ("bufio""encoding/binary""fmt""os""skraak/audio")// WriteWAVFile writes audio samples to a WAV file.// Samples should be in the range -1.0 to 1.0.// Output is mono 16-bit PCM.func WriteWAVFile(filepath string, samples []float64, sampleRate int) error {if len(samples) == 0 {return fmt.Errorf("no samples to write")}file, err := os.Create(filepath)if err != nil {return fmt.Errorf("failed to create file: %w", err)}w := bufio.NewWriterSize(file, 64*1024)// Write WAV and flush; check close to ensure data is persisted.err = func() error {// WAV parameterschannels := 1bitsPerSample := 16bytesPerSample := bitsPerSample / 8byteRate := sampleRate * channels * bytesPerSampleblockAlign := channels * bytesPerSampledataSize := len(samples) * bytesPerSampletotalSize := 36 + dataSize // 36 = header size before data chunk// Write 44-byte WAV header in one goheader := make([]byte, 44)copy(header[0:4], "RIFF")binary.LittleEndian.PutUint32(header[4:8], uint32(totalSize))copy(header[8:12], "WAVE")copy(header[12:16], "fmt ")binary.LittleEndian.PutUint32(header[16:20], 16) // chunk sizebinary.LittleEndian.PutUint16(header[20:22], 1) // PCM formatbinary.LittleEndian.PutUint16(header[22:24], uint16(channels))binary.LittleEndian.PutUint32(header[24:28], uint32(sampleRate))binary.LittleEndian.PutUint32(header[28:32], uint32(byteRate))binary.LittleEndian.PutUint16(header[32:34], uint16(blockAlign))binary.LittleEndian.PutUint16(header[34:36], uint16(bitsPerSample))copy(header[36:40], "data")binary.LittleEndian.PutUint32(header[40:44], uint32(dataSize))if _, err := w.Write(header); err != nil {return err}// Convert all float64 samples to 16-bit PCM in a single bufferbuf := audio.Float64ToPCM16(samples)if _, err := w.Write(buf); err != nil {return err}return w.Flush()}()if err2 := file.Close(); err2 != nil {if err == nil {err = fmt.Errorf("failed to close file: %w", err2)}}return err}
package wavimport ("bytes""encoding/binary""fmt""math""os""path/filepath""testing""time")// createTestWAVFile creates a minimal valid WAV file for testingfunc createTestWAVFile(t *testing.T, dir string, filename string, options struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}) string {t.Helper()path := filepath.Join(dir, filename)file, err := os.Create(path)if err != nil {t.Fatalf("Failed to create test file: %v", err)}defer file.Close()// Calculate data chunk size based on durationbytesPerSample := options.bitsPerSample / 8samplesPerSecond := options.sampleRate * options.channelsdataSize := int(options.duration * float64(samplesPerSecond*bytesPerSample))// Calculate file size (excluding RIFF header)fileSize := 4 + 8 + 16 + 8 + dataSize // WAVE + fmt chunk + data chunk header// Add LIST INFO chunk size if metadata providedvar infoChunk []byteif options.comment != "" || options.artist != "" {infoChunk = buildINFOChunk(options.comment, options.artist)fileSize += 8 + len(infoChunk) // LIST chunk header + content}buf := &bytes.Buffer{}// Write RIFF headerbuf.WriteString("RIFF")binary.Write(buf, binary.LittleEndian, uint32(fileSize))buf.WriteString("WAVE")// Write fmt chunkbuf.WriteString("fmt ")binary.Write(buf, binary.LittleEndian, uint32(16)) // chunk sizebinary.Write(buf, binary.LittleEndian, uint16(1)) // audio format (PCM)binary.Write(buf, binary.LittleEndian, uint16(options.channels))binary.Write(buf, binary.LittleEndian, uint32(options.sampleRate))byteRate := options.sampleRate * options.channels * bytesPerSamplebinary.Write(buf, binary.LittleEndian, uint32(byteRate))blockAlign := options.channels * bytesPerSamplebinary.Write(buf, binary.LittleEndian, uint16(blockAlign))binary.Write(buf, binary.LittleEndian, uint16(options.bitsPerSample))// Write LIST INFO chunk if metadata providedif len(infoChunk) > 0 {buf.WriteString("LIST")binary.Write(buf, binary.LittleEndian, uint32(len(infoChunk)))buf.Write(infoChunk)}// Write data chunkbuf.WriteString("data")binary.Write(buf, binary.LittleEndian, uint32(dataSize))// Write silence for databuf.Write(make([]byte, dataSize))// Write to fileif _, err := file.Write(buf.Bytes()); err != nil {t.Fatalf("Failed to write test file: %v", err)}return path}// buildINFOChunk builds a LIST INFO chunk with optional comment and artistfunc buildINFOChunk(comment, artist string) []byte {buf := &bytes.Buffer{}buf.WriteString("INFO")if comment != "" {buf.WriteString("ICMT")// Size includes null terminatorsize := len(comment) + 1binary.Write(buf, binary.LittleEndian, uint32(size))buf.WriteString(comment)buf.WriteByte(0) // null terminator// Add padding byte if needed for word alignmentif size%2 != 0 {buf.WriteByte(0)}}if artist != "" {buf.WriteString("IART")size := len(artist) + 1binary.Write(buf, binary.LittleEndian, uint32(size))buf.WriteString(artist)buf.WriteByte(0) // null terminatorif size%2 != 0 {buf.WriteByte(0)}}return buf.Bytes()}func TestConvertToFloat64_16Bit(t *testing.T) {// 16-bit signed, little-endian: 0x0001 = 1 → 1/32768data := []byte{0x01, 0x00, 0xFF, 0x7F}samples := convertToFloat64(data, 16, 1)if len(samples) != 2 {t.Fatalf("expected 2 samples, got %d", len(samples))}if math.Abs(samples[0]-1.0/32768.0) > 1e-10 {t.Errorf("sample[0] = %v, want %v", samples[0], 1.0/32768.0)}// 0x7FFF = 32767 → 32767/32768 ≈ 0.99997if math.Abs(samples[1]-32767.0/32768.0) > 1e-10 {t.Errorf("sample[1] = %v, want %v", samples[1], 32767.0/32768.0)}}func TestConvertToFloat64_16BitNegative(t *testing.T) {// 0x8000 = -32768 → -32768/32768 = -1.0data := []byte{0x00, 0x80}samples := convertToFloat64(data, 16, 1)if len(samples) != 1 {t.Fatalf("expected 1 sample, got %d", len(samples))}if math.Abs(samples[0]-(-1.0)) > 1e-10 {t.Errorf("sample = %v, want -1.0", samples[0])}}func TestConvertToFloat64_16BitStereo(t *testing.T) {// Stereo: should extract only left channel// Left: 0x0001 = 1, Right: 0x0002 = 2data := []byte{0x01, 0x00, 0x02, 0x00}samples := convertToFloat64(data, 16, 2)if len(samples) != 1 {t.Fatalf("expected 1 sample (left only), got %d", len(samples))}if math.Abs(samples[0]-1.0/32768.0) > 1e-10 {t.Errorf("sample = %v, want %v", samples[0], 1.0/32768.0)}}func TestConvertToFloat64_24Bit(t *testing.T) {// 24-bit signed, little-endian: 0x000001 = 1 → 1/8388608data := []byte{0x01, 0x00, 0x00}samples := convertToFloat64(data, 24, 1)if len(samples) != 1 {t.Fatalf("expected 1 sample, got %d", len(samples))}if math.Abs(samples[0]-1.0/8388608.0) > 1e-12 {t.Errorf("sample = %v, want %v", samples[0], 1.0/8388608.0)}}func TestConvertToFloat64_24BitNegative(t *testing.T) {// 24-bit: 0xFFFFFF = -1 (sign extended) → -1/8388608data := []byte{0xFF, 0xFF, 0xFF}samples := convertToFloat64(data, 24, 1)if len(samples) != 1 {t.Fatalf("expected 1 sample, got %d", len(samples))}if math.Abs(samples[0]-(-1.0/8388608.0)) > 1e-12 {t.Errorf("sample = %v, want %v", samples[0], -1.0/8388608.0)}}func TestConvertToFloat64_24BitMaxPositive(t *testing.T) {// 24-bit max positive: 0x7FFFFF = 8388607 → ~0.9999999data := []byte{0xFF, 0xFF, 0x7F}samples := convertToFloat64(data, 24, 1)if len(samples) != 1 {t.Fatalf("expected 1 sample, got %d", len(samples))}if math.Abs(samples[0]-8388607.0/8388608.0) > 1e-12 {t.Errorf("sample = %v, want %v", samples[0], 8388607.0/8388608.0)}}func TestConvertToFloat64_24BitMinNegative(t *testing.T) {// 24-bit min negative: 0x800000 = -8388608 → -1.0data := []byte{0x00, 0x00, 0x80}samples := convertToFloat64(data, 24, 1)if len(samples) != 1 {t.Fatalf("expected 1 sample, got %d", len(samples))}if math.Abs(samples[0]-(-1.0)) > 1e-12 {t.Errorf("sample = %v, want -1.0", samples[0])}}func TestConvertToFloat64_24BitStereo(t *testing.T) {// 24-bit stereo: left=0x000001=1, right=0x000002=2data := []byte{0x01, 0x00, 0x00, 0x02, 0x00, 0x00}samples := convertToFloat64(data, 24, 2)if len(samples) != 1 {t.Fatalf("expected 1 sample (left only), got %d", len(samples))}if math.Abs(samples[0]-1.0/8388608.0) > 1e-12 {t.Errorf("sample = %v, want %v", samples[0], 1.0/8388608.0)}}func TestConvertToFloat64_32Bit(t *testing.T) {// 32-bit signed, little-endian: 0x00000001 = 1 → 1/2147483648data := []byte{0x01, 0x00, 0x00, 0x00}samples := convertToFloat64(data, 32, 1)if len(samples) != 1 {t.Fatalf("expected 1 sample, got %d", len(samples))}if math.Abs(samples[0]-1.0/2147483648.0) > 1e-15 {t.Errorf("sample = %v, want %v", samples[0], 1.0/2147483648.0)}}func TestConvertToFloat64_32BitNegative(t *testing.T) {// 32-bit: 0x80000000 = -2147483648 → -1.0data := []byte{0x00, 0x00, 0x00, 0x80}samples := convertToFloat64(data, 32, 1)if len(samples) != 1 {t.Fatalf("expected 1 sample, got %d", len(samples))}if math.Abs(samples[0]-(-1.0)) > 1e-15 {t.Errorf("sample = %v, want -1.0", samples[0])}}func TestConvertToFloat64_32BitMaxPositive(t *testing.T) {// 32-bit max positive: 0x7FFFFFFF = 2147483647 → ~0.9999999995data := []byte{0xFF, 0xFF, 0xFF, 0x7F}samples := convertToFloat64(data, 32, 1)if len(samples) != 1 {t.Fatalf("expected 1 sample, got %d", len(samples))}if math.Abs(samples[0]-2147483647.0/2147483648.0) > 1e-15 {t.Errorf("sample = %v, want %v", samples[0], 2147483647.0/2147483648.0)}}func TestConvertToFloat64_32BitStereo(t *testing.T) {// 32-bit stereo: left=1, right=2data := []byte{0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00}samples := convertToFloat64(data, 32, 2)if len(samples) != 1 {t.Fatalf("expected 1 sample (left only), got %d", len(samples))}if math.Abs(samples[0]-1.0/2147483648.0) > 1e-15 {t.Errorf("sample = %v, want %v", samples[0], 1.0/2147483648.0)}}func TestConvertToFloat64_UnsupportedBitDepth(t *testing.T) {// Unsupported bit depth (8) falls back to 16-bit parsing.// blockAlign = (8/8)*1 = 1, so numSamples = len(data)/1 = 4.// But 16-bit reads 2 bytes per sample, which panics with only 1 byte per block.// This tests the fallback path exists; for valid 8-bit data, blockAlign// must be 2 (so 2 bytes per sample in the data layout).data := []byte{0x01, 0x00, 0x02, 0x00} // 4 bytes, treated as 2x 16-bit samplessamples := convertToFloat64(data, 8, 2) // 2 channels → blockAlign = 1*2 = 2if len(samples) != 2 {t.Fatalf("expected 2 samples (fallback 16-bit), got %d", len(samples))}}func TestConvertToFloat64_EmptyData(t *testing.T) {samples := convertToFloat64([]byte{}, 16, 1)if len(samples) != 0 {t.Errorf("expected 0 samples, got %d", len(samples))}}func TestParseWAVHeader_BasicMetadata(t *testing.T) {tmpDir := t.TempDir()path := createTestWAVFile(t, tmpDir, "test_basic.wav", struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 60.0,sampleRate: 44100,channels: 2,bitsPerSample: 16,comment: "",artist: "",})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.SampleRate != 44100 {t.Errorf("SampleRate incorrect: got %d, want 44100", metadata.SampleRate)}if metadata.Channels != 2 {t.Errorf("Channels incorrect: got %d, want 2", metadata.Channels)}if metadata.BitsPerSample != 16 {t.Errorf("BitsPerSample incorrect: got %d, want 16", metadata.BitsPerSample)}if metadata.Duration < 59.9 || metadata.Duration > 60.1 {t.Errorf("Duration incorrect: got %f, want ~60.0", metadata.Duration)}}func TestParseWAVHeader_CommentMetadata(t *testing.T) {tmpDir := t.TempDir()expectedComment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549"path := createTestWAVFile(t, tmpDir, "test_comment.wav", struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 10.0,sampleRate: 48000,channels: 1,bitsPerSample: 16,comment: expectedComment,artist: "",})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.Comment != expectedComment {t.Errorf("Comment incorrect: got %q, want %q", metadata.Comment, expectedComment)}}func TestParseWAVHeader_ArtistMetadata(t *testing.T) {tmpDir := t.TempDir()expectedArtist := "AudioMoth"path := createTestWAVFile(t, tmpDir, "test_artist.wav", struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 5.0,sampleRate: 48000,channels: 1,bitsPerSample: 16,comment: "",artist: expectedArtist,})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.Artist != expectedArtist {t.Errorf("Artist incorrect: got %q, want %q", metadata.Artist, expectedArtist)}}func TestParseWAVHeader_CommentAndArtist(t *testing.T) {tmpDir := t.TempDir()expectedComment := "Test recording comment"expectedArtist := "Test Artist"path := createTestWAVFile(t, tmpDir, "test_both.wav", struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 15.0,sampleRate: 44100,channels: 2,bitsPerSample: 16,comment: expectedComment,artist: expectedArtist,})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.Comment != expectedComment {t.Errorf("Comment incorrect: got %q, want %q", metadata.Comment, expectedComment)}if metadata.Artist != expectedArtist {t.Errorf("Artist incorrect: got %q, want %q", metadata.Artist, expectedArtist)}}func TestParseWAVHeader_SampleRates(t *testing.T) {tmpDir := t.TempDir()testCases := []int{8000, 16000, 22050, 44100, 48000, 96000}for _, sr := range testCases {t.Run(fmt.Sprintf("%dHz", sr), func(t *testing.T) {path := createTestWAVFile(t, tmpDir, fmt.Sprintf("test_sr_%d.wav", sr), struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 1.0,sampleRate: sr,channels: 1,bitsPerSample: 16,comment: "",artist: "",})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.SampleRate != sr {t.Errorf("SampleRate incorrect: got %d, want %d", metadata.SampleRate, sr)}})}}func TestParseWAVHeader_ChannelCounts(t *testing.T) {tmpDir := t.TempDir()testCases := []int{1, 2}for _, ch := range testCases {t.Run(fmt.Sprintf("%dch", ch), func(t *testing.T) {path := createTestWAVFile(t, tmpDir, fmt.Sprintf("test_ch_%d.wav", ch), struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 1.0,sampleRate: 44100,channels: ch,bitsPerSample: 16,comment: "",artist: "",})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.Channels != ch {t.Errorf("Channels incorrect: got %d, want %d", metadata.Channels, ch)}})}}func TestParseWAVHeader_BitDepths(t *testing.T) {tmpDir := t.TempDir()testCases := []int{8, 16, 24, 32}for _, bits := range testCases {t.Run(fmt.Sprintf("%dbit", bits), func(t *testing.T) {path := createTestWAVFile(t, tmpDir, fmt.Sprintf("test_bits_%d.wav", bits), struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 1.0,sampleRate: 44100,channels: 1,bitsPerSample: bits,comment: "",artist: "",})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.BitsPerSample != bits {t.Errorf("BitsPerSample incorrect: got %d, want %d", metadata.BitsPerSample, bits)}})}}func TestParseWAVHeader_ShortDuration(t *testing.T) {tmpDir := t.TempDir()path := createTestWAVFile(t, tmpDir, "test_short.wav", struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 0.1, // 100mssampleRate: 44100,channels: 1,bitsPerSample: 16,comment: "",artist: "",})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.Duration < 0.09 || metadata.Duration > 0.11 {t.Errorf("Duration incorrect: got %f, want ~0.1", metadata.Duration)}}func TestParseWAVHeader_LongDuration(t *testing.T) {tmpDir := t.TempDir()path := createTestWAVFile(t, tmpDir, "test_long.wav", struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 600.0, // 10 minutessampleRate: 44100,channels: 1,bitsPerSample: 16,comment: "",artist: "",})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.Duration < 599.0 || metadata.Duration > 601.0 {t.Errorf("Duration incorrect: got %f, want ~600.0", metadata.Duration)}}func TestParseWAVHeader_NonExistentFile(t *testing.T) {_, err := ParseWAVHeader("/nonexistent/file.wav")if err == nil {t.Error("Expected error for non-existent file")}}func TestParseWAVHeader_NonWAVFile(t *testing.T) {tmpDir := t.TempDir()path := filepath.Join(tmpDir, "not_a_wav.txt")if err := os.WriteFile(path, []byte("This is not a WAV file"), 0644); err != nil {t.Fatalf("Failed to create test file: %v", err)}_, err := ParseWAVHeader(path)if err == nil {t.Error("Expected error for non-WAV file")}}func TestParseWAVHeader_TruncatedFile(t *testing.T) {tmpDir := t.TempDir()path := filepath.Join(tmpDir, "truncated.wav")if err := os.WriteFile(path, []byte("RIFF"), 0644); err != nil {t.Fatalf("Failed to create test file: %v", err)}_, err := ParseWAVHeader(path)if err == nil {t.Error("Expected error for truncated file")}}func TestParseWAVHeader_EmptyMetadataStrings(t *testing.T) {tmpDir := t.TempDir()path := createTestWAVFile(t, tmpDir, "test_empty.wav", struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 10.0,sampleRate: 44100,channels: 1,bitsPerSample: 16,comment: "",artist: "",})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.Comment != "" {t.Errorf("Comment should be empty, got %q", metadata.Comment)}if metadata.Artist != "" {t.Errorf("Artist should be empty, got %q", metadata.Artist)}}func TestParseWAVHeader_LongCommentString(t *testing.T) {tmpDir := t.TempDir()longComment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C. This is a very long comment with additional information about the recording session."path := createTestWAVFile(t, tmpDir, "test_long_comment.wav", struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 10.0,sampleRate: 44100,channels: 1,bitsPerSample: 16,comment: longComment,artist: "",})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.Comment != longComment {t.Errorf("Comment incorrect: got %q, want %q", metadata.Comment, longComment)}}func TestParseWAVHeader_FileModTime(t *testing.T) {tmpDir := t.TempDir()path := createTestWAVFile(t, tmpDir, "test_modtime.wav", struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 5.0,sampleRate: 44100,channels: 1,bitsPerSample: 16,comment: "",artist: "",})info, err := os.Stat(path)if err != nil {t.Fatalf("Failed to stat file: %v", err)}expectedModTime := info.ModTime()metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}// Allow 1 second tolerance for filesystem granularitydiff := metadata.FileModTime.Sub(expectedModTime)if diff < -1*time.Second || diff > 1*time.Second {t.Errorf("FileModTime incorrect: got %v, want %v (diff: %v)",metadata.FileModTime, expectedModTime, diff)}if metadata.FileModTime.IsZero() {t.Error("FileModTime should not be zero")}}func TestExtractNullTerminatedString(t *testing.T) {testCases := []struct {name stringinput []byteexpected string}{{name: "string with null terminator",input: []byte{'h', 'e', 'l', 'l', 'o', 0, 'w', 'o', 'r', 'l', 'd'},expected: "hello",},{name: "string without null terminator",input: []byte{'h', 'e', 'l', 'l', 'o'},expected: "hello",},{name: "empty string",input: []byte{},expected: "",},{name: "only null terminator",input: []byte{0},expected: "",},}for _, tc := range testCases {t.Run(tc.name, func(t *testing.T) {result := extractNullTerminatedString(tc.input)if result != tc.expected {t.Errorf("Result incorrect: got %q, want %q", result, tc.expected)}})}}// assertWAVHeader creates a WAV file and verifies ParseWAVHeaderMinimal returns expected values.func assertWAVHeader(t *testing.T, tmpDir, filename string, wantSR int, wantDur float64, opts struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}) {t.Helper()path := createTestWAVFile(t, tmpDir, filename, opts)sr, dur, err := ParseWAVHeaderMinimal(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if sr != wantSR {t.Errorf("SampleRate: got %d, want %d", sr, wantSR)}if dur < wantDur-0.1 || dur > wantDur+0.1 {t.Errorf("Duration: got %f, want ~%f", dur, wantDur)}}func TestParseWAVHeaderMinimal(t *testing.T) {tmpDir := t.TempDir()t.Run("basic", func(t *testing.T) {assertWAVHeader(t, tmpDir, "test_minimal.wav", 44100, 10.0, struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{10.0, 44100, 1, 16, "", ""})})t.Run("sample_rates", func(t *testing.T) {for _, sr := range []int{8000, 22050, 44100, 48000, 96000} {t.Run(fmt.Sprintf("%dHz", sr), func(t *testing.T) {assertWAVHeader(t, tmpDir, fmt.Sprintf("test_sr_%d.wav", sr), sr, 5.0, struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{5.0, sr, 1, 16, "", ""})})}})t.Run("stereo", func(t *testing.T) {assertWAVHeader(t, tmpDir, "test_stereo.wav", 44100, 3.0, struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{3.0, 44100, 2, 16, "", ""})})t.Run("nonexistent", func(t *testing.T) {_, _, err := ParseWAVHeaderMinimal("/nonexistent/file.wav")if err == nil {t.Error("Expected error for non-existent file")}})t.Run("non_wav", func(t *testing.T) {path := filepath.Join(tmpDir, "notawav.wav")if err := os.WriteFile(path, []byte("Not a WAV file"), 0644); err != nil {t.Fatalf("Failed to create test file: %v", err)}_, _, err := ParseWAVHeaderMinimal(path)if err == nil {t.Error("Expected error for non-WAV file")}})}
package wavimport ("bytes""encoding/binary""fmt""io""os""sync""time""github.com/cespare/xxhash/v2")// Buffer pools for reducing GC pressure during batch importsvar (// headerBufferPool stores 200KB buffers for WAV header reading (full metadata)headerBufferPool = sync.Pool{New: func() any {buf := make([]byte, 200*1024)return &buf},}// minimalHeaderBufferPool stores 4KB buffers for minimal WAV header reading// 4KB is sufficient for fmt + data chunk headers in 99% of WAV filesminimalHeaderBufferPool = sync.Pool{New: func() any {buf := make([]byte, 4*1024)return &buf},}// hashBufferPool stores 128KB buffers for hashing WAV fileshashBufferPool = sync.Pool{New: func() any {buf := make([]byte, 128*1024)return &buf},})// getHeaderBuffer gets a 200KB buffer from the poolfunc getHeaderBuffer() *[]byte {return headerBufferPool.Get().(*[]byte)}// putHeaderBuffer returns a 200KB buffer to the poolfunc putHeaderBuffer(buf *[]byte) {headerBufferPool.Put(buf)}// getMinimalHeaderBuffer gets a 4KB buffer from the poolfunc getMinimalHeaderBuffer() *[]byte {return minimalHeaderBufferPool.Get().(*[]byte)}// putMinimalHeaderBuffer returns a 4KB buffer to the poolfunc putMinimalHeaderBuffer(buf *[]byte) {minimalHeaderBufferPool.Put(buf)}// getHashBuffer gets a 128KB buffer from the poolfunc getHashBuffer() *[]byte {return hashBufferPool.Get().(*[]byte)}// putHashBuffer returns a 128KB buffer to the poolfunc putHashBuffer(buf *[]byte) {hashBufferPool.Put(buf)}// WAVMetadata contains metadata extracted from WAV file headerstype WAVMetadata struct {Duration float64 // Duration in secondsSampleRate int // Sample rate in HzComment string // Comment from INFO chunk (may contain AudioMoth data)Artist string // Artist from INFO chunkChannels int // Number of audio channelsBitsPerSample int // Bits per sampleFileModTime time.Time // File modification time (fallback timestamp)FileSize int64 // File size in bytes}// readAndParseHeader opens a WAV file, reads its header using the provided buffer pool,// parses metadata, and sets file modification time and size.func readAndParseHeader(filepath string, getBuf func() *[]byte, putBuf func(*[]byte)) (*WAVMetadata, error) {file, err := os.Open(filepath)if err != nil {return nil, fmt.Errorf("failed to open file: %w", err)}defer func() { _ = file.Close() }()fileInfo, err := file.Stat()if err != nil {return nil, fmt.Errorf("failed to get file info: %w", err)}bufPtr := getBuf()defer putBuf(bufPtr)buf := (*bufPtr)[:cap(*bufPtr)]n, err := file.Read(buf)if err != nil && err != io.EOF {return nil, fmt.Errorf("failed to read header: %w", err)}buf = buf[:n]metadata, err := parseWAVFromBytes(buf)if err != nil {return nil, err}metadata.FileModTime = fileInfo.ModTime()metadata.FileSize = fileInfo.Size()return metadata, nil}// ParseWAVHeader efficiently reads only the WAV file header to extract metadata.// It reads the first 200KB of the file, which should be sufficient for all header chunks.func ParseWAVHeader(filepath string) (*WAVMetadata, error) {return readAndParseHeader(filepath, getHeaderBuffer, putHeaderBuffer)}// ParseWAVHeaderMinimal reads only the first 4KB of a WAV file to extract essential metadata.// This is optimized for batch processing where INFO chunks (comment/artist) are not needed.// It's ~50x faster than ParseWAVHeader for large files due to reduced I/O.// Returns (sampleRate, duration, error) - the minimal data needed for .data file generation.func ParseWAVHeaderMinimal(filepath string) (sampleRate int, duration float64, err error) {metadata, err := readAndParseHeader(filepath, getMinimalHeaderBuffer, putMinimalHeaderBuffer)if err != nil {return 0, 0, err}return metadata.SampleRate, metadata.Duration, nil}// ParseWAVHeaderWithHash reads the WAV file once to extract both metadata and hash.// This is more efficient than calling ParseWAVHeader and ComputeXXH64 separately,// as it only opens the file once and reads it in a single pass.// Returns (metadata, hash, error).func ParseWAVHeaderWithHash(filepath string) (*WAVMetadata, string, error) {file, err := os.Open(filepath)if err != nil {return nil, "", fmt.Errorf("failed to open file: %w", err)}defer func() { _ = file.Close() }()fileInfo, err := file.Stat()if err != nil {return nil, "", fmt.Errorf("failed to get file info: %w", err)}headerBufPtr := getHeaderBuffer()defer putHeaderBuffer(headerBufPtr)headerBuf := (*headerBufPtr)[:cap(*headerBufPtr)]n, err := file.Read(headerBuf)if err != nil && err != io.EOF {return nil, "", fmt.Errorf("failed to read header: %w", err)}headerBuf = headerBuf[:n]metadata, err := parseWAVFromBytes(headerBuf)if err != nil {return nil, "", err}metadata.FileModTime = fileInfo.ModTime()metadata.FileSize = fileInfo.Size()// Hash: seek back to start and stream entire fileif _, err := file.Seek(0, 0); err != nil {return nil, "", fmt.Errorf("failed to seek: %w", err)}hashBufPtr := getHashBuffer()defer putHashBuffer(hashBufPtr)hashBuf := *hashBufPtrh := xxhash.New()if _, err := io.CopyBuffer(h, file, hashBuf); err != nil {return nil, "", fmt.Errorf("failed to read file for hash: %w", err)}hash := fmt.Sprintf("%016x", h.Sum64())return metadata, hash, nil}// parseWAVFromBytes parses WAV metadata from a byte bufferfunc parseWAVFromBytes(data []byte) (*WAVMetadata, error) {if len(data) < 44 {return nil, fmt.Errorf("file too small to be valid WAV")}if string(data[0:4]) != "RIFF" {return nil, fmt.Errorf("not a valid WAV file (missing RIFF header)")}if string(data[8:12]) != "WAVE" {return nil, fmt.Errorf("not a valid WAV file (missing WAVE format)")}metadata := &WAVMetadata{}offset := 12for offset < len(data)-8 {chunkID := string(data[offset : offset+4])chunkSize := int(binary.LittleEndian.Uint32(data[offset+4 : offset+8]))offset += 8switch chunkID {case "fmt ":parseFmtChunkData(data[offset:], chunkSize, metadata)case "data":calcDataChunkDuration(chunkSize, metadata)case "LIST":parseLISTChunkData(data[offset:], chunkSize, metadata)}offset += chunkSizeif chunkSize%2 != 0 {offset++}}if metadata.SampleRate == 0 {return nil, fmt.Errorf("invalid WAV file: missing or corrupt fmt chunk")}if metadata.Duration == 0 {return nil, fmt.Errorf("invalid WAV file: missing or corrupt data chunk")}return metadata, nil}// parseFmtChunkData extracts format info from a fmt chunk.func parseFmtChunkData(data []byte, chunkSize int, m *WAVMetadata) {if chunkSize >= 16 && len(data) >= 16 {m.Channels = int(binary.LittleEndian.Uint16(data[2:4]))m.SampleRate = int(binary.LittleEndian.Uint32(data[4:8]))m.BitsPerSample = int(binary.LittleEndian.Uint16(data[14:16]))}}// calcDataChunkDuration computes duration from the data chunk size.func calcDataChunkDuration(chunkSize int, m *WAVMetadata) {if m.SampleRate > 0 && m.Channels > 0 && m.BitsPerSample > 0 {bytesPerSample := m.BitsPerSample / 8bytesPerSecond := m.SampleRate * m.Channels * bytesPerSampleif bytesPerSecond > 0 {m.Duration = float64(chunkSize) / float64(bytesPerSecond)}}}// parseLISTChunkData parses a LIST chunk for INFO metadata.func parseLISTChunkData(data []byte, chunkSize int, m *WAVMetadata) {if chunkSize >= 4 && len(data) >= chunkSize {if string(data[:4]) == "INFO" {parseINFOChunk(data[4:chunkSize], m)}}}// parseINFOChunk parses INFO list chunk for comment and artist metadatafunc parseINFOChunk(data []byte, metadata *WAVMetadata) {offset := 0for offset < len(data)-8 {if offset+8 > len(data) {break}subchunkID := string(data[offset : offset+4])subchunkSize := int(binary.LittleEndian.Uint32(data[offset+4 : offset+8]))offset += 8if offset+subchunkSize > len(data) {break}value := extractNullTerminatedString(data[offset : offset+subchunkSize])switch subchunkID {case "ICMT":metadata.Comment = valuecase "IART":metadata.Artist = value}offset += subchunkSizeif subchunkSize%2 != 0 {offset++}}}// extractNullTerminatedString extracts a null-terminated string from bytesfunc extractNullTerminatedString(data []byte) string {before, _, ok := bytes.Cut(data, []byte{0})if ok {return string(before)}return string(data)}// wavChunkInfo holds parsed WAV format and data chunk locations.type wavChunkInfo struct {sampleRate intchannels intbitsPerSample intdataOffset int64dataSize int64}// parseWAVChunks reads WAV chunks from the current file position, returning// format info and data chunk location. Returns error if no data chunk is found.func parseWAVChunks(file *os.File) (wavChunkInfo, error) {var info wavChunkInfofor {chunkHeader := make([]byte, 8)if _, err := io.ReadFull(file, chunkHeader); err != nil {if err == io.EOF {break}return info, fmt.Errorf("failed to read chunk header: %w", err)}chunkID := string(chunkHeader[0:4])chunkSize := int64(binary.LittleEndian.Uint32(chunkHeader[4:8]))switch chunkID {case "fmt ":fmtData := make([]byte, chunkSize)if _, err := io.ReadFull(file, fmtData); err != nil {return info, fmt.Errorf("failed to read fmt chunk: %w", err)}if len(fmtData) >= 16 {info.channels = int(binary.LittleEndian.Uint16(fmtData[2:4]))info.sampleRate = int(binary.LittleEndian.Uint32(fmtData[4:8]))info.bitsPerSample = int(binary.LittleEndian.Uint16(fmtData[14:16]))}case "data":info.dataOffset, _ = file.Seek(0, io.SeekCurrent)info.dataSize = chunkSizereturn info, nildefault:if _, err := file.Seek(chunkSize, io.SeekCurrent); err != nil {return info, fmt.Errorf("failed to skip chunk: %w", err)}}if chunkSize%2 != 0 {if _, err := file.Seek(1, io.SeekCurrent); err != nil {return info, fmt.Errorf("failed to skip padding: %w", err)}}}return info, fmt.Errorf("no data chunk found in WAV file")}// calcWAVReadRange computes the byte offset and size to read from the data chunk.func calcWAVReadRange(startSec, endSec float64, info wavChunkInfo) (startOffset, readSize int64) {bytesPerSample := info.bitsPerSample / 8blockAlign := bytesPerSample * info.channelsif startSec > 0 {startSample := int64(startSec * float64(info.sampleRate))startOffset = min(startSample*int64(blockAlign), info.dataSize)}if endSec > 0 {endSample := int64(endSec * float64(info.sampleRate))endOffset := min(endSample*int64(blockAlign), info.dataSize)if endOffset > startOffset {readSize = endOffset - startOffset}} else {readSize = info.dataSize - startOffset}return}// parseWAVInfo opens a WAV file, validates its header, and parses chunks.// Returns the parsed chunk info and the open file (caller must close).func parseWAVInfo(filepath string) (f *os.File, info wavChunkInfo, err error) {f, err = os.Open(filepath)if err != nil {return nil, wavChunkInfo{}, fmt.Errorf("failed to open file: %w", err)}defer func() {if err != nil {_ = f.Close()}}()headerBuf := make([]byte, 44)if _, err = io.ReadFull(f, headerBuf); err != nil {return nil, wavChunkInfo{}, fmt.Errorf("failed to read header: %w", err)}if string(headerBuf[0:4]) != "RIFF" || string(headerBuf[8:12]) != "WAVE" {return nil, wavChunkInfo{}, fmt.Errorf("not a valid WAV file")}if _, err = f.Seek(12, 0); err != nil {return nil, wavChunkInfo{}, fmt.Errorf("failed to seek: %w", err)}info, err = parseWAVChunks(f)if err != nil {return nil, wavChunkInfo{}, err}if info.sampleRate == 0 || info.channels == 0 || info.bitsPerSample == 0 {return nil, wavChunkInfo{}, fmt.Errorf("missing or invalid fmt chunk")}return f, info, nil}// readAudioSegment reads audio bytes from an already-parsed WAV file.func readAudioSegment(file *os.File, info wavChunkInfo, startOffset, readSize int64) ([]byte, error) {if readSize == 0 {return nil, nil}if _, err := file.Seek(info.dataOffset+startOffset, io.SeekStart); err != nil {return nil, fmt.Errorf("failed to seek to data segment: %w", err)}audioData := make([]byte, readSize)if _, err := io.ReadFull(file, audioData); err != nil {if err != io.EOF && err != io.ErrUnexpectedEOF {return nil, fmt.Errorf("failed to read audio data: %w", err)}}return audioData, nil}// ReadWAVSegmentSamples reads a specific time range of audio samples from a WAV file.// If startSec < 0, it starts from 0.// If endSec <= 0 or endSec > duration, it reads to the end.func ReadWAVSegmentSamples(filepath string, startSec, endSec float64) ([]float64, int, error) {file, info, err := parseWAVInfo(filepath)if err != nil {return nil, 0, err}defer func() { _ = file.Close() }()startOffset, readSize := calcWAVReadRange(startSec, endSec, info)audioData, err := readAudioSegment(file, info, startOffset, readSize)if err != nil {return nil, 0, err}if readSize == 0 {return []float64{}, info.sampleRate, nil}samples := convertToFloat64(audioData, info.bitsPerSample, info.channels)return samples, info.sampleRate, nil}// ReadWAVSamples reads audio samples from a WAV file and returns them as float64.// Mono files: returns single channel.// Stereo files: returns left channel only.// Samples are normalized to the range -1.0 to 1.0.func ReadWAVSamples(filepath string) ([]float64, int, error) {return ReadWAVSegmentSamples(filepath, 0, 0)}// convertToFloat64 converts raw audio bytes to float64 samples// Returns mono (left channel only for stereo)func convertToFloat64(data []byte, bitsPerSample, channels int) []float64 {bytesPerSample := bitsPerSample / 8blockAlign := bytesPerSample * channelsnumSamples := len(data) / blockAlignsamples := make([]float64, numSamples)switch bitsPerSample {case 16:for i := range numSamples {offset := i * blockAlignsample := int16(binary.LittleEndian.Uint16(data[offset : offset+2]))samples[i] = float64(sample) / 32768.0}case 24:for i := range numSamples {offset := i * blockAlignb := data[offset : offset+3]sample := int32(b[0]) | int32(b[1])<<8 | int32(b[2])<<16if sample >= 0x800000 {sample -= 0x1000000}samples[i] = float64(sample) / 8388608.0}case 32:for i := range numSamples {offset := i * blockAlignsample := int32(binary.LittleEndian.Uint32(data[offset : offset+4]))samples[i] = float64(sample) / 2147483648.0}default:for i := range numSamples {offset := i * blockAlignsample := int16(binary.LittleEndian.Uint16(data[offset : offset+2]))samples[i] = float64(sample) / 32768.0}}return samples}
package wavimport ("strings""testing""time")func TestIsAudioMoth(t *testing.T) {t.Run("should identify AudioMoth files by artist field", func(t *testing.T) {if !IsAudioMoth("", "AudioMoth") {t.Error("Should identify AudioMoth by artist field")}if !IsAudioMoth("", "AudioMoth 123456") {t.Error("Should identify AudioMoth with ID in artist field")}if IsAudioMoth("", "Other Artist") {t.Error("Should not identify non-AudioMoth artist")}})t.Run("should identify AudioMoth files by comment field", func(t *testing.T) {if !IsAudioMoth("Recorded by AudioMoth...", "") {t.Error("Should identify AudioMoth by comment field")}if IsAudioMoth("Regular recording comment", "") {t.Error("Should not identify non-AudioMoth comment")}})t.Run("should handle missing metadata", func(t *testing.T) {if IsAudioMoth("", "") {t.Error("Should not identify empty strings as AudioMoth")}})t.Run("should be case insensitive", func(t *testing.T) {if !IsAudioMoth("", "audiomoth") {t.Error("Should be case insensitive")}if !IsAudioMoth("", "AUDIOMOTH") {t.Error("Should be case insensitive")}})}func TestParseAudioMothComment(t *testing.T) {t.Run("should parse a valid structured AudioMoth comment", testParseStructuredComment)t.Run("should return error for invalid comments", testParseInvalidComments)t.Run("should handle different timezone formats", testParseTimezoneFormats)t.Run("should parse all gain levels", testParseAllGainLevels)t.Run("should handle negative temperatures", testParseNegativeTemp)t.Run("should fallback to legacy parsing", testParseLegacyFallback)}func testParseStructuredComment(t *testing.T) {comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C."result, err := ParseAudioMothComment(comment)if err != nil {t.Fatalf("Failed to parse comment: %v", err)}expected := time.Date(2025, 2, 24, 21, 0, 0, 0, time.FixedZone("UTC+13", 13*3600))if !result.Timestamp.Equal(expected) {t.Errorf("Timestamp incorrect: got %v, want %v", result.Timestamp, expected)}utc := result.Timestamp.UTC()expectedUTC := time.Date(2025, 2, 24, 8, 0, 0, 0, time.UTC)if !utc.Equal(expectedUTC) {t.Errorf("UTC timestamp incorrect: got %v, want %v", utc, expectedUTC)}if result.RecorderID != "248AB50153AB0549" {t.Errorf("RecorderID incorrect: got %s, want 248AB50153AB0549", result.RecorderID)}if result.Gain != GainMedium {t.Errorf("Gain incorrect: got %s, want %s", result.Gain, GainMedium)}if result.BatteryV != 4.3 {t.Errorf("BatteryV incorrect: got %f, want 4.3", result.BatteryV)}if result.TempC != 15.8 {t.Errorf("TempC incorrect: got %f, want 15.8", result.TempC)}}func testParseInvalidComments(t *testing.T) {invalidComments := []string{"Not an AudioMoth comment","Recorded at invalid time format","Short comment","","AudioMoth without proper format",}for _, comment := range invalidComments {_, err := ParseAudioMothComment(comment)if err == nil {t.Errorf("Expected error for invalid comment: %s", comment)}}}func testParseTimezoneFormats(t *testing.T) {commentUTCMinus := "Recorded at 10:30:45 15/06/2024 (UTC-5) by AudioMoth 123456789ABCDEF0 at high gain while battery was 3.9V and temperature was 22.1C."result, err := ParseAudioMothComment(commentUTCMinus)if err != nil {t.Fatalf("Failed to parse comment: %v", err)}expected := time.Date(2024, 6, 15, 10, 30, 45, 0, time.FixedZone("UTC-5", -5*3600))if !result.Timestamp.Equal(expected) {t.Errorf("Timestamp incorrect: got %v, want %v", result.Timestamp, expected)}if result.Gain != GainHigh {t.Errorf("Gain incorrect: got %s, want %s", result.Gain, GainHigh)}if result.BatteryV != 3.9 {t.Errorf("BatteryV incorrect: got %f, want 3.9", result.BatteryV)}if result.TempC != 22.1 {t.Errorf("TempC incorrect: got %f, want 22.1", result.TempC)}}func testParseAllGainLevels(t *testing.T) {testCases := []struct {gainStr stringexpected GainLevel}{{"low", GainLow},{"low-medium", GainLowMedium},{"medium", GainMedium},{"medium-high", GainMediumHigh},{"high", GainHigh},}for _, tc := range testCases {comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at " + tc.gainStr + " gain while battery was 4.3V and temperature was 15.8C."result, err := ParseAudioMothComment(comment)if err != nil {t.Errorf("Failed to parse comment with gain %s: %v", tc.gainStr, err)continue}if result.Gain != tc.expected {t.Errorf("Gain incorrect for %s: got %s, want %s", tc.gainStr, result.Gain, tc.expected)}}}func testParseNegativeTemp(t *testing.T) {comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was -5.2C."result, err := ParseAudioMothComment(comment)if err != nil {t.Fatalf("Failed to parse comment: %v", err)}if result.TempC != -5.2 {t.Errorf("TempC incorrect: got %f, want -5.2", result.TempC)}}func testParseLegacyFallback(t *testing.T) {comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C"result, err := ParseAudioMothComment(comment)if err == nil {if result.RecorderID == "" {t.Error("RecorderID should not be empty")}}}func TestParseGainLevel(t *testing.T) {testCases := []struct {input stringexpected GainLevelwantErr bool}{{"low", GainLow, false},{"LOW", GainLow, false},{" low ", GainLow, false},{"low-medium", GainLowMedium, false},{"medium", GainMedium, false},{"medium-high", GainMediumHigh, false},{"high", GainHigh, false},{"invalid", "", true},{"", "", true},{"ultra", "", true},}for _, tc := range testCases {t.Run(tc.input, func(t *testing.T) {result, err := parseGainLevel(tc.input)if tc.wantErr {if err == nil {t.Errorf("Expected error for input %q, got nil", tc.input)}} else {if err != nil {t.Errorf("Unexpected error for input %q: %v", tc.input, err)}if result != tc.expected {t.Errorf("Result incorrect for %q: got %s, want %s", tc.input, result, tc.expected)}}})}}func TestParseAudioMothTimestamp(t *testing.T) {t.Run("should parse standard timestamp format", func(t *testing.T) {result, err := parseAudioMothTimestamp("21:00:00", "24/02/2025", "UTC+13")if err != nil {t.Fatalf("Failed to parse timestamp: %v", err)}expected := time.Date(2025, 2, 24, 21, 0, 0, 0, time.FixedZone("UTC+13", 13*3600))if !result.Equal(expected) {t.Errorf("Timestamp incorrect: got %v, want %v", result, expected)}})t.Run("should parse timestamp with +HH format", func(t *testing.T) {result, err := parseAudioMothTimestamp("10:30:45", "15/06/2024", "+13")if err != nil {t.Fatalf("Failed to parse timestamp: %v", err)}expected := time.Date(2024, 6, 15, 10, 30, 45, 0, time.FixedZone("UTC+13", 13*3600))if !result.Equal(expected) {t.Errorf("Timestamp incorrect: got %v, want %v", result, expected)}})t.Run("should parse negative timezone offset", func(t *testing.T) {result, err := parseAudioMothTimestamp("10:30:45", "15/06/2024", "UTC-5")if err != nil {t.Fatalf("Failed to parse timestamp: %v", err)}expected := time.Date(2024, 6, 15, 10, 30, 45, 0, time.FixedZone("UTC-5", -5*3600))if !result.Equal(expected) {t.Errorf("Timestamp incorrect: got %v, want %v", result, expected)}})t.Run("should handle invalid time format", func(t *testing.T) {_, err := parseAudioMothTimestamp("25:00:00", "15/06/2024", "UTC+13")// Note: Go's time.Date will normalize invalid times, so this might not error// The error would be caught if the format doesn't match_ = err})t.Run("should handle invalid date format", func(t *testing.T) {_, err := parseAudioMothTimestamp("10:30:45", "32/13/2024", "UTC+13")// Note: Go's time.Date will normalize invalid dates_ = err})}func TestStructuredVsLegacyParsing(t *testing.T) {t.Run("should prefer structured parsing", func(t *testing.T) {comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C."result, err := ParseAudioMothComment(comment)if err != nil {t.Fatalf("Failed to parse comment: %v", err)}// Verify it parsed correctlyif result.RecorderID != "248AB50153AB0549" {t.Errorf("RecorderID incorrect: got %s, want 248AB50153AB0549", result.RecorderID)}})t.Run("should handle legacy format", func(t *testing.T) {// Create a comment that matches legacy space-separated formatcomment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C."// The structured parser should handle thisresult, err := ParseAudioMothComment(comment)if err != nil {// If structured fails, legacy should catch it// (though for this format, structured should work)t.Logf("Note: Structured parsing failed, expected legacy to handle: %v", err)} else {if result.RecorderID == "" {t.Error("RecorderID should not be empty")}}})}func TestAudioMothCommentEdgeCases(t *testing.T) {t.Run("should handle extra whitespace", func(t *testing.T) {comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C."// Depending on implementation, this might or might not parse_, err := ParseAudioMothComment(comment)if err != nil {// Expected - structured regex is strictt.Logf("Extra whitespace causes parsing to fail (expected): %v", err)}})t.Run("should handle different case in gain", func(t *testing.T) {comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at MEDIUM gain while battery was 4.3V and temperature was 15.8C."result, err := ParseAudioMothComment(comment)if err == nil {if result.Gain != GainMedium {t.Errorf("Gain should be normalized: got %s, want %s", result.Gain, GainMedium)}}})t.Run("should handle non-hex recorder ID via legacy parser", func(t *testing.T) {// Structured regex expects [A-F0-9]+ hex format and will not match// Legacy parser will catch this and parse it (more lenient)comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth GGGGGGGGGGGGGGGG at medium gain while battery was 4.3V and temperature was 15.8C."result, err := ParseAudioMothComment(comment)// Legacy parser is lenient and accepts any recorder IDif err != nil {t.Fatalf("Legacy parser should handle non-hex recorder ID: %v", err)}// Verify it parsed the recorder ID (even though it's not valid hex)if result.RecorderID != "GGGGGGGGGGGGGGGG" {t.Errorf("RecorderID incorrect: got %s, want GGGGGGGGGGGGGGGG", result.RecorderID)}})t.Run("should handle recorder ID of different lengths", func(t *testing.T) {// Short IDcomment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth ABCD at medium gain while battery was 4.3V and temperature was 15.8C."result, err := ParseAudioMothComment(comment)if err != nil {t.Fatalf("Failed to parse comment with short ID: %v", err)}if !strings.Contains(result.RecorderID, "ABCD") {t.Errorf("RecorderID should contain ABCD, got %s", result.RecorderID)}})}
package wavimport ("fmt""regexp""strconv""strings""time")// GainLevel represents the gain_level enum for AudioMoth recordingstype GainLevel string// AudioMoth gain level enum constantsconst (GainLow GainLevel = "low"GainLowMedium GainLevel = "low-medium"GainMedium GainLevel = "medium"GainMediumHigh GainLevel = "medium-high"GainHigh GainLevel = "high")// AudioMothData contains parsed data from AudioMoth comment fieldtype AudioMothData struct {Timestamp time.TimeRecorderID stringGain GainLevelBatteryV float64TempC float64}// AudioMoth comment example:// "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C."var (// Pattern to detect AudioMoth commentsaudiomothPattern = regexp.MustCompile(`(?i)AudioMoth`)// Pattern to extract structured datastructuredPattern = regexp.MustCompile(`Recorded at (\d{2}:\d{2}:\d{2}) (\d{2}/\d{2}/\d{4}) \(UTC([+-]\d+)\) by AudioMoth ([A-F0-9]+) at ([\w-]+) gain while battery was ([\d.]+)V and temperature was ([-\d.]+)C`,))// IsAudioMoth detects if a WAV file is from an AudioMoth recorder by checking comment and artist fieldsfunc IsAudioMoth(comment, artist string) bool {return audiomothPattern.MatchString(comment) || audiomothPattern.MatchString(artist)}// ParseAudioMothComment extracts timestamp, gain, battery, and temperature from an AudioMoth comment.func ParseAudioMothComment(comment string) (*AudioMothData, error) {// Try structured parsing first (newer format)if data, err := parseStructuredComment(comment); err == nil {return data, nil}// Fallback to legacy space-separated parsingreturn parseLegacyComment(comment)}// parseStructuredComment parses newer AudioMoth comment format using regexfunc parseStructuredComment(comment string) (*AudioMothData, error) {matches := structuredPattern.FindStringSubmatch(comment)if matches == nil {return nil, fmt.Errorf("comment does not match structured AudioMoth format")}timeStr := matches[1]dateStr := matches[2]timezoneStr := matches[3]recorderID := matches[4]gainStr := matches[5]batteryStr := matches[6]tempStr := matches[7]timestamp, err := parseAudioMothTimestamp(timeStr, dateStr, timezoneStr)if err != nil {return nil, fmt.Errorf("failed to parse timestamp: %w", err)}gain, err := parseGainLevel(gainStr)if err != nil {return nil, fmt.Errorf("failed to parse gain: %w", err)}batteryV, err := strconv.ParseFloat(batteryStr, 64)if err != nil {return nil, fmt.Errorf("failed to parse battery voltage: %w", err)}tempC, err := strconv.ParseFloat(tempStr, 64)if err != nil {return nil, fmt.Errorf("failed to parse temperature: %w", err)}return &AudioMothData{Timestamp: timestamp,RecorderID: recorderID,Gain: gain,BatteryV: batteryV,TempC: tempC,}, nil}// parseLegacyComment parses older AudioMoth comment format (space-separated).func parseLegacyComment(comment string) (*AudioMothData, error) {parts := strings.Fields(comment)if len(parts) < 10 {return nil, fmt.Errorf("comment has insufficient parts (got %d, need at least 10)", len(parts))}timeStr := parts[2]dateStr := parts[3]timezoneStr := strings.Trim(parts[4], "()")recorderID := parts[7]gainStr := parts[9]timestamp, err := parseAudioMothTimestamp(timeStr, dateStr, timezoneStr)if err != nil {return nil, fmt.Errorf("failed to parse timestamp: %w", err)}gain, err := parseGainLevel(gainStr)if err != nil {return nil, fmt.Errorf("failed to parse gain: %w", err)}batteryStr := parts[len(parts)-5]batteryStr = strings.TrimSuffix(batteryStr, "V")batteryV, err := strconv.ParseFloat(batteryStr, 64)if err != nil {return nil, fmt.Errorf("failed to parse battery voltage: %w", err)}tempStr := parts[len(parts)-1]tempStr = strings.TrimSuffix(tempStr, ".")tempStr = strings.TrimSuffix(tempStr, "C")tempC, err := strconv.ParseFloat(tempStr, 64)if err != nil {return nil, fmt.Errorf("failed to parse temperature: %w", err)}return &AudioMothData{Timestamp: timestamp,RecorderID: recorderID,Gain: gain,BatteryV: batteryV,TempC: tempC,}, nil}// parseAudioMothTimestamp parses AudioMoth timestamp from time, date, and timezone stringsfunc parseAudioMothTimestamp(timeStr, dateStr, timezoneStr string) (time.Time, error) {timeParts := strings.Split(timeStr, ":")if len(timeParts) != 3 {return time.Time{}, fmt.Errorf("invalid time format: %s", timeStr)}hour, _ := strconv.Atoi(timeParts[0])minute, _ := strconv.Atoi(timeParts[1])second, _ := strconv.Atoi(timeParts[2])dateParts := strings.Split(dateStr, "/")if len(dateParts) != 3 {return time.Time{}, fmt.Errorf("invalid date format: %s", dateStr)}day, _ := strconv.Atoi(dateParts[0])month, _ := strconv.Atoi(dateParts[1])year, _ := strconv.Atoi(dateParts[2])timezoneStr = strings.TrimPrefix(timezoneStr, "UTC")offsetHours, err := strconv.Atoi(timezoneStr)if err != nil {return time.Time{}, fmt.Errorf("invalid timezone offset: %s", timezoneStr)}offsetSeconds := offsetHours * 3600loc := time.FixedZone(fmt.Sprintf("UTC%+d", offsetHours), offsetSeconds)timestamp := time.Date(year, time.Month(month), day, hour, minute, second, 0, loc)return timestamp, nil}// parseGainLevel converts string gain level to GainLevel enumfunc parseGainLevel(gainStr string) (GainLevel, error) {gainStr = strings.ToLower(strings.TrimSpace(gainStr))switch gainStr {case "low":return GainLow, nilcase "low-medium":return GainLowMedium, nilcase "medium":return GainMedium, nilcase "medium-high":return GainMediumHigh, nilcase "high":return GainHigh, nildefault:return "", fmt.Errorf("unknown gain level: %s", gainStr)}}
package utilsimport ("os""path/filepath""testing")func TestWriteWAVFile(t *testing.T) {t.Run("writes valid samples", func(t *testing.T) {path := filepath.Join(t.TempDir(), "test.wav")samples := []float64{0.5, -0.5, 1.5, -1.5} // includes out of bounds for clampingerr := WriteWAVFile(path, samples, 8000)if err != nil {t.Fatalf("unexpected error: %v", err)}info, err := os.Stat(path)if err != nil || info.Size() == 0 {t.Error("expected file to be written with data")}})t.Run("fails on empty samples", func(t *testing.T) {path := filepath.Join(t.TempDir(), "empty.wav")err := WriteWAVFile(path, []float64{}, 8000)if err == nil {t.Error("expected error for empty samples")}})}
package utilsimport ("bufio""encoding/binary""fmt""os""skraak/audio")// WriteWAVFile writes audio samples to a WAV file.// Samples should be in the range -1.0 to 1.0.// Output is mono 16-bit PCM.func WriteWAVFile(filepath string, samples []float64, sampleRate int) error {if len(samples) == 0 {return fmt.Errorf("no samples to write")}file, err := os.Create(filepath)if err != nil {return fmt.Errorf("failed to create file: %w", err)}w := bufio.NewWriterSize(file, 64*1024)// Write WAV and flush; check close to ensure data is persisted.err = func() error {// WAV parameterschannels := 1bitsPerSample := 16bytesPerSample := bitsPerSample / 8byteRate := sampleRate * channels * bytesPerSampleblockAlign := channels * bytesPerSampledataSize := len(samples) * bytesPerSampletotalSize := 36 + dataSize // 36 = header size before data chunk// Write 44-byte WAV header in one goheader := make([]byte, 44)copy(header[0:4], "RIFF")binary.LittleEndian.PutUint32(header[4:8], uint32(totalSize))copy(header[8:12], "WAVE")copy(header[12:16], "fmt ")binary.LittleEndian.PutUint32(header[16:20], 16) // chunk sizebinary.LittleEndian.PutUint16(header[20:22], 1) // PCM formatbinary.LittleEndian.PutUint16(header[22:24], uint16(channels))binary.LittleEndian.PutUint32(header[24:28], uint32(sampleRate))binary.LittleEndian.PutUint32(header[28:32], uint32(byteRate))binary.LittleEndian.PutUint16(header[32:34], uint16(blockAlign))binary.LittleEndian.PutUint16(header[34:36], uint16(bitsPerSample))copy(header[36:40], "data")binary.LittleEndian.PutUint32(header[40:44], uint32(dataSize))if _, err := w.Write(header); err != nil {return err}// Convert all float64 samples to 16-bit PCM in a single bufferif _, err := w.Write(buf); err != nil {return err}return w.Flush()}()if err2 := file.Close(); err2 != nil {if err == nil {err = fmt.Errorf("failed to close file: %w", err2)}}return err}buf := audio.Float64ToPCM16(samples)
package utilsimport ("bytes""encoding/binary""fmt""math""os""path/filepath""testing""time")// createTestWAVFile creates a minimal valid WAV file for testingfunc createTestWAVFile(t *testing.T, dir string, filename string, options struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}) string {t.Helper()path := filepath.Join(dir, filename)file, err := os.Create(path)if err != nil {t.Fatalf("Failed to create test file: %v", err)}defer file.Close()// Calculate data chunk size based on durationbytesPerSample := options.bitsPerSample / 8samplesPerSecond := options.sampleRate * options.channelsdataSize := int(options.duration * float64(samplesPerSecond*bytesPerSample))// Calculate file size (excluding RIFF header)fileSize := 4 + 8 + 16 + 8 + dataSize // WAVE + fmt chunk + data chunk header// Add LIST INFO chunk size if metadata providedvar infoChunk []byteif options.comment != "" || options.artist != "" {infoChunk = buildINFOChunk(options.comment, options.artist)fileSize += 8 + len(infoChunk) // LIST chunk header + content}buf := &bytes.Buffer{}// Write RIFF headerbuf.WriteString("RIFF")binary.Write(buf, binary.LittleEndian, uint32(fileSize))buf.WriteString("WAVE")// Write fmt chunkbuf.WriteString("fmt ")binary.Write(buf, binary.LittleEndian, uint32(16)) // chunk sizebinary.Write(buf, binary.LittleEndian, uint16(1)) // audio format (PCM)binary.Write(buf, binary.LittleEndian, uint16(options.channels))binary.Write(buf, binary.LittleEndian, uint32(options.sampleRate))byteRate := options.sampleRate * options.channels * bytesPerSamplebinary.Write(buf, binary.LittleEndian, uint32(byteRate))blockAlign := options.channels * bytesPerSamplebinary.Write(buf, binary.LittleEndian, uint16(blockAlign))binary.Write(buf, binary.LittleEndian, uint16(options.bitsPerSample))// Write LIST INFO chunk if metadata providedif len(infoChunk) > 0 {buf.WriteString("LIST")binary.Write(buf, binary.LittleEndian, uint32(len(infoChunk)))buf.Write(infoChunk)}// Write data chunkbuf.WriteString("data")binary.Write(buf, binary.LittleEndian, uint32(dataSize))// Write silence for databuf.Write(make([]byte, dataSize))// Write to fileif _, err := file.Write(buf.Bytes()); err != nil {t.Fatalf("Failed to write test file: %v", err)}return path}// buildINFOChunk builds a LIST INFO chunk with optional comment and artistfunc buildINFOChunk(comment, artist string) []byte {buf := &bytes.Buffer{}buf.WriteString("INFO")if comment != "" {buf.WriteString("ICMT")// Size includes null terminatorsize := len(comment) + 1binary.Write(buf, binary.LittleEndian, uint32(size))buf.WriteString(comment)buf.WriteByte(0) // null terminator// Add padding byte if needed for word alignmentif size%2 != 0 {buf.WriteByte(0)}}if artist != "" {buf.WriteString("IART")size := len(artist) + 1binary.Write(buf, binary.LittleEndian, uint32(size))buf.WriteString(artist)buf.WriteByte(0) // null terminatorif size%2 != 0 {buf.WriteByte(0)}}return buf.Bytes()}func TestConvertToFloat64_16Bit(t *testing.T) {// 16-bit signed, little-endian: 0x0001 = 1 → 1/32768data := []byte{0x01, 0x00, 0xFF, 0x7F}samples := convertToFloat64(data, 16, 1)if len(samples) != 2 {t.Fatalf("expected 2 samples, got %d", len(samples))}if math.Abs(samples[0]-1.0/32768.0) > 1e-10 {t.Errorf("sample[0] = %v, want %v", samples[0], 1.0/32768.0)}// 0x7FFF = 32767 → 32767/32768 ≈ 0.99997if math.Abs(samples[1]-32767.0/32768.0) > 1e-10 {t.Errorf("sample[1] = %v, want %v", samples[1], 32767.0/32768.0)}}func TestConvertToFloat64_16BitNegative(t *testing.T) {// 0x8000 = -32768 → -32768/32768 = -1.0data := []byte{0x00, 0x80}samples := convertToFloat64(data, 16, 1)if len(samples) != 1 {t.Fatalf("expected 1 sample, got %d", len(samples))}if math.Abs(samples[0]-(-1.0)) > 1e-10 {t.Errorf("sample = %v, want -1.0", samples[0])}}func TestConvertToFloat64_16BitStereo(t *testing.T) {// Stereo: should extract only left channel// Left: 0x0001 = 1, Right: 0x0002 = 2data := []byte{0x01, 0x00, 0x02, 0x00}samples := convertToFloat64(data, 16, 2)if len(samples) != 1 {t.Fatalf("expected 1 sample (left only), got %d", len(samples))}if math.Abs(samples[0]-1.0/32768.0) > 1e-10 {t.Errorf("sample = %v, want %v", samples[0], 1.0/32768.0)}}func TestConvertToFloat64_24Bit(t *testing.T) {// 24-bit signed, little-endian: 0x000001 = 1 → 1/8388608data := []byte{0x01, 0x00, 0x00}samples := convertToFloat64(data, 24, 1)if len(samples) != 1 {t.Fatalf("expected 1 sample, got %d", len(samples))}if math.Abs(samples[0]-1.0/8388608.0) > 1e-12 {t.Errorf("sample = %v, want %v", samples[0], 1.0/8388608.0)}}func TestConvertToFloat64_24BitNegative(t *testing.T) {// 24-bit: 0xFFFFFF = -1 (sign extended) → -1/8388608data := []byte{0xFF, 0xFF, 0xFF}samples := convertToFloat64(data, 24, 1)if len(samples) != 1 {t.Fatalf("expected 1 sample, got %d", len(samples))}if math.Abs(samples[0]-(-1.0/8388608.0)) > 1e-12 {t.Errorf("sample = %v, want %v", samples[0], -1.0/8388608.0)}}func TestConvertToFloat64_24BitMaxPositive(t *testing.T) {// 24-bit max positive: 0x7FFFFF = 8388607 → ~0.9999999data := []byte{0xFF, 0xFF, 0x7F}samples := convertToFloat64(data, 24, 1)if len(samples) != 1 {t.Fatalf("expected 1 sample, got %d", len(samples))}if math.Abs(samples[0]-8388607.0/8388608.0) > 1e-12 {t.Errorf("sample = %v, want %v", samples[0], 8388607.0/8388608.0)}}func TestConvertToFloat64_24BitMinNegative(t *testing.T) {// 24-bit min negative: 0x800000 = -8388608 → -1.0data := []byte{0x00, 0x00, 0x80}samples := convertToFloat64(data, 24, 1)if len(samples) != 1 {t.Fatalf("expected 1 sample, got %d", len(samples))}if math.Abs(samples[0]-(-1.0)) > 1e-12 {t.Errorf("sample = %v, want -1.0", samples[0])}}func TestConvertToFloat64_24BitStereo(t *testing.T) {// 24-bit stereo: left=0x000001=1, right=0x000002=2data := []byte{0x01, 0x00, 0x00, 0x02, 0x00, 0x00}samples := convertToFloat64(data, 24, 2)if len(samples) != 1 {t.Fatalf("expected 1 sample (left only), got %d", len(samples))}if math.Abs(samples[0]-1.0/8388608.0) > 1e-12 {t.Errorf("sample = %v, want %v", samples[0], 1.0/8388608.0)}}func TestConvertToFloat64_32Bit(t *testing.T) {// 32-bit signed, little-endian: 0x00000001 = 1 → 1/2147483648data := []byte{0x01, 0x00, 0x00, 0x00}samples := convertToFloat64(data, 32, 1)if len(samples) != 1 {t.Fatalf("expected 1 sample, got %d", len(samples))}if math.Abs(samples[0]-1.0/2147483648.0) > 1e-15 {t.Errorf("sample = %v, want %v", samples[0], 1.0/2147483648.0)}}func TestConvertToFloat64_32BitNegative(t *testing.T) {// 32-bit: 0x80000000 = -2147483648 → -1.0data := []byte{0x00, 0x00, 0x00, 0x80}samples := convertToFloat64(data, 32, 1)if len(samples) != 1 {t.Fatalf("expected 1 sample, got %d", len(samples))}if math.Abs(samples[0]-(-1.0)) > 1e-15 {t.Errorf("sample = %v, want -1.0", samples[0])}}func TestConvertToFloat64_32BitMaxPositive(t *testing.T) {// 32-bit max positive: 0x7FFFFFFF = 2147483647 → ~0.9999999995data := []byte{0xFF, 0xFF, 0xFF, 0x7F}samples := convertToFloat64(data, 32, 1)if len(samples) != 1 {t.Fatalf("expected 1 sample, got %d", len(samples))}if math.Abs(samples[0]-2147483647.0/2147483648.0) > 1e-15 {t.Errorf("sample = %v, want %v", samples[0], 2147483647.0/2147483648.0)}}func TestConvertToFloat64_32BitStereo(t *testing.T) {// 32-bit stereo: left=1, right=2data := []byte{0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00}samples := convertToFloat64(data, 32, 2)if len(samples) != 1 {t.Fatalf("expected 1 sample (left only), got %d", len(samples))}if math.Abs(samples[0]-1.0/2147483648.0) > 1e-15 {t.Errorf("sample = %v, want %v", samples[0], 1.0/2147483648.0)}}func TestConvertToFloat64_UnsupportedBitDepth(t *testing.T) {// Unsupported bit depth (8) falls back to 16-bit parsing.// blockAlign = (8/8)*1 = 1, so numSamples = len(data)/1 = 4.// But 16-bit reads 2 bytes per sample, which panics with only 1 byte per block.// This tests the fallback path exists; for valid 8-bit data, blockAlign// must be 2 (so 2 bytes per sample in the data layout).data := []byte{0x01, 0x00, 0x02, 0x00} // 4 bytes, treated as 2x 16-bit samplessamples := convertToFloat64(data, 8, 2) // 2 channels → blockAlign = 1*2 = 2if len(samples) != 2 {t.Fatalf("expected 2 samples (fallback 16-bit), got %d", len(samples))}}func TestConvertToFloat64_EmptyData(t *testing.T) {samples := convertToFloat64([]byte{}, 16, 1)if len(samples) != 0 {t.Errorf("expected 0 samples, got %d", len(samples))}}func TestParseWAVHeader_BasicMetadata(t *testing.T) {tmpDir := t.TempDir()path := createTestWAVFile(t, tmpDir, "test_basic.wav", struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 60.0,sampleRate: 44100,channels: 2,bitsPerSample: 16,comment: "",artist: "",})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.SampleRate != 44100 {t.Errorf("SampleRate incorrect: got %d, want 44100", metadata.SampleRate)}if metadata.Channels != 2 {t.Errorf("Channels incorrect: got %d, want 2", metadata.Channels)}if metadata.BitsPerSample != 16 {t.Errorf("BitsPerSample incorrect: got %d, want 16", metadata.BitsPerSample)}if metadata.Duration < 59.9 || metadata.Duration > 60.1 {t.Errorf("Duration incorrect: got %f, want ~60.0", metadata.Duration)}}func TestParseWAVHeader_CommentMetadata(t *testing.T) {tmpDir := t.TempDir()expectedComment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549"path := createTestWAVFile(t, tmpDir, "test_comment.wav", struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 10.0,sampleRate: 48000,channels: 1,bitsPerSample: 16,comment: expectedComment,artist: "",})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.Comment != expectedComment {t.Errorf("Comment incorrect: got %q, want %q", metadata.Comment, expectedComment)}}func TestParseWAVHeader_ArtistMetadata(t *testing.T) {tmpDir := t.TempDir()expectedArtist := "AudioMoth"path := createTestWAVFile(t, tmpDir, "test_artist.wav", struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 5.0,sampleRate: 48000,channels: 1,bitsPerSample: 16,comment: "",artist: expectedArtist,})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.Artist != expectedArtist {t.Errorf("Artist incorrect: got %q, want %q", metadata.Artist, expectedArtist)}}func TestParseWAVHeader_CommentAndArtist(t *testing.T) {tmpDir := t.TempDir()expectedComment := "Test recording comment"expectedArtist := "Test Artist"path := createTestWAVFile(t, tmpDir, "test_both.wav", struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 15.0,sampleRate: 44100,channels: 2,bitsPerSample: 16,comment: expectedComment,artist: expectedArtist,})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.Comment != expectedComment {t.Errorf("Comment incorrect: got %q, want %q", metadata.Comment, expectedComment)}if metadata.Artist != expectedArtist {t.Errorf("Artist incorrect: got %q, want %q", metadata.Artist, expectedArtist)}}func TestParseWAVHeader_SampleRates(t *testing.T) {tmpDir := t.TempDir()testCases := []int{8000, 16000, 22050, 44100, 48000, 96000}for _, sr := range testCases {t.Run(fmt.Sprintf("%dHz", sr), func(t *testing.T) {path := createTestWAVFile(t, tmpDir, fmt.Sprintf("test_sr_%d.wav", sr), struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 1.0,sampleRate: sr,channels: 1,bitsPerSample: 16,comment: "",artist: "",})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.SampleRate != sr {t.Errorf("SampleRate incorrect: got %d, want %d", metadata.SampleRate, sr)}})}}func TestParseWAVHeader_ChannelCounts(t *testing.T) {tmpDir := t.TempDir()testCases := []int{1, 2}for _, ch := range testCases {t.Run(fmt.Sprintf("%dch", ch), func(t *testing.T) {path := createTestWAVFile(t, tmpDir, fmt.Sprintf("test_ch_%d.wav", ch), struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 1.0,sampleRate: 44100,channels: ch,bitsPerSample: 16,comment: "",artist: "",})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.Channels != ch {t.Errorf("Channels incorrect: got %d, want %d", metadata.Channels, ch)}})}}func TestParseWAVHeader_BitDepths(t *testing.T) {tmpDir := t.TempDir()testCases := []int{8, 16, 24, 32}for _, bits := range testCases {t.Run(fmt.Sprintf("%dbit", bits), func(t *testing.T) {path := createTestWAVFile(t, tmpDir, fmt.Sprintf("test_bits_%d.wav", bits), struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 1.0,sampleRate: 44100,channels: 1,bitsPerSample: bits,comment: "",artist: "",})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.BitsPerSample != bits {t.Errorf("BitsPerSample incorrect: got %d, want %d", metadata.BitsPerSample, bits)}})func TestParseWAVHeader_ShortDuration(t *testing.T) {tmpDir := t.TempDir()path := createTestWAVFile(t, tmpDir, "test_short.wav", struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 0.1, // 100mssampleRate: 44100,channels: 1,bitsPerSample: 16,comment: "",artist: "",})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.Duration < 0.09 || metadata.Duration > 0.11 {t.Errorf("Duration incorrect: got %f, want ~0.1", metadata.Duration)}}func TestParseWAVHeader_LongDuration(t *testing.T) {tmpDir := t.TempDir()path := createTestWAVFile(t, tmpDir, "test_long.wav", struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 600.0, // 10 minutessampleRate: 44100,channels: 1,bitsPerSample: 16,comment: "",artist: "",})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.Duration < 599.0 || metadata.Duration > 601.0 {t.Errorf("Duration incorrect: got %f, want ~600.0", metadata.Duration)}}func TestParseWAVHeader_NonExistentFile(t *testing.T) {_, err := ParseWAVHeader("/nonexistent/file.wav")if err == nil {t.Error("Expected error for non-existent file")}}func TestParseWAVHeader_NonWAVFile(t *testing.T) {tmpDir := t.TempDir()path := filepath.Join(tmpDir, "not_a_wav.txt")if err := os.WriteFile(path, []byte("This is not a WAV file"), 0644); err != nil {t.Fatalf("Failed to create test file: %v", err)}_, err := ParseWAVHeader(path)if err == nil {t.Error("Expected error for non-WAV file")}}func TestParseWAVHeader_TruncatedFile(t *testing.T) {tmpDir := t.TempDir()path := filepath.Join(tmpDir, "truncated.wav")if err := os.WriteFile(path, []byte("RIFF"), 0644); err != nil {t.Fatalf("Failed to create test file: %v", err)}_, err := ParseWAVHeader(path)if err == nil {t.Error("Expected error for truncated file")}}func TestParseWAVHeader_EmptyMetadataStrings(t *testing.T) {tmpDir := t.TempDir()path := createTestWAVFile(t, tmpDir, "test_empty.wav", struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 10.0,sampleRate: 44100,channels: 1,bitsPerSample: 16,comment: "",artist: "",})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.Comment != "" {t.Errorf("Comment should be empty, got %q", metadata.Comment)}if metadata.Artist != "" {t.Errorf("Artist should be empty, got %q", metadata.Artist)}}func TestParseWAVHeader_LongCommentString(t *testing.T) {tmpDir := t.TempDir()longComment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C. This is a very long comment with additional information about the recording session."path := createTestWAVFile(t, tmpDir, "test_long_comment.wav", struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 10.0,sampleRate: 44100,channels: 1,bitsPerSample: 16,comment: longComment,artist: "",})metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if metadata.Comment != longComment {t.Errorf("Comment incorrect: got %q, want %q", metadata.Comment, longComment)}}func TestParseWAVHeader_FileModTime(t *testing.T) {tmpDir := t.TempDir()path := createTestWAVFile(t, tmpDir, "test_modtime.wav", struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{duration: 5.0,sampleRate: 44100,channels: 1,bitsPerSample: 16,comment: "",artist: "",})info, err := os.Stat(path)if err != nil {t.Fatalf("Failed to stat file: %v", err)}expectedModTime := info.ModTime()metadata, err := ParseWAVHeader(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}// Allow 1 second tolerance for filesystem granularitydiff := metadata.FileModTime.Sub(expectedModTime)if diff < -1*time.Second || diff > 1*time.Second {t.Errorf("FileModTime incorrect: got %v, want %v (diff: %v)",metadata.FileModTime, expectedModTime, diff)}if metadata.FileModTime.IsZero() {t.Error("FileModTime should not be zero")}}func TestExtractNullTerminatedString(t *testing.T) {testCases := []struct {name stringinput []byteexpected string}{{name: "string with null terminator",input: []byte{'h', 'e', 'l', 'l', 'o', 0, 'w', 'o', 'r', 'l', 'd'},expected: "hello",},{name: "string without null terminator",input: []byte{'h', 'e', 'l', 'l', 'o'},expected: "hello",},{name: "empty string",input: []byte{},expected: "",},{name: "only null terminator",input: []byte{0},expected: "",},}for _, tc := range testCases {t.Run(tc.name, func(t *testing.T) {result := extractNullTerminatedString(tc.input)if result != tc.expected {t.Errorf("Result incorrect: got %q, want %q", result, tc.expected)}})}}func TestParseWAVHeaderMinimal(t *testing.T) {tmpDir := t.TempDir()t.Run("basic", func(t *testing.T) {assertWAVHeader(t, tmpDir, "test_minimal.wav", 44100, 10.0, struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{10.0, 44100, 1, 16, "", ""})})t.Run("sample_rates", func(t *testing.T) {for _, sr := range []int{8000, 22050, 44100, 48000, 96000} {t.Run(fmt.Sprintf("%dHz", sr), func(t *testing.T) {assertWAVHeader(t, tmpDir, fmt.Sprintf("test_sr_%d.wav", sr), sr, 5.0, struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{5.0, sr, 1, 16, "", ""})})}})t.Run("stereo", func(t *testing.T) {assertWAVHeader(t, tmpDir, "test_stereo.wav", 44100, 3.0, struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}{3.0, 44100, 2, 16, "", ""})})t.Run("nonexistent", func(t *testing.T) {_, _, err := ParseWAVHeaderMinimal("/nonexistent/file.wav")if err == nil {t.Error("Expected error for non-existent file")}})t.Run("non_wav", func(t *testing.T) {path := filepath.Join(tmpDir, "notawav.wav")if err := os.WriteFile(path, []byte("Not a WAV file"), 0644); err != nil {t.Fatalf("Failed to create test file: %v", err)}_, _, err := ParseWAVHeaderMinimal(path)if err == nil {t.Error("Expected error for non-WAV file")}})}// assertWAVHeader creates a WAV file and verifies ParseWAVHeaderMinimal returns expected values.func assertWAVHeader(t *testing.T, tmpDir, filename string, wantSR int, wantDur float64, opts struct {duration float64sampleRate intchannels intbitsPerSample intcomment stringartist string}) {t.Helper()path := createTestWAVFile(t, tmpDir, filename, opts)sr, dur, err := ParseWAVHeaderMinimal(path)if err != nil {t.Fatalf("Failed to parse WAV header: %v", err)}if sr != wantSR {t.Errorf("SampleRate: got %d, want %d", sr, wantSR)}if dur < wantDur-0.1 || dur > wantDur+0.1 {t.Errorf("Duration: got %f, want ~%f", dur, wantDur)}}}}
package utilsimport ("bytes""encoding/binary""fmt""io""os""sync""time""github.com/cespare/xxhash/v2")// Buffer pools for reducing GC pressure during batch importsvar (// headerBufferPool stores 200KB buffers for WAV header reading (full metadata)headerBufferPool = sync.Pool{New: func() any {buf := make([]byte, 200*1024)return &buf},}// minimalHeaderBufferPool stores 4KB buffers for minimal WAV header reading// 4KB is sufficient for fmt + data chunk headers in 99% of WAV filesminimalHeaderBufferPool = sync.Pool{New: func() any {buf := make([]byte, 4*1024)return &buf},})// getHeaderBuffer gets a 200KB buffer from the poolfunc getHeaderBuffer() *[]byte {return headerBufferPool.Get().(*[]byte)}// putHeaderBuffer returns a 200KB buffer to the poolfunc putHeaderBuffer(buf *[]byte) {headerBufferPool.Put(buf)}// getMinimalHeaderBuffer gets a 4KB buffer from the poolfunc getMinimalHeaderBuffer() *[]byte {return minimalHeaderBufferPool.Get().(*[]byte)}// putMinimalHeaderBuffer returns a 4KB buffer to the poolfunc putMinimalHeaderBuffer(buf *[]byte) {minimalHeaderBufferPool.Put(buf)}// WAVMetadata contains metadata extracted from WAV file headerstype WAVMetadata struct {Duration float64 // Duration in secondsSampleRate int // Sample rate in HzComment string // Comment from INFO chunk (may contain AudioMoth data)Artist string // Artist from INFO chunkChannels int // Number of audio channelsBitsPerSample int // Bits per sampleFileModTime time.Time // File modification time (fallback timestamp)FileSize int64 // File size in bytes}// readAndParseHeader opens a WAV file, reads its header using the provided buffer pool,// parses metadata, and sets file modification time and size.func readAndParseHeader(filepath string, getBuf func() *[]byte, putBuf func(*[]byte)) (*WAVMetadata, error) {file, err := os.Open(filepath)if err != nil {return nil, fmt.Errorf("failed to open file: %w", err)}defer func() { _ = file.Close() }()fileInfo, err := file.Stat()if err != nil {return nil, fmt.Errorf("failed to get file info: %w", err)}bufPtr := getBuf()defer putBuf(bufPtr)buf := (*bufPtr)[:cap(*bufPtr)]n, err := file.Read(buf)if err != nil && err != io.EOF {return nil, fmt.Errorf("failed to read header: %w", err)}buf = buf[:n]metadata, err := parseWAVFromBytes(buf)if err != nil {return nil, err}return metadata, nil}// ParseWAVHeaderMinimal reads only the first 4KB of a WAV file to extract essential metadata.// This is optimized for batch processing where INFO chunks (comment/artist) are not needed.// It's ~50x faster than ParseWAVHeader for large files due to reduced I/O.// Returns (sampleRate, duration, error) - the minimal data needed for .data file generation.func ParseWAVHeaderMinimal(filepath string) (sampleRate int, duration float64, err error) {metadata, err := readAndParseHeader(filepath, getMinimalHeaderBuffer, putMinimalHeaderBuffer)if err != nil {return 0, 0, err}return metadata.SampleRate, metadata.Duration, nil}// ParseWAVHeaderWithHash reads the WAV file once to extract both metadata and hash.// This is more efficient than calling ParseWAVHeader and ComputeXXH64 separately,// as it only opens the file once and reads it in a single pass.// Returns (metadata, hash, error).func ParseWAVHeaderWithHash(filepath string) (*WAVMetadata, string, error) {file, err := os.Open(filepath)if err != nil {return nil, "", fmt.Errorf("failed to open file: %w", err)}defer func() { _ = file.Close() }()fileInfo, err := file.Stat()if err != nil {return nil, "", fmt.Errorf("failed to get file info: %w", err)}headerBufPtr := getHeaderBuffer()defer putHeaderBuffer(headerBufPtr)headerBuf := (*headerBufPtr)[:cap(*headerBufPtr)]n, err := file.Read(headerBuf)if err != nil && err != io.EOF {return nil, "", fmt.Errorf("failed to read header: %w", err)}headerBuf = headerBuf[:n]metadata, err := parseWAVFromBytes(headerBuf)if err != nil {return nil, "", err}metadata.FileModTime = fileInfo.ModTime()metadata.FileSize = fileInfo.Size()// Hash: seek back to start and stream entire fileif _, err := file.Seek(0, 0); err != nil {return nil, "", fmt.Errorf("failed to seek: %w", err)}hashBufPtr := getHashBuffer()defer putHashBuffer(hashBufPtr)hashBuf := *hashBufPtrh := xxhash.New()if _, err := io.CopyBuffer(h, file, hashBuf); err != nil {return nil, "", fmt.Errorf("failed to read file for hash: %w", err)}hash := fmt.Sprintf("%016x", h.Sum64())return metadata, hash, nil}// parseWAVFromBytes parses WAV metadata from a byte bufferfunc parseWAVFromBytes(data []byte) (*WAVMetadata, error) {if len(data) < 44 {return nil, fmt.Errorf("file too small to be valid WAV")}if string(data[0:4]) != "RIFF" {return nil, fmt.Errorf("not a valid WAV file (missing RIFF header)")}if string(data[8:12]) != "WAVE" {return nil, fmt.Errorf("not a valid WAV file (missing WAVE format)")}metadata := &WAVMetadata{}offset := 12for offset < len(data)-8 {chunkID := string(data[offset : offset+4])chunkSize := int(binary.LittleEndian.Uint32(data[offset+4 : offset+8]))offset += 8switch chunkID {case "fmt ":parseFmtChunkData(data[offset:], chunkSize, metadata)case "data":calcDataChunkDuration(chunkSize, metadata)case "LIST":parseLISTChunkData(data[offset:], chunkSize, metadata)}offset += chunkSizeif chunkSize%2 != 0 {offset++}}if metadata.SampleRate == 0 {return nil, fmt.Errorf("invalid WAV file: missing or corrupt fmt chunk")}if metadata.Duration == 0 {return nil, fmt.Errorf("invalid WAV file: missing or corrupt data chunk")}// parseFmtChunkData extracts format info from a fmt chunk.func parseFmtChunkData(data []byte, chunkSize int, m *WAVMetadata) {if chunkSize >= 16 && len(data) >= 16 {m.Channels = int(binary.LittleEndian.Uint16(data[2:4]))m.SampleRate = int(binary.LittleEndian.Uint32(data[4:8]))m.BitsPerSample = int(binary.LittleEndian.Uint16(data[14:16]))}}// calcDataChunkDuration computes duration from the data chunk size.func calcDataChunkDuration(chunkSize int, m *WAVMetadata) {if m.SampleRate > 0 && m.Channels > 0 && m.BitsPerSample > 0 {bytesPerSample := m.BitsPerSample / 8bytesPerSecond := m.SampleRate * m.Channels * bytesPerSampleif bytesPerSecond > 0 {m.Duration = float64(chunkSize) / float64(bytesPerSecond)}}}// parseINFOChunk parses INFO list chunk for comment and artist metadatafunc parseINFOChunk(data []byte, metadata *WAVMetadata) {offset := 0for offset < len(data)-8 {// Read subchunk ID and sizeif offset+8 > len(data) {break}subchunkID := string(data[offset : offset+4])subchunkSize := int(binary.LittleEndian.Uint32(data[offset+4 : offset+8]))offset += 8if offset+subchunkSize > len(data) {break}// Extract null-terminated stringvalue := extractNullTerminatedString(data[offset : offset+subchunkSize])switch subchunkID {case "ICMT": // Commentmetadata.Comment = valuecase "IART": // Artistmetadata.Artist = value}// Move to next subchunk (word-aligned)offset += subchunkSizeif subchunkSize%2 != 0 {offset++ // Skip padding byte}}}// extractNullTerminatedString extracts a null-terminated string from bytesfunc extractNullTerminatedString(data []byte) string {before, _, ok := bytes.Cut(data, []byte{0})if ok {return string(before)}return string(data)}// wavChunkInfo holds parsed WAV format and data chunk locations.type wavChunkInfo struct {sampleRate intchannels intbitsPerSample intdataOffset int64dataSize int64}// parseWAVChunks reads WAV chunks from the current file position, returning// format info and data chunk location. Returns error if no data chunk is found.func parseWAVChunks(file *os.File) (wavChunkInfo, error) {var info wavChunkInfofor {chunkHeader := make([]byte, 8)if _, err := io.ReadFull(file, chunkHeader); err != nil {if err == io.EOF {break}return info, fmt.Errorf("failed to read chunk header: %w", err)}chunkID := string(chunkHeader[0:4])chunkSize := int64(binary.LittleEndian.Uint32(chunkHeader[4:8]))switch chunkID {case "fmt ":fmtData := make([]byte, chunkSize)if _, err := io.ReadFull(file, fmtData); err != nil {return info, fmt.Errorf("failed to read fmt chunk: %w", err)}if len(fmtData) >= 16 {info.channels = int(binary.LittleEndian.Uint16(fmtData[2:4]))info.sampleRate = int(binary.LittleEndian.Uint32(fmtData[4:8]))info.bitsPerSample = int(binary.LittleEndian.Uint16(fmtData[14:16]))}case "data":info.dataOffset, _ = file.Seek(0, io.SeekCurrent)info.dataSize = chunkSizereturn info, nildefault:if _, err := file.Seek(chunkSize, io.SeekCurrent); err != nil {return info, fmt.Errorf("failed to skip chunk: %w", err)}}// Word alignif chunkSize%2 != 0 {if _, err := file.Seek(1, io.SeekCurrent); err != nil {return info, fmt.Errorf("failed to skip padding: %w", err)}}}// calcWAVReadRange computes the byte offset and size to read from the data chunk.func calcWAVReadRange(startSec, endSec float64, info wavChunkInfo) (startOffset, readSize int64) {bytesPerSample := info.bitsPerSample / 8blockAlign := bytesPerSample * info.channelsif startSec > 0 {startSample := int64(startSec * float64(info.sampleRate))startOffset = min(startSample*int64(blockAlign), info.dataSize)}if endSec > 0 {endSample := int64(endSec * float64(info.sampleRate))endOffset := min(endSample*int64(blockAlign), info.dataSize)if endOffset > startOffset {readSize = endOffset - startOffset}} else {readSize = info.dataSize - startOffset}return}// parseWAVInfo opens a WAV file, validates its header, and parses chunks.// Returns the parsed chunk info and the open file (caller must close).func parseWAVInfo(filepath string) (f *os.File, info wavChunkInfo, err error) {f, err = os.Open(filepath)if err != nil {return nil, wavChunkInfo{}, fmt.Errorf("failed to open file: %w", err)}defer func() {if err != nil {_ = f.Close()}}()headerBuf := make([]byte, 44)if _, err = io.ReadFull(f, headerBuf); err != nil {return nil, wavChunkInfo{}, fmt.Errorf("failed to read header: %w", err)}if string(headerBuf[0:4]) != "RIFF" || string(headerBuf[8:12]) != "WAVE" {return nil, wavChunkInfo{}, fmt.Errorf("not a valid WAV file")}if _, err = f.Seek(12, 0); err != nil {return nil, wavChunkInfo{}, fmt.Errorf("failed to seek: %w", err)}info, err = parseWAVChunks(f)if err != nil {return nil, wavChunkInfo{}, err}if readSize == 0 {return nil, nil}if _, err := file.Seek(info.dataOffset+startOffset, io.SeekStart); err != nil {return nil, fmt.Errorf("failed to seek to data segment: %w", err)}audioData := make([]byte, readSize)if _, err := io.ReadFull(file, audioData); err != nil {if err != io.EOF && err != io.ErrUnexpectedEOF {return nil, fmt.Errorf("failed to read audio data: %w", err)}}return audioData, nil}samples := convertToFloat64(audioData, info.bitsPerSample, info.channels)return samples, info.sampleRate, nil}// convertToFloat64 converts raw audio bytes to float64 samples// Returns mono (left channel only for stereo)func convertToFloat64(data []byte, bitsPerSample, channels int) []float64 {bytesPerSample := bitsPerSample / 8blockAlign := bytesPerSample * channelsnumSamples := len(data) / blockAlignsamples := make([]float64, numSamples)switch bitsPerSample {case 16:for i := range numSamples {// Read first (left) channel only for stereooffset := i * blockAlignsample := int16(binary.LittleEndian.Uint16(data[offset : offset+2]))samples[i] = float64(sample) / 32768.0}case 24:for i := range numSamples {offset := i * blockAlign// 24-bit signed, little-endianb := data[offset : offset+3]sample := int32(b[0]) | int32(b[1])<<8 | int32(b[2])<<16// Sign extendif sample >= 0x800000 {sample -= 0x1000000}samples[i] = float64(sample) / 8388608.0}case 32:for i := range numSamples {offset := i * blockAlignsample := int32(binary.LittleEndian.Uint32(data[offset : offset+4]))samples[i] = float64(sample) / 2147483648.0}default:// Fallback: treat as 16-bitfor i := range numSamples {offset := i * blockAlignsample := int16(binary.LittleEndian.Uint16(data[offset : offset+2]))samples[i] = float64(sample) / 32768.0}}return samples}// ReadWAVSamples reads audio samples from a WAV file and returns them as float64.// Mono files: returns single channel.// Stereo files: returns left channel only.// Samples are normalized to the range -1.0 to 1.0.func ReadWAVSamples(filepath string) ([]float64, int, error) {return ReadWAVSegmentSamples(filepath, 0, 0)}// ReadWAVSegmentSamples reads a specific time range of audio samples from a WAV file.// If startSec < 0, it starts from 0.// If endSec <= 0 or endSec > duration, it reads to the end.func ReadWAVSegmentSamples(filepath string, startSec, endSec float64) ([]float64, int, error) {file, info, err := parseWAVInfo(filepath)if err != nil {return nil, 0, err}defer func() { _ = file.Close() }()startOffset, readSize := calcWAVReadRange(startSec, endSec, info)audioData, err := readAudioSegment(file, info, startOffset, readSize)if err != nil {return nil, 0, err}if readSize == 0 {return []float64{}, info.sampleRate, nil}return f, info, nil}// readAudioSegment reads audio bytes from an already-parsed WAV file.func readAudioSegment(file *os.File, info wavChunkInfo, startOffset, readSize int64) ([]byte, error) {if info.sampleRate == 0 || info.channels == 0 || info.bitsPerSample == 0 {return nil, wavChunkInfo{}, fmt.Errorf("missing or invalid fmt chunk")}return info, fmt.Errorf("no data chunk found in WAV file")}// parseLISTChunkData parses a LIST chunk for INFO metadata.func parseLISTChunkData(data []byte, chunkSize int, m *WAVMetadata) {if chunkSize >= 4 && len(data) >= chunkSize {if string(data[:4]) == "INFO" {parseINFOChunk(data[4:chunkSize], m)}}}return metadata, nil}// Use readAndParseHeader for the header portion, but we need the file handle// for hashing, so we can't fully delegate.}// ParseWAVHeader efficiently reads only the WAV file header to extract metadata.// It reads the first 200KB of the file, which should be sufficient for all header chunks.func ParseWAVHeader(filepath string) (*WAVMetadata, error) {return readAndParseHeader(filepath, getHeaderBuffer, putHeaderBuffer)metadata.FileModTime = fileInfo.ModTime()metadata.FileSize = fileInfo.Size()
package utilsimport ("strings""testing""time")func TestIsAudioMoth(t *testing.T) {t.Run("should identify AudioMoth files by artist field", func(t *testing.T) {if !IsAudioMoth("", "AudioMoth") {t.Error("Should identify AudioMoth by artist field")}if !IsAudioMoth("", "AudioMoth 123456") {t.Error("Should identify AudioMoth with ID in artist field")}if IsAudioMoth("", "Other Artist") {t.Error("Should not identify non-AudioMoth artist")}})t.Run("should identify AudioMoth files by comment field", func(t *testing.T) {if !IsAudioMoth("Recorded by AudioMoth...", "") {t.Error("Should identify AudioMoth by comment field")}if IsAudioMoth("Regular recording comment", "") {t.Error("Should not identify non-AudioMoth comment")}})t.Run("should handle missing metadata", func(t *testing.T) {if IsAudioMoth("", "") {t.Error("Should not identify empty strings as AudioMoth")}})t.Run("should be case insensitive", func(t *testing.T) {if !IsAudioMoth("", "audiomoth") {t.Error("Should be case insensitive")}if !IsAudioMoth("", "AUDIOMOTH") {t.Error("Should be case insensitive")}})}func TestParseAudioMothComment(t *testing.T) {t.Run("should parse a valid structured AudioMoth comment", testParseStructuredComment)t.Run("should return error for invalid comments", testParseInvalidComments)t.Run("should handle different timezone formats", testParseTimezoneFormats)t.Run("should parse all gain levels", testParseAllGainLevels)t.Run("should handle negative temperatures", testParseNegativeTemp)t.Run("should fallback to legacy parsing", testParseLegacyFallback)}func testParseStructuredComment(t *testing.T) {comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C."result, err := ParseAudioMothComment(comment)if err != nil {t.Fatalf("Failed to parse comment: %v", err)}expected := time.Date(2025, 2, 24, 21, 0, 0, 0, time.FixedZone("UTC+13", 13*3600))if !result.Timestamp.Equal(expected) {t.Errorf("Timestamp incorrect: got %v, want %v", result.Timestamp, expected)}utc := result.Timestamp.UTC()expectedUTC := time.Date(2025, 2, 24, 8, 0, 0, 0, time.UTC)if !utc.Equal(expectedUTC) {t.Errorf("UTC timestamp incorrect: got %v, want %v", utc, expectedUTC)}if result.RecorderID != "248AB50153AB0549" {t.Errorf("RecorderID incorrect: got %s, want 248AB50153AB0549", result.RecorderID)}}if result.BatteryV != 4.3 {t.Errorf("BatteryV incorrect: got %f, want 4.3", result.BatteryV)}if result.TempC != 15.8 {t.Errorf("TempC incorrect: got %f, want 15.8", result.TempC)}}func testParseInvalidComments(t *testing.T) {invalidComments := []string{"Not an AudioMoth comment","Recorded at invalid time format","Short comment","","AudioMoth without proper format",}for _, comment := range invalidComments {_, err := ParseAudioMothComment(comment)if err == nil {t.Errorf("Expected error for invalid comment: %s", comment)}}}func testParseTimezoneFormats(t *testing.T) {commentUTCMinus := "Recorded at 10:30:45 15/06/2024 (UTC-5) by AudioMoth 123456789ABCDEF0 at high gain while battery was 3.9V and temperature was 22.1C."result, err := ParseAudioMothComment(commentUTCMinus)if err != nil {t.Fatalf("Failed to parse comment: %v", err)}expected := time.Date(2024, 6, 15, 10, 30, 45, 0, time.FixedZone("UTC-5", -5*3600))if !result.Timestamp.Equal(expected) {t.Errorf("Timestamp incorrect: got %v, want %v", result.Timestamp, expected)}}if result.BatteryV != 3.9 {t.Errorf("BatteryV incorrect: got %f, want 3.9", result.BatteryV)}if result.TempC != 22.1 {t.Errorf("TempC incorrect: got %f, want 22.1", result.TempC)}}func testParseAllGainLevels(t *testing.T) {testCases := []struct {gainStr string}{}for _, tc := range testCases {comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at " + tc.gainStr + " gain while battery was 4.3V and temperature was 15.8C."result, err := ParseAudioMothComment(comment)if err != nil {t.Errorf("Failed to parse comment with gain %s: %v", tc.gainStr, err)continue}if result.Gain != tc.expected {t.Errorf("Gain incorrect for %s: got %s, want %s", tc.gainStr, result.Gain, tc.expected)}}}func testParseNegativeTemp(t *testing.T) {comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was -5.2C."result, err := ParseAudioMothComment(comment)if err != nil {t.Fatalf("Failed to parse comment: %v", err)}if result.TempC != -5.2 {t.Errorf("TempC incorrect: got %f, want -5.2", result.TempC)}}func testParseLegacyFallback(t *testing.T) {comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C"result, err := ParseAudioMothComment(comment)if err == nil {if result.RecorderID == "" {t.Error("RecorderID should not be empty")}}}func TestParseGainLevel(t *testing.T) {testCases := []struct {input stringwantErr bool}{{"invalid", "", true},{"", "", true},{"ultra", "", true},}for _, tc := range testCases {t.Run(tc.input, func(t *testing.T) {result, err := parseGainLevel(tc.input)if tc.wantErr {if err == nil {t.Errorf("Expected error for input %q, got nil", tc.input)}} else {if err != nil {t.Errorf("Unexpected error for input %q: %v", tc.input, err)}if result != tc.expected {t.Errorf("Result incorrect for %q: got %s, want %s", tc.input, result, tc.expected)}}})}}func TestParseAudioMothTimestamp(t *testing.T) {t.Run("should parse standard timestamp format", func(t *testing.T) {result, err := parseAudioMothTimestamp("21:00:00", "24/02/2025", "UTC+13")if err != nil {t.Fatalf("Failed to parse timestamp: %v", err)}expected := time.Date(2025, 2, 24, 21, 0, 0, 0, time.FixedZone("UTC+13", 13*3600))if !result.Equal(expected) {t.Errorf("Timestamp incorrect: got %v, want %v", result, expected)}})t.Run("should parse timestamp with +HH format", func(t *testing.T) {result, err := parseAudioMothTimestamp("10:30:45", "15/06/2024", "+13")if err != nil {t.Fatalf("Failed to parse timestamp: %v", err)}expected := time.Date(2024, 6, 15, 10, 30, 45, 0, time.FixedZone("UTC+13", 13*3600))if !result.Equal(expected) {t.Errorf("Timestamp incorrect: got %v, want %v", result, expected)}})t.Run("should parse negative timezone offset", func(t *testing.T) {result, err := parseAudioMothTimestamp("10:30:45", "15/06/2024", "UTC-5")if err != nil {t.Fatalf("Failed to parse timestamp: %v", err)}expected := time.Date(2024, 6, 15, 10, 30, 45, 0, time.FixedZone("UTC-5", -5*3600))if !result.Equal(expected) {t.Errorf("Timestamp incorrect: got %v, want %v", result, expected)}})t.Run("should handle invalid time format", func(t *testing.T) {_, err := parseAudioMothTimestamp("25:00:00", "15/06/2024", "UTC+13")// Note: Go's time.Date will normalize invalid times, so this might not error// The error would be caught if the format doesn't match_ = err})t.Run("should handle invalid date format", func(t *testing.T) {_, err := parseAudioMothTimestamp("10:30:45", "32/13/2024", "UTC+13")// Note: Go's time.Date will normalize invalid dates_ = err})}func TestStructuredVsLegacyParsing(t *testing.T) {t.Run("should prefer structured parsing", func(t *testing.T) {comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C."result, err := ParseAudioMothComment(comment)if err != nil {t.Fatalf("Failed to parse comment: %v", err)}// Verify it parsed correctlyif result.RecorderID != "248AB50153AB0549" {t.Errorf("RecorderID incorrect: got %s, want 248AB50153AB0549", result.RecorderID)}})t.Run("should handle legacy format", func(t *testing.T) {// Create a comment that matches legacy space-separated formatcomment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C."// The structured parser should handle thisresult, err := ParseAudioMothComment(comment)if err != nil {// If structured fails, legacy should catch it// (though for this format, structured should work)t.Logf("Note: Structured parsing failed, expected legacy to handle: %v", err)} else {if result.RecorderID == "" {t.Error("RecorderID should not be empty")}}})}func TestAudioMothCommentEdgeCases(t *testing.T) {t.Run("should handle extra whitespace", func(t *testing.T) {comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C."// Depending on implementation, this might or might not parse_, err := ParseAudioMothComment(comment)if err != nil {// Expected - structured regex is strictt.Logf("Extra whitespace causes parsing to fail (expected): %v", err)}})t.Run("should handle different case in gain", func(t *testing.T) {comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at MEDIUM gain while battery was 4.3V and temperature was 15.8C."result, err := ParseAudioMothComment(comment)if err == nil {}}})t.Run("should handle non-hex recorder ID via legacy parser", func(t *testing.T) {// Structured regex expects [A-F0-9]+ hex format and will not match// Legacy parser will catch this and parse it (more lenient)comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth GGGGGGGGGGGGGGGG at medium gain while battery was 4.3V and temperature was 15.8C."result, err := ParseAudioMothComment(comment)// Legacy parser is lenient and accepts any recorder IDif err != nil {t.Fatalf("Legacy parser should handle non-hex recorder ID: %v", err)}// Verify it parsed the recorder ID (even though it's not valid hex)if result.RecorderID != "GGGGGGGGGGGGGGGG" {t.Errorf("RecorderID incorrect: got %s, want GGGGGGGGGGGGGGGG", result.RecorderID)}})t.Run("should handle recorder ID of different lengths", func(t *testing.T) {// Short IDcomment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth ABCD at medium gain while battery was 4.3V and temperature was 15.8C."result, err := ParseAudioMothComment(comment)if err != nil {t.Fatalf("Failed to parse comment with short ID: %v", err)}if !strings.Contains(result.RecorderID, "ABCD") {t.Errorf("RecorderID should contain ABCD, got %s", result.RecorderID)}})}if result.Gain != GainMedium {t.Errorf("Gain should be normalized: got %s, want %s", result.Gain, GainMedium){"low", GainLow, false},{"LOW", GainLow, false},{" low ", GainLow, false},{"low-medium", GainLowMedium, false},{"medium", GainMedium, false},{"medium-high", GainMediumHigh, false},{"high", GainHigh, false},expected GainLevel{"low", GainLow},{"low-medium", GainLowMedium},{"medium", GainMedium},{"medium-high", GainMediumHigh},{"high", GainHigh},expected GainLevelif result.Gain != GainHigh {t.Errorf("Gain incorrect: got %s, want %s", result.Gain, GainHigh)if result.Gain != GainMedium {t.Errorf("Gain incorrect: got %s, want %s", result.Gain, GainMedium)
package utilsimport ("fmt""regexp""strconv""strings""time")// AudioMothData contains parsed data from AudioMoth comment fieldtype AudioMothData struct {Timestamp time.TimeRecorderID stringBatteryV float64TempC float64}// AudioMoth comment example:// "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C."var (// Pattern to detect AudioMoth commentsaudiomothPattern = regexp.MustCompile(`(?i)AudioMoth`)// Pattern to extract structured data// Matches: "Recorded at HH:MM:SS DD/MM/YYYY (UTC±HH) by AudioMoth HEXID at GAIN gain while battery was X.XV and temperature was Y.YC."structuredPattern = regexp.MustCompile(`Recorded at (\d{2}:\d{2}:\d{2}) (\d{2}/\d{2}/\d{4}) \(UTC([+-]\d+)\) by AudioMoth ([A-F0-9]+) at ([\w-]+) gain while battery was ([\d.]+)V and temperature was ([-\d.]+)C`,))// IsAudioMoth detects if a WAV file is from an AudioMoth recorder by checking comment and artist fieldsfunc IsAudioMoth(comment, artist string) bool {return audiomothPattern.MatchString(comment) || audiomothPattern.MatchString(artist)}// ParseAudioMothComment extracts timestamp, gain, battery, and temperature from an AudioMoth comment.func ParseAudioMothComment(comment string) (*AudioMothData, error) {// Try structured parsing first (newer format)if data, err := parseStructuredComment(comment); err == nil {return data, nil}// Fallback to legacy space-separated parsingreturn parseLegacyComment(comment)}// parseStructuredComment parses newer AudioMoth comment format using regexfunc parseStructuredComment(comment string) (*AudioMothData, error) {matches := structuredPattern.FindStringSubmatch(comment)if matches == nil {return nil, fmt.Errorf("comment does not match structured AudioMoth format")}// Extract matched groupstimeStr := matches[1] // HH:MM:SSdateStr := matches[2] // DD/MM/YYYYtimezoneStr := matches[3] // ±HHrecorderID := matches[4] // Hex IDgainStr := matches[5] // gain levelbatteryStr := matches[6] // battery voltagetempStr := matches[7] // temperature// Parse timestamptimestamp, err := parseAudioMothTimestamp(timeStr, dateStr, timezoneStr)if err != nil {return nil, fmt.Errorf("failed to parse timestamp: %w", err)}// Parse gaingain, err := parseGainLevel(gainStr)if err != nil {return nil, fmt.Errorf("failed to parse gain: %w", err)}// Parse battery voltagebatteryV, err := strconv.ParseFloat(batteryStr, 64)if err != nil {return nil, fmt.Errorf("failed to parse battery voltage: %w", err)}// Parse temperaturetempC, err := strconv.ParseFloat(tempStr, 64)if err != nil {return nil, fmt.Errorf("failed to parse temperature: %w", err)}return &AudioMothData{Timestamp: timestamp,RecorderID: recorderID,Gain: gain,BatteryV: batteryV,TempC: tempC,}, nil}// parseLegacyComment parses older AudioMoth comment format (space-separated).// Example: "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C."func parseLegacyComment(comment string) (*AudioMothData, error) {parts := strings.Fields(comment)if len(parts) < 10 {return nil, fmt.Errorf("comment has insufficient parts (got %d, need at least 10)", len(parts))}// 0-based indices after split by space:// parts[2] = "21:00:00" (time HH:MM:SS)// parts[3] = "24/02/2025" (date DD/MM/YYYY)// parts[4] = "(UTC+13)" (timezone offset)// parts[7] = "248AB50153AB0549" (moth ID)// parts[9] = "medium" (gain)// parts[len-5] = "4.3V" (battery voltage)// parts[len-1] = "15.8C." (temperature)timeStr := parts[2]dateStr := parts[3]timezoneStr := strings.Trim(parts[4], "()")recorderID := parts[7]gainStr := parts[9]// Parse timestamptimestamp, err := parseAudioMothTimestamp(timeStr, dateStr, timezoneStr)if err != nil {return nil, fmt.Errorf("failed to parse timestamp: %w", err)}// Parse gaingain, err := parseGainLevel(gainStr)if err != nil {return nil, fmt.Errorf("failed to parse gain: %w", err)}// Parse battery voltage (e.g., "4.3V")batteryStr := parts[len(parts)-5]batteryStr = strings.TrimSuffix(batteryStr, "V")batteryV, err := strconv.ParseFloat(batteryStr, 64)if err != nil {return nil, fmt.Errorf("failed to parse battery voltage: %w", err)}// Parse temperature (e.g., "15.8C." or "15.8C")tempStr := parts[len(parts)-1]tempStr = strings.TrimSuffix(tempStr, ".")tempStr = strings.TrimSuffix(tempStr, "C")tempC, err := strconv.ParseFloat(tempStr, 64)if err != nil {return nil, fmt.Errorf("failed to parse temperature: %w", err)}return &AudioMothData{Timestamp: timestamp,RecorderID: recorderID,Gain: gain,BatteryV: batteryV,TempC: tempC,}, nil}// parseAudioMothTimestamp parses AudioMoth timestamp from time, date, and timezone strings// timeStr: "HH:MM:SS"// dateStr: "DD/MM/YYYY"// timezoneStr: "UTC+13" or "+13"func parseAudioMothTimestamp(timeStr, dateStr, timezoneStr string) (time.Time, error) {// Parse time componentstimeParts := strings.Split(timeStr, ":")if len(timeParts) != 3 {return time.Time{}, fmt.Errorf("invalid time format: %s", timeStr)}hour, _ := strconv.Atoi(timeParts[0])minute, _ := strconv.Atoi(timeParts[1])second, _ := strconv.Atoi(timeParts[2])// Parse date componentsdateParts := strings.Split(dateStr, "/")if len(dateParts) != 3 {return time.Time{}, fmt.Errorf("invalid date format: %s", dateStr)}day, _ := strconv.Atoi(dateParts[0])month, _ := strconv.Atoi(dateParts[1])year, _ := strconv.Atoi(dateParts[2])// Parse timezone offsettimezoneStr = strings.TrimPrefix(timezoneStr, "UTC")offsetHours, err := strconv.Atoi(timezoneStr)if err != nil {return time.Time{}, fmt.Errorf("invalid timezone offset: %s", timezoneStr)}// Create fixed timezone locationoffsetSeconds := offsetHours * 3600loc := time.FixedZone(fmt.Sprintf("UTC%+d", offsetHours), offsetSeconds)// Construct timestamptimestamp := time.Date(year, time.Month(month), day, hour, minute, second, 0, loc)return timestamp, nil}// parseGainLevel converts string gain level to GainLevel enumgainStr = strings.ToLower(strings.TrimSpace(gainStr))switch gainStr {case "low":case "low-medium":case "medium":case "medium-high":case "high":default:return "", fmt.Errorf("unknown gain level: %s", gainStr)}}return GainHigh, nilreturn GainMediumHigh, nilreturn GainMedium, nilreturn GainLowMedium, nilreturn GainLow, nilfunc parseGainLevel(gainStr string) (GainLevel, error) {Gain GainLevel// AudioMoth gain level enum constantsconst (GainLow GainLevel = "low"GainLowMedium GainLevel = "low-medium"GainMedium GainLevel = "medium"GainMediumHigh GainLevel = "medium-high"GainHigh GainLevel = "high")// GainLevel represents the gain_level enum for AudioMoth recordingstype GainLevel string
func mustResolveTimestamp(t *testing.T, meta *WAVMetadata, filename, tz string, useModTime bool, preParsed *time.Time) *TimestampResult {
func mustResolveTimestamp(t *testing.T, meta *wav.WAVMetadata, filename, tz string, useModTime bool, preParsed *time.Time) *TimestampResult {
// Test 1: Read specific segment (0.25s = 2 samples at 8000Hz)// Actually, 1 sample is 1/8000s. Let's just read the whole thing for the test to keep it simple and test the binary chunk reading logicsamples, rate, err := ReadWAVSegmentSamples(tmpPath, 0, 0)
// Read specific segmentsamples, rate, err := wav.ReadWAVSegmentSamples(tmpPath, 0, 0)
func ResolveTimestamp(wavMeta *WAVMetadata, filePath string, timezoneID string, useFileModTime bool, preParsedFilenameTime *time.Time) (*TimestampResult, error) {
func ResolveTimestamp(wavMeta *wav.WAVMetadata, filePath string, timezoneID string, useFileModTime bool, preParsedFilenameTime *time.Time) (*TimestampResult, error) {
GainLow = utils.GainLowGainLowMedium = utils.GainLowMediumGainMedium = utils.GainMediumGainMediumHigh = utils.GainMediumHighGainHigh = utils.GainHigh
GainLow = wav.GainLowGainLowMedium = wav.GainLowMediumGainMedium = wav.GainMediumGainMediumHigh = wav.GainMediumHighGainHigh = wav.GainHigh
## [2026-05-19] Extract wav/ package (Phase 3)- **Created `wav/` package** with 3 files from `utils/`:- `wav_metadata.go` — `WAVMetadata`, `ParseWAVHeader`, `ParseWAVHeaderMinimal`, `ParseWAVHeaderWithHash`, `ReadWAVSegmentSamples`, `ReadWAVSamples`- `wav_writer.go` — `WriteWAVFile`- `audiomoth_parser.go` — `AudioMothData`, `GainLevel`, `ParseAudioMothComment`, `IsAudioMoth`, gain constants- **`ResolveTimestamp` + `ProcessSingleFile` stayed in `utils/file_import.go`** — moving them to `wav/` would create an import cycle (`utils/spectrogram.go` → `wav/`), since they depend on `utils.ComputeXXH64`, `utils.HasTimestampFilename`, etc. They can move after Phase 4 extracts `spectrogram/`.- `utils/file_import.go` updated to import `wav/` for types (`wav.WAVMetadata`, `wav.AudioMothData`, `wav.IsAudioMoth`, `wav.ParseAudioMothComment`, `wav.ParseWAVHeader`)- `utils/wav_writer.go` imports `audio/` for `Float64ToPCM16` (was already updated in Phase 2)- `wav/wav_metadata.go` has its own local hash buffer pool (was sharing `utils.getHashBuffer`/`putHashBuffer`)- Updated callers: `db/types.go` (`utils.GainLevel` → `wav.GainLevel`), `cmd/metadata.go`, `tools/calls/*.go`, `tools/import/cluster_import.go`, `tools/import/import_file.go`, `tools/import/import_unstructured.go`, `tui/update.go`, `utils/spectrogram.go`- Moved 3 test files to `wav/`; updated `utils/file_import_test.go` with `wav.` qualified calls- `utils/` shrinks from 2,914 → 2,112 LOC (19 → 16 non-test files)