third phase of utils refactor, wav/

quietlight
May 18, 2026, 11:07 PM
XU7FTYK3YAM5TADBGEJZ44UJ6LNJ7YVABSFEXKOBGUHXBFHVLR2AC

Dependencies

  • [2] ZTOMARKZ fix lint test fails
  • [3] N57PNZPF second phase of utils refactor, audio/
  • [4] GVOVKH5R more cyclo refactoring
  • [5] TUC452XH new util shared by 3 cmd's needing location
  • [6] 43TMU2JO more tests, glm much better than claude
  • [7] KLUEQ6X5 cyclo 21+
  • [8] YVFPP5VJ refactor of tui/ first iteration
  • [9] M34GDDTW fill calls add, check duration
  • [10] P4CJMBYK added first version of --bandpass flag to calls classify, work to do
  • [11] KZKLAINJ run out of space on nest, cleaned out
  • [12] 3ETJ6KPI refactor of tui/ second iteration
  • [13] RUVJ3V4N cyclo to 14 now
  • [14] O45G7VX2 added an add and a remove command
  • [15] QVIGQOQZ more work on utils/ with glm
  • [16] 3DVPQOKB big tidy up of tools/
  • [17] JZRF7OBJ refactor to get db omports out of utils, but still have failing tests, may need updating
  • [18] E27ZWCDP cyclo over 18
  • [19] ZOSYO3IB ck 3
  • [20] A6MCX2V6 emptied audio/ and moved files into testdata folders
  • [21] Q4JPMGET fixed tests
  • [22] GPQSOVBP cyclo complexity over 25
  • [23] WKQ7LFTP refactor of utils/
  • [24] LQLC7S3A trying gemini: Inconsistent Standards in @utils/ refactoring
  • [25] VU3KBTQ6 more tests
  • [26] LBWQJEDH minor refactor and more tests for utils/
  • [27] DS22DKV3 added shell script integration tests.
  • [28] HYCZTLSZ fixed tests with cyclo over 15
  • [29] NS4TDPLN cyclomatic complexity
  • [30] 2HAQZPV3 more refactoring with glm
  • [31] FCCJNYCV more tests for utils/
  • [32] NQPVZ3PP first phase of utils refactor, all realted to db interfaces
  • [33] ZKLAOPUR fix event logging
  • [*] SJN7IKIV

Change contents

  • file addition: wav (d--r------)
    [35.1]
  • file addition: wav_writer_test.go (----------)
    [0.1]
    package wav
    import (
    "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 clamping
    err := 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")
    }
    })
    }
  • file addition: wav_writer.go (----------)
    [0.1]
    package wav
    import (
    "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 parameters
    channels := 1
    bitsPerSample := 16
    bytesPerSample := bitsPerSample / 8
    byteRate := sampleRate * channels * bytesPerSample
    blockAlign := channels * bytesPerSample
    dataSize := len(samples) * bytesPerSample
    totalSize := 36 + dataSize // 36 = header size before data chunk
    // Write 44-byte WAV header in one go
    header := 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 size
    binary.LittleEndian.PutUint16(header[20:22], 1) // PCM format
    binary.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 buffer
    buf := 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
    }
  • file addition: wav_metadata_test.go (----------)
    [0.1]
    package wav
    import (
    "bytes"
    "encoding/binary"
    "fmt"
    "math"
    "os"
    "path/filepath"
    "testing"
    "time"
    )
    // createTestWAVFile creates a minimal valid WAV file for testing
    func createTestWAVFile(t *testing.T, dir string, filename string, options struct {
    duration float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 duration
    bytesPerSample := options.bitsPerSample / 8
    samplesPerSecond := options.sampleRate * options.channels
    dataSize := 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 provided
    var infoChunk []byte
    if options.comment != "" || options.artist != "" {
    infoChunk = buildINFOChunk(options.comment, options.artist)
    fileSize += 8 + len(infoChunk) // LIST chunk header + content
    }
    buf := &bytes.Buffer{}
    // Write RIFF header
    buf.WriteString("RIFF")
    binary.Write(buf, binary.LittleEndian, uint32(fileSize))
    buf.WriteString("WAVE")
    // Write fmt chunk
    buf.WriteString("fmt ")
    binary.Write(buf, binary.LittleEndian, uint32(16)) // chunk size
    binary.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 * bytesPerSample
    binary.Write(buf, binary.LittleEndian, uint32(byteRate))
    blockAlign := options.channels * bytesPerSample
    binary.Write(buf, binary.LittleEndian, uint16(blockAlign))
    binary.Write(buf, binary.LittleEndian, uint16(options.bitsPerSample))
    // Write LIST INFO chunk if metadata provided
    if len(infoChunk) > 0 {
    buf.WriteString("LIST")
    binary.Write(buf, binary.LittleEndian, uint32(len(infoChunk)))
    buf.Write(infoChunk)
    }
    // Write data chunk
    buf.WriteString("data")
    binary.Write(buf, binary.LittleEndian, uint32(dataSize))
    // Write silence for data
    buf.Write(make([]byte, dataSize))
    // Write to file
    if _, 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 artist
    func buildINFOChunk(comment, artist string) []byte {
    buf := &bytes.Buffer{}
    buf.WriteString("INFO")
    if comment != "" {
    buf.WriteString("ICMT")
    // Size includes null terminator
    size := len(comment) + 1
    binary.Write(buf, binary.LittleEndian, uint32(size))
    buf.WriteString(comment)
    buf.WriteByte(0) // null terminator
    // Add padding byte if needed for word alignment
    if size%2 != 0 {
    buf.WriteByte(0)
    }
    }
    if artist != "" {
    buf.WriteString("IART")
    size := len(artist) + 1
    binary.Write(buf, binary.LittleEndian, uint32(size))
    buf.WriteString(artist)
    buf.WriteByte(0) // null terminator
    if size%2 != 0 {
    buf.WriteByte(0)
    }
    }
    return buf.Bytes()
    }
    func TestConvertToFloat64_16Bit(t *testing.T) {
    // 16-bit signed, little-endian: 0x0001 = 1 → 1/32768
    data := []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.99997
    if 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.0
    data := []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 = 2
    data := []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/8388608
    data := []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/8388608
    data := []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.9999999
    data := []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.0
    data := []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=2
    data := []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/2147483648
    data := []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.0
    data := []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.9999999995
    data := []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=2
    data := []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 samples
    samples := convertToFloat64(data, 8, 2) // 2 channels → blockAlign = 1*2 = 2
    if 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist string
    }{
    duration: 0.1, // 100ms
    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.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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist string
    }{
    duration: 600.0, // 10 minutes
    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.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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 granularity
    diff := 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 string
    input []byte
    expected 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist string
    }{5.0, sr, 1, 16, "", ""})
    })
    }
    })
    t.Run("stereo", func(t *testing.T) {
    assertWAVHeader(t, tmpDir, "test_stereo.wav", 44100, 3.0, struct {
    duration float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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")
    }
    })
    }
  • file addition: wav_metadata.go (----------)
    [0.1]
    package wav
    import (
    "bytes"
    "encoding/binary"
    "fmt"
    "io"
    "os"
    "sync"
    "time"
    "github.com/cespare/xxhash/v2"
    )
    // Buffer pools for reducing GC pressure during batch imports
    var (
    // 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 files
    minimalHeaderBufferPool = sync.Pool{
    New: func() any {
    buf := make([]byte, 4*1024)
    return &buf
    },
    }
    // hashBufferPool stores 128KB buffers for hashing WAV files
    hashBufferPool = sync.Pool{
    New: func() any {
    buf := make([]byte, 128*1024)
    return &buf
    },
    }
    )
    // getHeaderBuffer gets a 200KB buffer from the pool
    func getHeaderBuffer() *[]byte {
    return headerBufferPool.Get().(*[]byte)
    }
    // putHeaderBuffer returns a 200KB buffer to the pool
    func putHeaderBuffer(buf *[]byte) {
    headerBufferPool.Put(buf)
    }
    // getMinimalHeaderBuffer gets a 4KB buffer from the pool
    func getMinimalHeaderBuffer() *[]byte {
    return minimalHeaderBufferPool.Get().(*[]byte)
    }
    // putMinimalHeaderBuffer returns a 4KB buffer to the pool
    func putMinimalHeaderBuffer(buf *[]byte) {
    minimalHeaderBufferPool.Put(buf)
    }
    // getHashBuffer gets a 128KB buffer from the pool
    func getHashBuffer() *[]byte {
    return hashBufferPool.Get().(*[]byte)
    }
    // putHashBuffer returns a 128KB buffer to the pool
    func putHashBuffer(buf *[]byte) {
    hashBufferPool.Put(buf)
    }
    // WAVMetadata contains metadata extracted from WAV file headers
    type WAVMetadata struct {
    Duration float64 // Duration in seconds
    SampleRate int // Sample rate in Hz
    Comment string // Comment from INFO chunk (may contain AudioMoth data)
    Artist string // Artist from INFO chunk
    Channels int // Number of audio channels
    BitsPerSample int // Bits per sample
    FileModTime 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 file
    if _, err := file.Seek(0, 0); err != nil {
    return nil, "", fmt.Errorf("failed to seek: %w", err)
    }
    hashBufPtr := getHashBuffer()
    defer putHashBuffer(hashBufPtr)
    hashBuf := *hashBufPtr
    h := 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 buffer
    func 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 := 12
    for offset < len(data)-8 {
    chunkID := string(data[offset : offset+4])
    chunkSize := int(binary.LittleEndian.Uint32(data[offset+4 : offset+8]))
    offset += 8
    switch chunkID {
    case "fmt ":
    parseFmtChunkData(data[offset:], chunkSize, metadata)
    case "data":
    calcDataChunkDuration(chunkSize, metadata)
    case "LIST":
    parseLISTChunkData(data[offset:], chunkSize, metadata)
    }
    offset += chunkSize
    if 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 / 8
    bytesPerSecond := m.SampleRate * m.Channels * bytesPerSample
    if 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 metadata
    func parseINFOChunk(data []byte, metadata *WAVMetadata) {
    offset := 0
    for 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 += 8
    if offset+subchunkSize > len(data) {
    break
    }
    value := extractNullTerminatedString(data[offset : offset+subchunkSize])
    switch subchunkID {
    case "ICMT":
    metadata.Comment = value
    case "IART":
    metadata.Artist = value
    }
    offset += subchunkSize
    if subchunkSize%2 != 0 {
    offset++
    }
    }
    }
    // extractNullTerminatedString extracts a null-terminated string from bytes
    func 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 int
    channels int
    bitsPerSample int
    dataOffset int64
    dataSize 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 wavChunkInfo
    for {
    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 = chunkSize
    return info, nil
    default:
    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 / 8
    blockAlign := bytesPerSample * info.channels
    if 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 / 8
    blockAlign := bytesPerSample * channels
    numSamples := len(data) / blockAlign
    samples := make([]float64, numSamples)
    switch bitsPerSample {
    case 16:
    for i := range numSamples {
    offset := i * blockAlign
    sample := int16(binary.LittleEndian.Uint16(data[offset : offset+2]))
    samples[i] = float64(sample) / 32768.0
    }
    case 24:
    for i := range numSamples {
    offset := i * blockAlign
    b := data[offset : offset+3]
    sample := int32(b[0]) | int32(b[1])<<8 | int32(b[2])<<16
    if sample >= 0x800000 {
    sample -= 0x1000000
    }
    samples[i] = float64(sample) / 8388608.0
    }
    case 32:
    for i := range numSamples {
    offset := i * blockAlign
    sample := int32(binary.LittleEndian.Uint32(data[offset : offset+4]))
    samples[i] = float64(sample) / 2147483648.0
    }
    default:
    for i := range numSamples {
    offset := i * blockAlign
    sample := int16(binary.LittleEndian.Uint16(data[offset : offset+2]))
    samples[i] = float64(sample) / 32768.0
    }
    }
    return samples
    }
  • file addition: audiomoth_parser_test.go (----------)
    [0.1]
    package wav
    import (
    "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 string
    expected 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 string
    expected GainLevel
    wantErr 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 correctly
    if 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 format
    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."
    // The structured parser should handle this
    result, 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 strict
    t.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 ID
    if 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 ID
    comment := "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)
    }
    })
    }
  • file addition: audiomoth_parser.go (----------)
    [0.1]
    package wav
    import (
    "fmt"
    "regexp"
    "strconv"
    "strings"
    "time"
    )
    // GainLevel represents the gain_level enum for AudioMoth recordings
    type GainLevel string
    // AudioMoth gain level enum constants
    const (
    GainLow GainLevel = "low"
    GainLowMedium GainLevel = "low-medium"
    GainMedium GainLevel = "medium"
    GainMediumHigh GainLevel = "medium-high"
    GainHigh GainLevel = "high"
    )
    // AudioMothData contains parsed data from AudioMoth comment field
    type AudioMothData struct {
    Timestamp time.Time
    RecorderID string
    Gain GainLevel
    BatteryV float64
    TempC 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 comments
    audiomothPattern = regexp.MustCompile(`(?i)AudioMoth`)
    // Pattern to extract structured data
    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 fields
    func 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 parsing
    return parseLegacyComment(comment)
    }
    // parseStructuredComment parses newer AudioMoth comment format using regex
    func 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 strings
    func 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 * 3600
    loc := 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 enum
    func parseGainLevel(gainStr string) (GainLevel, error) {
    gainStr = strings.ToLower(strings.TrimSpace(gainStr))
    switch gainStr {
    case "low":
    return GainLow, nil
    case "low-medium":
    return GainLowMedium, nil
    case "medium":
    return GainMedium, nil
    case "medium-high":
    return GainMediumHigh, nil
    case "high":
    return GainHigh, nil
    default:
    return "", fmt.Errorf("unknown gain level: %s", gainStr)
    }
    }
  • file deletion: wav_writer_test.go (----------)
    [4.1][4.749:791](),[4.791][4.1:1]()
    package utils
    import (
    "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 clamping
    err := 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")
    }
    })
    }
  • file deletion: wav_writer.go (----------)
    [4.1][4.6783:6820](),[4.6820][4.4507:4507]()
    package utils
    import (
    "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 parameters
    channels := 1
    bitsPerSample := 16
    bytesPerSample := bitsPerSample / 8
    byteRate := sampleRate * channels * bytesPerSample
    blockAlign := channels * bytesPerSample
    dataSize := len(samples) * bytesPerSample
    totalSize := 36 + dataSize // 36 = header size before data chunk
    // Write 44-byte WAV header in one go
    header := 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 size
    binary.LittleEndian.PutUint16(header[20:22], 1) // PCM format
    binary.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 buffer
    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
    }
    buf := audio.Float64ToPCM16(samples)
  • file deletion: wav_metadata_test.go (----------)
    [4.1][4.25412:25456](),[4.25456][4.6822:6822]()
    package utils
    import (
    "bytes"
    "encoding/binary"
    "fmt"
    "math"
    "os"
    "path/filepath"
    "testing"
    "time"
    )
    // createTestWAVFile creates a minimal valid WAV file for testing
    func createTestWAVFile(t *testing.T, dir string, filename string, options struct {
    duration float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 duration
    bytesPerSample := options.bitsPerSample / 8
    samplesPerSecond := options.sampleRate * options.channels
    dataSize := 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 provided
    var infoChunk []byte
    if options.comment != "" || options.artist != "" {
    infoChunk = buildINFOChunk(options.comment, options.artist)
    fileSize += 8 + len(infoChunk) // LIST chunk header + content
    }
    buf := &bytes.Buffer{}
    // Write RIFF header
    buf.WriteString("RIFF")
    binary.Write(buf, binary.LittleEndian, uint32(fileSize))
    buf.WriteString("WAVE")
    // Write fmt chunk
    buf.WriteString("fmt ")
    binary.Write(buf, binary.LittleEndian, uint32(16)) // chunk size
    binary.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 * bytesPerSample
    binary.Write(buf, binary.LittleEndian, uint32(byteRate))
    blockAlign := options.channels * bytesPerSample
    binary.Write(buf, binary.LittleEndian, uint16(blockAlign))
    binary.Write(buf, binary.LittleEndian, uint16(options.bitsPerSample))
    // Write LIST INFO chunk if metadata provided
    if len(infoChunk) > 0 {
    buf.WriteString("LIST")
    binary.Write(buf, binary.LittleEndian, uint32(len(infoChunk)))
    buf.Write(infoChunk)
    }
    // Write data chunk
    buf.WriteString("data")
    binary.Write(buf, binary.LittleEndian, uint32(dataSize))
    // Write silence for data
    buf.Write(make([]byte, dataSize))
    // Write to file
    if _, 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 artist
    func buildINFOChunk(comment, artist string) []byte {
    buf := &bytes.Buffer{}
    buf.WriteString("INFO")
    if comment != "" {
    buf.WriteString("ICMT")
    // Size includes null terminator
    size := len(comment) + 1
    binary.Write(buf, binary.LittleEndian, uint32(size))
    buf.WriteString(comment)
    buf.WriteByte(0) // null terminator
    // Add padding byte if needed for word alignment
    if size%2 != 0 {
    buf.WriteByte(0)
    }
    }
    if artist != "" {
    buf.WriteString("IART")
    size := len(artist) + 1
    binary.Write(buf, binary.LittleEndian, uint32(size))
    buf.WriteString(artist)
    buf.WriteByte(0) // null terminator
    if size%2 != 0 {
    buf.WriteByte(0)
    }
    }
    return buf.Bytes()
    }
    func TestConvertToFloat64_16Bit(t *testing.T) {
    // 16-bit signed, little-endian: 0x0001 = 1 → 1/32768
    data := []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.99997
    if 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.0
    data := []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 = 2
    data := []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/8388608
    data := []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/8388608
    data := []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.9999999
    data := []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.0
    data := []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=2
    data := []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/2147483648
    data := []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.0
    data := []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.9999999995
    data := []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=2
    data := []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 samples
    samples := convertToFloat64(data, 8, 2) // 2 channels → blockAlign = 1*2 = 2
    if 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist string
    }{
    duration: 0.1, // 100ms
    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.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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist string
    }{
    duration: 600.0, // 10 minutes
    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.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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 granularity
    diff := 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 string
    input []byte
    expected 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist string
    }{5.0, sr, 1, 16, "", ""})
    })
    }
    })
    t.Run("stereo", func(t *testing.T) {
    assertWAVHeader(t, tmpDir, "test_stereo.wav", 44100, 3.0, struct {
    duration float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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 float64
    sampleRate int
    channels int
    bitsPerSample int
    comment string
    artist 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)
    }
    }
    }
    }
  • file deletion: wav_metadata.go (----------)
    [4.1][4.40953:40992](),[4.40992][4.25458:25458]()
    package utils
    import (
    "bytes"
    "encoding/binary"
    "fmt"
    "io"
    "os"
    "sync"
    "time"
    "github.com/cespare/xxhash/v2"
    )
    // Buffer pools for reducing GC pressure during batch imports
    var (
    // 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 files
    minimalHeaderBufferPool = sync.Pool{
    New: func() any {
    buf := make([]byte, 4*1024)
    return &buf
    },
    }
    )
    // getHeaderBuffer gets a 200KB buffer from the pool
    func getHeaderBuffer() *[]byte {
    return headerBufferPool.Get().(*[]byte)
    }
    // putHeaderBuffer returns a 200KB buffer to the pool
    func putHeaderBuffer(buf *[]byte) {
    headerBufferPool.Put(buf)
    }
    // getMinimalHeaderBuffer gets a 4KB buffer from the pool
    func getMinimalHeaderBuffer() *[]byte {
    return minimalHeaderBufferPool.Get().(*[]byte)
    }
    // putMinimalHeaderBuffer returns a 4KB buffer to the pool
    func putMinimalHeaderBuffer(buf *[]byte) {
    minimalHeaderBufferPool.Put(buf)
    }
    // WAVMetadata contains metadata extracted from WAV file headers
    type WAVMetadata struct {
    Duration float64 // Duration in seconds
    SampleRate int // Sample rate in Hz
    Comment string // Comment from INFO chunk (may contain AudioMoth data)
    Artist string // Artist from INFO chunk
    Channels int // Number of audio channels
    BitsPerSample int // Bits per sample
    FileModTime 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 file
    if _, err := file.Seek(0, 0); err != nil {
    return nil, "", fmt.Errorf("failed to seek: %w", err)
    }
    hashBufPtr := getHashBuffer()
    defer putHashBuffer(hashBufPtr)
    hashBuf := *hashBufPtr
    h := 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 buffer
    func 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 := 12
    for offset < len(data)-8 {
    chunkID := string(data[offset : offset+4])
    chunkSize := int(binary.LittleEndian.Uint32(data[offset+4 : offset+8]))
    offset += 8
    switch chunkID {
    case "fmt ":
    parseFmtChunkData(data[offset:], chunkSize, metadata)
    case "data":
    calcDataChunkDuration(chunkSize, metadata)
    case "LIST":
    parseLISTChunkData(data[offset:], chunkSize, metadata)
    }
    offset += chunkSize
    if 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 / 8
    bytesPerSecond := m.SampleRate * m.Channels * bytesPerSample
    if bytesPerSecond > 0 {
    m.Duration = float64(chunkSize) / float64(bytesPerSecond)
    }
    }
    }
    // parseINFOChunk parses INFO list chunk for comment and artist metadata
    func parseINFOChunk(data []byte, metadata *WAVMetadata) {
    offset := 0
    for offset < len(data)-8 {
    // Read subchunk ID and size
    if offset+8 > len(data) {
    break
    }
    subchunkID := string(data[offset : offset+4])
    subchunkSize := int(binary.LittleEndian.Uint32(data[offset+4 : offset+8]))
    offset += 8
    if offset+subchunkSize > len(data) {
    break
    }
    // Extract null-terminated string
    value := extractNullTerminatedString(data[offset : offset+subchunkSize])
    switch subchunkID {
    case "ICMT": // Comment
    metadata.Comment = value
    case "IART": // Artist
    metadata.Artist = value
    }
    // Move to next subchunk (word-aligned)
    offset += subchunkSize
    if subchunkSize%2 != 0 {
    offset++ // Skip padding byte
    }
    }
    }
    // extractNullTerminatedString extracts a null-terminated string from bytes
    func 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 int
    channels int
    bitsPerSample int
    dataOffset int64
    dataSize 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 wavChunkInfo
    for {
    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 = chunkSize
    return info, nil
    default:
    if _, err := file.Seek(chunkSize, io.SeekCurrent); err != nil {
    return info, fmt.Errorf("failed to skip chunk: %w", err)
    }
    }
    // Word align
    if 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 / 8
    blockAlign := bytesPerSample * info.channels
    if 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 / 8
    blockAlign := bytesPerSample * channels
    numSamples := len(data) / blockAlign
    samples := make([]float64, numSamples)
    switch bitsPerSample {
    case 16:
    for i := range numSamples {
    // Read first (left) channel only for stereo
    offset := i * blockAlign
    sample := 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-endian
    b := data[offset : offset+3]
    sample := int32(b[0]) | int32(b[1])<<8 | int32(b[2])<<16
    // Sign extend
    if sample >= 0x800000 {
    sample -= 0x1000000
    }
    samples[i] = float64(sample) / 8388608.0
    }
    case 32:
    for i := range numSamples {
    offset := i * blockAlign
    sample := int32(binary.LittleEndian.Uint32(data[offset : offset+4]))
    samples[i] = float64(sample) / 2147483648.0
    }
    default:
    // Fallback: treat as 16-bit
    for i := range numSamples {
    offset := i * blockAlign
    sample := 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()
  • file deletion: audiomoth_parser_test.go (----------)
    [4.1][4.206837:206885](),[4.206885][4.194786:194786]()
    package utils
    import (
    "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 string
    wantErr 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 correctly
    if 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 format
    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."
    // The structured parser should handle this
    result, 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 strict
    t.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 ID
    if 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 ID
    comment := "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 GainLevel
    if 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)
  • file deletion: audiomoth_parser.go (----------)
    [4.1][4.213840:213883](),[4.213883][4.206887:206887]()
    package utils
    import (
    "fmt"
    "regexp"
    "strconv"
    "strings"
    "time"
    )
    // AudioMothData contains parsed data from AudioMoth comment field
    type AudioMothData struct {
    Timestamp time.Time
    RecorderID string
    BatteryV float64
    TempC 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 comments
    audiomothPattern = 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 fields
    func 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 parsing
    return parseLegacyComment(comment)
    }
    // parseStructuredComment parses newer AudioMoth comment format using regex
    func 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 groups
    timeStr := matches[1] // HH:MM:SS
    dateStr := matches[2] // DD/MM/YYYY
    timezoneStr := matches[3] // ±HH
    recorderID := matches[4] // Hex ID
    gainStr := matches[5] // gain level
    batteryStr := matches[6] // battery voltage
    tempStr := matches[7] // temperature
    // Parse timestamp
    timestamp, err := parseAudioMothTimestamp(timeStr, dateStr, timezoneStr)
    if err != nil {
    return nil, fmt.Errorf("failed to parse timestamp: %w", err)
    }
    // Parse gain
    gain, err := parseGainLevel(gainStr)
    if err != nil {
    return nil, fmt.Errorf("failed to parse gain: %w", err)
    }
    // Parse battery voltage
    batteryV, err := strconv.ParseFloat(batteryStr, 64)
    if err != nil {
    return nil, fmt.Errorf("failed to parse battery voltage: %w", err)
    }
    // Parse temperature
    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).
    // 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 timestamp
    timestamp, err := parseAudioMothTimestamp(timeStr, dateStr, timezoneStr)
    if err != nil {
    return nil, fmt.Errorf("failed to parse timestamp: %w", err)
    }
    // Parse gain
    gain, 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 components
    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])
    // Parse date components
    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])
    // Parse timezone offset
    timezoneStr = 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 location
    offsetSeconds := offsetHours * 3600
    loc := time.FixedZone(fmt.Sprintf("UTC%+d", offsetHours), offsetSeconds)
    // Construct timestamp
    timestamp := time.Date(year, time.Month(month), day, hour, minute, second, 0, loc)
    return timestamp, nil
    }
    // parseGainLevel converts string gain level to GainLevel enum
    gainStr = 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, nil
    return GainMediumHigh, nil
    return GainMedium, nil
    return GainLowMedium, nil
    return GainLow, nil
    func parseGainLevel(gainStr string) (GainLevel, error) {
    Gain GainLevel
    // AudioMoth gain level enum constants
    const (
    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 recordings
    type GainLevel string
  • edit in utils/spectrogram.go at line 14
    [3.24873]
    [4.62826]
    "skraak/wav"
  • replacement in utils/spectrogram.go at line 229
    [4.4710][4.4710:4793]()
    segSamples, sampleRate, err := ReadWAVSegmentSamples(wavPath, startTime, endTime)
    [4.4710]
    [4.68207]
    segSamples, sampleRate, err := wav.ReadWAVSegmentSamples(wavPath, startTime, endTime)
  • edit in utils/file_import_test.go at line 7
    [4.130955]
    [4.130955]
    "skraak/wav"
  • replacement in utils/file_import_test.go at line 56
    [4.2664][4.2664:2802]()
    func mustResolveTimestamp(t *testing.T, meta *WAVMetadata, filename, tz string, useModTime bool, preParsed *time.Time) *TimestampResult {
    [4.2664]
    [4.2802]
    func mustResolveTimestamp(t *testing.T, meta *wav.WAVMetadata, filename, tz string, useModTime bool, preParsed *time.Time) *TimestampResult {
  • replacement in utils/file_import_test.go at line 67
    [4.132057][4.132057:132081]()
    meta := &WAVMetadata{
    [4.132057]
    [4.132081]
    meta := &wav.WAVMetadata{
  • replacement in utils/file_import_test.go at line 85
    [4.132892][4.3064:3171]()
    result := mustResolveTimestamp(t, &WAVMetadata{}, "20250224_210000.wav", "Pacific/Auckland", false, nil)
    [4.132892]
    [4.133105]
    result := mustResolveTimestamp(t, &wav.WAVMetadata{}, "20250224_210000.wav", "Pacific/Auckland", false, nil)
  • replacement in utils/file_import_test.go at line 96
    [4.133398][4.3172:3217]()
    meta := &WAVMetadata{FileModTime: modTime}
    [4.133398]
    [4.3217]
    meta := &wav.WAVMetadata{FileModTime: modTime}
  • replacement in utils/file_import_test.go at line 113
    [4.3562][4.3562:3666]()
    _, err := ResolveTimestamp(&WAVMetadata{}, "nopattern.wav", "Pacific/Auckland", tc.useModTime, nil)
    [4.3562]
    [4.3666]
    _, err := ResolveTimestamp(&wav.WAVMetadata{}, "nopattern.wav", "Pacific/Auckland", tc.useModTime, nil)
  • replacement in utils/file_import_test.go at line 122
    [4.134472][4.3758:3816]()
    meta := &WAVMetadata{Comment: "AudioMoth garbage data"}
    [4.134472]
    [4.3816]
    meta := &wav.WAVMetadata{Comment: "AudioMoth garbage data"}
  • replacement in utils/file_import_test.go at line 140
    [4.9087][4.9087:9154]()
    err := WriteWAVFile(tmpPath, []float64{0.1, 0.2, 0.3, 0.4}, 8000)
    [4.9087]
    [4.9154]
    err := wav.WriteWAVFile(tmpPath, []float64{0.1, 0.2, 0.3, 0.4}, 8000)
  • replacement in utils/file_import_test.go at line 145
    [4.9224][4.9224:9486]()
    // 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 logic
    samples, rate, err := ReadWAVSegmentSamples(tmpPath, 0, 0)
    [4.9224]
    [4.9486]
    // Read specific segment
    samples, rate, err := wav.ReadWAVSegmentSamples(tmpPath, 0, 0)
  • replacement in utils/file_import_test.go at line 157
    [4.9705][4.9705:9792]()
    // Test 2: Helper ReadWAVSamples wrapper
    samples2, _, err := ReadWAVSamples(tmpPath)
    [4.9705]
    [4.9792]
    // Helper ReadWAVSamples wrapper
    samples2, _, err := wav.ReadWAVSamples(tmpPath)
  • replacement in utils/file_import_test.go at line 169
    [4.10044][4.10044:10101]()
    err := WriteWAVFile(tmpPath, []float64{0.0, 0.0}, 8000)
    [4.10044]
    [4.10101]
    err := wav.WriteWAVFile(tmpPath, []float64{0.0, 0.0}, 8000)
  • replacement in utils/file_import_test.go at line 189
    [4.10576][4.10576:10633]()
    err := WriteWAVFile(tmpPath, []float64{0.0, 0.0}, 8000)
    [4.10576]
    [4.10633]
    err := wav.WriteWAVFile(tmpPath, []float64{0.0, 0.0}, 8000)
  • replacement in utils/file_import_test.go at line 194
    [4.10703][4.10703:10755]()
    meta, hash, err := ParseWAVHeaderWithHash(tmpPath)
    [4.10703]
    [4.10755]
    meta, hash, err := wav.ParseWAVHeaderWithHash(tmpPath)
  • edit in utils/file_import.go at line 7
    [4.135140]
    [4.135140]
    "skraak/wav"
  • replacement in utils/file_import.go at line 15
    [4.135292][4.135292:135320]()
    MothData *AudioMothData
    [4.135292]
    [4.135320]
    MothData *wav.AudioMothData
  • replacement in utils/file_import.go at line 24
    [4.135606][4.10962:11125]()
    func ResolveTimestamp(wavMeta *WAVMetadata, filePath string, timezoneID string, useFileModTime bool, preParsedFilenameTime *time.Time) (*TimestampResult, error) {
    [4.135606]
    [4.135735]
    func ResolveTimestamp(wavMeta *wav.WAVMetadata, filePath string, timezoneID string, useFileModTime bool, preParsedFilenameTime *time.Time) (*TimestampResult, error) {
  • replacement in utils/file_import.go at line 28
    [4.135800][4.135800:135851]()
    if IsAudioMoth(wavMeta.Comment, wavMeta.Artist) {
    [4.135800]
    [4.135851]
    if wav.IsAudioMoth(wavMeta.Comment, wavMeta.Artist) {
  • replacement in utils/file_import.go at line 30
    [4.135879][4.135879:135937]()
    mothData, err := ParseAudioMothComment(wavMeta.Comment)
    [4.135879]
    [4.135937]
    mothData, err := wav.ParseAudioMothComment(wavMeta.Comment)
  • replacement in utils/file_import.go at line 71
    [4.137052][4.137052:137083]()
    MothData *AudioMothData
    [4.137052]
    [4.137083]
    MothData *wav.AudioMothData
  • replacement in utils/file_import.go at line 81
    [4.137532][4.137532:137575]()
    metadata, err := ParseWAVHeader(filePath)
    [4.137532]
    [4.137575]
    metadata, err := wav.ParseWAVHeader(filePath)
  • edit in tui/update.go at line 15
    [4.6322]
    [4.3972]
    "skraak/wav"
  • replacement in tui/update.go at line 454
    [4.8672][4.8672:8767]()
    samples, sampleRate, err := utils.ReadWAVSegmentSamples(wavPath, seg.StartTime, seg.EndTime)
    [4.8672]
    [4.8767]
    samples, sampleRate, err := wav.ReadWAVSegmentSamples(wavPath, seg.StartTime, seg.EndTime)
  • edit in tools/import/import_unstructured.go at line 15
    [4.159]
    [4.159]
    "skraak/wav"
  • replacement in tools/import/import_unstructured.go at line 111
    [4.3261][4.3261:3310]()
    metadata, err := utils.ParseWAVHeader(filePath)
    [4.3261]
    [4.3310]
    metadata, err := wav.ParseWAVHeader(filePath)
  • edit in tools/import/cluster_import.go at line 12
    [4.17950]
    [4.17950]
    "skraak/wav"
  • replacement in tools/import/cluster_import.go at line 217
    [4.24156][4.24156:24185]()
    metadata *utils.WAVMetadata
    [4.24156]
    [4.24185]
    metadata *wav.WAVMetadata
  • replacement in tools/import/cluster_import.go at line 297
    [4.26709][4.26709:26769]()
    metadata, hash, err := utils.ParseWAVHeaderWithHash(path)
    [4.26709]
    [4.26769]
    metadata, hash, err := wav.ParseWAVHeaderWithHash(path)
  • edit in tools/calls/isnight.go at line 11
    [4.69815]
    [4.69815]
    "skraak/wav"
  • replacement in tools/calls/isnight.go at line 50
    [4.71208][4.71208:71263]()
    metadata, err := utils.ParseWAVHeader(input.FilePath)
    [4.71208]
    [4.71263]
    metadata, err := wav.ParseWAVHeader(input.FilePath)
  • replacement in tools/calls/calls_from_raven.go at line 11
    [4.143428][4.143428:143444]()
    "skraak/utils"
    [4.143428]
    [4.143444]
    "skraak/wav"
  • replacement in tools/calls/calls_from_raven.go at line 229
    [4.150032][4.150032:150099]()
    sampleRate, duration, err := utils.ParseWAVHeaderMinimal(wavPath)
    [4.150032]
    [4.150099]
    sampleRate, duration, err := wav.ParseWAVHeaderMinimal(wavPath)
  • edit in tools/calls/calls_from_preds.go at line 17
    [4.163730]
    [4.163730]
    "skraak/wav"
  • replacement in tools/calls/calls_from_preds.go at line 421
    [4.176596][4.176596:176664]()
    sampleRate, duration, err := utils.ParseWAVHeaderMinimal(wavPath)
    [4.176596]
    [4.176664]
    sampleRate, duration, err := wav.ParseWAVHeaderMinimal(wavPath)
  • replacement in tools/calls/calls_from_preds.go at line 516
    [4.179007][4.179007:179075]()
    sampleRate, duration, err := utils.ParseWAVHeaderMinimal(wavPath)
    [4.179007]
    [4.179075]
    sampleRate, duration, err := wav.ParseWAVHeaderMinimal(wavPath)
  • replacement in tools/calls/calls_from_birda.go at line 12
    [4.203941][4.203941:203957]()
    "skraak/utils"
    [4.203941]
    [4.203957]
    "skraak/wav"
  • replacement in tools/calls/calls_from_birda.go at line 221
    [4.210200][4.210200:210267]()
    sampleRate, duration, err := utils.ParseWAVHeaderMinimal(wavPath)
    [4.210200]
    [4.210267]
    sampleRate, duration, err := wav.ParseWAVHeaderMinimal(wavPath)
  • edit in tools/calls/calls_clip_bench_test.go at line 11
    [4.248157]
    [4.248157]
    "skraak/wav"
  • replacement in tools/calls/calls_clip_bench_test.go at line 21
    [4.248350][4.248350:248396]()
    _, _, err := utils.ReadWAVSamples(benchWAV)
    [4.248350]
    [4.248396]
    _, _, err := wav.ReadWAVSamples(benchWAV)
  • replacement in tools/calls/calls_clip_bench_test.go at line 57
    [4.249368][4.249368:249418]()
    samples, sr, _ := utils.ReadWAVSamples(benchWAV)
    [4.249368]
    [4.249418]
    samples, sr, _ := wav.ReadWAVSamples(benchWAV)
  • replacement in tools/calls/calls_clip_bench_test.go at line 64
    [4.249636][4.249636:249683]()
    utils.WriteWAVFile(f.Name(), segSamples, sr)
    [4.249636]
    [4.249683]
    wav.WriteWAVFile(f.Name(), segSamples, sr)
  • replacement in tools/calls/calls_clip_bench_test.go at line 73
    [4.249825][4.249825:249874]()
    samples, _, _ := utils.ReadWAVSamples(benchWAV)
    [4.249825]
    [4.249874]
    samples, _, _ := wav.ReadWAVSamples(benchWAV)
  • replacement in tools/calls/calls_clip_bench_test.go at line 83
    [4.250094][4.250094:250143]()
    samples, _, _ := utils.ReadWAVSamples(benchWAV)
    [4.250094]
    [4.250143]
    samples, _, _ := wav.ReadWAVSamples(benchWAV)
  • replacement in tools/calls/calls_clip_bench_test.go at line 95
    [4.250429][4.250429:250479]()
    samples, sr, _ := utils.ReadWAVSamples(benchWAV)
    [4.250429]
    [4.250479]
    samples, sr, _ := wav.ReadWAVSamples(benchWAV)
  • replacement in tools/calls/calls_clip_bench_test.go at line 109
    [4.250779][4.250779:250829]()
    samples, sr, _ := utils.ReadWAVSamples(benchWAV)
    [4.250779]
    [4.250829]
    samples, sr, _ := wav.ReadWAVSamples(benchWAV)
  • replacement in tools/calls/calls_clip_bench_test.go at line 126
    [4.251326][4.251326:251376]()
    samples, sr, _ := utils.ReadWAVSamples(benchWAV)
    [4.251326]
    [4.251376]
    samples, sr, _ := wav.ReadWAVSamples(benchWAV)
  • replacement in tools/calls/calls_clip_bench_test.go at line 141
    [4.251813][4.251813:251863]()
    samples, sr, _ := utils.ReadWAVSamples(benchWAV)
    [4.251813]
    [4.251863]
    samples, sr, _ := wav.ReadWAVSamples(benchWAV)
  • replacement in tools/calls/calls_clip_bench_test.go at line 158
    [4.252320][4.252320:252370]()
    samples, sr, _ := utils.ReadWAVSamples(benchWAV)
    [4.252320]
    [4.252370]
    samples, sr, _ := wav.ReadWAVSamples(benchWAV)
  • replacement in tools/calls/calls_clip_bench_test.go at line 173
    [4.252737][4.252737:252787]()
    samples, sr, _ := utils.ReadWAVSamples(benchWAV)
    [4.252737]
    [4.252787]
    samples, sr, _ := wav.ReadWAVSamples(benchWAV)
  • replacement in tools/calls/calls_clip_bench_test.go at line 189
    [4.253197][4.253197:253247]()
    samples, sr, _ := utils.ReadWAVSamples(benchWAV)
    [4.253197]
    [4.253247]
    samples, sr, _ := wav.ReadWAVSamples(benchWAV)
  • replacement in tools/calls/calls_clip_bench_test.go at line 204
    [4.253623][4.253623:253673]()
    samples, sr, _ := utils.ReadWAVSamples(benchWAV)
    [4.253623]
    [4.253673]
    samples, sr, _ := wav.ReadWAVSamples(benchWAV)
  • replacement in tools/calls/calls_clip_bench_test.go at line 220
    [4.254089][4.254089:254139]()
    samples, sr, _ := utils.ReadWAVSamples(benchWAV)
    [4.254089]
    [4.254139]
    samples, sr, _ := wav.ReadWAVSamples(benchWAV)
  • replacement in tools/calls/calls_clip_bench_test.go at line 238
    [4.254610][4.254610:254660]()
    samples, sr, _ := utils.ReadWAVSamples(benchWAV)
    [4.254610]
    [4.254660]
    samples, sr, _ := wav.ReadWAVSamples(benchWAV)
  • replacement in tools/calls/calls_clip_bench_test.go at line 257
    [4.255196][4.255196:255246]()
    samples, sr, _ := utils.ReadWAVSamples(benchWAV)
    [4.255196]
    [4.255246]
    samples, sr, _ := wav.ReadWAVSamples(benchWAV)
  • replacement in tools/calls/calls_clip_bench_test.go at line 275
    [4.255791][4.255791:255844]()
    utils.WriteWAVFile(f.Name(), segSamples, outputSR)
    [4.255791]
    [4.255844]
    wav.WriteWAVFile(f.Name(), segSamples, outputSR)
  • replacement in tools/calls/calls_clip_bench_test.go at line 282
    [4.255937][4.255937:255987]()
    samples, sr, _ := utils.ReadWAVSamples(benchWAV)
    [4.255937]
    [4.255987]
    samples, sr, _ := wav.ReadWAVSamples(benchWAV)
  • replacement in tools/calls/calls_clip_bench_test.go at line 301
    [4.256574][4.256574:256627]()
    utils.WriteWAVFile(f.Name(), segSamples, outputSR)
    [4.256574]
    [4.256627]
    wav.WriteWAVFile(f.Name(), segSamples, outputSR)
  • replacement in tools/calls/calls_clip_bench_test.go at line 310
    [4.256781][4.256781:256831]()
    samples, sr, _ := utils.ReadWAVSamples(benchWAV)
    [4.256781]
    [4.256831]
    samples, sr, _ := wav.ReadWAVSamples(benchWAV)
  • edit in tools/calls/calls_clip.go at line 13
    [4.258005]
    [4.258005]
    "skraak/wav"
  • replacement in tools/calls/calls_clip.go at line 221
    [4.264914][4.264914:264973]()
    samples, sampleRate, err := utils.ReadWAVSamples(wavPath)
    [4.264914]
    [4.264973]
    samples, sampleRate, err := wav.ReadWAVSamples(wavPath)
  • replacement in tools/calls/calls_clip.go at line 367
    [4.270780][4.9814:9891]()
    if err := utils.WriteWAVFile(wavPath, segSamples, sampleRate); err != nil {
    [4.270780]
    [4.270863]
    if err := wav.WriteWAVFile(wavPath, segSamples, sampleRate); err != nil {
  • edit in tools/calls/calls_classify.go at line 15
    [4.292189]
    [4.292189]
    "skraak/wav"
  • replacement in tools/calls/calls_classify.go at line 714
    [4.10272][4.10272:10369]()
    segSamples, sampleRate, err := utils.ReadWAVSegmentSamples(wavPath, seg.StartTime, seg.EndTime)
    [4.10272]
    [4.10369]
    segSamples, sampleRate, err := wav.ReadWAVSegmentSamples(wavPath, seg.StartTime, seg.EndTime)
  • replacement in tools/calls/calls_classify.go at line 765
    [4.11999][4.11999:12076]()
    if err := utils.WriteWAVFile(wavPath, segSamples, sampleRate); err != nil {
    [4.11999]
    [4.12076]
    if err := wav.WriteWAVFile(wavPath, segSamples, sampleRate); err != nil {
  • edit in tools/calls/calls_add.go at line 9
    [4.27668]
    [4.27668]
    "skraak/wav"
  • replacement in tools/calls/calls_add.go at line 142
    [4.31838][4.31838:31898]()
    sampleRate, _, err := utils.ParseWAVHeaderMinimal(wavPath)
    [4.31838]
    [4.31898]
    sampleRate, _, err := wav.ParseWAVHeaderMinimal(wavPath)
  • replacement in tools/calls/calls_add.go at line 165
    [4.32277][4.32277:32335]()
    _, duration, err := utils.ParseWAVHeaderMinimal(wavPath)
    [4.32277]
    [4.32335]
    _, duration, err := wav.ParseWAVHeaderMinimal(wavPath)
  • replacement in db/types.go at line 7
    [4.6300][4.6300:6316]()
    "skraak/utils"
    [4.6300]
    [4.790984]
    "skraak/wav"
  • replacement in db/types.go at line 198
    [4.6384][4.6384:6417]()
    type GainLevel = utils.GainLevel
    [4.6384]
    [4.797780]
    type GainLevel = wav.GainLevel
  • replacement in db/types.go at line 200
    [4.797781][4.6418:6466]()
    // Gain level constants re-exported from utils.
    [4.797781]
    [4.797820]
    // Gain level constants re-exported from wav.
  • replacement in db/types.go at line 202
    [4.797828][4.6467:6644]()
    GainLow = utils.GainLow
    GainLowMedium = utils.GainLowMedium
    GainMedium = utils.GainMedium
    GainMediumHigh = utils.GainMediumHigh
    GainHigh = utils.GainHigh
    [4.797828]
    [4.798017]
    GainLow = wav.GainLow
    GainLowMedium = wav.GainLowMedium
    GainMedium = wav.GainMedium
    GainMediumHigh = wav.GainMediumHigh
    GainHigh = wav.GainHigh
  • replacement in cmd/metadata.go at line 9
    [4.1058292][4.1058292:1058308]()
    "skraak/utils"
    [4.1058292]
    [4.1058308]
    "skraak/wav"
  • replacement in cmd/metadata.go at line 49
    [4.1059691][4.1059691:1059741]()
    metadata, err := utils.ParseWAVHeader(*filePath)
    [4.1059670]
    [4.1059741]
    metadata, err := wav.ParseWAVHeader(*filePath)
  • edit in CHANGELOG.md at line 4
    [4.1198010]
    [3.26323]
    ## [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)