fourth phase of utils refactor, spectrogram/

quietlight
May 18, 2026, 11:28 PM
PXQDGTR53ST5T4EV6XFRCAOC7N5RQX23GWVKMJGS2J35VUQLZL4AC

Dependencies

  • [2] XU7FTYK3 third phase of utils refactor, wav/
  • [3] JZRF7OBJ refactor to get db omports out of utils, but still have failing tests, may need updating
  • [4] TUC452XH new util shared by 3 cmd's needing location
  • [5] N57PNZPF second phase of utils refactor, audio/
  • [6] QVIGQOQZ more work on utils/ with glm
  • [7] P4CJMBYK added first version of --bandpass flag to calls classify, work to do
  • [8] LQLC7S3A trying gemini: Inconsistent Standards in @utils/ refactoring
  • [9] NUOFNUIQ simplified --bandpass
  • [10] YVFPP5VJ refactor of tui/ first iteration
  • [11] HYCZTLSZ fixed tests with cyclo over 15
  • [12] DS22DKV3 added shell script integration tests.
  • [13] 3DVPQOKB big tidy up of tools/
  • [14] ZKLAOPUR fix event logging
  • [15] 3ETJ6KPI refactor of tui/ second iteration
  • [16] RUVJ3V4N cyclo to 14 now
  • [17] VU3KBTQ6 more tests
  • [18] KZKLAINJ run out of space on nest, cleaned out
  • [19] A6MCX2V6 emptied audio/ and moved files into testdata folders
  • [20] FCCJNYCV more tests for utils/
  • [21] Q4JPMGET fixed tests
  • [22] 43TMU2JO more tests, glm much better than claude
  • [23] WKQ7LFTP refactor of utils/
  • [24] NQPVZ3PP first phase of utils refactor, all realted to db interfaces
  • [25] LBWQJEDH minor refactor and more tests for utils/
  • [26] HCOBJB6W ck 4
  • [*] SJN7IKIV

Change contents

  • file addition: file_import_test.go (----------)
    [2.1]
    package wav
    import (
    "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 string
    useModTime 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")
    }
    }
  • file addition: file_import.go (----------)
    [2.1]
    package wav
    import (
    "fmt"
    "path/filepath"
    "time"
    "skraak/utils"
    )
    // TimestampResult holds the result of timestamp resolution for a single file
    type TimestampResult struct {
    Timestamp time.Time
    IsAudioMoth bool
    MothData *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 comment
    if IsAudioMoth(wavMeta.Comment, wavMeta.Artist) {
    result.IsAudioMoth = true
    mothData, err := ParseAudioMothComment(wavMeta.Comment)
    if err == nil {
    result.MothData = mothData
    result.Timestamp = mothData.Timestamp
    return result, nil
    }
    // AudioMoth detected but parsing failed — fall through to filename
    }
    // Step 2: Try filename timestamp
    if preParsedFilenameTime != nil && !preParsedFilenameTime.IsZero() {
    result.Timestamp = *preParsedFilenameTime
    return 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.FileModTime
    return 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 file
    type FileProcessingResult struct {
    FileName string
    Hash string
    Duration float64
    SampleRate int
    TimestampLocal time.Time
    IsAudioMoth bool
    MothData *AudioMothData
    AstroData 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 header
    metadata, err := ParseWAVHeader(filePath)
    if err != nil {
    return nil, fmt.Errorf("WAV header parsing failed: %w", err)
    }
    // Step 2: Calculate hash
    hash, err := utils.ComputeXXH64(filePath)
    if err != nil {
    return nil, fmt.Errorf("hash calculation failed: %w", err)
    }
    // Step 3: Resolve timestamp
    tsResult, err := ResolveTimestamp(metadata, filePath, timezoneID, useFileModTime, nil)
    if err != nil {
    return nil, err
    }
    // Step 4: Calculate astronomical data
    astroData := 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
    }
  • file deletion: spectrogram_test.go (----------)
    [3.1][3.2118:2161](),[3.2161][3.793:793]()
    package utils
    import (
    "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 bounds
    empty := 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 buffer
    cfg := 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 wave
    const sampleRate = 16000
    const duration = 0.1 // 100ms
    samples := 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 variant
    imgGray := SpectrogramImageFromSamples(samples, sampleRate, false, 224)
    if imgGray == nil {
    t.Error("expected non-nil grayscale image")
    }
    // Empty samples
    imgEmpty := SpectrogramImageFromSamples(nil, sampleRate, true, 224)
    if imgEmpty != nil {
    t.Error("expected nil for empty samples")
    }
    }
    }
    }
    func TestClipBaseName(t *testing.T) {
    tests := []struct {
    prefix string
    basename string
    startTime float64
    endTime float64
    want 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 string
    want 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 case
    pngPath, 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 detection
    os.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 exists
    if _, err := os.Stat(path); err != nil {
    t.Errorf("file not created: %v", err)
    }
    // Collision detection
    err := WritePNGFile(path, img)
    if err == nil {
    t.Error("expected error for existing file")
    "os"
    "path/filepath"
  • file deletion: colormap_test.go (----------)
    [3.1][3.6567:6607](),[3.6607][3.3035:3035]()
    package utils
    import (
    "testing"
    )
    func TestL4Colormap_ControlPoints(t *testing.T) {
    tests := []struct {
    idx int
    wantR uint8
    wantG uint8
    wantB uint8
    desc 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.0
    p85 := 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.0
    p170 := 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 increases
    for 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 85
    for 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 → black
    if result[0][0] != (RGBPixel{0, 0, 0}) {
    t.Errorf("pixel 0: got %v, want black", result[0][0])
    }
    // Index 255 → yellow
    if 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 string
    data [][]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)
    }
    })
    }
    }
  • file deletion: terminal_image_test.go (----------)
    [3.1][3.56570:56616](),[3.56616][3.51518:51518]()
    package utils
    import (
    "image"
    "image/color"
    "math/rand"
    "strings"
    "testing"
    )
    func TestWriteKittyImage_SmallImage(t *testing.T) {
    // 2x2 image produces small base64 payload — single chunk, no m= key
    img := image.NewGray(image.Rect(0, 0, 2, 2))
    img.SetGray(0, 0, color.Gray{Y: 128})
    var buf strings.Builder
    if 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 LZ77
    rng := 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.Builder
    if err := WriteKittyImage(img, &buf); err != nil {
    t.Fatalf("WriteKittyImage: %v", err)
    }
    out := buf.String()
    // Should have multiple escape sequences
    chunks := strings.Split(out, "\x1b\\")
    // Last element is empty after final terminator
    chunks = 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=1
    if !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=0
    last := 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=1
    for 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.Builder
    ClearKittyImages(&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.Builder
    if err := WriteSixelImage(img, &buf); err != nil {
    t.Fatalf("WriteSixelImage: %v", err)
    }
    out := buf.String()
    // Sixel DCS introducer
    if !strings.HasPrefix(out, "\x1bP") {
    t.Error("expected DCS prefix \\x1bP")
    }
    // String terminator
    if !strings.HasSuffix(out, "\x1b\\") {
    t.Error("expected ST suffix \\x1b\\\\")
    }
    // Should contain 'q' after DCS parameters
    if !strings.Contains(out, "q") {
    t.Error("expected 'q' in DCS sequence")
    }
    }
    func TestClearImages_Kitty(t *testing.T) {
    var buf strings.Builder
    ClearImages(&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.Builder
    ClearImages(&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.Builder
    if 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.Builder
    if 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.Builder
    if 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.Builder
    if 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.Builder
    ClearImages(&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 string
    data [][]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 value
    data := 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 color
    rgbData := 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*4
    if 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 path
    palette := 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 upscale
    data := [][]uint8{
    {0, 255},
    {128, 64},
    }
    src := CreateGrayscaleImage(data)
    resized := ResizeImage(src, 4, 4)
    gray := resized.(*image.Gray)
    // Top-left quadrant should be 0
    if gray.Pix[0] != 0 {
    t.Errorf("(0,0) = %d, want 0", gray.Pix[0])
    }
    // Top-right quadrant should be 255
    if 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.Buffer
    if 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 decoding
    decoded, 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) // red
    assertRGBAPixel(t, rgba, 1, 0, 0, 255, 0, 255) // green
    assertRGBAPixel(t, rgba, 0, 1, 0, 0, 255, 255) // blue
    }
    }
    func TestClampImageSize(t *testing.T) {
    tests := []struct {
    input int
    want 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 0
    if 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 1
    if 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 string
    data [][]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*4
    got := 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"
  • file deletion: terminal_image.go (----------)
    [3.1][3.62680:62721](),[3.62721][3.56618:56618]()
    package utils
    import (
    "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 int
    const (
    ProtocolKitty ImageProtocol = iota
    ProtocolSixel
    ProtocolITerm
    )
    // 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 images
    func 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.Buffer
    enc := &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.Buffer
    if 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.Stride
    row := 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.Stride
    row := data[y]
    for x := range width {
    i := off + x*4
    img.Pix[i] = row[x].R
    img.Pix[i+1] = row[x].G
    img.Pix[i+2] = row[x].B
    img.Pix[i+3] = 255
    }
    }
    return img
    }
    // resizeScale holds precomputed scale factors for nearest-neighbor resizing.
    type resizeScale struct {
    srcWidth, srcHeight int
    scaleX, 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.Stride
    for 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.Stride
    for x := range newWidth {
    srcX, _ := s.srcCoord(x, 0)
    si := srcRowOff + srcX*4
    di := dstOff + x*4
    result.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
    }
  • file deletion: spectrogram.go (----------)
    [3.1][3.69174:69212](),[3.69212][3.62723:62723]()
    package utils
    import (
    "image"
    "math"
    "strings"
    "sync"
    "github.com/madelynnblue/go-dsp/window"
    )
    // cached Hann windows by size, computed once
    var (
    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 lock
    if w, ok := hannCache[size]; ok {
    return w
    }
    w := window.Hann(size)
    hannCache[size] = w
    return w
    }
    // SpectrogramConfig holds STFT parameters
    type 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 implementation
    func 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 window
    hannWindow := getCachedHannWindow(cfg.WindowSize)
    // Calculate number of frames
    numFrames := (len(samples)-cfg.WindowSize)/cfg.HopSize + 1
    if 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 STFT
    for frame := range numFrames {
    start := frame * cfg.HopSize
    // Extract and window the frame
    for 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 6
    return 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.MaxFloat64
    for _, 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] = db
    if db < minDB {
    minDB = db
    }
    if db > maxDB {
    maxDB = db
    }
    }
    rangeDB := maxDB - minDB
    if rangeDB == 0 {
    rangeDB = 1
    }
    scale := 255.0 / rangeDB
    resultFlat := make([]uint8, rows*cols)
    result := make([][]uint8, rows)
    for i := range result {
    srcRow := rows - 1 - i
    result[i] = resultFlat[i*cols : (i+1)*cols]
    srcOff := srcRow * cols
    for j := range cols {
    result[i][j] = uint8((power[srcOff+j] - minDB) * scale)
    }
    }
    return result
    }
    // ExtractSegmentSamples extracts samples from a time range
    func 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 file
    if 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 16kHz
    if sampleRate > audio.DefaultMaxSampleRate {
    segSamples = audio.ResampleRate(segSamples, sampleRate, audio.DefaultMaxSampleRate)
    sampleRate = audio.DefaultMaxSampleRate
    segSamples, sampleRate, err := wav.ReadWAVSegmentSamples(wavPath, startTime, endTime)
    func GenerateSegmentSpectrogram(dataFilePath string, startTime, endTime float64, color bool, imgSize int) (image.Image, error) {
    var img image.Image
    if 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.MaxFloat64
    maxDB = -math.MaxFloat64
    minNonZero = 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"
  • file deletion: file_import_test.go (----------)
    [3.1][3.135022:135065](),[3.135065][3.130911:130911]()
    package utils
    import (
    "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 string
    useModTime 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 samples
    if 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 wrapper
    samples2, _, err := wav.ReadWAVSamples(tmpPath)
    // Read specific segment
    samples, 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 result
    func 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"
  • file deletion: file_import.go (----------)
    [3.1][3.139230:139268](),[3.139268][3.135067:135067]()
    package utils
    import (
    "fmt"
    "path/filepath"
    "time"
    )
    // TimestampResult holds the result of timestamp resolution for a single file
    type TimestampResult struct {
    Timestamp time.Time
    IsAudioMoth 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 comment
    result.IsAudioMoth = true
    if err == nil {
    result.MothData = mothData
    result.Timestamp = mothData.Timestamp
    return result, nil
    }
    // AudioMoth detected but parsing failed — fall through to filename
    }
    // Step 2: Try filename timestamp
    if preParsedFilenameTime != nil && !preParsedFilenameTime.IsZero() {
    result.Timestamp = *preParsedFilenameTime
    return 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.FileModTime
    return 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 file
    type FileProcessingResult struct {
    FileName string
    Hash string
    Duration float64
    SampleRate int
    TimestampLocal time.Time
    IsAudioMoth bool
    AstroData 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 header
    if err != nil {
    return nil, fmt.Errorf("WAV header parsing failed: %w", err)
    }
    // Step 2: Calculate hash
    hash, err := ComputeXXH64(filePath)
    if err != nil {
    return nil, fmt.Errorf("hash calculation failed: %w", err)
    }
    // Step 3: Resolve timestamp
    tsResult, err := ResolveTimestamp(metadata, filePath, timezoneID, useFileModTime, nil)
    if err != nil {
    return nil, err
    }
    // Step 4: Calculate astronomical data
    astroData := 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.AudioMothData
    mothData, 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"
  • file deletion: colormap.go (----------)
    [3.1][3.171476:171511](),[3.171511][3.169684:169684]()
    package utils
    // RGBPixel represents an RGB color value
    type 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]RGBPixel
    func init() {
    // Generate L4 colormap using piecewise linear interpolation
    // This avoids overshoot issues with cubic splines
    controlPoints := []struct {
    idx int
    r float64
    g float64
    b 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 in
    var seg int
    for 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 segment
    p0 := controlPoints[seg]
    p1 := controlPoints[seg+1]
    t := 0.0
    if 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 colormap
    func 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
    }
  • edit in tui/view.go at line 9
    [3.75]
    [3.75]
    "skraak/spectrogram"
  • replacement in tui/view.go at line 18
    [3.214][3.214:256]()
    _ = utils.ClearImages(&b, m.protocol())
    [3.214]
    [3.256]
    _ = spectrogram.ClearImages(&b, m.protocol())
  • edit in tui/update.go at line 13
    [3.25153]
    [3.3950]
    "skraak/spectrogram"
  • replacement in tui/update.go at line 438
    [3.8056][3.8056:8105]()
    _ = utils.ClearImages(os.Stdout, m.protocol())
    [3.8056]
    [3.8105]
    _ = spectrogram.ClearImages(os.Stdout, m.protocol())
  • replacement in tui/update.go at line 440
    [3.8148][3.8148:8201]()
    _ = utils.WriteImage(img, os.Stdout, m.protocol())
    [3.8148]
    [3.8201]
    _ = spectrogram.WriteImage(img, os.Stdout, m.protocol())
  • replacement in tui/update.go at line 449
    [3.8447][3.8447:8488]()
    imgSize = utils.SpectrogramDisplaySize
    [3.8447]
    [3.8488]
    imgSize = spectrogram.SpectrogramDisplaySize
  • replacement in tui/update.go at line 460
    [3.25282][3.8952:9047](),[3.8952][3.8952:9047]()
    return utils.SpectrogramImageFromSamples(samples, sampleRate, m.state.Config.Color, imgSize)
    [3.25282]
    [3.9047]
    return spectrogram.SpectrogramImageFromSamples(samples, sampleRate, m.state.Config.Color, imgSize)
  • replacement in tui/update.go at line 464
    [3.9069][3.9069:9184]()
    img, err := utils.GenerateSegmentSpectrogram(dataPath, seg.StartTime, seg.EndTime, m.state.Config.Color, imgSize)
    [3.9069]
    [3.9184]
    img, err := spectrogram.GenerateSegmentSpectrogram(dataPath, seg.StartTime, seg.EndTime, m.state.Config.Color, imgSize)
  • edit in tui/model.go at line 11
    [3.16371]
    [3.16371]
    "skraak/spectrogram"
  • edit in tui/model.go at line 13
    [3.16393][3.16393:16409]()
    "skraak/utils"
  • replacement in tui/model.go at line 124
    [3.19181][3.19181:19229]()
    func (m Model) protocol() utils.ImageProtocol {
    [3.19181]
    [3.19229]
    func (m Model) protocol() spectrogram.ImageProtocol {
  • replacement in tui/model.go at line 126
    [3.19256][3.19256:19285]()
    return utils.ProtocolITerm
    [3.19256]
    [3.19285]
    return spectrogram.ProtocolITerm
  • replacement in tui/model.go at line 129
    [3.19315][3.19315:19344]()
    return utils.ProtocolSixel
    [3.19315]
    [3.19344]
    return spectrogram.ProtocolSixel
  • replacement in tui/model.go at line 131
    [3.19347][3.19347:19375]()
    return utils.ProtocolKitty
    [3.19347]
    [3.19375]
    return spectrogram.ProtocolKitty
  • edit in tools/import/import_file.go at line 14
    [3.43884]
    [3.43884]
    "skraak/wav"
  • replacement in tools/import/import_file.go at line 74
    [3.45973][3.45973:46092]()
    result, err := utils.ProcessSingleFile(input.FilePath, locData.Latitude, locData.Longitude, locData.TimezoneID, true)
    [3.45973]
    [3.46092]
    result, err := wav.ProcessSingleFile(input.FilePath, locData.Latitude, locData.Longitude, locData.TimezoneID, true)
  • replacement in tools/import/import_file.go at line 141
    [3.48264][3.48264:48301]()
    result *utils.FileProcessingResult,
    [3.48264]
    [3.3485]
    result *wav.FileProcessingResult,
  • replacement in tools/import/import_file.go at line 185
    [3.4328][3.4328:4365]()
    result *utils.FileProcessingResult,
    [3.4328]
    [3.4365]
    result *wav.FileProcessingResult,
  • replacement in tools/import/cluster_import.go at line 264
    [3.25534][3.25534:25767]()
    func resolveFileData(info wavInfo, preParsedTime *time.Time, location *LocationData) (*utils.FileProcessingResult, error) {
    tsResult, err := utils.ResolveTimestamp(info.metadata, info.path, location.TimezoneID, true, preParsedTime)
    [3.25534]
    [3.25767]
    func resolveFileData(info wavInfo, preParsedTime *time.Time, location *LocationData) (*wav.FileProcessingResult, error) {
    tsResult, err := wav.ResolveTimestamp(info.metadata, info.path, location.TimezoneID, true, preParsedTime)
  • replacement in tools/import/cluster_import.go at line 277
    [3.25954][3.25954:25991]()
    return &utils.FileProcessingResult{
    [3.25954]
    [3.25991]
    return &wav.FileProcessingResult{
  • replacement in tools/import/cluster_import.go at line 290
    [3.26380][3.26380:26544]()
    func batchProcessFiles(wavFiles []string, location *LocationData) ([]*utils.FileProcessingResult, []FileImportError) {
    var filesData []*utils.FileProcessingResult
    [3.26380]
    [3.26544]
    func batchProcessFiles(wavFiles []string, location *LocationData) ([]*wav.FileProcessingResult, []FileImportError) {
    var filesData []*wav.FileProcessingResult
  • replacement in tools/import/cluster_import.go at line 360
    [3.28478][3.28478:28511]()
    fd *utils.FileProcessingResult,
    [3.28478]
    [3.28511]
    fd *wav.FileProcessingResult,
  • replacement in tools/import/cluster_import.go at line 430
    [3.30576][3.30576:30618]()
    filesData []*utils.FileProcessingResult,
    [3.30576]
    [3.30618]
    filesData []*wav.FileProcessingResult,
  • replacement in tools/calls/isnight.go at line 58
    [3.71453][3.71453:71547]()
    tsResult, err := utils.ResolveTimestamp(metadata, input.FilePath, input.Timezone, true, nil)
    [3.71453]
    [3.71547]
    tsResult, err := wav.ResolveTimestamp(metadata, input.FilePath, input.Timezone, true, nil)
  • edit in tools/calls/calls_show_images.go at line 8
    [3.83078]
    [3.83078]
    "skraak/spectrogram"
  • replacement in tools/calls/calls_show_images.go at line 63
    [3.84804][3.84804:84845]()
    imgSize = utils.SpectrogramDisplaySize
    [3.84804]
    [3.84845]
    imgSize = spectrogram.SpectrogramDisplaySize
  • replacement in tools/calls/calls_show_images.go at line 67
    [3.84878][3.84878:84911]()
    protocol := utils.ProtocolKitty
    [3.84878]
    [3.84911]
    protocol := spectrogram.ProtocolKitty
  • replacement in tools/calls/calls_show_images.go at line 69
    [3.84929][3.84929:84962]()
    protocol = utils.ProtocolITerm
    [3.84929]
    [3.84962]
    protocol = spectrogram.ProtocolITerm
  • replacement in tools/calls/calls_show_images.go at line 71
    [3.84987][3.84987:85020]()
    protocol = utils.ProtocolSixel
    [3.84987]
    [3.85020]
    protocol = spectrogram.ProtocolSixel
  • replacement in tools/calls/calls_show_images.go at line 77
    [3.85150][3.7980:8097]()
    img, err := utils.GenerateSegmentSpectrogram(input.DataFilePath, seg.StartTime, seg.EndTime, input.Color, imgSize)
    [3.85150]
    [3.85267]
    img, err := spectrogram.GenerateSegmentSpectrogram(input.DataFilePath, seg.StartTime, seg.EndTime, input.Color, imgSize)
  • replacement in tools/calls/calls_show_images.go at line 88
    [3.85580][3.85580:85649]()
    if err := utils.WriteImage(img, os.Stdout, protocol); err != nil {
    [3.85580]
    [3.85649]
    if err := spectrogram.WriteImage(img, os.Stdout, protocol); err != nil {
  • replacement in tools/calls/calls_clip_bench_test.go at line 10
    [3.25359][3.248141:248157](),[3.248141][3.248141:248157]()
    "skraak/utils"
    [3.25359]
    [2.60680]
    "skraak/spectrogram"
  • replacement in tools/calls/calls_clip_bench_test.go at line 58
    [2.60788][3.249418:249484](),[3.249418][3.249418:249484]()
    segSamples := utils.ExtractSegmentSamples(samples, sr, 872, 895)
    [2.60788]
    [3.249484]
    segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 872, 895)
  • replacement in tools/calls/calls_clip_bench_test.go at line 100
    [3.250599][3.250599:250659]()
    seg := utils.ExtractSegmentSamples(samples, sr, 872, 895)
    [3.250599]
    [3.250659]
    seg := spectrogram.ExtractSegmentSamples(samples, sr, 872, 895)
  • replacement in tools/calls/calls_clip_bench_test.go at line 110
    [2.61028][3.250829:250895](),[3.250829][3.250829:250895]()
    segSamples := utils.ExtractSegmentSamples(samples, sr, 872, 895)
    [2.61028]
    [3.250895]
    segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 872, 895)
  • replacement in tools/calls/calls_clip_bench_test.go at line 127
    [2.61077][3.251376:251488](),[3.251376][3.251376:251488]()
    segSamples := utils.ExtractSegmentSamples(samples, sr, 872, 895)
    cfg := utils.DefaultSpectrogramConfig(16000)
    [2.61077]
    [3.251488]
    segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 872, 895)
    cfg := spectrogram.DefaultSpectrogramConfig(16000)
  • replacement in tools/calls/calls_clip_bench_test.go at line 133
    [3.251653][3.251653:251707]()
    spect := utils.GenerateSpectrogram(segSamples, cfg)
    [3.251653]
    [3.251707]
    spect := spectrogram.GenerateSpectrogram(segSamples, cfg)
  • replacement in tools/calls/calls_clip_bench_test.go at line 142
    [2.61126][3.251863:251972](),[3.251863][3.251863:251972]()
    segSamples := utils.ExtractSegmentSamples(samples, sr, 0, 60)
    cfg := utils.DefaultSpectrogramConfig(16000)
    [2.61126]
    [3.251972]
    segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 0, 60)
    cfg := spectrogram.DefaultSpectrogramConfig(16000)
  • replacement in tools/calls/calls_clip_bench_test.go at line 148
    [3.252085][3.252085:252139]()
    spect := utils.GenerateSpectrogram(segSamples, cfg)
    [3.252085]
    [3.252139]
    spect := spectrogram.GenerateSpectrogram(segSamples, cfg)
  • replacement in tools/calls/calls_clip_bench_test.go at line 159
    [2.61175][3.252370:252535](),[3.252370][3.252370:252535]()
    segSamples := utils.ExtractSegmentSamples(samples, sr, 872, 895)
    cfg := utils.DefaultSpectrogramConfig(16000)
    spect := utils.GenerateSpectrogram(segSamples, cfg)
    [2.61175]
    [3.252535]
    segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 872, 895)
    cfg := spectrogram.DefaultSpectrogramConfig(16000)
    spect := spectrogram.GenerateSpectrogram(segSamples, cfg)
  • replacement in tools/calls/calls_clip_bench_test.go at line 165
    [3.252597][3.252597:252640]()
    img := utils.CreateGrayscaleImage(spect)
    [3.252597]
    [3.252640]
    img := spectrogram.CreateGrayscaleImage(spect)
  • replacement in tools/calls/calls_clip_bench_test.go at line 174
    [2.61224][3.252787:252952](),[3.252787][3.252787:252952]()
    segSamples := utils.ExtractSegmentSamples(samples, sr, 872, 895)
    cfg := utils.DefaultSpectrogramConfig(16000)
    spect := utils.GenerateSpectrogram(segSamples, cfg)
    [2.61224]
    [3.252952]
    segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 872, 895)
    cfg := spectrogram.DefaultSpectrogramConfig(16000)
    spect := spectrogram.GenerateSpectrogram(segSamples, cfg)
  • replacement in tools/calls/calls_clip_bench_test.go at line 180
    [3.253014][3.253014:253099]()
    colorData := utils.ApplyL4Colormap(spect)
    img := utils.CreateRGBImage(colorData)
    [3.253014]
    [3.253099]
    colorData := spectrogram.ApplyL4Colormap(spect)
    img := spectrogram.CreateRGBImage(colorData)
  • replacement in tools/calls/calls_clip_bench_test.go at line 190
    [2.61273][3.253247:253412](),[3.253247][3.253247:253412]()
    segSamples := utils.ExtractSegmentSamples(samples, sr, 872, 895)
    cfg := utils.DefaultSpectrogramConfig(16000)
    spect := utils.GenerateSpectrogram(segSamples, cfg)
    [2.61273]
    [3.253412]
    segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 872, 895)
    cfg := spectrogram.DefaultSpectrogramConfig(16000)
    spect := spectrogram.GenerateSpectrogram(segSamples, cfg)
  • replacement in tools/calls/calls_clip_bench_test.go at line 196
    [3.253474][3.253474:253518]()
    colorData := utils.ApplyL4Colormap(spect)
    [3.253474]
    [3.253518]
    colorData := spectrogram.ApplyL4Colormap(spect)
  • replacement in tools/calls/calls_clip_bench_test.go at line 205
    [2.61322][3.253673:253880](),[3.253673][3.253673:253880]()
    segSamples := utils.ExtractSegmentSamples(samples, sr, 872, 895)
    cfg := utils.DefaultSpectrogramConfig(16000)
    spect := utils.GenerateSpectrogram(segSamples, cfg)
    img := utils.CreateGrayscaleImage(spect)
    [2.61322]
    [3.253880]
    segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 872, 895)
    cfg := spectrogram.DefaultSpectrogramConfig(16000)
    spect := spectrogram.GenerateSpectrogram(segSamples, cfg)
    img := spectrogram.CreateGrayscaleImage(spect)
  • replacement in tools/calls/calls_clip_bench_test.go at line 212
    [3.253942][3.253942:253988]()
    resized := utils.ResizeImage(img, 224, 224)
    [3.253942]
    [3.253988]
    resized := spectrogram.ResizeImage(img, 224, 224)
  • replacement in tools/calls/calls_clip_bench_test.go at line 221
    [2.61371][3.254139:254346](),[3.254139][3.254139:254346]()
    segSamples := utils.ExtractSegmentSamples(samples, sr, 872, 895)
    cfg := utils.DefaultSpectrogramConfig(16000)
    spect := utils.GenerateSpectrogram(segSamples, cfg)
    img := utils.CreateGrayscaleImage(spect)
    [2.61371]
    [3.254346]
    segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 872, 895)
    cfg := spectrogram.DefaultSpectrogramConfig(16000)
    spect := spectrogram.GenerateSpectrogram(segSamples, cfg)
    img := spectrogram.CreateGrayscaleImage(spect)
  • replacement in tools/calls/calls_clip_bench_test.go at line 228
    [3.254408][3.254408:254454]()
    resized := utils.ResizeImage(img, 448, 448)
    [3.254408]
    [3.254454]
    resized := spectrogram.ResizeImage(img, 448, 448)
  • replacement in tools/calls/calls_clip_bench_test.go at line 239
    [2.61420][3.254660:254912](),[3.254660][3.254660:254912]()
    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)
    [2.61420]
    [3.254912]
    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)
  • replacement in tools/calls/calls_clip_bench_test.go at line 248
    [3.255017][3.255017:255046]()
    utils.WritePNG(resized, f)
    [3.255017]
    [3.255046]
    spectrogram.WritePNG(resized, f)
  • replacement in tools/calls/calls_clip_bench_test.go at line 261
    [3.255308][3.255308:255375]()
    segSamples := utils.ExtractSegmentSamples(samples, sr, 872, 895)
    [3.255308]
    [3.255375]
    segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 872, 895)
  • replacement in tools/calls/calls_clip_bench_test.go at line 267
    [3.255492][3.255492:255685]()
    cfg := utils.DefaultSpectrogramConfig(outputSR)
    spect := utils.GenerateSpectrogram(segSamples, cfg)
    img := utils.CreateGrayscaleImage(spect)
    resized := utils.ResizeImage(img, 224, 224)
    [3.255492]
    [3.255685]
    cfg := spectrogram.DefaultSpectrogramConfig(outputSR)
    spect := spectrogram.GenerateSpectrogram(segSamples, cfg)
    img := spectrogram.CreateGrayscaleImage(spect)
    resized := spectrogram.ResizeImage(img, 224, 224)
  • replacement in tools/calls/calls_clip_bench_test.go at line 272
    [3.255728][3.255728:255757]()
    utils.WritePNG(resized, f)
    [3.255728]
    [3.255757]
    spectrogram.WritePNG(resized, f)
  • replacement in tools/calls/calls_clip_bench_test.go at line 286
    [3.256049][3.256049:256116]()
    segSamples := utils.ExtractSegmentSamples(samples, sr, 872, 895)
    [3.256049]
    [3.256116]
    segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 872, 895)
  • replacement in tools/calls/calls_clip_bench_test.go at line 292
    [3.256233][3.256233:256468]()
    cfg := utils.DefaultSpectrogramConfig(outputSR)
    spect := utils.GenerateSpectrogram(segSamples, cfg)
    colorData := utils.ApplyL4Colormap(spect)
    img := utils.CreateRGBImage(colorData)
    resized := utils.ResizeImage(img, 448, 448)
    [3.256233]
    [3.256468]
    cfg := spectrogram.DefaultSpectrogramConfig(outputSR)
    spect := spectrogram.GenerateSpectrogram(segSamples, cfg)
    colorData := spectrogram.ApplyL4Colormap(spect)
    img := spectrogram.CreateRGBImage(colorData)
    resized := spectrogram.ResizeImage(img, 448, 448)
  • replacement in tools/calls/calls_clip_bench_test.go at line 298
    [3.256511][3.256511:256540]()
    utils.WritePNG(resized, f)
    [3.256511]
    [3.256540]
    spectrogram.WritePNG(resized, f)
  • replacement in tools/calls/calls_clip_bench_test.go at line 311
    [2.61671][3.256831:256897](),[3.256831][3.256831:256897]()
    segSamples := utils.ExtractSegmentSamples(samples, sr, 872, 895)
    [2.61671]
    [3.256897]
    segSamples := spectrogram.ExtractSegmentSamples(samples, sr, 872, 895)
  • replacement in tools/calls/calls_clip_bench_test.go at line 316
    [3.257038][3.257038:257084]()
    cfg := utils.DefaultSpectrogramConfig(16000)
    [3.257038]
    [3.257084]
    cfg := spectrogram.DefaultSpectrogramConfig(16000)
  • replacement in tools/calls/calls_clip_bench_test.go at line 322
    [3.257288][3.257288:257341]()
    spect := utils.GenerateSpectrogram(segSamples, cfg)
    [3.257288]
    [3.257341]
    spect := spectrogram.GenerateSpectrogram(segSamples, cfg)
  • replacement in tools/calls/calls_clip_bench_test.go at line 325
    [3.257410][3.257410:257452]()
    img := utils.CreateGrayscaleImage(spect)
    [3.257410]
    [3.257452]
    img := spectrogram.CreateGrayscaleImage(spect)
  • replacement in tools/calls/calls_clip_bench_test.go at line 329
    [3.257581][3.257581:257626]()
    resized := utils.ResizeImage(img, 224, 224)
    [3.257581]
    [3.257626]
    resized := spectrogram.ResizeImage(img, 224, 224)
  • replacement in tools/calls/calls_clip_bench_test.go at line 332
    [3.257703][3.257703:257751]()
    resized448 := utils.ResizeImage(img, 448, 448)
    [3.257703]
    [3.257751]
    resized448 := spectrogram.ResizeImage(img, 448, 448)
  • edit in tools/calls/calls_clip.go at line 12
    [3.25639]
    [3.257989]
    "skraak/spectrogram"
  • replacement in tools/calls/calls_clip.go at line 69
    [3.259686][3.259686:259731]()
    imgSize := utils.ClampImageSize(input.Size)
    [3.259686]
    [3.259731]
    imgSize := spectrogram.ClampImageSize(input.Size)
  • replacement in tools/calls/calls_clip.go at line 339
    [3.9392][3.9392:9483]()
    pngPath, wavPath, err := utils.ClipPaths(outputDir, prefix, basename, startTime, endTime)
    [3.9392]
    [3.9483]
    pngPath, wavPath, err := spectrogram.ClipPaths(outputDir, prefix, basename, startTime, endTime)
  • replacement in tools/calls/calls_clip.go at line 345
    [3.269104][3.269104:269188]()
    segSamples := utils.ExtractSegmentSamples(samples, sampleRate, startTime, endTime)
    [3.269104]
    [3.269188]
    segSamples := spectrogram.ExtractSegmentSamples(samples, sampleRate, startTime, endTime)
  • replacement in tools/calls/calls_clip.go at line 357
    [3.9623][3.9623:9705]()
    img := utils.SpectrogramImageFromSamples(segSamples, sampleRate, color, imgSize)
    [3.9623]
    [3.9705]
    img := spectrogram.SpectrogramImageFromSamples(segSamples, sampleRate, color, imgSize)
  • replacement in tools/calls/calls_clip.go at line 363
    [3.9737][3.9737:9795]()
    if err := utils.WritePNGFile(pngPath, img); err != nil {
    [3.9737]
    [3.9795]
    if err := spectrogram.WritePNGFile(pngPath, img); err != nil {
  • edit in tools/calls/calls_classify.go at line 14
    [3.25836]
    [3.292173]
    "skraak/spectrogram"
  • replacement in tools/calls/calls_classify.go at line 746
    [3.11416][3.11416:11559]()
    basename := utils.WAVBasename(df.FilePath)
    pngPath, wavPath, err := utils.ClipPaths(outputDir, prefix, basename, seg.StartTime, seg.EndTime)
    [3.11416]
    [3.11559]
    basename := spectrogram.WAVBasename(df.FilePath)
    pngPath, wavPath, err := spectrogram.ClipPaths(outputDir, prefix, basename, seg.StartTime, seg.EndTime)
  • replacement in tools/calls/calls_classify.go at line 758
    [3.11763][3.11763:11840]()
    img := utils.SpectrogramImageFromSamples(segSamples, sampleRate, true, 224)
    [3.11763]
    [3.11840]
    img := spectrogram.SpectrogramImageFromSamples(segSamples, sampleRate, true, 224)
  • replacement in tools/calls/calls_classify.go at line 763
    [3.11920][3.11920:11978]()
    if err := utils.WritePNGFile(pngPath, img); err != nil {
    [3.11920]
    [3.11978]
    if err := spectrogram.WritePNGFile(pngPath, img); err != nil {
  • file addition: spectrogram (d--r------)
    [28.1]
  • file addition: terminal_image_test.go (----------)
    [0.7177]
    package spectrogram
    import (
    "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= key
    img := image.NewGray(image.Rect(0, 0, 2, 2))
    img.SetGray(0, 0, color.Gray{Y: 128})
    var buf strings.Builder
    if 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 LZ77
    rng := 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.Builder
    if err := WriteKittyImage(img, &buf); err != nil {
    t.Fatalf("WriteKittyImage: %v", err)
    }
    out := buf.String()
    // Should have multiple escape sequences
    chunks := strings.Split(out, "\x1b\\")
    // Last element is empty after final terminator
    chunks = 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=1
    if !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=0
    last := 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=1
    for 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.Builder
    ClearKittyImages(&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.Builder
    if err := WriteSixelImage(img, &buf); err != nil {
    t.Fatalf("WriteSixelImage: %v", err)
    }
    out := buf.String()
    // Sixel DCS introducer
    if !strings.HasPrefix(out, "\x1bP") {
    t.Error("expected DCS prefix \\x1bP")
    }
    // String terminator
    if !strings.HasSuffix(out, "\x1b\\") {
    t.Error("expected ST suffix \\x1b\\\\")
    }
    // Should contain 'q' after DCS parameters
    if !strings.Contains(out, "q") {
    t.Error("expected 'q' in DCS sequence")
    }
    }
    func TestClearImages_Kitty(t *testing.T) {
    var buf strings.Builder
    ClearImages(&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.Builder
    ClearImages(&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.Builder
    if 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.Builder
    if 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.Builder
    if 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.Builder
    if 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 int
    want 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 0
    if 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 1
    if 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 string
    data [][]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*4
    got := 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) // red
    assertRGBAPixel(t, rgba, 1, 0, 0, 255, 0, 255) // green
    assertRGBAPixel(t, rgba, 0, 1, 0, 0, 255, 255) // blue
    }
    func TestCreateRGBImage_Empty(t *testing.T) {
    tests := []struct {
    name string
    data [][]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 value
    data := 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 color
    rgbData := 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*4
    if 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 path
    palette := 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 upscale
    data := [][]uint8{
    {0, 255},
    {128, 64},
    }
    src := CreateGrayscaleImage(data)
    resized := ResizeImage(src, 4, 4)
    gray := resized.(*image.Gray)
    // Top-left quadrant should be 0
    if gray.Pix[0] != 0 {
    t.Errorf("(0,0) = %d, want 0", gray.Pix[0])
    }
    // Top-right quadrant should be 255
    if 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.Buffer
    if 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 decoding
    decoded, 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.Builder
    ClearImages(&buf, ProtocolITerm)
    if buf.String() != "" {
    t.Errorf("expected no output for iTerm2 clear, got %q", buf.String())
    }
    }
  • file addition: terminal_image.go (----------)
    [0.7177]
    package spectrogram
    import (
    "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 int
    const (
    ProtocolKitty ImageProtocol = iota
    ProtocolSixel
    ProtocolITerm
    )
    // 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 images
    func 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.Buffer
    enc := &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.Buffer
    if 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.Stride
    row := 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.Stride
    row := data[y]
    for x := range width {
    i := off + x*4
    img.Pix[i] = row[x].R
    img.Pix[i+1] = row[x].G
    img.Pix[i+2] = row[x].B
    img.Pix[i+3] = 255
    }
    }
    return img
    }
    // resizeScale holds precomputed scale factors for nearest-neighbor resizing.
    type resizeScale struct {
    srcWidth, srcHeight int
    scaleX, 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.Stride
    for 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.Stride
    for x := range newWidth {
    srcX, _ := s.srcCoord(x, 0)
    si := srcRowOff + srcX*4
    di := dstOff + x*4
    result.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)
    }
  • file addition: spectrogram_test.go (----------)
    [0.7177]
    package spectrogram
    import (
    "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 bounds
    empty := 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 buffer
    cfg := 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 string
    basename string
    startTime float64
    endTime float64
    want 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 string
    want 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 case
    pngPath, 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 detection
    os.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 exists
    if _, err := os.Stat(path); err != nil {
    t.Errorf("file not created: %v", err)
    }
    // Collision detection
    err := WritePNGFile(path, img)
    if err == nil {
    t.Error("expected error for existing file")
    }
    }
    func TestSpectrogramImageFromSamples(t *testing.T) {
    // Create a simple sine wave
    const sampleRate = 16000
    const duration = 0.1 // 100ms
    samples := 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 variant
    imgGray := SpectrogramImageFromSamples(samples, sampleRate, false, 224)
    if imgGray == nil {
    t.Error("expected non-nil grayscale image")
    }
    // Empty samples
    imgEmpty := SpectrogramImageFromSamples(nil, sampleRate, true, 224)
    if imgEmpty != nil {
    t.Error("expected nil for empty samples")
    }
    }
  • file addition: spectrogram.go (----------)
    [0.7177]
    package spectrogram
    import (
    "fmt"
    "image"
    "math"
    "os"
    "path/filepath"
    "strings"
    "sync"
    "github.com/madelynnblue/go-dsp/window"
    "skraak/audio"
    "skraak/wav"
    )
    // cached Hann windows by size, computed once
    var (
    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 lock
    if w, ok := hannCache[size]; ok {
    return w
    }
    w := window.Hann(size)
    hannCache[size] = w
    return w
    }
    // SpectrogramConfig holds STFT parameters
    type 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 implementation
    func 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 window
    hannWindow := getCachedHannWindow(cfg.WindowSize)
    // Calculate number of frames
    numFrames := (len(samples)-cfg.WindowSize)/cfg.HopSize + 1
    if 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 STFT
    for frame := range numFrames {
    start := frame * cfg.HopSize
    // Extract and window the frame
    for 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 6
    return 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.MaxFloat64
    for _, val := range power {
    if val > 0 && val < minNonZero {
    minNonZero = val
    }
    }
    if minNonZero == math.MaxFloat64 {
    minNonZero = 1e-20
    }
    minDB = math.MaxFloat64
    maxDB = -math.MaxFloat64
    for i, val := range power {
    if val <= 0 {
    val = minNonZero
    }
    db := 10.0 * math.Log10(val)
    power[i] = db
    if 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 - minDB
    if rangeDB == 0 {
    rangeDB = 1
    }
    scale := 255.0 / rangeDB
    resultFlat := make([]uint8, rows*cols)
    result := make([][]uint8, rows)
    for i := range result {
    srcRow := rows - 1 - i
    result[i] = resultFlat[i*cols : (i+1)*cols]
    srcOff := srcRow * cols
    for j := range cols {
    result[i][j] = uint8((power[srcOff+j] - minDB) * scale)
    }
    }
    return result
    }
    // ExtractSegmentSamples extracts samples from a time range
    func 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.Image
    if 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 file
    segSamples, 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 16kHz
    if 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))
    }
  • file addition: colormap_test.go (----------)
    [0.7177]
    package spectrogram
    import (
    "testing"
    )
    func TestL4Colormap_ControlPoints(t *testing.T) {
    tests := []struct {
    idx int
    wantR uint8
    wantG uint8
    wantB uint8
    desc 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.0
    p85 := 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.0
    p170 := 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 increases
    for 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 85
    for 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 → black
    if result[0][0] != (RGBPixel{0, 0, 0}) {
    t.Errorf("pixel 0: got %v, want black", result[0][0])
    }
    // Index 255 → yellow
    if 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 string
    data [][]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)
    }
    })
    }
    }
  • file addition: colormap.go (----------)
    [0.7177]
    package spectrogram
    // RGBPixel represents an RGB color value
    type 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]RGBPixel
    func init() {
    // Generate L4 colormap using piecewise linear interpolation
    // This avoids overshoot issues with cubic splines
    controlPoints := []struct {
    idx int
    r float64
    g float64
    b 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 in
    var seg int
    for 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 segment
    p0 := controlPoints[seg]
    p1 := controlPoints[seg+1]
    t := 0.0
    if 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 colormap
    func 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
    }
  • edit in CHANGELOG.md at line 4
    [3.1198010]
    [2.62575]
    ## [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)