3ETJ6KPIYI23DLXSKISNJSY3DUGHOACE6CPCPF6V7KJK4EXIADQAC YVFPP5VJJSR4EVGOMB5565IZFYAVFNRH37L6AXEUFU5AP6CL7DJAC LBWQJEDHCNUNMEJWXILGBGYZUKQI7CDAMH2BD44HULM77SVH5UYQC KZKLAINJJWZ64T5MUZT34LJVQIKBTKZ6EJGD7C7TTSSDGCHEDPMAC NUOFNUIQIKWOBFHMJXY2K4AJGIROGCPFK5NJHR6Y6HYHHCOJPVCAC P4CJMBYKB6LTJASFYF5FX4MHQW7TH7D6KLDLWBBLFKDHTWK5SZ6AC 3DVPQOKB6BX63XSBIYYCPWBL2RBG3LXZS3XPQBANJP2FWVRAOVZQC }}func TestClipBaseName(t *testing.T) {tests := []struct {prefix stringbasename stringstartTime float64endTime float64want string}{{"clip", "file", 1.0, 3.0, "clip_file_1_3"},{"test", "recording", 0.0, 5.5, "test_recording_0_6"}, // ceil(5.5) = 6{"a", "b", 2.7, 4.2, "a_b_2_5"}, // floor(2.7)=2, ceil(4.2)=5}for _, tt := range tests {got := ClipBaseName(tt.prefix, tt.basename, tt.startTime, tt.endTime)if got != tt.want {t.Errorf("ClipBaseName(%q, %q, %.1f, %.1f) = %q, want %q",tt.prefix, tt.basename, tt.startTime, tt.endTime, got, tt.want)}}}func TestWAVBasename(t *testing.T) {tests := []struct {path stringwant string}{{"/path/to/file.wav.data", "file"},{"/audio/2024-01-15_recording.wav.data", "2024-01-15_recording"},{"simple.wav.data", "simple"},}for _, tt := range tests {got := WAVBasename(tt.path)if got != tt.want {t.Errorf("WAVBasename(%q) = %q, want %q", tt.path, got, tt.want)}}}func TestClipPaths(t *testing.T) {tmp := t.TempDir()// Normal casepngPath, wavPath, err := ClipPaths(tmp, "clip", "file", 1.0, 3.0)if err != nil {t.Fatalf("unexpected error: %v", err)}expectedPng := filepath.Join(tmp, "clip_file_1_3.png")expectedWav := filepath.Join(tmp, "clip_file_1_3.wav")if pngPath != expectedPng {t.Errorf("pngPath = %q, want %q", pngPath, expectedPng)}if wavPath != expectedWav {t.Errorf("wavPath = %q, want %q", wavPath, expectedWav)}// Collision detectionos.Create(expectedPng)_, _, err = ClipPaths(tmp, "clip", "file", 1.0, 3.0)if err == nil {t.Error("expected error for existing file")}}func TestWritePNGFile(t *testing.T) {tmp := t.TempDir()// Create a simple test image (grayscale 2x2)gray := [][]uint8{{128, 64}, {32, 16}}img := CreateGrayscaleImage(gray)if img == nil {t.Fatal("failed to create test image")}path := filepath.Join(tmp, "test.png")if err := WritePNGFile(path, img); err != nil {t.Fatalf("WritePNGFile failed: %v", err)}// Verify file existsif _, err := os.Stat(path); err != nil {t.Errorf("file not created: %v", err)}// Collision detectionerr := WritePNGFile(path, img)if err == nil {t.Error("expected error for existing file")
func TestSpectrogramImageFromSamples(t *testing.T) {// Create a simple sine waveconst sampleRate = 16000const duration = 0.1 // 100mssamples := make([]float64, int(sampleRate*duration))for i := range samples {samples[i] = 0.5 // DC signal}img := SpectrogramImageFromSamples(samples, sampleRate, true, 224)if img == nil {t.Fatal("expected non-nil image")}bounds := img.Bounds()if bounds.Dx() != 224 || bounds.Dy() != 224 {t.Errorf("expected 224x224, got %dx%d", bounds.Dx(), bounds.Dy())}// Grayscale variantimgGray := SpectrogramImageFromSamples(samples, sampleRate, false, 224)if imgGray == nil {t.Error("expected non-nil grayscale image")}// Empty samplesimgEmpty := SpectrogramImageFromSamples(nil, sampleRate, true, 224)if imgEmpty != nil {t.Error("expected nil for empty samples")}}
// 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}
// Generate spectrogramconfig := DefaultSpectrogramConfig(spectSampleRate)spectrogram := GenerateSpectrogram(segSamples, config)if spectrogram == nil {return nil, 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)}
// Create image (grayscale or color)var img image.Imageif color {colorData := ApplyL4Colormap(spectrogram)img = CreateRGBImage(colorData)} else {img = CreateGrayscaleImage(spectrogram)
// 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)
// ResizeimgSize = ClampImageSize(imgSize)return ResizeImage(img, imgSize, imgSize), nil
// WAVBasename extracts the base filename from a .data file path.// E.g., "/path/to/file.wav.data" -> "file".func WAVBasename(dataFilePath string) string {wavPath := strings.TrimSuffix(dataFilePath, ".data")basename := filepath.Base(wavPath)return strings.TrimSuffix(basename, filepath.Ext(basename))
package tuiimport ("fmt""image""os"tea "charm.land/bubbletea/v2""skraak/tools/calls""skraak/utils")// generateSpectrogramImage creates a resized spectrogram image from a segment.func generateSpectrogramImage(state *calls.ClassifyState, dataPath string, seg *utils.Segment) image.Image {imgSize := state.Config.ImageSizeif imgSize == 0 {imgSize = utils.SpectrogramDisplaySize}if state.Config.BandpassLow > 0 || state.Config.BandpassHigh > 0 {return generateBandpassSpectrogram(state, dataPath, seg, imgSize)}img, err := utils.GenerateSegmentSpectrogram(dataPath, seg.StartTime, seg.EndTime, state.Config.Color, imgSize)if err != nil {return nil}return img}// generateBandpassSpectrogram generates a spectrogram from bandpass-shifted audio.// Reads the segment, applies bandpass+shift+downsample, then generates spectrogram// from the processed samples.func generateBandpassSpectrogram(state *calls.ClassifyState, dataPath string, seg *utils.Segment, imgSize int) image.Image {wavPath := dataPath[:len(dataPath)-5] // strip ".data"samples, sampleRate, err := utils.ReadWAVSegmentSamples(wavPath, seg.StartTime, seg.EndTime)if err != nil || len(samples) == 0 {return nil}samples, sampleRate = utils.BandpassShiftFilter(samples, sampleRate, state.Config.BandpassLow, state.Config.BandpassHigh)config := utils.DefaultSpectrogramConfig(sampleRate)spectrogram := utils.GenerateSpectrogram(samples, config)if spectrogram == nil {return nil}var img image.Imageif state.Config.Color {colorData := utils.ApplyL4Colormap(spectrogram)img = utils.CreateRGBImage(colorData)} else {img = utils.CreateGrayscaleImage(spectrogram)}if img == nil {return nil}imgSize = utils.ClampImageSize(imgSize)return utils.ResizeImage(img, imgSize, imgSize)}// inlineImageCmd returns a tea.Cmd that generates and writes an inline image// directly to the terminal, bypassing BubbleTea's renderer.// gen is the generation at dispatch time; currentGen points to the live counter.// If they differ when the image is ready, a newer segment change has occurred// and this image is stale — discard it instead of writing.func inlineImageCmd(state *calls.ClassifyState, protocol utils.ImageProtocol, gen uint64, currentGen *uint64) func() tea.Msg {return func() tea.Msg {df := state.CurrentFile()seg := state.CurrentSegment()if df == nil || seg == nil {return nil}img := generateSpectrogramImage(state, df.FilePath, seg)if img == nil {return nil}// Discard if a newer segment change has superseded this oneif *currentGen != gen {return nil}// Clear previous kitty images before writing new one.// Terminal write errors during render are non-recoverable; ignore._ = utils.ClearImages(os.Stdout, protocol)_, _ = fmt.Fprint(os.Stdout, "\r\n\r\n")_ = utils.WriteImage(img, os.Stdout, protocol)return nil}}
package tuiimport ("fmt""image""os""path/filepath""strings""skraak/tools/calls""skraak/utils")// saveClip saves a clip of the current segment to the current working directoryfunc saveClip(state *calls.ClassifyState, prefix string) error {df := state.CurrentFile()seg := state.CurrentSegment()if df == nil || seg == nil {return fmt.Errorf("no segment selected")}pngPath, wavOutPath, err := buildClipPaths(df, seg, prefix)if err != nil {return err}segSamples, outputSampleRate, err := loadFilteredSegment(state, df, seg)if err != nil {return err}// Generate spectrogram imageresized, err := generateClipSpectrogram(segSamples, outputSampleRate)if err != nil {return err}// Write output filesif err := writeClipPNG(resized, pngPath); err != nil {return err}if err := utils.WriteWAVFile(wavOutPath, segSamples, outputSampleRate); err != nil {return fmt.Errorf("failed to write WAV: %w", err)}return nil}// loadFilteredSegment reads, bandpass-shifts, and downsamples a segment's audio.func loadFilteredSegment(state *calls.ClassifyState, df *utils.DataFile, seg *utils.Segment) ([]float64, int, error) {wavPath := df.FilePath[:len(df.FilePath)-5] // strip ".data"segSamples, sampleRate, err := utils.ReadWAVSegmentSamples(wavPath, seg.StartTime, seg.EndTime)if err != nil {return nil, 0, fmt.Errorf("failed to read WAV: %w", err)}if len(segSamples) == 0 {return nil, 0, fmt.Errorf("no samples in segment")}// Apply bandpass+shift+downsample if configuredif state.Config.BandpassLow > 0 || state.Config.BandpassHigh > 0 {segSamples, sampleRate = utils.BandpassShiftFilter(segSamples, sampleRate, state.Config.BandpassLow, state.Config.BandpassHigh)return segSamples, sampleRate, nil}// No bandpass: downsample if sample rate exceeds defaultif sampleRate > utils.DefaultMaxSampleRate {segSamples = utils.ResampleRate(segSamples, sampleRate, utils.DefaultMaxSampleRate)sampleRate = utils.DefaultMaxSampleRate}return segSamples, sampleRate, nil}// writeClipPNG writes a spectrogram image to a PNG file with proper cleanup.func writeClipPNG(img image.Image, path string) error {pngFile, err := os.Create(path)if err != nil {return fmt.Errorf("failed to create PNG: %w", err)}if err := utils.WritePNG(img, pngFile); err != nil {_ = pngFile.Close()return fmt.Errorf("failed to write PNG: %w", err)}if err := pngFile.Close(); err != nil {return fmt.Errorf("failed to close PNG: %w", err)}return nil}// buildClipPaths constructs output file paths for a clip and checks they don't already exist.func buildClipPaths(df *utils.DataFile, seg *utils.Segment, prefix string) (pngPath, wavOutPath string, err error) {wavPath := df.FilePath[:len(df.FilePath)-5] // strip ".data"basename := wavPath[strings.LastIndex(wavPath, "/")+1:]basename = strings.TrimSuffix(basename, ".wav")startInt := int(seg.StartTime)endInt := int(seg.EndTime)if seg.EndTime > float64(endInt) {endInt++}cwd, err := os.Getwd()if err != nil {return "", "", fmt.Errorf("failed to get working directory: %w", err)}baseName := fmt.Sprintf("%s_%s_%d_%d", prefix, basename, startInt, endInt)pngPath = filepath.Join(cwd, baseName+".png")wavOutPath = filepath.Join(cwd, baseName+".wav")if _, err := os.Stat(pngPath); err == nil {return "", "", fmt.Errorf("file already exists: %s", pngPath)}if _, err := os.Stat(wavOutPath); err == nil {return "", "", fmt.Errorf("file already exists: %s", wavOutPath)}return pngPath, wavOutPath, nil}// generateClipSpectrogram generates a 224px color spectrogram image from audio samples.func generateClipSpectrogram(segSamples []float64, sampleRate int) (image.Image, error) {config := utils.DefaultSpectrogramConfig(sampleRate)spectrogram := utils.GenerateSpectrogram(segSamples, config)if spectrogram == nil {return nil, fmt.Errorf("failed to generate spectrogram")}colorData := utils.ApplyL4Colormap(spectrogram)img := utils.CreateRGBImage(colorData)if img == nil {return nil, fmt.Errorf("failed to create image")}return utils.ResizeImage(img, 224, 224), nil}// playCurrentSegmentAtSpeed loads and plays the current segment's audio at the given speed.// speed=1.0 is normal, speed=0.5 is half speed.// Returns an error message string, or empty string on success.func playCurrentSegmentAtSpeed(state *calls.ClassifyState, speed float64) string {df := state.CurrentFile()seg := state.CurrentSegment()if df == nil || seg == nil {return ""}segSamples, playSampleRate, err := loadFilteredSegment(state, df, seg)if err != nil {return fmt.Sprintf("audio: %v", err)}// Initialize player lazily on first playif state.Player == nil {player, err := utils.NewAudioPlayer(playSampleRate)if err != nil {return fmt.Sprintf("audio init: %v", err)}state.Player = player}if len(segSamples) > 0 {state.PlaybackSpeed = speedstate.Player.PlayAtSpeed(segSamples, playSampleRate, speed)}return ""}
if m.clipInput == "" {m.clipMode = falsereturn m, nil}// Save the cliperr := saveClip(m.state, m.clipInput)if err != nil {m.err = err.Error()} else {m.err = "Clip saved: " + m.clipInput}m.clipMode = falsereturn m, nil
return m.handleClipEnter()
s := msg.String()if len(s) == 1 && s[0] >= 32 && s[0] <= 126 { // printable ASCII
return m.handleClipPrintable(msg.String())}// handleClipEnter handles Enter key in clip mode.func (m Model) handleClipEnter() (tea.Model, tea.Cmd) {if m.clipInput == "" {m.clipMode = falsereturn m, nil}m.clipMode = falsecwd, err := os.Getwd()if err != nil {m.err = err.Error()return m, nil}_, err = m.state.SaveClip(cwd, m.clipInput)if err != nil {m.err = err.Error()} else {m.err = "Clip saved: " + m.clipInput}return m, nil}// handleClipPrintable handles printable character input in clip mode.func (m Model) handleClipPrintable(s string) (tea.Model, tea.Cmd) {if len(s) == 1 && s[0] >= 32 && s[0] <= 126 {
// inlineImageCmd returns a tea.Cmd that generates and writes an inline image// directly to the terminal, bypassing BubbleTea's renderer.// gen is the generation at dispatch time; currentGen points to the live counter.// If they differ when the image is ready, a newer segment change has occurred// and this image is stale — discard it instead of writing.func (m Model) inlineImageCmd(gen uint64, currentGen *uint64) tea.Cmd {return func() tea.Msg {df := m.state.CurrentFile()seg := m.state.CurrentSegment()if df == nil || seg == nil {return nil}img := m.generateSpectrogramImage(df.FilePath, seg)if img == nil {return nil}// Discard if a newer segment change has superseded this oneif *currentGen != gen {return nil}// Clear previous kitty images before writing new one.// Terminal write errors during render are non-recoverable; ignore._ = utils.ClearImages(os.Stdout, m.protocol())_, _ = fmt.Fprint(os.Stdout, "\r\n\r\n")_ = utils.WriteImage(img, os.Stdout, m.protocol())return nil}}// generateSpectrogramImage creates a resized spectrogram image from a segment.func (m Model) generateSpectrogramImage(dataPath string, seg *utils.Segment) image.Image {imgSize := m.state.Config.ImageSizeif imgSize == 0 {imgSize = utils.SpectrogramDisplaySize}// For bandpass, load and filter samples manuallyif m.state.Config.BandpassLow > 0 || m.state.Config.BandpassHigh > 0 {wavPath := dataPath[:len(dataPath)-5] // strip ".data"samples, sampleRate, err := utils.ReadWAVSegmentSamples(wavPath, seg.StartTime, seg.EndTime)if err != nil || len(samples) == 0 {return nil}samples, sampleRate = utils.BandpassShiftFilter(samples, sampleRate, m.state.Config.BandpassLow, m.state.Config.BandpassHigh)return utils.SpectrogramImageFromSamples(samples, sampleRate, m.state.Config.Color, imgSize)}// Standard pathimg, err := utils.GenerateSegmentSpectrogram(dataPath, seg.StartTime, seg.EndTime, m.state.Config.Color, imgSize)if err != nil {return nil}return img}
pngPath := filepath.Join(outputDir, baseName+".png")spectSampleRate := outputSampleRateconfig := utils.DefaultSpectrogramConfig(spectSampleRate)spectrogram := utils.GenerateSpectrogram(segSamples, config)if spectrogram == nil {
// Generate spectrogram imageimg := utils.SpectrogramImageFromSamples(segSamples, sampleRate, color, imgSize)if img == nil {
// Create image (grayscale or color)var img image.Imageif color {colorData := utils.ApplyL4Colormap(spectrogram)img = utils.CreateRGBImage(colorData)} else {img = utils.CreateGrayscaleImage(spectrogram)}if img == nil {return nil, fmt.Errorf("failed to create image")
// Write PNGif err := utils.WritePNGFile(pngPath, img); err != nil {return nil, err
resized := utils.ResizeImage(img, imgSize, imgSize)// Write PNG (O_EXCL fails atomically if file exists)pngFile, err := os.OpenFile(pngPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)if err != nil {if os.IsExist(err) {return nil, fmt.Errorf("file already exists: %s", pngPath)}return nil, fmt.Errorf("failed to create PNG: %w", err)}if err := utils.WritePNG(resized, pngFile); err != nil {_ = pngFile.Close()return nil, fmt.Errorf("failed to write PNG: %w", err)}if err := pngFile.Close(); err != nil {return nil, fmt.Errorf("failed to close PNG: %w", err)}files = append(files, pngPath)
// LoadFilteredSegment reads, bandpass-shifts, and downsamples a segment's audio.// Returns samples and effective sample rate after any filtering/downsampling.func (s *ClassifyState) LoadFilteredSegment(df *utils.DataFile, seg *utils.Segment) ([]float64, int, error) {wavPath := df.FilePath[:len(df.FilePath)-5] // strip ".data"segSamples, sampleRate, err := utils.ReadWAVSegmentSamples(wavPath, seg.StartTime, seg.EndTime)if err != nil {return nil, 0, fmt.Errorf("failed to read WAV: %w", err)}if len(segSamples) == 0 {return nil, 0, fmt.Errorf("no samples in segment")}// Apply bandpass+shift+downsample if configuredif s.Config.BandpassLow > 0 || s.Config.BandpassHigh > 0 {segSamples, sampleRate = utils.BandpassShiftFilter(segSamples, sampleRate, s.Config.BandpassLow, s.Config.BandpassHigh)return segSamples, sampleRate, nil}// No bandpass: downsample if sample rate exceeds defaultif sampleRate > utils.DefaultMaxSampleRate {segSamples = utils.ResampleRate(segSamples, sampleRate, utils.DefaultMaxSampleRate)sampleRate = utils.DefaultMaxSampleRate}return segSamples, sampleRate, nil}// SaveClip saves a spectrogram PNG and WAV of the current segment to outputDir.// The prefix is prepended to the filename.func (s *ClassifyState) SaveClip(outputDir, prefix string) ([]string, error) {df := s.CurrentFile()seg := s.CurrentSegment()if df == nil || seg == nil {return nil, fmt.Errorf("no segment selected")}basename := utils.WAVBasename(df.FilePath)pngPath, wavPath, err := utils.ClipPaths(outputDir, prefix, basename, seg.StartTime, seg.EndTime)if err != nil {return nil, err}segSamples, sampleRate, err := s.LoadFilteredSegment(df, seg)if err != nil {return nil, err}// Generate spectrogram image (always color, 224px for clips)img := utils.SpectrogramImageFromSamples(segSamples, sampleRate, true, 224)if img == nil {return nil, fmt.Errorf("failed to generate spectrogram")}if err := utils.WritePNGFile(pngPath, img); err != nil {return nil, err}if err := utils.WriteWAVFile(wavPath, segSamples, sampleRate); err != nil {return nil, fmt.Errorf("failed to write WAV: %w", err)}return []string{pngPath, wavPath}, nil}// PlaySegmentAtSpeed loads and plays the current segment's audio at the given speed.// speed=1.0 is normal, speed=0.5 is half speed.// Returns an error message string, or empty string on success.func (s *ClassifyState) PlaySegmentAtSpeed(speed float64) string {df := s.CurrentFile()seg := s.CurrentSegment()if df == nil || seg == nil {return ""}segSamples, playSampleRate, err := s.LoadFilteredSegment(df, seg)if err != nil {return fmt.Sprintf("audio: %v", err)}// Initialize player lazily on first playif s.Player == nil {player, err := utils.NewAudioPlayer(playSampleRate)if err != nil {return fmt.Sprintf("audio init: %v", err)}s.Player = player}if len(segSamples) > 0 {s.PlaybackSpeed = speeds.Player.PlayAtSpeed(segSamples, playSampleRate, speed)}return ""}