PXQDGTR53ST5T4EV6XFRCAOC7N5RQX23GWVKMJGS2J35VUQLZL4AC XU7FTYK3YAM5TADBGEJZ44UJ6LNJ7YVABSFEXKOBGUHXBFHVLR2AC KZKLAINJJWZ64T5MUZT34LJVQIKBTKZ6EJGD7C7TTSSDGCHEDPMAC LBWQJEDHCNUNMEJWXILGBGYZUKQI7CDAMH2BD44HULM77SVH5UYQC 3ETJ6KPIYI23DLXSKISNJSY3DUGHOACE6CPCPF6V7KJK4EXIADQAC FCCJNYCVGOW6WVHYUUQ3RHHKB4QU3S4LU4AINB4VKWBOXLCPXILQC DS22DKV35FHPKEBSCUCE5VYYUSYBFN46YUP2JZCN72YX7SKQRLVQC HYCZTLSZ5WVJFMP4EPVHAVWRYSYNCIBJ62LLBNO4IRVEBY7WJI6QC RUVJ3V4N5V4Z3HSH2YYESKQF5G7RIHBFB5TLV2IPDWXSGJDRD54AC N57PNZPFM6QU5FK4SHC3473IV6IN3HRVKSPZSFIJJ5LLCAXICNIAC HCOBJB6WGQ5VUJFRFTZKUE3IDGEONLENOMQ3LXZZ2YMPJ2BJRJVQC NUOFNUIQIKWOBFHMJXY2K4AJGIROGCPFK5NJHR6Y6HYHHCOJPVCAC P4CJMBYKB6LTJASFYF5FX4MHQW7TH7D6KLDLWBBLFKDHTWK5SZ6AC LQLC7S3ADBR4O2JYVUSQJD65U3HG4ADOQBGB4F7KQCXUMNKMNEKAC WKQ7LFTPDGWTPJKRWB6DH5PUCX2HF34UCGJDIPYC5PTDX4MCZJXAC QVIGQOQZIEXLFMMAA7RTL7MQWI4MC3CH22R6YO6J7LGLHWLCSD4AC YVFPP5VJJSR4EVGOMB5565IZFYAVFNRH37L6AXEUFU5AP6CL7DJAC 3DVPQOKB6BX63XSBIYYCPWBL2RBG3LXZS3XPQBANJP2FWVRAOVZQC ZKLAOPURUGKKG4KC7C5NEQ5WSZSFTZM7SCV7PIYJMWN4UKI7UI3QC NQPVZ3PPQG6EPTTAEHXOXXGK27HZCISHZCOZU6K6RKWTRTOHMY6QC SJN7IKIVTAZX3ACEWPLFVUT7P2TLB3RQBD4PKC6PEQQ33ECXFJRQC package wavimport ("path/filepath""testing""time")// mustResolveTimestamp is a test helper that calls ResolveTimestamp and fatals on error.func mustResolveTimestamp(t *testing.T, meta *WAVMetadata, filename, tz string, useModTime bool, preParsed *time.Time) *TimestampResult {t.Helper()result, err := ResolveTimestamp(meta, filename, tz, useModTime, preParsed)if err != nil {t.Fatalf("unexpected error: %v", err)}return result}func TestResolveTimestamp(t *testing.T) {t.Run("resolves AudioMoth timestamp", func(t *testing.T) {meta := &WAVMetadata{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.",Artist: "AudioMoth",}result := mustResolveTimestamp(t, meta, "20250224_210000.wav", "Pacific/Auckland", false, nil)if !result.IsAudioMoth {t.Error("expected IsAudioMoth to be true")}if result.MothData == nil {t.Error("expected MothData to be non-nil")}expectedUTC := time.Date(2025, 2, 24, 8, 0, 0, 0, time.UTC)if !result.Timestamp.UTC().Equal(expectedUTC) {t.Errorf("expected UTC timestamp %v, got %v", expectedUTC, result.Timestamp.UTC())}})t.Run("falls back to filename timestamp", func(t *testing.T) {result := mustResolveTimestamp(t, &WAVMetadata{}, "20250224_210000.wav", "Pacific/Auckland", false, nil)if result.IsAudioMoth {t.Error("expected IsAudioMoth to be false")}if result.Timestamp.IsZero() {t.Error("expected non-zero timestamp")}})t.Run("falls back to file mod time when enabled", func(t *testing.T) {modTime := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)meta := &WAVMetadata{FileModTime: modTime}result := mustResolveTimestamp(t, meta, "nopattern.wav", "Pacific/Auckland", true, nil)if !result.Timestamp.Equal(modTime) {t.Errorf("expected timestamp %v, got %v", modTime, result.Timestamp)}})t.Run("errors_on_no_timestamp", func(t *testing.T) {cases := []struct {name stringuseModTime bool}{{"mod_time_disabled", false},{"no_file_mod_time", true},}for _, tc := range cases {t.Run(tc.name, func(t *testing.T) {_, err := ResolveTimestamp(&WAVMetadata{}, "nopattern.wav", "Pacific/Auckland", tc.useModTime, nil)if err == nil {t.Error("expected error when no timestamp available")}})}})t.Run("AudioMoth detected but parse fails falls back to filename", func(t *testing.T) {meta := &WAVMetadata{Comment: "AudioMoth garbage data"}result := mustResolveTimestamp(t, meta, "20250224_210000.wav", "Pacific/Auckland", false, nil)if !result.IsAudioMoth {t.Error("expected IsAudioMoth to be true (detected even if parse failed)")}if result.MothData != nil {t.Error("expected MothData to be nil since parsing failed")}if result.Timestamp.IsZero() {t.Error("expected non-zero timestamp from filename fallback")}})}func TestProcessSingleFile(t *testing.T) {tmpPath := filepath.Join(t.TempDir(), "20240101_120000.wav")err := WriteWAVFile(tmpPath, []float64{0.0, 0.0}, 8000)if err != nil {t.Fatalf("failed to create temp wav: %v", err)}res, err := ProcessSingleFile(tmpPath, -41.0, 174.0, "Pacific/Auckland", false)if err != nil {t.Fatalf("unexpected error: %v", err)}if res.SampleRate != 8000 {t.Errorf("expected sample rate 8000, got %d", res.SampleRate)}if res.Hash == "" {t.Error("expected non-empty hash")}}
package wavimport ("fmt""path/filepath""time""skraak/utils")// TimestampResult holds the result of timestamp resolution for a single filetype TimestampResult struct {Timestamp time.TimeIsAudioMoth boolMothData *AudioMothData}// ResolveTimestamp resolves a file's timestamp using the standard priority chain:// 1. AudioMoth comment parsing// 2. Filename timestamp parsing + timezone offset// 3. File modification time (if useFileModTime is true)//// Returns an error if no timestamp could be determined.func ResolveTimestamp(wavMeta *WAVMetadata, filePath string, timezoneID string, useFileModTime bool, preParsedFilenameTime *time.Time) (*TimestampResult, error) {result := &TimestampResult{}// Step 1: Try AudioMoth commentif IsAudioMoth(wavMeta.Comment, wavMeta.Artist) {result.IsAudioMoth = truemothData, err := ParseAudioMothComment(wavMeta.Comment)if err == nil {result.MothData = mothDataresult.Timestamp = mothData.Timestampreturn result, nil}// AudioMoth detected but parsing failed — fall through to filename}// Step 2: Try filename timestampif preParsedFilenameTime != nil && !preParsedFilenameTime.IsZero() {result.Timestamp = *preParsedFilenameTimereturn result, nil} else if utils.HasTimestampFilename(filePath) {filenameTimestamps, err := utils.ParseFilenameTimestamps([]string{filepath.Base(filePath)})if err == nil {adjustedTimestamps, err := utils.ApplyTimezoneOffset(filenameTimestamps, timezoneID)if err == nil && len(adjustedTimestamps) > 0 {result.Timestamp = adjustedTimestamps[0]return result, nil}}}// Step 3: File modification time fallback (optional)if useFileModTime && !wavMeta.FileModTime.IsZero() {result.Timestamp = wavMeta.FileModTimereturn result, nil}return nil, fmt.Errorf("cannot resolve timestamp (no AudioMoth, filename pattern, or file modification time)")}// FileProcessingResult holds all extracted metadata for a single filetype FileProcessingResult struct {FileName stringHash stringDuration float64SampleRate intTimestampLocal time.TimeIsAudioMoth boolMothData *AudioMothDataAstroData utils.AstronomicalData}// ProcessSingleFile runs the full single-file processing pipeline:// WAV header parsing → XXH64 hash → timestamp resolution → astronomical data//// Set useFileModTime to true to allow file modification time as a timestamp fallback.func ProcessSingleFile(filePath string, latitude, longitude float64, timezoneID string, useFileModTime bool) (*FileProcessingResult, error) {// Step 1: Parse WAV headermetadata, err := ParseWAVHeader(filePath)if err != nil {return nil, fmt.Errorf("WAV header parsing failed: %w", err)}// Step 2: Calculate hashhash, err := utils.ComputeXXH64(filePath)if err != nil {return nil, fmt.Errorf("hash calculation failed: %w", err)}// Step 3: Resolve timestamptsResult, err := ResolveTimestamp(metadata, filePath, timezoneID, useFileModTime, nil)if err != nil {return nil, err}// Step 4: Calculate astronomical dataastroData := utils.CalculateAstronomicalData(tsResult.Timestamp.UTC(),metadata.Duration,latitude,longitude,)return &FileProcessingResult{FileName: filepath.Base(filePath),Hash: hash,Duration: metadata.Duration,SampleRate: metadata.SampleRate,TimestampLocal: tsResult.Timestamp,IsAudioMoth: tsResult.IsAudioMoth,MothData: tsResult.MothData,AstroData: astroData,}, nil}
package utilsimport ("testing")func TestExtractSegmentSamples(t *testing.T) {samples := []float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}sampleRate := 2 // 2 samples per sec// sec 1.0 to 3.0 => indices 2 to 6 => [2, 3, 4, 5]seg := ExtractSegmentSamples(samples, sampleRate, 1.0, 3.0)if len(seg) != 4 || seg[0] != 2 || seg[len(seg)-1] != 5 {t.Errorf("unexpected segment extraction: %v", seg)}// out of boundsempty := ExtractSegmentSamples(samples, sampleRate, 5.0, 6.0)if len(empty) != 0 {t.Error("expected empty segment")}}func TestGenerateSpectrogram_Basic(t *testing.T) {samples := make([]float64, 1000) // Silent buffercfg := DefaultSpectrogramConfig(16000)res := GenerateSpectrogram(samples, cfg)if len(res) == 0 {t.Error("expected spectrogram generation to succeed")}shortSamples := []float64{0.0, 0.1}resShort := GenerateSpectrogram(shortSamples, cfg)if resShort != nil {t.Error("expected nil for samples smaller than window size")}}func TestGetCachedHannWindow(t *testing.T) {w1 := getCachedHannWindow(256)w2 := getCachedHannWindow(256)if len(w1) != 256 {t.Errorf("expected length 256, got %d", len(w1))}// Ensure memory address is the same (cached)if &w1[0] != &w2[0] {t.Error("expected cached slice to have the same memory address")}}func TestSpectrogramImageFromSamples(t *testing.T) {// Create a simple sine waveconst sampleRate = 16000const duration = 0.1 // 100mssamples := make([]float64, int(sampleRate*duration))for i := range samples {samples[i] = 0.5 // DC signal}img := SpectrogramImageFromSamples(samples, sampleRate, true, 224)if img == nil {t.Fatal("expected non-nil image")}bounds := img.Bounds()if bounds.Dx() != 224 || bounds.Dy() != 224 {t.Errorf("expected 224x224, got %dx%d", bounds.Dx(), bounds.Dy())}// Grayscale variantimgGray := SpectrogramImageFromSamples(samples, sampleRate, false, 224)if imgGray == nil {t.Error("expected non-nil grayscale image")}// Empty samplesimgEmpty := SpectrogramImageFromSamples(nil, sampleRate, true, 224)if imgEmpty != nil {t.Error("expected nil for empty samples")}}}}func TestClipBaseName(t *testing.T) {tests := []struct {prefix stringbasename stringstartTime float64endTime float64want string}{{"clip", "file", 1.0, 3.0, "clip_file_1_3"},{"test", "recording", 0.0, 5.5, "test_recording_0_6"}, // ceil(5.5) = 6{"a", "b", 2.7, 4.2, "a_b_2_5"}, // floor(2.7)=2, ceil(4.2)=5}for _, tt := range tests {got := ClipBaseName(tt.prefix, tt.basename, tt.startTime, tt.endTime)if got != tt.want {t.Errorf("ClipBaseName(%q, %q, %.1f, %.1f) = %q, want %q",tt.prefix, tt.basename, tt.startTime, tt.endTime, got, tt.want)}}}func TestWAVBasename(t *testing.T) {tests := []struct {path stringwant string}{{"/path/to/file.wav.data", "file"},{"/audio/2024-01-15_recording.wav.data", "2024-01-15_recording"},{"simple.wav.data", "simple"},}for _, tt := range tests {got := WAVBasename(tt.path)if got != tt.want {t.Errorf("WAVBasename(%q) = %q, want %q", tt.path, got, tt.want)}}}func TestClipPaths(t *testing.T) {tmp := t.TempDir()// Normal casepngPath, wavPath, err := ClipPaths(tmp, "clip", "file", 1.0, 3.0)if err != nil {t.Fatalf("unexpected error: %v", err)}expectedPng := filepath.Join(tmp, "clip_file_1_3.png")expectedWav := filepath.Join(tmp, "clip_file_1_3.wav")if pngPath != expectedPng {t.Errorf("pngPath = %q, want %q", pngPath, expectedPng)}if wavPath != expectedWav {t.Errorf("wavPath = %q, want %q", wavPath, expectedWav)}// Collision detectionos.Create(expectedPng)_, _, err = ClipPaths(tmp, "clip", "file", 1.0, 3.0)if err == nil {t.Error("expected error for existing file")}}func TestWritePNGFile(t *testing.T) {tmp := t.TempDir()// Create a simple test image (grayscale 2x2)gray := [][]uint8{{128, 64}, {32, 16}}img := CreateGrayscaleImage(gray)if img == nil {t.Fatal("failed to create test image")}path := filepath.Join(tmp, "test.png")if err := WritePNGFile(path, img); err != nil {t.Fatalf("WritePNGFile failed: %v", err)}// Verify file existsif _, err := os.Stat(path); err != nil {t.Errorf("file not created: %v", err)}// Collision detectionerr := WritePNGFile(path, img)if err == nil {t.Error("expected error for existing file")"os""path/filepath"
package utilsimport ("testing")func TestL4Colormap_ControlPoints(t *testing.T) {tests := []struct {idx intwantR uint8wantG uint8wantB uint8desc string}{{0, 0, 0, 0, "black at index 0"},{255, 255, 255, 0, "yellow at index 255"},}for _, tt := range tests {pixel := L4Colormap[tt.idx]if pixel.R != tt.wantR || pixel.G != tt.wantG || pixel.B != tt.wantB {t.Errorf("L4Colormap[%d] (%s): got (%d,%d,%d), want (%d,%d,%d)",tt.idx, tt.desc, pixel.R, pixel.G, pixel.B, tt.wantR, tt.wantG, tt.wantB)}}// Index 85: first segment is (0,0,0)→(0.85,0,0), t=85/85=1.0p85 := L4Colormap[85]if p85.R != 216 || p85.G != 0 || p85.B != 0 {t.Errorf("L4Colormap[85]: got (%d,%d,%d), want dark red", p85.R, p85.G, p85.B)}// Index 170: second segment is (0.85,0,0)→(1.0,0.15,0), t=85/85=1.0p170 := L4Colormap[170]if p170.R != 255 || p170.G != 38 || p170.B != 0 {t.Errorf("L4Colormap[170]: got (%d,%d,%d), want orange-red", p170.R, p170.G, p170.B)}}func TestL4Colormap_MonotonicRed(t *testing.T) {// Red channel should be non-decreasing (black→red→orange→yellow)for i := 1; i < 256; i++ {if L4Colormap[i].R < L4Colormap[i-1].R {t.Errorf("R decreased at index %d: %d → %d", i-1, L4Colormap[i-1].R, L4Colormap[i].R)}}}func TestL4Colormap_GreenZeroThenIncreasing(t *testing.T) {// Green is 0 in the first segment (indices 0-85), then increasesfor i := 0; i <= 85; i++ {if L4Colormap[i].G != 0 {t.Errorf("G should be 0 at index %d, got %d", i, L4Colormap[i].G)}}// Green should be non-decreasing after index 85for i := 86; i < 256; i++ {if L4Colormap[i].G < L4Colormap[i-1].G {t.Errorf("G decreased at index %d: %d → %d", i-1, L4Colormap[i-1].G, L4Colormap[i].G)}}}func TestL4Colormap_BlueAlwaysZero(t *testing.T) {for i := range 256 {if L4Colormap[i].B != 0 {t.Errorf("B should be 0 at index %d, got %d", i, L4Colormap[i].B)}}}func TestApplyL4Colormap_Basic(t *testing.T) {gray := [][]uint8{{0, 128, 255},}result := ApplyL4Colormap(gray)if len(result) != 1 || len(result[0]) != 3 {t.Fatalf("shape mismatch: got %dx%d", len(result), len(result[0]))}// Index 0 → blackif result[0][0] != (RGBPixel{0, 0, 0}) {t.Errorf("pixel 0: got %v, want black", result[0][0])}// Index 255 → yellowif result[0][2] != (RGBPixel{255, 255, 0}) {t.Errorf("pixel 255: got %v, want yellow", result[0][2])}// Index 128 → should be a valid colormap entry (just check it matches the table)if result[0][1] != L4Colormap[128] {t.Errorf("pixel 128: got %v, want %v", result[0][1], L4Colormap[128])}}func TestApplyL4Colormap_MultiRow(t *testing.T) {gray := [][]uint8{{0, 255},{255, 0},}result := ApplyL4Colormap(gray)if len(result) != 2 || len(result[0]) != 2 {t.Fatalf("shape mismatch: got %dx%d", len(result), len(result[0]))}if result[0][0] != L4Colormap[0] {t.Errorf("(0,0): got %v, want %v", result[0][0], L4Colormap[0])}if result[1][1] != L4Colormap[0] {t.Errorf("(1,1): got %v, want %v", result[1][1], L4Colormap[0])}}func TestApplyL4Colormap_Empty(t *testing.T) {tests := []struct {name stringdata [][]uint8}{{"nil", nil},{"empty outer", [][]uint8{}},{"empty inner", [][]uint8{{}}},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {result := ApplyL4Colormap(tt.data)if result != nil {t.Errorf("expected nil for %s, got %v", tt.name, result)}})}}
package utilsimport ("image""image/color""math/rand""strings""testing")func TestWriteKittyImage_SmallImage(t *testing.T) {// 2x2 image produces small base64 payload — single chunk, no m= keyimg := image.NewGray(image.Rect(0, 0, 2, 2))img.SetGray(0, 0, color.Gray{Y: 128})var buf strings.Builderif err := WriteKittyImage(img, &buf); err != nil {t.Fatalf("WriteKittyImage: %v", err)}out := buf.String()if !strings.HasPrefix(out, "\x1b_Gf=100,a=T;") {t.Error("expected single-chunk header with f=100,a=T")}if strings.Contains(out, "m=") {t.Error("small image should not use chunked m= key")}if !strings.HasSuffix(out, "\x1b\\") {t.Error("expected escape sequence terminator")}}func TestWriteKittyImage_LargeImage_Chunked(t *testing.T) {// 128x128 random noise image is incompressible — produces >4096 bytes of base64 even with proper LZ77rng := rand.New(rand.NewSource(42))img := image.NewGray(image.Rect(0, 0, 128, 128))for y := range 128 {for x := range 128 {img.SetGray(x, y, color.Gray{Y: uint8(rng.Intn(256))})}}var buf strings.Builderif err := WriteKittyImage(img, &buf); err != nil {t.Fatalf("WriteKittyImage: %v", err)}out := buf.String()// Should have multiple escape sequenceschunks := strings.Split(out, "\x1b\\")// Last element is empty after final terminatorchunks = chunks[:len(chunks)-1]if len(chunks) < 2 {t.Fatalf("expected multiple chunks, got %d", len(chunks))}// First chunk should have f=100,a=T,m=1if !strings.Contains(chunks[0], "f=100,a=T,m=1") {t.Errorf("first chunk missing f=100,a=T,m=1: %s", chunks[0][:min(80, len(chunks[0]))])}// Last chunk should have m=0last := chunks[len(chunks)-1]if !strings.Contains(last, "\x1b_Gm=0;") {t.Errorf("last chunk missing m=0: %s", last[:min(80, len(last))])}// Middle chunks should have m=1for i := 1; i < len(chunks)-1; i++ {if !strings.Contains(chunks[i], "\x1b_Gm=1;") {t.Errorf("middle chunk %d missing m=1", i)}}}func TestClearKittyImages(t *testing.T) {var buf strings.BuilderClearKittyImages(&buf)expected := "\x1b_Ga=d\x1b\\"if buf.String() != expected {t.Errorf("got %q, want %q", buf.String(), expected)}}func TestWriteSixelImage(t *testing.T) {img := image.NewGray(image.Rect(0, 0, 4, 6))for y := range 6 {for x := range 4 {img.SetGray(x, y, color.Gray{Y: uint8((x + y) * 40)})}}var buf strings.Builderif err := WriteSixelImage(img, &buf); err != nil {t.Fatalf("WriteSixelImage: %v", err)}out := buf.String()// Sixel DCS introducerif !strings.HasPrefix(out, "\x1bP") {t.Error("expected DCS prefix \\x1bP")}// String terminatorif !strings.HasSuffix(out, "\x1b\\") {t.Error("expected ST suffix \\x1b\\\\")}// Should contain 'q' after DCS parametersif !strings.Contains(out, "q") {t.Error("expected 'q' in DCS sequence")}}func TestClearImages_Kitty(t *testing.T) {var buf strings.BuilderClearImages(&buf, ProtocolKitty)if buf.String() != "\x1b_Ga=d\x1b\\" {t.Errorf("got %q, want kitty clear sequence", buf.String())}}func TestClearImages_Sixel(t *testing.T) {var buf strings.BuilderClearImages(&buf, ProtocolSixel)if buf.String() != "" {t.Errorf("expected no output for sixel clear, got %q", buf.String())}}func TestWriteImage_Kitty(t *testing.T) {img := image.NewGray(image.Rect(0, 0, 2, 2))var buf strings.Builderif err := WriteImage(img, &buf, ProtocolKitty); err != nil {t.Fatalf("WriteImage kitty: %v", err)}if !strings.HasPrefix(buf.String(), "\x1b_G") {t.Error("expected kitty escape prefix")}}func TestWriteImage_Sixel(t *testing.T) {img := image.NewGray(image.Rect(0, 0, 4, 6))var buf strings.Builderif err := WriteImage(img, &buf, ProtocolSixel); err != nil {t.Fatalf("WriteImage sixel: %v", err)}if !strings.HasPrefix(buf.String(), "\x1bP") {t.Error("expected sixel DCS prefix")}}func TestWriteITermImage(t *testing.T) {img := image.NewGray(image.Rect(0, 0, 4, 4))img.SetGray(0, 0, color.Gray{Y: 128})var buf strings.Builderif err := WriteITermImage(img, &buf); err != nil {t.Fatalf("WriteITermImage: %v", err)}out := buf.String()if !strings.HasPrefix(out, "\x1b]1337;File=") {t.Errorf("expected iTerm2 OSC prefix, got %q", out[:min(30, len(out))])}if !strings.Contains(out, "inline=1") {t.Error("expected inline=1 parameter")}if !strings.HasSuffix(out, "\x07") {t.Error("expected BEL terminator")}}func TestWriteImage_ITerm(t *testing.T) {img := image.NewGray(image.Rect(0, 0, 4, 4))var buf strings.Builderif err := WriteImage(img, &buf, ProtocolITerm); err != nil {t.Fatalf("WriteImage iterm: %v", err)}if !strings.HasPrefix(buf.String(), "\x1b]1337;File=") {t.Error("expected iTerm2 OSC prefix")}}func TestClearImages_ITerm(t *testing.T) {var buf strings.BuilderClearImages(&buf, ProtocolITerm)if buf.String() != "" {t.Errorf("expected no output for iTerm2 clear, got %q", buf.String())}}func TestCreateRGBImage_Empty(t *testing.T) {tests := []struct {name stringdata [][]RGBPixel}{{"nil", nil},{"empty outer", [][]RGBPixel{}},{"empty inner", [][]RGBPixel{{}}},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {img := CreateRGBImage(tt.data)if img != nil {t.Errorf("expected nil for %s", tt.name)}})}}func TestResizeImage_Grayscale(t *testing.T) {// 4x4 uniform gray → 2x2 should produce same gray valuedata := make([][]uint8, 4)for i := range data {data[i] = []uint8{128, 128, 128, 128}}src := CreateGrayscaleImage(data)resized := ResizeImage(src, 2, 2)gray := resized.(*image.Gray)for y := range 2 {for x := range 2 {if gray.Pix[y*gray.Stride+x] != 128 {t.Errorf("pixel (%d,%d) = %d, want 128", x, y, gray.Pix[y*gray.Stride+x])}}}}func TestResizeImage_RGBA(t *testing.T) {// 4x4 uniform color → 2x2 should preserve colorrgbData := make([][]RGBPixel, 4)for i := range rgbData {rgbData[i] = []RGBPixel{{R: 100, G: 150, B: 200}, {R: 100, G: 150, B: 200}, {R: 100, G: 150, B: 200}, {R: 100, G: 150, B: 200}}}src := CreateRGBImage(rgbData)resized := ResizeImage(src, 2, 2)rgba := resized.(*image.RGBA)for y := range 2 {for x := range 2 {off := y*rgba.Stride + x*4if rgba.Pix[off] != 100 || rgba.Pix[off+1] != 150 || rgba.Pix[off+2] != 200 {t.Errorf("pixel (%d,%d) = (%d,%d,%d), want (100,150,200)",x, y, rgba.Pix[off], rgba.Pix[off+1], rgba.Pix[off+2])}}}}func TestResizeImage_GenericFallback(t *testing.T) {// Create a paletted image (not *image.Gray or *image.RGBA) to exercise fallback pathpalette := color.Palette{color.Black, color.White}src := image.NewPaletted(image.Rect(0, 0, 4, 4), palette)for y := range 4 {for x := range 4 {src.SetColorIndex(x, y, 0) // black}}resized := ResizeImage(src, 2, 2)bounds := resized.Bounds()if bounds.Dx() != 2 || bounds.Dy() != 2 {t.Errorf("bounds = %v, want 2x2", bounds)}}func TestResizeImage_Upscale(t *testing.T) {// 2x2 → 4x4 nearest-neighbor upscaledata := [][]uint8{{0, 255},{128, 64},}src := CreateGrayscaleImage(data)resized := ResizeImage(src, 4, 4)gray := resized.(*image.Gray)// Top-left quadrant should be 0if gray.Pix[0] != 0 {t.Errorf("(0,0) = %d, want 0", gray.Pix[0])}// Top-right quadrant should be 255if gray.Pix[3] != 255 {t.Errorf("(3,0) = %d, want 255", gray.Pix[3])}}func TestWritePNG(t *testing.T) {img := image.NewGray(image.Rect(0, 0, 4, 4))var buf bytes.Bufferif err := WritePNG(img, &buf); err != nil {t.Fatalf("WritePNG: %v", err)}if buf.Len() == 0 {t.Error("expected non-empty PNG output")}// Verify it's a valid PNG by decodingdecoded, err := png.Decode(&buf)if err != nil {t.Fatalf("failed to decode PNG: %v", err)}if decoded.Bounds().Dx() != 4 || decoded.Bounds().Dy() != 4 {t.Errorf("decoded bounds = %v, want 4x4", decoded.Bounds())}}bounds := img.Bounds()if bounds.Dx() != 2 {t.Errorf("width = %d, want 2", bounds.Dx())}if bounds.Dy() != 2 {t.Errorf("height = %d, want 2", bounds.Dy())}rgba := img.(*image.RGBA)assertRGBAPixel(t, rgba, 0, 0, 255, 0, 0, 255) // redassertRGBAPixel(t, rgba, 1, 0, 0, 255, 0, 255) // greenassertRGBAPixel(t, rgba, 0, 1, 0, 0, 255, 255) // blue}}func TestClampImageSize(t *testing.T) {tests := []struct {input intwant int}{{100, 224}, // below minimum, clamped up{224, 224}, // at minimum{448, 448}, // in range{600, 600}, // in range{896, 896}, // at maximum{1000, 896}, // above maximum, clamped down}for _, tt := range tests {got := ClampImageSize(tt.input)if got != tt.want {t.Errorf("ClampImageSize(%d) = %d, want %d", tt.input, got, tt.want)}}}func TestCreateGrayscaleImage_Basic(t *testing.T) {data := [][]uint8{{0, 128, 255},{64, 192, 32},}img := CreateGrayscaleImage(data)if img == nil {t.Fatal("expected non-nil image")}bounds := img.Bounds()if bounds.Dx() != 3 {t.Errorf("width = %d, want 3", bounds.Dx())}if bounds.Dy() != 2 {t.Errorf("height = %d, want 2", bounds.Dy())}gray := img.(*image.Gray)// Row 0if gray.Pix[0] != 0 {t.Errorf("pix[0] = %d, want 0", gray.Pix[0])}if gray.Pix[1] != 128 {t.Errorf("pix[1] = %d, want 128", gray.Pix[1])}if gray.Pix[2] != 255 {t.Errorf("pix[2] = %d, want 255", gray.Pix[2])}// Row 1if gray.Pix[gray.Stride+0] != 64 {t.Errorf("pix[row1+0] = %d, want 64", gray.Pix[gray.Stride])}if gray.Pix[gray.Stride+1] != 192 {t.Errorf("pix[row1+1] = %d, want 192", gray.Pix[gray.Stride+1])}if gray.Pix[gray.Stride+2] != 32 {t.Errorf("pix[row1+2] = %d, want 32", gray.Pix[gray.Stride+2])}}func TestCreateGrayscaleImage_Empty(t *testing.T) {tests := []struct {name stringdata [][]uint8}{{"nil", nil},{"empty outer", [][]uint8{}},{"empty inner", [][]uint8{{}}},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {img := CreateGrayscaleImage(tt.data)if img != nil {t.Errorf("expected nil for %s", tt.name)}})}}func TestCreateRGBImage_Basic(t *testing.T) {data := [][]RGBPixel{{{R: 255, G: 0, B: 0}, {R: 0, G: 255, B: 0}},{{R: 0, G: 0, B: 255}, {R: 255, G: 255, B: 255}},}img := CreateRGBImage(data)if img == nil {t.Fatal("expected non-nil image")}}// assertRGBAPixel checks that the pixel at (x,y) in an RGBA image matches the expected RGBA values.func assertRGBAPixel(t *testing.T, rgba *image.RGBA, x, y int, wantR, wantG, wantB, wantA uint8) {t.Helper()off := y*rgba.Stride + x*4got := rgba.Pix[off : off+4]if got[0] != wantR || got[1] != wantG || got[2] != wantB || got[3] != wantA {t.Errorf("pixel (%d,%d) = [%d,%d,%d,%d], want [%d,%d,%d,%d]",x, y, got[0], got[1], got[2], got[3], wantR, wantG, wantB, wantA)"image/png""bytes"
package utilsimport ("bytes""encoding/base64""image""image/color""image/png""io""github.com/charmbracelet/x/ansi""github.com/charmbracelet/x/ansi/iterm2""github.com/charmbracelet/x/ansi/kitty""github.com/charmbracelet/x/ansi/sixel")// ImageProtocol selects the terminal graphics protocol.type ImageProtocol intconst (ProtocolKitty ImageProtocol = iotaProtocolSixelProtocolITerm)// SpectrogramDisplaySize is the default pixel dimension for spectrogram images.// 448px suits Retina/HiDPI screens (224 logical pixels at 2x).const SpectrogramDisplaySize = 448// ClampImageSize clamps a dimension to [224, 448].func ClampImageSize(size int) int {return max(224, min(896, size))}// WriteImage writes an image using the specified terminal graphics protocol.func WriteImage(img image.Image, w io.Writer, protocol ImageProtocol) error {switch protocol {case ProtocolSixel:return WriteSixelImage(img, w)case ProtocolITerm:return WriteITermImage(img, w)default:return WriteKittyImage(img, w)}}// ClearImages clears previously displayed images.// For kitty, deletes all image placements. For sixel/iTerm2, no-op (inline text).func ClearImages(w io.Writer, protocol ImageProtocol) error {switch protocol {case ProtocolKitty:return ClearKittyImages(w)default:return nil}}// ClearKittyImages clears all previously displayed Kitty imagesfunc ClearKittyImages(w io.Writer) error {_, err := io.WriteString(w, ansi.KittyGraphics(nil, "a=d"))return err}// WriteKittyImage writes an image to the writer using the Kitty graphics protocol.// The image is encoded as PNG, base64'd, and sent via chunked Kitty escape sequences.func WriteKittyImage(img image.Image, w io.Writer) error {return kitty.EncodeGraphics(w, img, &kitty.Options{Format: kitty.PNG,Action: kitty.TransmitAndPut,Transmission: kitty.Direct,Chunk: true,})}// WriteSixelImage writes an image using the Sixel graphics protocol.func WriteSixelImage(img image.Image, w io.Writer) error {var buf bytes.Bufferenc := &sixel.Encoder{}if err := enc.Encode(&buf, img); err != nil {return err}_, err := io.WriteString(w, ansi.SixelGraphics(0, 1, 0, buf.Bytes()))return err}// WriteITermImage writes an image using the iTerm2 Inline Image Protocol.func WriteITermImage(img image.Image, w io.Writer) error {var buf bytes.Bufferif err := png.Encode(&buf, img); err != nil {return err}b64 := base64.StdEncoding.EncodeToString(buf.Bytes())_, err := io.WriteString(w, ansi.ITerm2(iterm2.File{Inline: true,Content: []byte(b64),}))return err}// CreateGrayscaleImage creates an image.Image from a 2D uint8 array.// The array is organized as [rows][cols] where rows = frequency bins.func CreateGrayscaleImage(data [][]uint8) image.Image {if len(data) == 0 || len(data[0]) == 0 {return nil}height := len(data)width := len(data[0])img := image.NewGray(image.Rect(0, 0, width, height))for y := range height {off := y * img.Striderow := data[y]copy(img.Pix[off:off+width], row)}return img}// CreateRGBImage creates an image.Image from a 2D RGBPixel array.// The array is organized as [rows][cols] where rows = frequency bins.func CreateRGBImage(data [][]RGBPixel) image.Image {if len(data) == 0 || len(data[0]) == 0 {return nil}height := len(data)width := len(data[0])img := image.NewRGBA(image.Rect(0, 0, width, height))for y := range height {off := y * img.Striderow := data[y]for x := range width {i := off + x*4img.Pix[i] = row[x].Rimg.Pix[i+1] = row[x].Gimg.Pix[i+2] = row[x].Bimg.Pix[i+3] = 255}}return img}// resizeScale holds precomputed scale factors for nearest-neighbor resizing.type resizeScale struct {srcWidth, srcHeight intscaleX, scaleY float64}func newResizeScale(img image.Image, newWidth, newHeight int) resizeScale {bounds := img.Bounds()return resizeScale{srcWidth: bounds.Dx(),srcHeight: bounds.Dy(),scaleX: float64(bounds.Dx()) / float64(newWidth),scaleY: float64(bounds.Dy()) / float64(newHeight),}}// srcCoord maps a destination pixel coordinate to source coordinate, clamped to bounds.func (s resizeScale) srcCoord(dstX, dstY int) (srcX, srcY int) {srcX = int(float64(dstX) * s.scaleX)srcY = int(float64(dstY) * s.scaleY)if srcX >= s.srcWidth {srcX = s.srcWidth - 1}if srcY >= s.srcHeight {srcY = s.srcHeight - 1}return}// resizeGray resizes a Gray image using nearest-neighbor interpolation.func resizeGray(src *image.Gray, s resizeScale, newWidth, newHeight int) *image.Gray {result := image.NewGray(image.Rect(0, 0, newWidth, newHeight))for y := range newHeight {dstOff := y * result.Stride_, srcY := s.srcCoord(0, y)srcRowOff := srcY * src.Stridefor x := range newWidth {srcX, _ := s.srcCoord(x, 0)result.Pix[dstOff+x] = src.Pix[srcRowOff+srcX]}}// resizeRGBA resizes an RGBA image using nearest-neighbor interpolation.func resizeRGBA(src *image.RGBA, s resizeScale, newWidth, newHeight int) *image.RGBA {result := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))for y := range newHeight {dstOff := y * result.Stride_, srcY := s.srcCoord(0, y)srcRowOff := srcY * src.Stridefor x := range newWidth {srcX, _ := s.srcCoord(x, 0)si := srcRowOff + srcX*4di := dstOff + x*4result.Pix[di] = src.Pix[si]result.Pix[di+1] = src.Pix[si+1]result.Pix[di+2] = src.Pix[si+2]result.Pix[di+3] = src.Pix[si+3]}}// resizeGeneric resizes any image using nearest-neighbor interpolation (slow fallback).func resizeGeneric(img image.Image, s resizeScale, newWidth, newHeight int) *image.RGBA {bounds := img.Bounds()result := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))for y := range newHeight {for x := range newWidth {srcX, srcY := s.srcCoord(x, y)c := img.At(srcX+bounds.Min.X, srcY+bounds.Min.Y)r, g, b, _ := c.RGBA()result.SetRGBA(x, y, color.RGBA{R: uint8(r >> 8),G: uint8(g >> 8),B: uint8(b >> 8),A: 255,})}}return result}// WritePNG writes an image to a writer in PNG format using fast compression.func WritePNG(img image.Image, w io.Writer) error {enc := &png.Encoder{CompressionLevel: png.BestSpeed}return enc.Encode(w, img)}}// ResizeImage resizes an image using nearest-neighbor interpolation.// For higher quality, use golang.org/x/image/draw, but this keeps dependencies minimal.func ResizeImage(img image.Image, newWidth, newHeight int) image.Image {s := newResizeScale(img, newWidth, newHeight)if srcGray, ok := img.(*image.Gray); ok {return resizeGray(srcGray, s, newWidth, newHeight)}if srcRGBA, ok := img.(*image.RGBA); ok {return resizeRGBA(srcRGBA, s, newWidth, newHeight)}return resizeGeneric(img, s, newWidth, newHeight)return result}return result}
package utilsimport ("image""math""strings""sync""github.com/madelynnblue/go-dsp/window")// cached Hann windows by size, computed oncevar (hannCache = map[int][]float64{}hannCacheMu sync.RWMutex)// getCachedHannWindow returns a cached Hann window of the given size.func getCachedHannWindow(size int) []float64 {hannCacheMu.RLock()if w, ok := hannCache[size]; ok {hannCacheMu.RUnlock()return w}hannCacheMu.RUnlock()hannCacheMu.Lock()defer hannCacheMu.Unlock()// Double-check after acquiring write lockif w, ok := hannCache[size]; ok {return w}w := window.Hann(size)hannCache[size] = wreturn w}// SpectrogramConfig holds STFT parameterstype SpectrogramConfig struct {WindowSize int // FFT window size (e.g., 400)HopSize int // Hop between windows (e.g., 200 for 50% overlap)SampleRate int // Sample rate in Hz}// DefaultSpectrogramConfig returns default config matching Julia implementationfunc DefaultSpectrogramConfig(sampleRate int) SpectrogramConfig {return SpectrogramConfig{WindowSize: 512,HopSize: 256, // 50% overlap (window/2)SampleRate: sampleRate,}}// GenerateSpectrogram generates a spectrogram from audio samples.// Returns a 2D array of uint8 (0-255) where:// - First dimension is frequency bins (rows)// - Second dimension is time frames (columns)func GenerateSpectrogram(samples []float64, cfg SpectrogramConfig) [][]uint8 {if len(samples) < cfg.WindowSize {return nil}// Get cached Hann windowhannWindow := getCachedHannWindow(cfg.WindowSize)// Calculate number of framesnumFrames := (len(samples)-cfg.WindowSize)/cfg.HopSize + 1if numFrames <= 0 {return nil}// Number of frequency bins (half of FFT due to symmetry)numFreqBins := cfg.WindowSize/2 + 1// Allocate power spectrum as flat backing slice (single allocation)powerFlat := make([]float64, numFreqBins*numFrames)// Pre-allocate scratch buffers (reused across all frames — zero allocs in loop)frameData := make([]float64, cfg.WindowSize)scratch := make([]complex128, cfg.WindowSize)framePower := make([]float64, numFreqBins)// Perform STFTfor frame := range numFrames {start := frame * cfg.HopSize// Extract and window the framefor i := 0; i < cfg.WindowSize; i++ {frameData[i] = samples[start+i] * hannWindow[i]}// Compute power spectrum via inline FFT (zero allocations)// Copy power into flat matrix (freq bins x time frames layout)for bin := range numFreqBins {powerFlat[bin*numFrames+frame] = framePower[bin]}}// Fused normalization: replace zeros, convert to dB, find min/max, normalize to uint8// All in 2 passes instead of 6return normalizeFlat(powerFlat, numFreqBins, numFrames)}// normalizeFlat converts power values to dB, normalizes to 0-255, in 2 passes.// Operates on a flat slice laid out as [row0_col0, row0_col1, ..., row1_col0, ...].// Returns [][]uint8 with rows flipped vertically (low frequencies at bottom).minNonZero := math.MaxFloat64for _, val := range power {if val > 0 && val < minNonZero {minNonZero = val}}if minNonZero == math.MaxFloat64 {}for i, val := range power {if val <= 0 {val = minNonZero}db := 10.0 * math.Log10(val)power[i] = dbif db < minDB {minDB = db}if db > maxDB {maxDB = db}}rangeDB := maxDB - minDBif rangeDB == 0 {rangeDB = 1}scale := 255.0 / rangeDBresultFlat := make([]uint8, rows*cols)result := make([][]uint8, rows)for i := range result {srcRow := rows - 1 - iresult[i] = resultFlat[i*cols : (i+1)*cols]srcOff := srcRow * colsfor j := range cols {result[i][j] = uint8((power[srcOff+j] - minDB) * scale)}}return result}// ExtractSegmentSamples extracts samples from a time rangefunc ExtractSegmentSamples(samples []float64, sampleRate int, startSec, endSec float64) []float64 {startIdx := int(startSec * float64(sampleRate))endIdx := int(endSec * float64(sampleRate))if startIdx < 0 {startIdx = 0}if endIdx > len(samples) {endIdx = len(samples)}if startIdx >= endIdx {return nil}return samples[startIdx:endIdx]}// GenerateSegmentSpectrogram generates a spectrogram image for a time segment.// Handles WAV loading, downsampling, and image creation.// color=true applies L4 colormap, color=false creates grayscale.// imgSize specifies the output image dimensions (clamped to [224, 896]).// Derive WAV file path (strip .data suffix)wavPath := strings.TrimSuffix(dataFilePath, ".data")// Read only the requested segment's samples from the WAV fileif err != nil {return nil, err}if len(segSamples) == 0 {return nil, nil}}}}}}// WAVBasename extracts the base filename from a .data file path.// E.g., "/path/to/file.wav.data" -> "file".func WAVBasename(dataFilePath string) string {wavPath := strings.TrimSuffix(dataFilePath, ".data")basename := filepath.Base(wavPath)return strings.TrimSuffix(basename, filepath.Ext(basename))return pngPath, wavPath, nil}if _, err := os.Stat(wavPath); err == nil {return "", "", fmt.Errorf("file already exists: %s", wavPath)// ClipPaths returns full PNG and WAV paths for a clip in the given output directory.// Also checks that neither file exists. Returns an error if files exist.func ClipPaths(outputDir, prefix, basename string, startTime, endTime float64) (pngPath, wavPath string, err error) {baseName := ClipBaseName(prefix, basename, startTime, endTime)pngPath = filepath.Join(outputDir, baseName+".png")wavPath = filepath.Join(outputDir, baseName+".wav")if _, err := os.Stat(pngPath); err == nil {return "", "", fmt.Errorf("file already exists: %s", pngPath)if err := WritePNG(img, file); err != nil {_ = file.Close()return fmt.Errorf("failed to write PNG: %w", err)}if err := file.Close(); err != nil {return fmt.Errorf("failed to close PNG: %w", err)}return nil}// ClipBaseName generates the base filename for a clip in the format:// prefix_basename_startTime_endTime// Times are integers (floor for start, ceil for end).func ClipBaseName(prefix, basename string, startTime, endTime float64) string {startInt := int(math.Floor(startTime))endInt := int(math.Ceil(endTime))return fmt.Sprintf("%s_%s_%d_%d", prefix, basename, startInt, endInt)}// WritePNGFile writes an image to a PNG file. Uses O_EXCL to atomically fail// if the file already exists. Returns an error with path context on failure.func WritePNGFile(path string, img image.Image) error {file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)if err != nil {if os.IsExist(err) {return fmt.Errorf("file already exists: %s", path)}return fmt.Errorf("failed to create PNG: %w", err)img := SpectrogramImageFromSamples(segSamples, sampleRate, color, imgSize)return img, nil}// For spectrograms, downsample if sample rate exceeds 16kHzif sampleRate > audio.DefaultMaxSampleRate {segSamples = audio.ResampleRate(segSamples, sampleRate, audio.DefaultMaxSampleRate)sampleRate = audio.DefaultMaxSampleRatesegSamples, sampleRate, err := wav.ReadWAVSegmentSamples(wavPath, startTime, endTime)func GenerateSegmentSpectrogram(dataFilePath string, startTime, endTime float64, color bool, imgSize int) (image.Image, error) {var img image.Imageif color {colorData := ApplyL4Colormap(spectrogram)img = CreateRGBImage(colorData)} else {img = CreateGrayscaleImage(spectrogram)}if img == nil {return nil}imgSize = ClampImageSize(imgSize)return ResizeImage(img, imgSize, imgSize)}// SpectrogramImageFromSamples generates a spectrogram image from audio samples.// This is the core pipeline: spectrogram -> colormap/grayscale -> image -> resize.// Use this when you already have samples (e.g., after bandpass filtering).func SpectrogramImageFromSamples(samples []float64, sampleRate int, color bool, imgSize int) image.Image {if len(samples) == 0 {return nil}config := DefaultSpectrogramConfig(sampleRate)spectrogram := GenerateSpectrogram(samples, config)if spectrogram == nil {return nil}func normalizeFlat(power []float64, rows, cols int) [][]uint8 {if rows == 0 || cols == 0 {return nil}minDB, maxDB := convertToDB(power)// Normalize dB to uint8 and write into result (with vertical flip)return minDB, maxDB}minDB = math.MaxFloat64maxDB = -math.MaxFloat64minNonZero = 1e-20// convertToDB replaces power values with dB values in-place, returning min/max dB.// Zero/negative values are clamped to minNonZero before conversion.func convertToDB(power []float64) (minDB, maxDB float64) {audio.PowerSpectrumFFT(frameData, framePower, scratch)"skraak/audio""skraak/wav""os""path/filepath""fmt"
package utilsimport ("testing""time")func TestGenerateFileID(t *testing.T) {t.Run("generates 21-character ID", func(t *testing.T) {id := mustGenerateID(t)if len(id) != 21 {t.Errorf("expected length 21, got %d: %q", len(id), id)}})t.Run("uses only valid alphabet characters", func(t *testing.T) {id := mustGenerateID(t)for _, c := range id {if !isValidAlphabetChar(c) {t.Errorf("invalid character %q in ID %q", string(c), id)}}})t.Run("generates unique IDs", func(t *testing.T) {seen := make(map[string]bool)for range 100 {id := mustGenerateID(t)if seen[id] {t.Errorf("duplicate ID generated: %q", id)}seen[id] = true}})}func TestResolveTimestamp(t *testing.T) {t.Run("resolves AudioMoth timestamp", 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.",Artist: "AudioMoth",}result := mustResolveTimestamp(t, meta, "20250224_210000.wav", "Pacific/Auckland", false, nil)if !result.IsAudioMoth {t.Error("expected IsAudioMoth to be true")}if result.MothData == nil {t.Error("expected MothData to be non-nil")}expectedUTC := time.Date(2025, 2, 24, 8, 0, 0, 0, time.UTC)if !result.Timestamp.UTC().Equal(expectedUTC) {t.Errorf("expected UTC timestamp %v, got %v", expectedUTC, result.Timestamp.UTC())}})t.Run("falls back to filename timestamp", func(t *testing.T) {if result.IsAudioMoth {t.Error("expected IsAudioMoth to be false")}if result.Timestamp.IsZero() {t.Error("expected non-zero timestamp")}})t.Run("falls back to file mod time when enabled", func(t *testing.T) {modTime := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)result := mustResolveTimestamp(t, meta, "nopattern.wav", "Pacific/Auckland", true, nil)if !result.Timestamp.Equal(modTime) {t.Errorf("expected timestamp %v, got %v", modTime, result.Timestamp)}})t.Run("errors_on_no_timestamp", func(t *testing.T) {cases := []struct {name stringuseModTime bool}{{"mod_time_disabled", false},{"no_file_mod_time", true},}for _, tc := range cases {t.Run(tc.name, func(t *testing.T) {if err == nil {t.Error("expected error when no timestamp available")}})}})t.Run("AudioMoth detected but parse fails falls back to filename", func(t *testing.T) {result := mustResolveTimestamp(t, meta, "20250224_210000.wav", "Pacific/Auckland", false, nil)if !result.IsAudioMoth {t.Error("expected IsAudioMoth to be true (detected even if parse failed)")}if result.MothData != nil {t.Error("expected MothData to be nil since parsing failed")}if result.Timestamp.IsZero() {t.Error("expected non-zero timestamp from filename fallback")}})}func TestParseWAVHeaderWithHash(t *testing.T) {tmpPath := filepath.Join(t.TempDir(), "hash_test.wav")if err != nil {t.Fatalf("failed to create temp wav: %v", err)}if err != nil {t.Fatalf("unexpected error: %v", err)}if meta.SampleRate != 8000 {t.Errorf("expected 8000, got %d", meta.SampleRate)}if hash == "" {t.Error("expected non-empty hash")}}meta, hash, err := wav.ParseWAVHeaderWithHash(tmpPath)err := wav.WriteWAVFile(tmpPath, []float64{0.0, 0.0}, 8000)}func TestReadWAVSegmentSamples(t *testing.T) {tmpPath := filepath.Join(t.TempDir(), "segment_test.wav")// Create a simple WAV file with 4 samplesif err != nil {t.Fatalf("failed to create temp wav: %v", err)}if err != nil {t.Fatalf("unexpected error: %v", err)}if rate != 8000 {t.Errorf("expected sample rate 8000, got %d", rate)}if len(samples) != 4 {t.Errorf("expected 4 samples, got %d", len(samples))}if err != nil {t.Fatalf("unexpected error: %v", err)}if len(samples2) != 4 {t.Errorf("expected 4 samples, got %d", len(samples2))}}func TestProcessSingleFile(t *testing.T) {tmpPath := filepath.Join(t.TempDir(), "20240101_120000.wav")if err != nil {t.Fatalf("failed to create temp wav: %v", err)}res, err := ProcessSingleFile(tmpPath, -41.0, 174.0, "Pacific/Auckland", false)if err != nil {t.Fatalf("unexpected error: %v", err)}if res.SampleRate != 8000 {t.Errorf("expected sample rate 8000, got %d", res.SampleRate)}if res.Hash == "" {t.Error("expected non-empty hash")}err := wav.WriteWAVFile(tmpPath, []float64{0.0, 0.0}, 8000)// Helper ReadWAVSamples wrappersamples2, _, err := wav.ReadWAVSamples(tmpPath)// Read specific segmentsamples, rate, err := wav.ReadWAVSegmentSamples(tmpPath, 0, 0)err := wav.WriteWAVFile(tmpPath, []float64{0.1, 0.2, 0.3, 0.4}, 8000)meta := &wav.WAVMetadata{Comment: "AudioMoth garbage data"}_, err := ResolveTimestamp(&wav.WAVMetadata{}, "nopattern.wav", "Pacific/Auckland", tc.useModTime, nil)meta := &wav.WAVMetadata{FileModTime: modTime}result := mustResolveTimestamp(t, &wav.WAVMetadata{}, "20250224_210000.wav", "Pacific/Auckland", false, nil)meta := &wav.WAVMetadata{}// mustResolveTimestamp is a test helper that calls ResolveTimestamp and fatals on error.t.Helper()result, err := ResolveTimestamp(meta, filename, tz, useModTime, preParsed)if err != nil {t.Fatalf("unexpected error: %v", err)}return resultfunc mustResolveTimestamp(t *testing.T, meta *wav.WAVMetadata, filename, tz string, useModTime bool, preParsed *time.Time) *TimestampResult {// mustGenerateID is a test helper that calls GenerateLongID and fatals on error.func mustGenerateID(t *testing.T) string {t.Helper()id, err := GenerateLongID()if err != nil {t.Fatalf("unexpected error: %v", err)}return id}// isValidAlphabetChar checks if c is in the nanoid default alphabet (0-9, A-Z, a-z, _, -).func isValidAlphabetChar(c rune) bool {return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_' || c == '-'}"skraak/wav""path/filepath"
package utilsimport ("fmt""path/filepath""time")// TimestampResult holds the result of timestamp resolution for a single filetype TimestampResult struct {Timestamp time.TimeIsAudioMoth bool}// ResolveTimestamp resolves a file's timestamp using the standard priority chain:// 1. AudioMoth comment parsing// 2. Filename timestamp parsing + timezone offset// 3. File modification time (if useFileModTime is true)//// Returns an error if no timestamp could be determined.result := &TimestampResult{}// Step 1: Try AudioMoth commentresult.IsAudioMoth = trueif err == nil {result.MothData = mothDataresult.Timestamp = mothData.Timestampreturn result, nil}// AudioMoth detected but parsing failed — fall through to filename}// Step 2: Try filename timestampif preParsedFilenameTime != nil && !preParsedFilenameTime.IsZero() {result.Timestamp = *preParsedFilenameTimereturn result, nil} else if HasTimestampFilename(filePath) {filenameTimestamps, err := ParseFilenameTimestamps([]string{filepath.Base(filePath)})if err == nil {adjustedTimestamps, err := ApplyTimezoneOffset(filenameTimestamps, timezoneID)if err == nil && len(adjustedTimestamps) > 0 {result.Timestamp = adjustedTimestamps[0]return result, nil}}}// Step 3: File modification time fallback (optional)if useFileModTime && !wavMeta.FileModTime.IsZero() {result.Timestamp = wavMeta.FileModTimereturn result, nil}return nil, fmt.Errorf("cannot resolve timestamp (no AudioMoth, filename pattern, or file modification time)")}// FileProcessingResult holds all extracted metadata for a single filetype FileProcessingResult struct {FileName stringHash stringDuration float64SampleRate intTimestampLocal time.TimeIsAudioMoth boolAstroData AstronomicalData}// ProcessSingleFile runs the full single-file processing pipeline:// WAV header parsing → XXH64 hash → timestamp resolution → astronomical data//// Set useFileModTime to true to allow file modification time as a timestamp fallback.func ProcessSingleFile(filePath string, latitude, longitude float64, timezoneID string, useFileModTime bool) (*FileProcessingResult, error) {// Step 1: Parse WAV headerif err != nil {return nil, fmt.Errorf("WAV header parsing failed: %w", err)}// Step 2: Calculate hashhash, err := ComputeXXH64(filePath)if err != nil {return nil, fmt.Errorf("hash calculation failed: %w", err)}// Step 3: Resolve timestamptsResult, err := ResolveTimestamp(metadata, filePath, timezoneID, useFileModTime, nil)if err != nil {return nil, err}// Step 4: Calculate astronomical dataastroData := CalculateAstronomicalData(tsResult.Timestamp.UTC(),metadata.Duration,latitude,longitude,)return &FileProcessingResult{FileName: filepath.Base(filePath),Hash: hash,Duration: metadata.Duration,SampleRate: metadata.SampleRate,TimestampLocal: tsResult.Timestamp,IsAudioMoth: tsResult.IsAudioMoth,MothData: tsResult.MothData,AstroData: astroData,}, nil}metadata, err := wav.ParseWAVHeader(filePath)MothData *wav.AudioMothDatamothData, err := wav.ParseAudioMothComment(wavMeta.Comment)if wav.IsAudioMoth(wavMeta.Comment, wavMeta.Artist) {func ResolveTimestamp(wavMeta *wav.WAVMetadata, filePath string, timezoneID string, useFileModTime bool, preParsedFilenameTime *time.Time) (*TimestampResult, error) {MothData *wav.AudioMothData"skraak/wav"
package utils// RGBPixel represents an RGB color valuetype RGBPixel struct {R, G, B uint8}// L4Colormap is the Black-Red-Yellow heat colormap from PerceptualColourMaps.jl// Control points://// Index 0: Black (0.0, 0.0, 0.0)// Index 85: Dark Red (0.85, 0.0, 0.0)// Index 170: Orange-Red (1.0, 0.15, 0.0)// Index 255: Yellow (1.0, 1.0, 0.0)var L4Colormap [256]RGBPixelfunc init() {// Generate L4 colormap using piecewise linear interpolation// This avoids overshoot issues with cubic splinescontrolPoints := []struct {idx intr float64g float64b float64}{{0, 0.0, 0.0, 0.0},{85, 0.85, 0.0, 0.0},{170, 1.0, 0.15, 0.0},{255, 1.0, 1.0, 0.0},}for i := range 256 {// Find the segment we're invar seg intfor seg = 0; seg < len(controlPoints)-1; seg++ {if i <= controlPoints[seg+1].idx {break}}if seg >= len(controlPoints)-1 {seg = len(controlPoints) - 2}// Linear interpolation within segmentp0 := controlPoints[seg]p1 := controlPoints[seg+1]t := 0.0if p1.idx != p0.idx {t = float64(i-p0.idx) / float64(p1.idx-p0.idx)}L4Colormap[i] = RGBPixel{R: uint8((p0.r + t*(p1.r-p0.r)) * 255.0),G: uint8((p0.g + t*(p1.g-p0.g)) * 255.0),B: uint8((p0.b + t*(p1.b-p0.b)) * 255.0),}}}// ApplyL4Colormap converts a grayscale image to RGB using the L4 colormapfunc ApplyL4Colormap(grayscale [][]uint8) [][]RGBPixel {if len(grayscale) == 0 || len(grayscale[0]) == 0 {return nil}rows := len(grayscale)cols := len(grayscale[0])result := make([][]RGBPixel, rows)for i := range result {result[i] = make([]RGBPixel, cols)}for y := range rows {for x := range cols {result[y][x] = L4Colormap[grayscale[y][x]]}}return result}
func resolveFileData(info wavInfo, preParsedTime *time.Time, location *LocationData) (*utils.FileProcessingResult, error) {tsResult, err := utils.ResolveTimestamp(info.metadata, info.path, location.TimezoneID, true, preParsedTime)
func resolveFileData(info wavInfo, preParsedTime *time.Time, location *LocationData) (*wav.FileProcessingResult, error) {tsResult, err := wav.ResolveTimestamp(info.metadata, info.path, location.TimezoneID, true, preParsedTime)
func batchProcessFiles(wavFiles []string, location *LocationData) ([]*utils.FileProcessingResult, []FileImportError) {var filesData []*utils.FileProcessingResult
func batchProcessFiles(wavFiles []string, location *LocationData) ([]*wav.FileProcessingResult, []FileImportError) {var filesData []*wav.FileProcessingResult
segSamples := utils.ExtractSegmentSamples(samples, sr, 872, 895)cfg := utils.DefaultSpectrogramConfig(16000)
segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 872, 895)cfg := spectrogram.DefaultSpectrogramConfig(16000)
segSamples := utils.ExtractSegmentSamples(samples, sr, 0, 60)cfg := utils.DefaultSpectrogramConfig(16000)
segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 0, 60)cfg := spectrogram.DefaultSpectrogramConfig(16000)
segSamples := utils.ExtractSegmentSamples(samples, sr, 872, 895)cfg := utils.DefaultSpectrogramConfig(16000)spect := utils.GenerateSpectrogram(segSamples, cfg)
segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 872, 895)cfg := spectrogram.DefaultSpectrogramConfig(16000)spect := spectrogram.GenerateSpectrogram(segSamples, cfg)
segSamples := utils.ExtractSegmentSamples(samples, sr, 872, 895)cfg := utils.DefaultSpectrogramConfig(16000)spect := utils.GenerateSpectrogram(segSamples, cfg)
segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 872, 895)cfg := spectrogram.DefaultSpectrogramConfig(16000)spect := spectrogram.GenerateSpectrogram(segSamples, cfg)
segSamples := utils.ExtractSegmentSamples(samples, sr, 872, 895)cfg := utils.DefaultSpectrogramConfig(16000)spect := utils.GenerateSpectrogram(segSamples, cfg)
segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 872, 895)cfg := spectrogram.DefaultSpectrogramConfig(16000)spect := spectrogram.GenerateSpectrogram(segSamples, cfg)
segSamples := utils.ExtractSegmentSamples(samples, sr, 872, 895)cfg := utils.DefaultSpectrogramConfig(16000)spect := utils.GenerateSpectrogram(segSamples, cfg)img := utils.CreateGrayscaleImage(spect)
segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 872, 895)cfg := spectrogram.DefaultSpectrogramConfig(16000)spect := spectrogram.GenerateSpectrogram(segSamples, cfg)img := spectrogram.CreateGrayscaleImage(spect)
segSamples := utils.ExtractSegmentSamples(samples, sr, 872, 895)cfg := utils.DefaultSpectrogramConfig(16000)spect := utils.GenerateSpectrogram(segSamples, cfg)img := utils.CreateGrayscaleImage(spect)
segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 872, 895)cfg := spectrogram.DefaultSpectrogramConfig(16000)spect := spectrogram.GenerateSpectrogram(segSamples, cfg)img := spectrogram.CreateGrayscaleImage(spect)
segSamples := utils.ExtractSegmentSamples(samples, sr, 872, 895)cfg := utils.DefaultSpectrogramConfig(16000)spect := utils.GenerateSpectrogram(segSamples, cfg)img := utils.CreateGrayscaleImage(spect)resized := utils.ResizeImage(img, 224, 224)
segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 872, 895)cfg := spectrogram.DefaultSpectrogramConfig(16000)spect := spectrogram.GenerateSpectrogram(segSamples, cfg)img := spectrogram.CreateGrayscaleImage(spect)resized := spectrogram.ResizeImage(img, 224, 224)
cfg := utils.DefaultSpectrogramConfig(outputSR)spect := utils.GenerateSpectrogram(segSamples, cfg)img := utils.CreateGrayscaleImage(spect)resized := utils.ResizeImage(img, 224, 224)
cfg := spectrogram.DefaultSpectrogramConfig(outputSR)spect := spectrogram.GenerateSpectrogram(segSamples, cfg)img := spectrogram.CreateGrayscaleImage(spect)resized := spectrogram.ResizeImage(img, 224, 224)
cfg := utils.DefaultSpectrogramConfig(outputSR)spect := utils.GenerateSpectrogram(segSamples, cfg)colorData := utils.ApplyL4Colormap(spect)img := utils.CreateRGBImage(colorData)resized := utils.ResizeImage(img, 448, 448)
cfg := spectrogram.DefaultSpectrogramConfig(outputSR)spect := spectrogram.GenerateSpectrogram(segSamples, cfg)colorData := spectrogram.ApplyL4Colormap(spect)img := spectrogram.CreateRGBImage(colorData)resized := spectrogram.ResizeImage(img, 448, 448)
basename := utils.WAVBasename(df.FilePath)pngPath, wavPath, err := utils.ClipPaths(outputDir, prefix, basename, seg.StartTime, seg.EndTime)
basename := spectrogram.WAVBasename(df.FilePath)pngPath, wavPath, err := spectrogram.ClipPaths(outputDir, prefix, basename, seg.StartTime, seg.EndTime)
package spectrogramimport ("bytes""image""image/color""image/png""math/rand""strings""testing")func TestWriteKittyImage_SmallImage(t *testing.T) {// 2x2 image produces small base64 payload — single chunk, no m= keyimg := image.NewGray(image.Rect(0, 0, 2, 2))img.SetGray(0, 0, color.Gray{Y: 128})var buf strings.Builderif err := WriteKittyImage(img, &buf); err != nil {t.Fatalf("WriteKittyImage: %v", err)}out := buf.String()if !strings.HasPrefix(out, "\x1b_Gf=100,a=T;") {t.Error("expected single-chunk header with f=100,a=T")}if strings.Contains(out, "m=") {t.Error("small image should not use chunked m= key")}if !strings.HasSuffix(out, "\x1b\\") {t.Error("expected escape sequence terminator")}}func TestWriteKittyImage_LargeImage_Chunked(t *testing.T) {// 128x128 random noise image is incompressible — produces >4096 bytes of base64 even with proper LZ77rng := rand.New(rand.NewSource(42))img := image.NewGray(image.Rect(0, 0, 128, 128))for y := range 128 {for x := range 128 {img.SetGray(x, y, color.Gray{Y: uint8(rng.Intn(256))})}}var buf strings.Builderif err := WriteKittyImage(img, &buf); err != nil {t.Fatalf("WriteKittyImage: %v", err)}out := buf.String()// Should have multiple escape sequenceschunks := strings.Split(out, "\x1b\\")// Last element is empty after final terminatorchunks = chunks[:len(chunks)-1]if len(chunks) < 2 {t.Fatalf("expected multiple chunks, got %d", len(chunks))}// First chunk should have f=100,a=T,m=1if !strings.Contains(chunks[0], "f=100,a=T,m=1") {t.Errorf("first chunk missing f=100,a=T,m=1: %s", chunks[0][:min(80, len(chunks[0]))])}// Last chunk should have m=0last := chunks[len(chunks)-1]if !strings.Contains(last, "\x1b_Gm=0;") {t.Errorf("last chunk missing m=0: %s", last[:min(80, len(last))])}// Middle chunks should have m=1for i := 1; i < len(chunks)-1; i++ {if !strings.Contains(chunks[i], "\x1b_Gm=1;") {t.Errorf("middle chunk %d missing m=1", i)}}}func TestClearKittyImages(t *testing.T) {var buf strings.BuilderClearKittyImages(&buf)expected := "\x1b_Ga=d\x1b\\"if buf.String() != expected {t.Errorf("got %q, want %q", buf.String(), expected)}}func TestWriteSixelImage(t *testing.T) {img := image.NewGray(image.Rect(0, 0, 4, 6))for y := range 6 {for x := range 4 {img.SetGray(x, y, color.Gray{Y: uint8((x + y) * 40)})}}var buf strings.Builderif err := WriteSixelImage(img, &buf); err != nil {t.Fatalf("WriteSixelImage: %v", err)}out := buf.String()// Sixel DCS introducerif !strings.HasPrefix(out, "\x1bP") {t.Error("expected DCS prefix \\x1bP")}// String terminatorif !strings.HasSuffix(out, "\x1b\\") {t.Error("expected ST suffix \\x1b\\\\")}// Should contain 'q' after DCS parametersif !strings.Contains(out, "q") {t.Error("expected 'q' in DCS sequence")}}func TestClearImages_Kitty(t *testing.T) {var buf strings.BuilderClearImages(&buf, ProtocolKitty)if buf.String() != "\x1b_Ga=d\x1b\\" {t.Errorf("got %q, want kitty clear sequence", buf.String())}}func TestClearImages_Sixel(t *testing.T) {var buf strings.BuilderClearImages(&buf, ProtocolSixel)if buf.String() != "" {t.Errorf("expected no output for sixel clear, got %q", buf.String())}}func TestWriteImage_Kitty(t *testing.T) {img := image.NewGray(image.Rect(0, 0, 2, 2))var buf strings.Builderif err := WriteImage(img, &buf, ProtocolKitty); err != nil {t.Fatalf("WriteImage kitty: %v", err)}if !strings.HasPrefix(buf.String(), "\x1b_G") {t.Error("expected kitty escape prefix")}}func TestWriteImage_Sixel(t *testing.T) {img := image.NewGray(image.Rect(0, 0, 4, 6))var buf strings.Builderif err := WriteImage(img, &buf, ProtocolSixel); err != nil {t.Fatalf("WriteImage sixel: %v", err)}if !strings.HasPrefix(buf.String(), "\x1bP") {t.Error("expected sixel DCS prefix")}}func TestWriteITermImage(t *testing.T) {img := image.NewGray(image.Rect(0, 0, 4, 4))img.SetGray(0, 0, color.Gray{Y: 128})var buf strings.Builderif err := WriteITermImage(img, &buf); err != nil {t.Fatalf("WriteITermImage: %v", err)}out := buf.String()if !strings.HasPrefix(out, "\x1b]1337;File=") {t.Errorf("expected iTerm2 OSC prefix, got %q", out[:min(30, len(out))])}if !strings.Contains(out, "inline=1") {t.Error("expected inline=1 parameter")}if !strings.HasSuffix(out, "\x07") {t.Error("expected BEL terminator")}}func TestWriteImage_ITerm(t *testing.T) {img := image.NewGray(image.Rect(0, 0, 4, 4))var buf strings.Builderif err := WriteImage(img, &buf, ProtocolITerm); err != nil {t.Fatalf("WriteImage iterm: %v", err)}if !strings.HasPrefix(buf.String(), "\x1b]1337;File=") {t.Error("expected iTerm2 OSC prefix")}}func TestClampImageSize(t *testing.T) {tests := []struct {input intwant int}{{100, 224}, // below minimum, clamped up{224, 224}, // at minimum{448, 448}, // in range{600, 600}, // in range{896, 896}, // at maximum{1000, 896}, // above maximum, clamped down}for _, tt := range tests {got := ClampImageSize(tt.input)if got != tt.want {t.Errorf("ClampImageSize(%d) = %d, want %d", tt.input, got, tt.want)}}}func TestCreateGrayscaleImage_Basic(t *testing.T) {data := [][]uint8{{0, 128, 255},{64, 192, 32},}img := CreateGrayscaleImage(data)if img == nil {t.Fatal("expected non-nil image")}bounds := img.Bounds()if bounds.Dx() != 3 {t.Errorf("width = %d, want 3", bounds.Dx())}if bounds.Dy() != 2 {t.Errorf("height = %d, want 2", bounds.Dy())}gray := img.(*image.Gray)// Row 0if gray.Pix[0] != 0 {t.Errorf("pix[0] = %d, want 0", gray.Pix[0])}if gray.Pix[1] != 128 {t.Errorf("pix[1] = %d, want 128", gray.Pix[1])}if gray.Pix[2] != 255 {t.Errorf("pix[2] = %d, want 255", gray.Pix[2])}// Row 1if gray.Pix[gray.Stride+0] != 64 {t.Errorf("pix[row1+0] = %d, want 64", gray.Pix[gray.Stride])}if gray.Pix[gray.Stride+1] != 192 {t.Errorf("pix[row1+1] = %d, want 192", gray.Pix[gray.Stride+1])}if gray.Pix[gray.Stride+2] != 32 {t.Errorf("pix[row1+2] = %d, want 32", gray.Pix[gray.Stride+2])}}func TestCreateGrayscaleImage_Empty(t *testing.T) {tests := []struct {name stringdata [][]uint8}{{"nil", nil},{"empty outer", [][]uint8{}},{"empty inner", [][]uint8{{}}},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {img := CreateGrayscaleImage(tt.data)if img != nil {t.Errorf("expected nil for %s", tt.name)}})}}// assertRGBAPixel checks that the pixel at (x,y) in an RGBA image matches the expected RGBA values.func assertRGBAPixel(t *testing.T, rgba *image.RGBA, x, y int, wantR, wantG, wantB, wantA uint8) {t.Helper()off := y*rgba.Stride + x*4got := rgba.Pix[off : off+4]if got[0] != wantR || got[1] != wantG || got[2] != wantB || got[3] != wantA {t.Errorf("pixel (%d,%d) = [%d,%d,%d,%d], want [%d,%d,%d,%d]",x, y, got[0], got[1], got[2], got[3], wantR, wantG, wantB, wantA)}}func TestCreateRGBImage_Basic(t *testing.T) {data := [][]RGBPixel{{{R: 255, G: 0, B: 0}, {R: 0, G: 255, B: 0}},{{R: 0, G: 0, B: 255}, {R: 255, G: 255, B: 255}},}img := CreateRGBImage(data)if img == nil {t.Fatal("expected non-nil image")}bounds := img.Bounds()if bounds.Dx() != 2 {t.Errorf("width = %d, want 2", bounds.Dx())}if bounds.Dy() != 2 {t.Errorf("height = %d, want 2", bounds.Dy())}rgba := img.(*image.RGBA)assertRGBAPixel(t, rgba, 0, 0, 255, 0, 0, 255) // redassertRGBAPixel(t, rgba, 1, 0, 0, 255, 0, 255) // greenassertRGBAPixel(t, rgba, 0, 1, 0, 0, 255, 255) // blue}func TestCreateRGBImage_Empty(t *testing.T) {tests := []struct {name stringdata [][]RGBPixel}{{"nil", nil},{"empty outer", [][]RGBPixel{}},{"empty inner", [][]RGBPixel{{}}},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {img := CreateRGBImage(tt.data)if img != nil {t.Errorf("expected nil for %s", tt.name)}})}}func TestResizeImage_Grayscale(t *testing.T) {// 4x4 uniform gray → 2x2 should produce same gray valuedata := make([][]uint8, 4)for i := range data {data[i] = []uint8{128, 128, 128, 128}}src := CreateGrayscaleImage(data)resized := ResizeImage(src, 2, 2)gray := resized.(*image.Gray)for y := range 2 {for x := range 2 {if gray.Pix[y*gray.Stride+x] != 128 {t.Errorf("pixel (%d,%d) = %d, want 128", x, y, gray.Pix[y*gray.Stride+x])}}}}func TestResizeImage_RGBA(t *testing.T) {// 4x4 uniform color → 2x2 should preserve colorrgbData := make([][]RGBPixel, 4)for i := range rgbData {rgbData[i] = []RGBPixel{{R: 100, G: 150, B: 200}, {R: 100, G: 150, B: 200}, {R: 100, G: 150, B: 200}, {R: 100, G: 150, B: 200}}}src := CreateRGBImage(rgbData)resized := ResizeImage(src, 2, 2)rgba := resized.(*image.RGBA)for y := range 2 {for x := range 2 {off := y*rgba.Stride + x*4if rgba.Pix[off] != 100 || rgba.Pix[off+1] != 150 || rgba.Pix[off+2] != 200 {t.Errorf("pixel (%d,%d) = (%d,%d,%d), want (100,150,200)",x, y, rgba.Pix[off], rgba.Pix[off+1], rgba.Pix[off+2])}}}}func TestResizeImage_GenericFallback(t *testing.T) {// Create a paletted image (not *image.Gray or *image.RGBA) to exercise fallback pathpalette := color.Palette{color.Black, color.White}src := image.NewPaletted(image.Rect(0, 0, 4, 4), palette)for y := range 4 {for x := range 4 {src.SetColorIndex(x, y, 0) // black}}resized := ResizeImage(src, 2, 2)bounds := resized.Bounds()if bounds.Dx() != 2 || bounds.Dy() != 2 {t.Errorf("bounds = %v, want 2x2", bounds)}}func TestResizeImage_Upscale(t *testing.T) {// 2x2 → 4x4 nearest-neighbor upscaledata := [][]uint8{{0, 255},{128, 64},}src := CreateGrayscaleImage(data)resized := ResizeImage(src, 4, 4)gray := resized.(*image.Gray)// Top-left quadrant should be 0if gray.Pix[0] != 0 {t.Errorf("(0,0) = %d, want 0", gray.Pix[0])}// Top-right quadrant should be 255if gray.Pix[3] != 255 {t.Errorf("(3,0) = %d, want 255", gray.Pix[3])}}func TestWritePNG(t *testing.T) {img := image.NewGray(image.Rect(0, 0, 4, 4))var buf bytes.Bufferif err := WritePNG(img, &buf); err != nil {t.Fatalf("WritePNG: %v", err)}if buf.Len() == 0 {t.Error("expected non-empty PNG output")}// Verify it's a valid PNG by decodingdecoded, err := png.Decode(&buf)if err != nil {t.Fatalf("failed to decode PNG: %v", err)}if decoded.Bounds().Dx() != 4 || decoded.Bounds().Dy() != 4 {t.Errorf("decoded bounds = %v, want 4x4", decoded.Bounds())}}func TestClearImages_ITerm(t *testing.T) {var buf strings.BuilderClearImages(&buf, ProtocolITerm)if buf.String() != "" {t.Errorf("expected no output for iTerm2 clear, got %q", buf.String())}}
package spectrogramimport ("bytes""encoding/base64""image""image/color""image/png""io""github.com/charmbracelet/x/ansi""github.com/charmbracelet/x/ansi/iterm2""github.com/charmbracelet/x/ansi/kitty""github.com/charmbracelet/x/ansi/sixel")// ImageProtocol selects the terminal graphics protocol.type ImageProtocol intconst (ProtocolKitty ImageProtocol = iotaProtocolSixelProtocolITerm)// SpectrogramDisplaySize is the default pixel dimension for spectrogram images.// 448px suits Retina/HiDPI screens (224 logical pixels at 2x).const SpectrogramDisplaySize = 448// ClampImageSize clamps a dimension to [224, 448].func ClampImageSize(size int) int {return max(224, min(896, size))}// WriteImage writes an image using the specified terminal graphics protocol.func WriteImage(img image.Image, w io.Writer, protocol ImageProtocol) error {switch protocol {case ProtocolSixel:return WriteSixelImage(img, w)case ProtocolITerm:return WriteITermImage(img, w)default:return WriteKittyImage(img, w)}}// ClearImages clears previously displayed images.// For kitty, deletes all image placements. For sixel/iTerm2, no-op (inline text).func ClearImages(w io.Writer, protocol ImageProtocol) error {switch protocol {case ProtocolKitty:return ClearKittyImages(w)default:return nil}}// ClearKittyImages clears all previously displayed Kitty imagesfunc ClearKittyImages(w io.Writer) error {_, err := io.WriteString(w, ansi.KittyGraphics(nil, "a=d"))return err}// WriteKittyImage writes an image to the writer using the Kitty graphics protocol.// The image is encoded as PNG, base64'd, and sent via chunked Kitty escape sequences.func WriteKittyImage(img image.Image, w io.Writer) error {return kitty.EncodeGraphics(w, img, &kitty.Options{Format: kitty.PNG,Action: kitty.TransmitAndPut,Transmission: kitty.Direct,Chunk: true,})}// WriteSixelImage writes an image using the Sixel graphics protocol.func WriteSixelImage(img image.Image, w io.Writer) error {var buf bytes.Bufferenc := &sixel.Encoder{}if err := enc.Encode(&buf, img); err != nil {return err}_, err := io.WriteString(w, ansi.SixelGraphics(0, 1, 0, buf.Bytes()))return err}// WriteITermImage writes an image using the iTerm2 Inline Image Protocol.func WriteITermImage(img image.Image, w io.Writer) error {var buf bytes.Bufferif err := png.Encode(&buf, img); err != nil {return err}b64 := base64.StdEncoding.EncodeToString(buf.Bytes())_, err := io.WriteString(w, ansi.ITerm2(iterm2.File{Inline: true,Content: []byte(b64),}))return err}// CreateGrayscaleImage creates an image.Image from a 2D uint8 array.// The array is organized as [rows][cols] where rows = frequency bins.func CreateGrayscaleImage(data [][]uint8) image.Image {if len(data) == 0 || len(data[0]) == 0 {return nil}height := len(data)width := len(data[0])img := image.NewGray(image.Rect(0, 0, width, height))for y := range height {off := y * img.Striderow := data[y]copy(img.Pix[off:off+width], row)}return img}// CreateRGBImage creates an image.Image from a 2D RGBPixel array.// The array is organized as [rows][cols] where rows = frequency bins.func CreateRGBImage(data [][]RGBPixel) image.Image {if len(data) == 0 || len(data[0]) == 0 {return nil}height := len(data)width := len(data[0])img := image.NewRGBA(image.Rect(0, 0, width, height))for y := range height {off := y * img.Striderow := data[y]for x := range width {i := off + x*4img.Pix[i] = row[x].Rimg.Pix[i+1] = row[x].Gimg.Pix[i+2] = row[x].Bimg.Pix[i+3] = 255}}return img}// resizeScale holds precomputed scale factors for nearest-neighbor resizing.type resizeScale struct {srcWidth, srcHeight intscaleX, scaleY float64}func newResizeScale(img image.Image, newWidth, newHeight int) resizeScale {bounds := img.Bounds()return resizeScale{srcWidth: bounds.Dx(),srcHeight: bounds.Dy(),scaleX: float64(bounds.Dx()) / float64(newWidth),scaleY: float64(bounds.Dy()) / float64(newHeight),}}// srcCoord maps a destination pixel coordinate to source coordinate, clamped to bounds.func (s resizeScale) srcCoord(dstX, dstY int) (srcX, srcY int) {srcX = int(float64(dstX) * s.scaleX)srcY = int(float64(dstY) * s.scaleY)if srcX >= s.srcWidth {srcX = s.srcWidth - 1}if srcY >= s.srcHeight {srcY = s.srcHeight - 1}return}// resizeGray resizes a Gray image using nearest-neighbor interpolation.func resizeGray(src *image.Gray, s resizeScale, newWidth, newHeight int) *image.Gray {result := image.NewGray(image.Rect(0, 0, newWidth, newHeight))for y := range newHeight {dstOff := y * result.Stride_, srcY := s.srcCoord(0, y)srcRowOff := srcY * src.Stridefor x := range newWidth {srcX, _ := s.srcCoord(x, 0)result.Pix[dstOff+x] = src.Pix[srcRowOff+srcX]}}return result}// resizeRGBA resizes an RGBA image using nearest-neighbor interpolation.func resizeRGBA(src *image.RGBA, s resizeScale, newWidth, newHeight int) *image.RGBA {result := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))for y := range newHeight {dstOff := y * result.Stride_, srcY := s.srcCoord(0, y)srcRowOff := srcY * src.Stridefor x := range newWidth {srcX, _ := s.srcCoord(x, 0)si := srcRowOff + srcX*4di := dstOff + x*4result.Pix[di] = src.Pix[si]result.Pix[di+1] = src.Pix[si+1]result.Pix[di+2] = src.Pix[si+2]result.Pix[di+3] = src.Pix[si+3]}}return result}// resizeGeneric resizes any image using nearest-neighbor interpolation (slow fallback).func resizeGeneric(img image.Image, s resizeScale, newWidth, newHeight int) *image.RGBA {bounds := img.Bounds()result := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))for y := range newHeight {for x := range newWidth {srcX, srcY := s.srcCoord(x, y)c := img.At(srcX+bounds.Min.X, srcY+bounds.Min.Y)r, g, b, _ := c.RGBA()result.SetRGBA(x, y, color.RGBA{R: uint8(r >> 8),G: uint8(g >> 8),B: uint8(b >> 8),A: 255,})}}return result}// ResizeImage resizes an image using nearest-neighbor interpolation.// For higher quality, use golang.org/x/image/draw, but this keeps dependencies minimal.func ResizeImage(img image.Image, newWidth, newHeight int) image.Image {s := newResizeScale(img, newWidth, newHeight)if srcGray, ok := img.(*image.Gray); ok {return resizeGray(srcGray, s, newWidth, newHeight)}if srcRGBA, ok := img.(*image.RGBA); ok {return resizeRGBA(srcRGBA, s, newWidth, newHeight)}return resizeGeneric(img, s, newWidth, newHeight)}// WritePNG writes an image to a writer in PNG format using fast compression.func WritePNG(img image.Image, w io.Writer) error {enc := &png.Encoder{CompressionLevel: png.BestSpeed}return enc.Encode(w, img)}
package spectrogramimport ("os""path/filepath""testing")func TestExtractSegmentSamples(t *testing.T) {samples := []float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}sampleRate := 2 // 2 samples per sec// sec 1.0 to 3.0 => indices 2 to 6 => [2, 3, 4, 5]seg := ExtractSegmentSamples(samples, sampleRate, 1.0, 3.0)if len(seg) != 4 || seg[0] != 2 || seg[len(seg)-1] != 5 {t.Errorf("unexpected segment extraction: %v", seg)}// out of boundsempty := ExtractSegmentSamples(samples, sampleRate, 5.0, 6.0)if len(empty) != 0 {t.Error("expected empty segment")}}func TestGenerateSpectrogram_Basic(t *testing.T) {samples := make([]float64, 1000) // Silent buffercfg := DefaultSpectrogramConfig(16000)res := GenerateSpectrogram(samples, cfg)if len(res) == 0 {t.Error("expected spectrogram generation to succeed")}shortSamples := []float64{0.0, 0.1}resShort := GenerateSpectrogram(shortSamples, cfg)if resShort != nil {t.Error("expected nil for samples smaller than window size")}}func TestGetCachedHannWindow(t *testing.T) {w1 := getCachedHannWindow(256)w2 := getCachedHannWindow(256)if len(w1) != 256 {t.Errorf("expected length 256, got %d", len(w1))}// Ensure memory address is the same (cached)if &w1[0] != &w2[0] {t.Error("expected cached slice to have the same memory address")}}func TestClipBaseName(t *testing.T) {tests := []struct {prefix stringbasename stringstartTime float64endTime float64want string}{{"clip", "file", 1.0, 3.0, "clip_file_1_3"},{"test", "recording", 0.0, 5.5, "test_recording_0_6"}, // ceil(5.5) = 6{"a", "b", 2.7, 4.2, "a_b_2_5"}, // floor(2.7)=2, ceil(4.2)=5}for _, tt := range tests {got := ClipBaseName(tt.prefix, tt.basename, tt.startTime, tt.endTime)if got != tt.want {t.Errorf("ClipBaseName(%q, %q, %.1f, %.1f) = %q, want %q",tt.prefix, tt.basename, tt.startTime, tt.endTime, got, tt.want)}}}func TestWAVBasename(t *testing.T) {tests := []struct {path stringwant string}{{"/path/to/file.wav.data", "file"},{"/audio/2024-01-15_recording.wav.data", "2024-01-15_recording"},{"simple.wav.data", "simple"},}for _, tt := range tests {got := WAVBasename(tt.path)if got != tt.want {t.Errorf("WAVBasename(%q) = %q, want %q", tt.path, got, tt.want)}}}func TestClipPaths(t *testing.T) {tmp := t.TempDir()// Normal casepngPath, wavPath, err := ClipPaths(tmp, "clip", "file", 1.0, 3.0)if err != nil {t.Fatalf("unexpected error: %v", err)}expectedPng := filepath.Join(tmp, "clip_file_1_3.png")expectedWav := filepath.Join(tmp, "clip_file_1_3.wav")if pngPath != expectedPng {t.Errorf("pngPath = %q, want %q", pngPath, expectedPng)}if wavPath != expectedWav {t.Errorf("wavPath = %q, want %q", wavPath, expectedWav)}// Collision detectionos.Create(expectedPng)_, _, err = ClipPaths(tmp, "clip", "file", 1.0, 3.0)if err == nil {t.Error("expected error for existing file")}}func TestWritePNGFile(t *testing.T) {tmp := t.TempDir()// Create a simple test image (grayscale 2x2)gray := [][]uint8{{128, 64}, {32, 16}}img := CreateGrayscaleImage(gray)if img == nil {t.Fatal("failed to create test image")}path := filepath.Join(tmp, "test.png")if err := WritePNGFile(path, img); err != nil {t.Fatalf("WritePNGFile failed: %v", err)}// Verify file existsif _, err := os.Stat(path); err != nil {t.Errorf("file not created: %v", err)}// Collision detectionerr := WritePNGFile(path, img)if err == nil {t.Error("expected error for existing file")}}func TestSpectrogramImageFromSamples(t *testing.T) {// Create a simple sine waveconst sampleRate = 16000const duration = 0.1 // 100mssamples := make([]float64, int(sampleRate*duration))for i := range samples {samples[i] = 0.5 // DC signal}img := SpectrogramImageFromSamples(samples, sampleRate, true, 224)if img == nil {t.Fatal("expected non-nil image")}bounds := img.Bounds()if bounds.Dx() != 224 || bounds.Dy() != 224 {t.Errorf("expected 224x224, got %dx%d", bounds.Dx(), bounds.Dy())}// Grayscale variantimgGray := SpectrogramImageFromSamples(samples, sampleRate, false, 224)if imgGray == nil {t.Error("expected non-nil grayscale image")}// Empty samplesimgEmpty := SpectrogramImageFromSamples(nil, sampleRate, true, 224)if imgEmpty != nil {t.Error("expected nil for empty samples")}}
package spectrogramimport ("fmt""image""math""os""path/filepath""strings""sync""github.com/madelynnblue/go-dsp/window""skraak/audio""skraak/wav")// cached Hann windows by size, computed oncevar (hannCache = map[int][]float64{}hannCacheMu sync.RWMutex)// getCachedHannWindow returns a cached Hann window of the given size.func getCachedHannWindow(size int) []float64 {hannCacheMu.RLock()if w, ok := hannCache[size]; ok {hannCacheMu.RUnlock()return w}hannCacheMu.RUnlock()hannCacheMu.Lock()defer hannCacheMu.Unlock()// Double-check after acquiring write lockif w, ok := hannCache[size]; ok {return w}w := window.Hann(size)hannCache[size] = wreturn w}// SpectrogramConfig holds STFT parameterstype SpectrogramConfig struct {WindowSize int // FFT window size (e.g., 400)HopSize int // Hop between windows (e.g., 200 for 50% overlap)SampleRate int // Sample rate in Hz}// DefaultSpectrogramConfig returns default config matching Julia implementationfunc DefaultSpectrogramConfig(sampleRate int) SpectrogramConfig {return SpectrogramConfig{WindowSize: 512,HopSize: 256, // 50% overlap (window/2)SampleRate: sampleRate,}}// GenerateSpectrogram generates a spectrogram from audio samples.// Returns a 2D array of uint8 (0-255) where:// - First dimension is frequency bins (rows)// - Second dimension is time frames (columns)func GenerateSpectrogram(samples []float64, cfg SpectrogramConfig) [][]uint8 {if len(samples) < cfg.WindowSize {return nil}// Get cached Hann windowhannWindow := getCachedHannWindow(cfg.WindowSize)// Calculate number of framesnumFrames := (len(samples)-cfg.WindowSize)/cfg.HopSize + 1if numFrames <= 0 {return nil}// Number of frequency bins (half of FFT due to symmetry)numFreqBins := cfg.WindowSize/2 + 1// Allocate power spectrum as flat backing slice (single allocation)powerFlat := make([]float64, numFreqBins*numFrames)// Pre-allocate scratch buffers (reused across all frames — zero allocs in loop)frameData := make([]float64, cfg.WindowSize)scratch := make([]complex128, cfg.WindowSize)framePower := make([]float64, numFreqBins)// Perform STFTfor frame := range numFrames {start := frame * cfg.HopSize// Extract and window the framefor i := 0; i < cfg.WindowSize; i++ {frameData[i] = samples[start+i] * hannWindow[i]}// Compute power spectrum via inline FFT (zero allocations)audio.PowerSpectrumFFT(frameData, framePower, scratch)// Copy power into flat matrix (freq bins x time frames layout)for bin := range numFreqBins {powerFlat[bin*numFrames+frame] = framePower[bin]}}// Fused normalization: replace zeros, convert to dB, find min/max, normalize to uint8// All in 2 passes instead of 6return normalizeFlat(powerFlat, numFreqBins, numFrames)}// normalizeFlat converts power values to dB, normalizes to 0-255, in 2 passes.// Operates on a flat slice laid out as [row0_col0, row0_col1, ..., row1_col0, ...].// Returns [][]uint8 with rows flipped vertically (low frequencies at bottom).// convertToDB replaces power values with dB values in-place, returning min/max dB.// Zero/negative values are clamped to minNonZero before conversion.func convertToDB(power []float64) (minDB, maxDB float64) {minNonZero := math.MaxFloat64for _, val := range power {if val > 0 && val < minNonZero {minNonZero = val}}if minNonZero == math.MaxFloat64 {minNonZero = 1e-20}minDB = math.MaxFloat64maxDB = -math.MaxFloat64for i, val := range power {if val <= 0 {val = minNonZero}db := 10.0 * math.Log10(val)power[i] = dbif db < minDB {minDB = db}if db > maxDB {maxDB = db}}return minDB, maxDB}func normalizeFlat(power []float64, rows, cols int) [][]uint8 {if rows == 0 || cols == 0 {return nil}minDB, maxDB := convertToDB(power)// Normalize dB to uint8 and write into result (with vertical flip)rangeDB := maxDB - minDBif rangeDB == 0 {rangeDB = 1}scale := 255.0 / rangeDBresultFlat := make([]uint8, rows*cols)result := make([][]uint8, rows)for i := range result {srcRow := rows - 1 - iresult[i] = resultFlat[i*cols : (i+1)*cols]srcOff := srcRow * colsfor j := range cols {result[i][j] = uint8((power[srcOff+j] - minDB) * scale)}}return result}// ExtractSegmentSamples extracts samples from a time rangefunc ExtractSegmentSamples(samples []float64, sampleRate int, startSec, endSec float64) []float64 {startIdx := int(startSec * float64(sampleRate))endIdx := int(endSec * float64(sampleRate))if startIdx < 0 {startIdx = 0}if endIdx > len(samples) {endIdx = len(samples)}if startIdx >= endIdx {return nil}return samples[startIdx:endIdx]}// SpectrogramImageFromSamples generates a spectrogram image from audio samples.// This is the core pipeline: spectrogram -> colormap/grayscale -> image -> resize.// Use this when you already have samples (e.g., after bandpass filtering).func SpectrogramImageFromSamples(samples []float64, sampleRate int, color bool, imgSize int) image.Image {if len(samples) == 0 {return nil}config := DefaultSpectrogramConfig(sampleRate)spectrogram := GenerateSpectrogram(samples, config)if spectrogram == nil {return nil}var img image.Imageif color {colorData := ApplyL4Colormap(spectrogram)img = CreateRGBImage(colorData)} else {img = CreateGrayscaleImage(spectrogram)}if img == nil {return nil}imgSize = ClampImageSize(imgSize)return ResizeImage(img, imgSize, imgSize)}// GenerateSegmentSpectrogram generates a spectrogram image for a time segment.// Handles WAV loading, downsampling, and image creation.// color=true applies L4 colormap, color=false creates grayscale.// imgSize specifies the output image dimensions (clamped to [224, 896]).func GenerateSegmentSpectrogram(dataFilePath string, startTime, endTime float64, color bool, imgSize int) (image.Image, error) {// Derive WAV file path (strip .data suffix)wavPath := strings.TrimSuffix(dataFilePath, ".data")// Read only the requested segment's samples from the WAV filesegSamples, sampleRate, err := wav.ReadWAVSegmentSamples(wavPath, startTime, endTime)if err != nil {return nil, err}if len(segSamples) == 0 {return nil, nil}// For spectrograms, downsample if sample rate exceeds 16kHzif sampleRate > audio.DefaultMaxSampleRate {segSamples = audio.ResampleRate(segSamples, sampleRate, audio.DefaultMaxSampleRate)sampleRate = audio.DefaultMaxSampleRate}img := SpectrogramImageFromSamples(segSamples, sampleRate, color, imgSize)return img, nil}// WritePNGFile writes an image to a PNG file. Uses O_EXCL to atomically fail// if the file already exists. Returns an error with path context on failure.func WritePNGFile(path string, img image.Image) error {file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)if err != nil {if os.IsExist(err) {return fmt.Errorf("file already exists: %s", path)}return fmt.Errorf("failed to create PNG: %w", err)}if err := WritePNG(img, file); err != nil {_ = file.Close()return fmt.Errorf("failed to write PNG: %w", err)}if err := file.Close(); err != nil {return fmt.Errorf("failed to close PNG: %w", err)}return nil}// ClipBaseName generates the base filename for a clip in the format:// prefix_basename_startTime_endTime// Times are integers (floor for start, ceil for end).func ClipBaseName(prefix, basename string, startTime, endTime float64) string {startInt := int(math.Floor(startTime))endInt := int(math.Ceil(endTime))return fmt.Sprintf("%s_%s_%d_%d", prefix, basename, startInt, endInt)}// ClipPaths returns full PNG and WAV paths for a clip in the given output directory.// Also checks that neither file exists. Returns an error if files exist.func ClipPaths(outputDir, prefix, basename string, startTime, endTime float64) (pngPath, wavPath string, err error) {baseName := ClipBaseName(prefix, basename, startTime, endTime)pngPath = filepath.Join(outputDir, baseName+".png")wavPath = filepath.Join(outputDir, baseName+".wav")if _, err := os.Stat(pngPath); err == nil {return "", "", fmt.Errorf("file already exists: %s", pngPath)}if _, err := os.Stat(wavPath); err == nil {return "", "", fmt.Errorf("file already exists: %s", wavPath)}return pngPath, wavPath, nil}// WAVBasename extracts the base filename from a .data file path.// E.g., "/path/to/file.wav.data" -> "file".func WAVBasename(dataFilePath string) string {wavPath := strings.TrimSuffix(dataFilePath, ".data")basename := filepath.Base(wavPath)return strings.TrimSuffix(basename, filepath.Ext(basename))}
package spectrogramimport ("testing")func TestL4Colormap_ControlPoints(t *testing.T) {tests := []struct {idx intwantR uint8wantG uint8wantB uint8desc string}{{0, 0, 0, 0, "black at index 0"},{255, 255, 255, 0, "yellow at index 255"},}for _, tt := range tests {pixel := L4Colormap[tt.idx]if pixel.R != tt.wantR || pixel.G != tt.wantG || pixel.B != tt.wantB {t.Errorf("L4Colormap[%d] (%s): got (%d,%d,%d), want (%d,%d,%d)",tt.idx, tt.desc, pixel.R, pixel.G, pixel.B, tt.wantR, tt.wantG, tt.wantB)}}// Index 85: first segment is (0,0,0)→(0.85,0,0), t=85/85=1.0p85 := L4Colormap[85]if p85.R != 216 || p85.G != 0 || p85.B != 0 {t.Errorf("L4Colormap[85]: got (%d,%d,%d), want dark red", p85.R, p85.G, p85.B)}// Index 170: second segment is (0.85,0,0)→(1.0,0.15,0), t=85/85=1.0p170 := L4Colormap[170]if p170.R != 255 || p170.G != 38 || p170.B != 0 {t.Errorf("L4Colormap[170]: got (%d,%d,%d), want orange-red", p170.R, p170.G, p170.B)}}func TestL4Colormap_MonotonicRed(t *testing.T) {// Red channel should be non-decreasing (black→red→orange→yellow)for i := 1; i < 256; i++ {if L4Colormap[i].R < L4Colormap[i-1].R {t.Errorf("R decreased at index %d: %d → %d", i-1, L4Colormap[i-1].R, L4Colormap[i].R)}}}func TestL4Colormap_GreenZeroThenIncreasing(t *testing.T) {// Green is 0 in the first segment (indices 0-85), then increasesfor i := 0; i <= 85; i++ {if L4Colormap[i].G != 0 {t.Errorf("G should be 0 at index %d, got %d", i, L4Colormap[i].G)}}// Green should be non-decreasing after index 85for i := 86; i < 256; i++ {if L4Colormap[i].G < L4Colormap[i-1].G {t.Errorf("G decreased at index %d: %d → %d", i-1, L4Colormap[i-1].G, L4Colormap[i].G)}}}func TestL4Colormap_BlueAlwaysZero(t *testing.T) {for i := range 256 {if L4Colormap[i].B != 0 {t.Errorf("B should be 0 at index %d, got %d", i, L4Colormap[i].B)}}}func TestApplyL4Colormap_Basic(t *testing.T) {gray := [][]uint8{{0, 128, 255},}result := ApplyL4Colormap(gray)if len(result) != 1 || len(result[0]) != 3 {t.Fatalf("shape mismatch: got %dx%d", len(result), len(result[0]))}// Index 0 → blackif result[0][0] != (RGBPixel{0, 0, 0}) {t.Errorf("pixel 0: got %v, want black", result[0][0])}// Index 255 → yellowif result[0][2] != (RGBPixel{255, 255, 0}) {t.Errorf("pixel 255: got %v, want yellow", result[0][2])}// Index 128 → should be a valid colormap entry (just check it matches the table)if result[0][1] != L4Colormap[128] {t.Errorf("pixel 128: got %v, want %v", result[0][1], L4Colormap[128])}}func TestApplyL4Colormap_MultiRow(t *testing.T) {gray := [][]uint8{{0, 255},{255, 0},}result := ApplyL4Colormap(gray)if len(result) != 2 || len(result[0]) != 2 {t.Fatalf("shape mismatch: got %dx%d", len(result), len(result[0]))}if result[0][0] != L4Colormap[0] {t.Errorf("(0,0): got %v, want %v", result[0][0], L4Colormap[0])}if result[1][1] != L4Colormap[0] {t.Errorf("(1,1): got %v, want %v", result[1][1], L4Colormap[0])}}func TestApplyL4Colormap_Empty(t *testing.T) {tests := []struct {name stringdata [][]uint8}{{"nil", nil},{"empty outer", [][]uint8{}},{"empty inner", [][]uint8{{}}},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {result := ApplyL4Colormap(tt.data)if result != nil {t.Errorf("expected nil for %s, got %v", tt.name, result)}})}}
package spectrogram// RGBPixel represents an RGB color valuetype RGBPixel struct {R, G, B uint8}// L4Colormap is the Black-Red-Yellow heat colormap from PerceptualColourMaps.jl// Control points://// Index 0: Black (0.0, 0.0, 0.0)// Index 85: Dark Red (0.85, 0.0, 0.0)// Index 170: Orange-Red (1.0, 0.15, 0.0)// Index 255: Yellow (1.0, 1.0, 0.0)var L4Colormap [256]RGBPixelfunc init() {// Generate L4 colormap using piecewise linear interpolation// This avoids overshoot issues with cubic splinescontrolPoints := []struct {idx intr float64g float64b float64}{{0, 0.0, 0.0, 0.0},{85, 0.85, 0.0, 0.0},{170, 1.0, 0.15, 0.0},{255, 1.0, 1.0, 0.0},}for i := range 256 {// Find the segment we're invar seg intfor seg = 0; seg < len(controlPoints)-1; seg++ {if i <= controlPoints[seg+1].idx {break}}if seg >= len(controlPoints)-1 {seg = len(controlPoints) - 2}// Linear interpolation within segmentp0 := controlPoints[seg]p1 := controlPoints[seg+1]t := 0.0if p1.idx != p0.idx {t = float64(i-p0.idx) / float64(p1.idx-p0.idx)}L4Colormap[i] = RGBPixel{R: uint8((p0.r + t*(p1.r-p0.r)) * 255.0),G: uint8((p0.g + t*(p1.g-p0.g)) * 255.0),B: uint8((p0.b + t*(p1.b-p0.b)) * 255.0),}}}// ApplyL4Colormap converts a grayscale image to RGB using the L4 colormapfunc ApplyL4Colormap(grayscale [][]uint8) [][]RGBPixel {if len(grayscale) == 0 || len(grayscale[0]) == 0 {return nil}rows := len(grayscale)cols := len(grayscale[0])result := make([][]RGBPixel, rows)for i := range result {result[i] = make([]RGBPixel, cols)}for y := range rows {for x := range cols {result[y][x] = L4Colormap[grayscale[y][x]]}}return result}
## [2026-05-19] Extract spectrogram/ package (Phase 4)- **Created `spectrogram/` package** with 3 files from `utils/`:- `spectrogram.go` — `SpectrogramConfig`, `GenerateSpectrogram`, `SpectrogramImageFromSamples`, `GenerateSegmentSpectrogram`, `WritePNGFile`, `ExtractSegmentSamples`, `ClipBaseName`, `ClipPaths`, `WAVBasename`- `colormap.go` — `ApplyL4Colormap`, `RGBPixel`, `L4Colormap`- `terminal_image.go` — `WriteImage`, `ClearImages`, `ClampImageSize`, `CreateGrayscaleImage`, `CreateRGBImage`, `ResizeImage`, `WritePNG`, `ImageProtocol`, `SpectrogramDisplaySize`- **Resolved deferred move from Phase 3**: `ResolveTimestamp` + `ProcessSingleFile` moved from `utils/` to `wav/file_import.go` (the import cycle `utils/ ↔ wav/` was caused by `utils/spectrogram.go` → `wav/`; with spectrogram extracted, `utils/` no longer imports `wav/`, so `wav/` can safely import `utils/`)- `wav/file_import.go` imports `utils/` for `ComputeXXH64`, `HasTimestampFilename`, `ParseFilenameTimestamps`, `ApplyTimezoneOffset`, `CalculateAstronomicalData`- Updated callers: `tui/model.go`, `tui/view.go`, `tui/update.go`, `tools/calls/calls_classify.go`, `tools/calls/calls_clip.go`, `tools/calls/calls_show_images.go`, `tools/calls/calls_clip_bench_test.go`, `tools/calls/isnight.go`, `tools/import/import_file.go`, `tools/import/cluster_import.go`- Moved 3 test files to `spectrogram/`; created `wav/file_import_test.go`; deleted `utils/file_import_test.go` (nanoid tests already in `utils/nanoid_test.go`)- `utils/` shrinks from 2,112 → 1,365 LOC (16 → 12 non-test files)