refactor of tui/ first iteration

quietlight
May 18, 2026, 8:41 PM
YVFPP5VJJSR4EVGOMB5565IZFYAVFNRH37L6AXEUFU5AP6CL7DJAC

Dependencies

  • [2] NUOFNUIQ simplified --bandpass
  • [3] KZKLAINJ run out of space on nest, cleaned out
  • [4] P4CJMBYK added first version of --bandpass flag to calls classify, work to do
  • [5] 3DVPQOKB big tidy up of tools/
  • [6] ZCCQ4P5T reduce complexity to under 14, gocyclo but cilint test still has 3 functions over
  • [7] GVOVKH5R more cyclo refactoring
  • [8] KLUEQ6X5 cyclo 21+
  • [9] JAT3DXOL cyclo over 15
  • [10] ZOSYO3IB ck 3

Change contents

  • file deletion: classify.go (----------)
    [3.227139][3.248700:248735](),[3.248735][3.227156:227156]()
    package tui
    import (
    "fmt"
    "image"
    "os"
    "path/filepath"
    "sort"
    "strings"
    "time"
    tea "charm.land/bubbletea/v2"
    "charm.land/lipgloss/v2"
    "skraak/utils"
    )
    // playbackTickMsg is sent every 50ms while audio is playing
    type playbackTickMsg struct{}
    // Styles
    var (
    titleStyle = lipgloss.NewStyle().
    Bold(true).
    Foreground(lipgloss.Color("15")).
    Background(lipgloss.Color("62")).
    Padding(0, 1)
    labelStyle = lipgloss.NewStyle().
    Foreground(lipgloss.Color("86"))
    errorStyle = lipgloss.NewStyle().
    Foreground(lipgloss.Color("196"))
    helpStyle = lipgloss.NewStyle().
    Foreground(lipgloss.Color("241"))
    helpDarkStyle = lipgloss.NewStyle().
    Foreground(lipgloss.Color("86"))
    commentBoxStyle = lipgloss.NewStyle().
    Border(lipgloss.RoundedBorder()).
    BorderForeground(lipgloss.Color("62")).
    Padding(0, 1)
    )
    // wrapText wraps text at word boundaries to fit within maxWidth.
    // Returns multiple lines joined with newlines.
    func wrapText(text string, maxWidth int) string {
    if len(text) <= maxWidth {
    return text
    }
    lines := strings.Split(text, "\n")
    var result []string
    for _, line := range lines {
    if len(line) <= maxWidth {
    result = append(result, line)
    continue
    }
    // Wrap at word boundaries
    words := strings.Fields(line)
    var currentLine string
    for _, word := range words {
    if len(currentLine)+len(word)+1 <= maxWidth {
    if currentLine == "" {
    currentLine = word
    } else {
    currentLine += " " + word
    }
    } else {
    if currentLine != "" {
    result = append(result, currentLine)
    }
    // If single word is longer than maxWidth, force break it
    if len(word) > maxWidth {
    result = append(result, word[:maxWidth])
    word = word[maxWidth:]
    }
    currentLine = word
    }
    }
    if currentLine != "" {
    result = append(result, currentLine)
    }
    }
    return strings.Join(result, "\n")
    }
    // Model holds TUI state
    type Model struct {
    err string
    quitting bool
    bindingsHelp string // pre-computed bindings text
    // Comment dialog state
    commentMode bool // true when comment dialog is open
    commentText string // current input text
    commentCursor int // cursor position in comment text
    // Clip dialog state
    clipMode bool // true when clip dialog is open
    clipInput string // current prefix input
    // Shift+primary wait mode: when non-empty, the next keypress is looked up
    // in Config.SecondaryBindings[awaitingSecondaryFor] as a calltype key.
    awaitingSecondaryFor string
    // Image generation counter - incremented on each segment change,
    // used to discard stale inline images (sixel/iTerm).
    // Pointer so it survives BubbleTea's value-copy update cycle.
    imageGen *uint64
    }
    // New creates a new TUI model
    // Pre-compute bindings help text, sorted letters a-z then digits 0-9
    // (other single-char keys sorted after).
    copy(sorted, state.Config.Bindings)
    keyRank := func(k string) int {
    if len(k) == 0 {
    return 3
    }
    c := k[0]
    switch {
    case c >= 'a' && c <= 'z':
    return 0
    case c >= 'A' && c <= 'Z':
    return 1
    case c >= '0' && c <= '9':
    return 2
    default:
    return 3
    }
    }
    sort.SliceStable(sorted, func(i, j int) bool {
    ri, rj := keyRank(sorted[i].Key), keyRank(sorted[j].Key)
    if ri != rj {
    return ri < rj
    }
    return sorted[i].Key < sorted[j].Key
    })
    var bindings []string
    for _, b := range sorted {
    if b.CallType != "" {
    bindings = append(bindings, fmt.Sprintf("%s=%s/%s", b.Key, b.Species, b.CallType))
    } else {
    bindings = append(bindings, fmt.Sprintf("%s=%s", b.Key, b.Species))
    }
    }
    bindingsHelp := strings.Join(bindings, " ")
    gen := uint64(0)
    return Model{
    state: state,
    bindingsHelp: bindingsHelp,
    imageGen: &gen,
    }
    }
    func (m Model) protocol() utils.ImageProtocol {
    if m.state.Config.ITerm {
    return utils.ProtocolITerm
    }
    if m.state.Config.Sixel {
    return utils.ProtocolSixel
    }
    return utils.ProtocolKitty
    }
    // Init initializes the model
    func (m Model) Init() tea.Cmd {
    return inlineImageCmd(m.state, m.protocol(), *m.imageGen, m.imageGen)
    }
    // Update handles messages
    func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyPressMsg:
    return m.handleKey(msg)
    case playbackTickMsg:
    if m.state.Player == nil || !m.state.Player.IsPlaying() {
    return m, nil // done, triggers re-render to clear "Playing..." text
    }
    return m, playbackTick()
    }
    return m, nil
    }
    // segmentChangeCmd returns the appropriate command after a segment change.
    // Clears screen then generates and writes the spectrogram image asynchronously.
    func (m Model) segmentChangeCmd() tea.Cmd {
    (*m.imageGen)++
    gen := *m.imageGen
    return tea.Sequence(tea.ClearScreen, inlineImageCmd(m.state, m.protocol(), gen, m.imageGen))
    }
    func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
    if m.commentMode {
    return m.handleCommentKey(msg)
    }
    if m.clipMode {
    return m.handleClipKey(msg)
    }
    m.err = ""
    if m.awaitingSecondaryFor != "" {
    if handled, model, cmd := m.handleSecondaryWait(msg); handled {
    return model, cmd
    }
    if handled, model, cmd := m.handleSpecialKey(msg); handled {
    return model, cmd
    }
    return m.handleSwitchKey(msg)
    }
    // handleSecondaryWait handles keypresses while awaiting a secondary calltype key.
    // Returns (true, model, cmd) if the key was consumed; (false, model, cmd) to fall through.
    func (m Model) handleSecondaryWait(msg tea.KeyPressMsg) (bool, tea.Model, tea.Cmd) {
    primary := m.awaitingSecondaryFor
    m.awaitingSecondaryFor = ""
    if msg.Key().Code == tea.KeyEscape || msg.Key().Code == tea.KeyEsc {
    return true, m, nil
    }
    s := msg.String()
    if len(s) == 1 {
    if callType, ok := m.state.Config.SecondaryBindings[primary][s]; ok {
    m.stopPlayer()
    m.state.ApplyCallTypeOnly(callType)
    if err := m.state.Save(); err != nil {
    m.err = err.Error()
    }
    }
    }
    // handleSpecialKey handles single-key-code bindings (Enter, Esc, Space, Ctrl+S).
    // Returns (true, model, cmd) if the key was consumed.
    func (m Model) handleSpecialKey(msg tea.KeyPressMsg) (bool, tea.Model, tea.Cmd) {
    key := msg.Key()
    if key.Code == tea.KeyEnter || key.Code == tea.KeyKpEnter {
    speed := 1.0
    if key.Mod&tea.ModShift != 0 {
    speed = 0.5
    }
    if errMsg := playCurrentSegmentAtSpeed(m.state, speed); errMsg != "" {
    m.err = errMsg
    }
    return true, m, playbackTick()
    }
    if key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {
    m.stopPlayer()
    m.quitting = true
    return true, m, tea.Quit
    }
    if key.Code == tea.KeySpace {
    m.commentText = m.state.GetCurrentComment()
    m.commentCursor = len(m.commentText)
    m.commentMode = true
    return true, m, nil
    }
    if msg.String() == "ctrl+s" {
    m.clipInput = ""
    m.clipMode = true
    return true, m, nil
    }
    switch msg.String() {
    case "ctrl+c":
    m.stopPlayer()
    m.quitting = true
    return m, tea.Quit
    case ",", "left":
    m.stopPlayer()
    m.state.PrevSegment()
    return m, m.segmentChangeCmd()
    case ".", "right":
    m.stopPlayer()
    case "ctrl+d":
    case "ctrl+,":
    case "ctrl+.":
    case "0":
    default:
    return m.handleBindingKey(msg)
    }
    }
    // handleBindingKey handles single-character key bindings (species/calltype shortcuts).
    func (m Model) handleBindingKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
    s := msg.String()
    if len(s) != 1 {
    return m, nil
    }
    k := s
    key := msg.Key()
    // Shift+letter: if the lowercase primary has secondary bindings,
    // label species-only and enter wait mode. Otherwise map to the
    // lowercase equivalent and dispatch as a normal primary keypress.
    if key.Mod&tea.ModShift != 0 {
    lower := strings.ToLower(s)
    if lower != s {
    if m.state.HasSecondary(lower) {
    if result := m.state.ParseKeyBuffer(lower); result != nil {
    m.stopPlayer()
    if err := m.state.Save(); err != nil {
    m.err = err.Error()
    }
    m.awaitingSecondaryFor = lower
    return m, nil
    }
    }
    if result := m.state.ParseKeyBuffer(k); result != nil {
    m.stopPlayer()
    m.state.ApplyBinding(result)
    if err := m.state.Save(); err != nil {
    m.err = err.Error()
    }
    return m.advanceOrQuit()
    }
    return m, nil
    }
    // stopPlayer stops the audio player if it exists.
    func (m Model) stopPlayer() {
    if m.state.Player != nil {
    m.state.Player.Stop()
    }
    }
    // advanceOrQuit advances to the next segment, or quits if none remain.
    func (m Model) advanceOrQuit() (tea.Model, tea.Cmd) {
    if !m.state.NextSegment() {
    m.quitting = true
    return m, tea.Quit
    }
    }
    // handleCommentKey handles key presses in comment mode
    func (m Model) handleCommentKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
    key := msg.Key()
    // Enter: save comment
    if key.Code == tea.KeyEnter {
    m.state.SetComment(m.commentText)
    if err := m.state.Save(); err != nil {
    m.err = err.Error()
    }
    m.commentMode = false
    return m, nil
    }
    // Escape: cancel
    if key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {
    m.commentMode = false
    return m, nil
    }
    // Printable ASCII character (space handled above via KeySpace)
    s := msg.String()
    if len(s) == 1 && s[0] >= 33 && s[0] <= 126 {
    if len(m.commentText) < 140 {
    m.commentText = m.commentText[:m.commentCursor] + s + m.commentText[m.commentCursor:]
    m.commentCursor++
    }
    }
    return m, nil
    }
    // handleCommentKeyCode handles navigation and editing keys in comment mode.
    // Returns true if the key was consumed.
    func (m *Model) handleCommentKeyCode(key tea.Key) bool {
    switch key.Code {
    case tea.KeyLeft:
    if m.commentCursor > 0 {
    m.commentCursor--
    }
    return true
    case tea.KeyRight:
    if m.commentCursor < len(m.commentText) {
    m.commentCursor++
    }
    return true
    case tea.KeySpace:
    if len(m.commentText) < 140 {
    m.commentText = m.commentText[:m.commentCursor] + " " + m.commentText[m.commentCursor:]
    m.commentCursor++
    }
    return true
    case tea.KeyBackspace:
    if m.commentCursor > 0 {
    m.commentText = m.commentText[:m.commentCursor-1] + m.commentText[m.commentCursor:]
    m.commentCursor--
    }
    return true
    case tea.KeyDelete:
    if m.commentCursor < len(m.commentText) {
    m.commentText = m.commentText[:m.commentCursor] + m.commentText[m.commentCursor+1:]
    }
    return true
    }
    // handleCommentCtrl handles ctrl-key combos in comment mode.
    // Returns true if the key was consumed.
    func (m *Model) handleCommentCtrl(s string) bool {
    switch s {
    case "ctrl+u":
    m.commentText = ""
    m.commentCursor = 0
    return true
    case "ctrl+a":
    m.commentCursor = 0
    return true
    case "ctrl+e":
    m.commentCursor = len(m.commentText)
    return true
    }
    return false
    }
    // handleClipKey handles key presses in clip mode
    func (m Model) handleClipKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
    key := msg.Key()
    // Enter: save clip
    if key.Code == tea.KeyEnter {
    if m.clipInput == "" {
    m.clipMode = false
    return m, nil
    }
    // Save the clip
    err := saveClip(m.state, m.clipInput)
    if err != nil {
    m.err = err.Error()
    } else {
    m.err = "Clip saved: " + m.clipInput
    }
    m.clipMode = false
    return m, nil
    }
    // Escape: cancel
    if key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {
    m.clipMode = false
    return m, nil
    }
    // Backspace: remove last character
    if key.Code == tea.KeyBackspace {
    if len(m.clipInput) > 0 {
    m.clipInput = m.clipInput[:len(m.clipInput)-1]
    }
    return m, nil
    }
    // Printable characters: append to input
    s := msg.String()
    if len(s) == 1 && s[0] >= 32 && s[0] <= 126 { // printable ASCII
    if len(m.clipInput) < 64 {
    m.clipInput += s
    }
    return m, nil
    }
    return m, nil
    }
    // saveClip saves a clip of the current segment to the current working directory
    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
    }
    if err != nil {
    }
    // Generate spectrogram image
    resized, err := generateClipSpectrogram(segSamples, outputSampleRate)
    if err != nil {
    return err
    }
    // Write output files
    if 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
    }
    func loadFilteredSegment(state *calls.ClassifyState, df *utils.DataFile, seg *utils.Segment) ([]float64, int, error) {
    wavPath := strings.TrimSuffix(df.FilePath, ".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")
    }
    if state.Config.BandpassLow > 0 || state.Config.BandpassHigh > 0 {
    }
    }
    return segSamples, sampleRate, nil
    // No bandpass: downsample if sample rate exceeds default
    if sampleRate > utils.DefaultMaxSampleRate {
    segSamples = utils.ResampleRate(segSamples, sampleRate, utils.DefaultMaxSampleRate)
    sampleRate = utils.DefaultMaxSampleRate
    segSamples, sampleRate = utils.BandpassShiftFilter(segSamples, sampleRate, state.Config.BandpassLow, state.Config.BandpassHigh)
    return segSamples, sampleRate, nil
    // Apply bandpass+shift+downsample if configured
    // loadFilteredSegment reads, bandpass-shifts, and downsamples a segment's audio.
    }
    // 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)
    }
    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.
    df := state.CurrentFile()
    seg := state.CurrentSegment()
    if df == nil || seg == nil {
    return ""
    }
    if err != nil {
    return fmt.Sprintf("audio: %v", err)
    }
    // Initialize player lazily on first play
    if state.Player == nil {
    if err != nil {
    return fmt.Sprintf("audio init: %v", err)
    }
    state.Player = player
    }
    if len(segSamples) > 0 {
    state.PlaybackSpeed = speed
    }
    return ""
    }
    // playbackTick returns a command that sends a playbackTickMsg after 50ms.
    func playbackTick() tea.Cmd {
    return tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg {
    return playbackTickMsg{}
    })
    }
    // View renders the TUI
    func (m Model) View() tea.View {
    if m.quitting {
    var b strings.Builder
    _ = utils.ClearImages(&b, m.protocol())
    b.WriteString("\nDone!\n")
    return tea.NewView(b.String())
    }
    var b strings.Builder
    df := m.state.CurrentFile()
    seg := m.state.CurrentSegment()
    total := m.state.TotalSegments()
    current := m.state.CurrentSegmentNumber()
    if df == nil || seg == nil {
    return tea.NewView("\nNo segments to review.\n")
    }
    const wrapWidth = 80
    b.WriteString(helpStyle.Render(wrapText(m.bindingsHelp, wrapWidth)))
    b.WriteString("\n")
    b.WriteString(helpDarkStyle.Render(wrapText("[esc]quit [,]prev [.]next [0]confirm [space]comment [ctrl+s]clip [ctrl+d]bookmark [ctrl+,]prev-bk [ctrl+.]next-bk [enter]play [shift+enter]½speed", wrapWidth)))
    b.WriteString("\n\n")
    progress := float64(current) / float64(total)
    barWidth := 30
    filled := int(progress * float64(barWidth))
    bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
    wavFile := strings.TrimSuffix(df.FilePath, ".data")
    wavFile = wavFile[strings.LastIndex(wavFile, "/")+1:]
    b.WriteString(titleStyle.Render(fmt.Sprintf(" %s [%s] %d/%d Segments ", wavFile, bar, current, total)))
    b.WriteString("\n\n")
    segInfo := fmt.Sprintf("Segment: %.1fs - %.1fs (%.1fs)", seg.StartTime, seg.EndTime, seg.EndTime-seg.StartTime)
    if m.state.HasBookmark() {
    segInfo += " [BOOKMARKED]"
    }
    if m.awaitingSecondaryFor != "" {
    segInfo += " Waiting..."
    }
    if m.state.Player != nil && m.state.Player.IsPlaying() {
    if m.state.PlaybackSpeed == 0.5 {
    segInfo += " ▶ Playing 0.5x..."
    } else {
    segInfo += " ▶ Playing..."
    }
    }
    b.WriteString(segInfo)
    b.WriteString("\n\n")
    filterLabels := seg.GetFilterLabels(m.state.Config.Filter)
    if len(filterLabels) > 0 {
    b.WriteString(labelStyle.Render("Labels:"))
    b.WriteString("\n")
    for _, l := range filterLabels {
    }
    }
    b.WriteString("\n")
    }
    // renderCommentDialog renders the comment input dialog
    func (m Model) renderCommentDialog(b *strings.Builder) {
    // Build input line with cursor at correct position
    before := m.commentText[:m.commentCursor]
    after := m.commentText[m.commentCursor:]
    inputLine := before + "█" + after
    charCount := fmt.Sprintf("%d/140", len(m.commentText))
    helpLine := "[enter]save [esc]cancel [←→]move [ctrl+u]clear [ctrl+a]start [ctrl+e]end"
    // Render box
    content := fmt.Sprintf("Comment:\n%s\n%s\n%s", inputLine, charCount, helpLine)
    b.WriteString(commentBoxStyle.Render(content))
    }
    // renderClipDialog renders the clip prefix input dialog
    func (m Model) renderClipDialog(b *strings.Builder) {
    inputLine := m.clipInput + "█"
    helpLine := "[enter]save [esc]cancel"
    // Render box
    content := fmt.Sprintf("Clip prefix:\n%s\n%s", inputLine, helpLine)
    b.WriteString(commentBoxStyle.Render(content))
    }
    // generateSpectrogramImage creates a resized spectrogram image from a segment.
    imgSize := state.Config.ImageSize
    if imgSize == 0 {
    imgSize = utils.SpectrogramDisplaySize
    }
    if err != nil {
    return nil
    }
    return img
    }
    // 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.
    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 one
    if *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
    }
    }
    func inlineImageCmd(state *calls.ClassifyState, protocol utils.ImageProtocol, gen uint64, currentGen *uint64) tea.Cmd {
    }
    // 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 := strings.TrimSuffix(dataPath, ".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.Image
    if 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)
    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)
    func generateSpectrogramImage(state *calls.ClassifyState, dataPath string, seg *utils.Segment) image.Image {
    fmt.Fprintf(b, " • %s\n", calls.FormatLabels([]*utils.Label{l}, m.state.Config.Filter))
    // renderLabels renders the filter labels for the current segment
    func (m Model) renderLabels(b *strings.Builder, seg *utils.Segment) {
    }
    // renderSegmentInfo renders segment timing and playback status
    func (m Model) renderSegmentInfo(b *strings.Builder, df *utils.DataFile, seg *utils.Segment) {
    }
    df := m.state.CurrentFile()
    // renderProgressBar renders the progress bar and file title
    func (m Model) renderProgressBar(b *strings.Builder, current, total int) {
    }
    m.renderHeader(&b)
    m.renderProgressBar(&b, current, total)
    m.renderSegmentInfo(&b, df, seg)
    m.renderLabels(&b, seg)
    // Clip dialog (when active)
    if m.clipMode {
    m.renderClipDialog(&b)
    return tea.NewView(b.String())
    }
    // Comment dialog (when active)
    if m.commentMode {
    m.renderCommentDialog(&b)
    return tea.NewView(b.String())
    }
    // Error
    if m.err != "" {
    b.WriteString(errorStyle.Render(m.err))
    }
    v := tea.NewView(b.String())
    v.AltScreen = true
    return v
    }
    // renderHeader renders the keybindings help text
    func (m Model) renderHeader(b *strings.Builder) {
    state.Player.PlayAtSpeed(segSamples, playSampleRate, speed)
    player, err := utils.NewAudioPlayer(playSampleRate)
    segSamples, playSampleRate, err := loadFilteredSegment(state, df, seg)
    func playCurrentSegmentAtSpeed(state *calls.ClassifyState, speed float64) string {
    }
    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 := strings.TrimSuffix(df.FilePath, ".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)
    return err
    segSamples, outputSampleRate, err := loadFilteredSegment(state, df, seg)
    func saveClip(state *calls.ClassifyState, prefix string) error {
    return false
    }
    return m, nil
    }
    // Navigation and editing keys
    if handled := m.handleCommentKeyCode(key); handled {
    return m, nil
    }
    // Ctrl combos
    if handled := m.handleCommentCtrl(msg.String()); handled {
    return m, m.segmentChangeCmd()
    k = lower
    }
    }
    m.state.ApplyBinding(&calls.BindingResult{Species: result.Species})
    // handleBookmarkToggle toggles the bookmark and saves.
    func (m Model) handleBookmarkToggle() (tea.Model, tea.Cmd) {
    m.state.ToggleBookmark()
    if err := m.state.Save(); err != nil {
    m.err = err.Error()
    }
    return m, nil
    }
    // handleBookmarkNav navigates to a bookmark. navFn returns true if found.
    func (m Model) handleBookmarkNav(navFn func() bool) (tea.Model, tea.Cmd) {
    m.stopPlayer()
    if navFn() {
    return m, m.segmentChangeCmd()
    }
    m.err = "No bookmarks found"
    return m, nil
    }
    // handleConfirmLabel confirms the current label and advances.
    func (m Model) handleConfirmLabel() (tea.Model, tea.Cmd) {
    m.stopPlayer()
    if m.state.ConfirmLabel() {
    if err := m.state.Save(); err != nil {
    m.err = err.Error()
    return m, nil
    }
    }
    return m.advanceOrQuit()
    }
    return m.handleConfirmLabel()
    return m.handleBookmarkNav(m.state.NextBookmark)
    return m.handleBookmarkNav(m.state.PrevBookmark)
    return m.handleBookmarkToggle()
    return m.advanceOrQuit()
    return false, m, nil
    }
    // handleSwitchKey handles string-based key bindings (ctrl+c, arrows, digits, bindings).
    func (m Model) handleSwitchKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
    return false, m, nil
    }
    model, cmd := m.advanceOrQuit()
    return true, model, cmd
    }
    sorted := make([]calls.KeyBinding, len(state.Config.Bindings))
    func New(state *calls.ClassifyState) Model {
    state *calls.ClassifyState
    "skraak/tools/calls"
  • file addition: view.go (----------)
    [3.227139]
    package tui
    import (
    "fmt"
    "strings"
    tea "charm.land/bubbletea/v2"
    "skraak/tools/calls"
    "skraak/utils"
    )
    // View renders the TUI
    func (m Model) View() tea.View {
    if m.quitting {
    var b strings.Builder
    _ = utils.ClearImages(&b, m.protocol())
    b.WriteString("\nDone!\n")
    return tea.NewView(b.String())
    }
    var b strings.Builder
    df := m.state.CurrentFile()
    seg := m.state.CurrentSegment()
    total := m.state.TotalSegments()
    current := m.state.CurrentSegmentNumber()
    if df == nil || seg == nil {
    return tea.NewView("\nNo segments to review.\n")
    }
    m.renderHeader(&b)
    m.renderProgressBar(&b, current, total)
    m.renderSegmentInfo(&b, df, seg)
    m.renderLabels(&b, seg)
    // Clip dialog (when active)
    if m.clipMode {
    m.renderClipDialog(&b)
    return tea.NewView(b.String())
    }
    // Comment dialog (when active)
    if m.commentMode {
    m.renderCommentDialog(&b)
    return tea.NewView(b.String())
    }
    // Error
    if m.err != "" {
    b.WriteString(errorStyle.Render(m.err))
    }
    v := tea.NewView(b.String())
    v.AltScreen = true
    return v
    }
    // renderHeader renders the keybindings help text
    func (m Model) renderHeader(b *strings.Builder) {
    const wrapWidth = 80
    b.WriteString(helpStyle.Render(wrapText(m.bindingsHelp, wrapWidth)))
    b.WriteString("\n")
    b.WriteString(helpDarkStyle.Render(wrapText(keyHelpText, wrapWidth)))
    b.WriteString("\n\n")
    }
    // renderProgressBar renders the progress bar and file title
    func (m Model) renderProgressBar(b *strings.Builder, current, total int) {
    progress := float64(current) / float64(total)
    barWidth := 30
    filled := int(progress * float64(barWidth))
    bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
    df := m.state.CurrentFile()
    wavFile := strings.TrimSuffix(df.FilePath, ".data")
    wavFile = wavFile[strings.LastIndex(wavFile, "/")+1:]
    b.WriteString(titleStyle.Render(fmt.Sprintf(" %s [%s] %d/%d Segments ", wavFile, bar, current, total)))
    b.WriteString("\n\n")
    }
    // renderSegmentInfo renders segment timing and playback status
    func (m Model) renderSegmentInfo(b *strings.Builder, df *utils.DataFile, seg *utils.Segment) {
    segInfo := fmt.Sprintf("Segment: %.1fs - %.1fs (%.1fs)", seg.StartTime, seg.EndTime, seg.EndTime-seg.StartTime)
    if m.state.HasBookmark() {
    segInfo += " [BOOKMARKED]"
    }
    if m.awaitingSecondaryFor != "" {
    segInfo += " Waiting..."
    }
    if m.state.Player != nil && m.state.Player.IsPlaying() {
    if m.state.PlaybackSpeed == 0.5 {
    segInfo += " ▶ Playing 0.5x..."
    } else {
    segInfo += " ▶ Playing..."
    }
    }
    b.WriteString(segInfo)
    b.WriteString("\n\n")
    }
    // renderLabels renders the filter labels for the current segment
    func (m Model) renderLabels(b *strings.Builder, seg *utils.Segment) {
    filterLabels := seg.GetFilterLabels(m.state.Config.Filter)
    if len(filterLabels) > 0 {
    b.WriteString(labelStyle.Render("Labels:"))
    b.WriteString("\n")
    for _, l := range filterLabels {
    fmt.Fprintf(b, " • %s\n", calls.FormatLabels([]*utils.Label{l}, m.state.Config.Filter))
    }
    }
    b.WriteString("\n")
    }
    // renderCommentDialog renders the comment input dialog
    func (m Model) renderCommentDialog(b *strings.Builder) {
    // Build input line with cursor at correct position
    before := m.commentText[:m.commentCursor]
    after := m.commentText[m.commentCursor:]
    inputLine := before + "█" + after
    charCount := fmt.Sprintf("%d/%d", len(m.commentText), maxCommentLength)
    // Render box
    content := fmt.Sprintf("Comment:\n%s\n%s\n%s", inputLine, charCount, commentHelpText)
    b.WriteString(commentBoxStyle.Render(content))
    }
    // renderClipDialog renders the clip prefix input dialog
    func (m Model) renderClipDialog(b *strings.Builder) {
    inputLine := m.clipInput + "█"
    // Render box
    content := fmt.Sprintf("Clip prefix:\n%s\n%s", inputLine, clipHelpText)
    b.WriteString(commentBoxStyle.Render(content))
    }
  • file addition: update.go (----------)
    [3.227139]
    package tui
    import (
    "strings"
    "time"
    tea "charm.land/bubbletea/v2"
    "skraak/tools/calls"
    )
    // Update handles messages
    func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyPressMsg:
    return m.handleKey(msg)
    case playbackTickMsg:
    if m.state.Player == nil || !m.state.Player.IsPlaying() {
    return m, nil // done, triggers re-render to clear "Playing..." text
    }
    return m, playbackTick()
    }
    return m, nil
    }
    // segmentChangeCmd returns the appropriate command after a segment change.
    // Clears screen then generates and writes the spectrogram image asynchronously.
    func (m Model) segmentChangeCmd() tea.Cmd {
    (*m.imageGen)++
    gen := *m.imageGen
    return tea.Sequence(tea.ClearScreen, inlineImageCmd(m.state, m.protocol(), gen, m.imageGen))
    }
    func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
    if m.commentMode {
    return m.handleCommentKey(msg)
    }
    if m.clipMode {
    return m.handleClipKey(msg)
    }
    m.err = ""
    if m.awaitingSecondaryFor != "" {
    if handled, model, cmd := m.handleSecondaryWait(msg); handled {
    return model, cmd
    }
    }
    if handled, model, cmd := m.handleSpecialKey(msg); handled {
    return model, cmd
    }
    return m.handleSwitchKey(msg)
    }
    // handleSecondaryWait handles keypresses while awaiting a secondary calltype key.
    // Returns (true, model, cmd) if the key was consumed; (false, model, cmd) to fall through.
    func (m Model) handleSecondaryWait(msg tea.KeyPressMsg) (bool, tea.Model, tea.Cmd) {
    primary := m.awaitingSecondaryFor
    m.awaitingSecondaryFor = ""
    if msg.Key().Code == tea.KeyEscape || msg.Key().Code == tea.KeyEsc {
    return true, m, nil
    }
    s := msg.String()
    if len(s) == 1 {
    if callType, ok := m.state.Config.SecondaryBindings[primary][s]; ok {
    m.stopPlayer()
    m.state.ApplyCallTypeOnly(callType)
    if err := m.state.Save(); err != nil {
    m.err = err.Error()
    }
    model, cmd := m.advanceOrQuit()
    return true, model, cmd
    }
    }
    return false, m, nil
    }
    // handleSpecialKey handles single-key-code bindings (Enter, Esc, Space, Ctrl+S).
    // Returns (true, model, cmd) if the key was consumed.
    func (m Model) handleSpecialKey(msg tea.KeyPressMsg) (bool, tea.Model, tea.Cmd) {
    key := msg.Key()
    if key.Code == tea.KeyEnter || key.Code == tea.KeyKpEnter {
    speed := 1.0
    if key.Mod&tea.ModShift != 0 {
    speed = 0.5
    }
    if errMsg := playCurrentSegmentAtSpeed(m.state, speed); errMsg != "" {
    m.err = errMsg
    }
    return true, m, playbackTick()
    }
    if key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {
    m.stopPlayer()
    m.quitting = true
    return true, m, tea.Quit
    }
    if key.Code == tea.KeySpace {
    m.commentText = m.state.GetCurrentComment()
    m.commentCursor = len(m.commentText)
    m.commentMode = true
    return true, m, nil
    }
    if msg.String() == "ctrl+s" {
    m.clipInput = ""
    m.clipMode = true
    return true, m, nil
    }
    return false, m, nil
    }
    // handleSwitchKey handles string-based key bindings (ctrl+c, arrows, digits, bindings).
    func (m Model) handleSwitchKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
    switch msg.String() {
    case "ctrl+c":
    m.stopPlayer()
    m.quitting = true
    return m, tea.Quit
    case ",", "left":
    m.stopPlayer()
    m.state.PrevSegment()
    return m, m.segmentChangeCmd()
    case ".", "right":
    m.stopPlayer()
    return m.advanceOrQuit()
    case "ctrl+d":
    return m.handleBookmarkToggle()
    case "ctrl+,":
    return m.handleBookmarkNav(m.state.PrevBookmark)
    case "ctrl+.":
    return m.handleBookmarkNav(m.state.NextBookmark)
    case "0":
    return m.handleConfirmLabel()
    default:
    return m.handleBindingKey(msg)
    }
    }
    // handleBookmarkToggle toggles the bookmark and saves.
    func (m Model) handleBookmarkToggle() (tea.Model, tea.Cmd) {
    m.state.ToggleBookmark()
    if err := m.state.Save(); err != nil {
    m.err = err.Error()
    }
    return m, nil
    }
    // handleBookmarkNav navigates to a bookmark. navFn returns true if found.
    func (m Model) handleBookmarkNav(navFn func() bool) (tea.Model, tea.Cmd) {
    m.stopPlayer()
    if navFn() {
    return m, m.segmentChangeCmd()
    }
    m.err = "No bookmarks found"
    return m, nil
    }
    // handleConfirmLabel confirms the current label and advances.
    func (m Model) handleConfirmLabel() (tea.Model, tea.Cmd) {
    m.stopPlayer()
    if m.state.ConfirmLabel() {
    if err := m.state.Save(); err != nil {
    m.err = err.Error()
    return m, nil
    }
    }
    return m.advanceOrQuit()
    }
    // handleBindingKey handles single-character key bindings (species/calltype shortcuts).
    func (m Model) handleBindingKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
    s := msg.String()
    if len(s) != 1 {
    return m, nil
    }
    k := s
    key := msg.Key()
    // Shift+letter: if the lowercase primary has secondary bindings,
    // label species-only and enter wait mode. Otherwise map to the
    // lowercase equivalent and dispatch as a normal primary keypress.
    if key.Mod&tea.ModShift != 0 {
    lower := strings.ToLower(s)
    if lower != s {
    if m.state.HasSecondary(lower) {
    if result := m.state.ParseKeyBuffer(lower); result != nil {
    m.stopPlayer()
    m.state.ApplyBinding(&calls.BindingResult{Species: result.Species})
    if err := m.state.Save(); err != nil {
    m.err = err.Error()
    }
    m.awaitingSecondaryFor = lower
    return m, nil
    }
    }
    k = lower
    }
    }
    if result := m.state.ParseKeyBuffer(k); result != nil {
    m.stopPlayer()
    m.state.ApplyBinding(result)
    if err := m.state.Save(); err != nil {
    m.err = err.Error()
    }
    return m.advanceOrQuit()
    }
    return m, nil
    }
    // stopPlayer stops the audio player if it exists.
    func (m Model) stopPlayer() {
    if m.state.Player != nil {
    m.state.Player.Stop()
    }
    }
    // advanceOrQuit advances to the next segment, or quits if none remain.
    func (m Model) advanceOrQuit() (tea.Model, tea.Cmd) {
    if !m.state.NextSegment() {
    m.quitting = true
    return m, tea.Quit
    }
    return m, m.segmentChangeCmd()
    }
    // handleCommentKey handles key presses in comment mode
    func (m Model) handleCommentKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
    key := msg.Key()
    // Enter: save comment
    if key.Code == tea.KeyEnter {
    m.state.SetComment(m.commentText)
    if err := m.state.Save(); err != nil {
    m.err = err.Error()
    }
    m.commentMode = false
    return m, nil
    }
    // Escape: cancel
    if key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {
    m.commentMode = false
    return m, nil
    }
    // Navigation and editing keys
    if handled := m.handleCommentKeyCode(key); handled {
    return m, nil
    }
    // Ctrl combos
    if handled := m.handleCommentCtrl(msg.String()); handled {
    return m, nil
    }
    // Printable ASCII character (space handled above via KeySpace)
    s := msg.String()
    if len(s) == 1 && s[0] >= 33 && s[0] <= 126 {
    if len(m.commentText) < maxCommentLength {
    m.commentText = m.commentText[:m.commentCursor] + s + m.commentText[m.commentCursor:]
    m.commentCursor++
    }
    }
    return m, nil
    }
    // handleCommentKeyCode handles navigation and editing keys in comment mode.
    // Returns true if the key was consumed.
    func (m *Model) handleCommentKeyCode(key tea.Key) bool {
    switch key.Code {
    case tea.KeyLeft:
    if m.commentCursor > 0 {
    m.commentCursor--
    }
    return true
    case tea.KeyRight:
    if m.commentCursor < len(m.commentText) {
    m.commentCursor++
    }
    return true
    case tea.KeySpace:
    if len(m.commentText) < maxCommentLength {
    m.commentText = m.commentText[:m.commentCursor] + " " + m.commentText[m.commentCursor:]
    m.commentCursor++
    }
    return true
    case tea.KeyBackspace:
    if m.commentCursor > 0 {
    m.commentText = m.commentText[:m.commentCursor-1] + m.commentText[m.commentCursor:]
    m.commentCursor--
    }
    return true
    case tea.KeyDelete:
    if m.commentCursor < len(m.commentText) {
    m.commentText = m.commentText[:m.commentCursor] + m.commentText[m.commentCursor+1:]
    }
    return true
    }
    return false
    }
    // handleCommentCtrl handles ctrl-key combos in comment mode.
    // Returns true if the key was consumed.
    func (m *Model) handleCommentCtrl(s string) bool {
    switch s {
    case "ctrl+u":
    m.commentText = ""
    m.commentCursor = 0
    return true
    case "ctrl+a":
    m.commentCursor = 0
    return true
    case "ctrl+e":
    m.commentCursor = len(m.commentText)
    return true
    }
    return false
    }
    // handleClipKey handles key presses in clip mode
    func (m Model) handleClipKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
    key := msg.Key()
    // Enter: save clip
    if key.Code == tea.KeyEnter {
    if m.clipInput == "" {
    m.clipMode = false
    return m, nil
    }
    // Save the clip
    err := saveClip(m.state, m.clipInput)
    if err != nil {
    m.err = err.Error()
    } else {
    m.err = "Clip saved: " + m.clipInput
    }
    m.clipMode = false
    return m, nil
    }
    // Escape: cancel
    if key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {
    m.clipMode = false
    return m, nil
    }
    // Backspace: remove last character
    if key.Code == tea.KeyBackspace {
    if len(m.clipInput) > 0 {
    m.clipInput = m.clipInput[:len(m.clipInput)-1]
    }
    return m, nil
    }
    // Printable characters: append to input
    s := msg.String()
    if len(s) == 1 && s[0] >= 32 && s[0] <= 126 { // printable ASCII
    if len(m.clipInput) < maxClipPrefixLength {
    m.clipInput += s
    }
    return m, nil
    }
    return m, nil
    }
    // playbackTick returns a command that sends a playbackTickMsg after 50ms.
    func playbackTick() tea.Cmd {
    return tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg {
    return playbackTickMsg{}
    })
    }
  • file addition: spectrogram.go (----------)
    [3.227139]
    package tui
    import (
    "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.ImageSize
    if 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.Image
    if 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 one
    if *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
    }
    }
  • file addition: model.go (----------)
    [3.227139]
    package tui
    import (
    "fmt"
    "sort"
    "strings"
    tea "charm.land/bubbletea/v2"
    "charm.land/lipgloss/v2"
    "skraak/tools/calls"
    "skraak/utils"
    )
    // playbackTickMsg is sent every 50ms while audio is playing
    type playbackTickMsg struct{}
    // Styles
    var (
    titleStyle = lipgloss.NewStyle().
    Bold(true).
    Foreground(lipgloss.Color("15")).
    Background(lipgloss.Color("62")).
    Padding(0, 1)
    labelStyle = lipgloss.NewStyle().
    Foreground(lipgloss.Color("86"))
    errorStyle = lipgloss.NewStyle().
    Foreground(lipgloss.Color("196"))
    helpStyle = lipgloss.NewStyle().
    Foreground(lipgloss.Color("241"))
    helpDarkStyle = lipgloss.NewStyle().
    Foreground(lipgloss.Color("86"))
    commentBoxStyle = lipgloss.NewStyle().
    Border(lipgloss.RoundedBorder()).
    BorderForeground(lipgloss.Color("62")).
    Padding(0, 1)
    )
    // Model holds TUI state
    type Model struct {
    state *calls.ClassifyState
    err string
    quitting bool
    bindingsHelp string // pre-computed bindings text
    // Comment dialog state
    commentMode bool // true when comment dialog is open
    commentText string // current input text
    commentCursor int // cursor position in comment text
    // Clip dialog state
    clipMode bool // true when clip dialog is open
    clipInput string // current prefix input
    // Shift+primary wait mode: when non-empty, the next keypress is looked up
    // in Config.SecondaryBindings[awaitingSecondaryFor] as a calltype key.
    awaitingSecondaryFor string
    // Image generation counter - incremented on each segment change,
    // used to discard stale inline images (sixel/iTerm).
    // Pointer so it survives BubbleTea's value-copy update cycle.
    imageGen *uint64
    }
    // New creates a new TUI model
    func New(state *calls.ClassifyState) Model {
    bindingsHelp := buildBindingsHelp(state.Config.Bindings)
    gen := uint64(0)
    return Model{
    state: state,
    bindingsHelp: bindingsHelp,
    imageGen: &gen,
    }
    }
    // buildBindingsHelp creates a sorted, formatted string of key bindings.
    func buildBindingsHelp(bindings []calls.KeyBinding) string {
    sorted := make([]calls.KeyBinding, len(bindings))
    copy(sorted, bindings)
    sort.SliceStable(sorted, func(i, j int) bool {
    ri, rj := keyRank(sorted[i].Key), keyRank(sorted[j].Key)
    if ri != rj {
    return ri < rj
    }
    return sorted[i].Key < sorted[j].Key
    })
    var parts []string
    for _, b := range sorted {
    if b.CallType != "" {
    parts = append(parts, fmt.Sprintf("%s=%s/%s", b.Key, b.Species, b.CallType))
    } else {
    parts = append(parts, fmt.Sprintf("%s=%s", b.Key, b.Species))
    }
    }
    return strings.Join(parts, " ")
    }
    // keyRank returns sort priority for a key: a-z (0), A-Z (1), 0-9 (2), other (3).
    func keyRank(k string) int {
    if len(k) == 0 {
    return 3
    }
    c := k[0]
    switch {
    case c >= 'a' && c <= 'z':
    return 0
    case c >= 'A' && c <= 'Z':
    return 1
    case c >= '0' && c <= '9':
    return 2
    default:
    return 3
    }
    }
    func (m Model) protocol() utils.ImageProtocol {
    if m.state.Config.ITerm {
    return utils.ProtocolITerm
    }
    if m.state.Config.Sixel {
    return utils.ProtocolSixel
    }
    return utils.ProtocolKitty
    }
    // Init initializes the model
    func (m Model) Init() tea.Cmd {
    return inlineImageCmd(m.state, m.protocol(), *m.imageGen, m.imageGen)
    }
    // wrapText wraps text at word boundaries to fit within maxWidth.
    // Returns multiple lines joined with newlines.
    func wrapText(text string, maxWidth int) string {
    if len(text) <= maxWidth {
    return text
    }
    lines := strings.Split(text, "\n")
    var result []string
    for _, line := range lines {
    if len(line) <= maxWidth {
    result = append(result, line)
    continue
    }
    // Wrap at word boundaries
    words := strings.Fields(line)
    var currentLine string
    for _, word := range words {
    if len(currentLine)+len(word)+1 <= maxWidth {
    if currentLine == "" {
    currentLine = word
    } else {
    currentLine += " " + word
    }
    } else {
    if currentLine != "" {
    result = append(result, currentLine)
    }
    // If single word is longer than maxWidth, force break it
    if len(word) > maxWidth {
    result = append(result, word[:maxWidth])
    word = word[maxWidth:]
    }
    currentLine = word
    }
    }
    if currentLine != "" {
    result = append(result, currentLine)
    }
    }
    return strings.Join(result, "\n")
    }
  • file addition: keymap.go (----------)
    [3.227139]
    package tui
    // Key binding help text for the TUI.
    const keyHelpText = "[esc]quit [,]prev [.]next [0]confirm [space]comment [ctrl+s]clip [ctrl+d]bookmark [ctrl+,]prev-bk [ctrl+.]next-bk [enter]play [shift+enter]½speed"
    // Comment dialog help text.
    const commentHelpText = "[enter]save [esc]cancel [←→]move [ctrl+u]clear [ctrl+a]start [ctrl+e]end"
    // Clip dialog help text.
    const clipHelpText = "[enter]save [esc]cancel"
    // Comment text limits.
    const (
    maxCommentLength = 140
    maxClipPrefixLength = 64
    )
  • file addition: clip.go (----------)
    [3.227139]
    package tui
    import (
    "fmt"
    "image"
    "os"
    "path/filepath"
    "strings"
    "skraak/tools/calls"
    "skraak/utils"
    )
    // saveClip saves a clip of the current segment to the current working directory
    func 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 image
    resized, err := generateClipSpectrogram(segSamples, outputSampleRate)
    if err != nil {
    return err
    }
    // Write output files
    if 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 configured
    if 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 default
    if 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 play
    if 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 = speed
    state.Player.PlayAtSpeed(segSamples, playSampleRate, speed)
    }
    return ""
    }
  • file addition: classify_test.go (----------)
    [3.227139]
    package tui
    import (
    "testing"
    tea "charm.land/bubbletea/v2"
    "skraak/tools/calls"
    )
    func TestWrapText(t *testing.T) {
    tests := []struct {
    name string
    input string
    maxWidth int
    want string
    }{
    {
    name: "short text unchanged",
    input: "hello world",
    maxWidth: 20,
    want: "hello world",
    },
    {
    name: "wraps at word boundary",
    input: "hello world this is a test",
    maxWidth: 10,
    want: "hello\nworld this\nis a test",
    },
    {
    name: "handles multiple lines",
    input: "hello world\nfoo bar baz",
    maxWidth: 8,
    want: "hello\nworld\nfoo bar\nbaz",
    },
    {
    name: "empty string",
    input: "",
    maxWidth: 10,
    want: "",
    },
    {
    name: "single long word",
    input: "abcdefghijklmnopqrstuvwxyz",
    maxWidth: 10,
    want: "abcdefghij\nklmnopqrstuvwxyz",
    },
    }
    for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
    got := wrapText(tt.input, tt.maxWidth)
    if got != tt.want {
    t.Errorf("wrapText() = %q, want %q", got, tt.want)
    }
    })
    }
    }
    func TestKeyRank(t *testing.T) {
    tests := []struct {
    key string
    want int
    }{
    {"a", 0},
    {"z", 0},
    {"m", 0},
    {"A", 1},
    {"Z", 1},
    {"0", 2},
    {"9", 2},
    {"!", 3},
    {"@", 3},
    {"", 3},
    // Note: keyRank checks first char only, so "ab" ranks as 'a' (lowercase = 0)
    {"ab", 0},
    }
    for _, tt := range tests {
    t.Run(tt.key, func(t *testing.T) {
    if got := keyRank(tt.key); got != tt.want {
    t.Errorf("keyRank(%q) = %d, want %d", tt.key, got, tt.want)
    }
    })
    }
    }
    func TestBuildBindingsHelp(t *testing.T) {
    tests := []struct {
    name string
    bindings []calls.KeyBinding
    want string
    }{
    {
    name: "empty bindings",
    bindings: nil,
    want: "",
    },
    {
    name: "single binding species only",
    bindings: []calls.KeyBinding{
    {Key: "k", Species: "Kiwi"},
    },
    want: "k=Kiwi",
    },
    {
    name: "single binding with calltype",
    bindings: []calls.KeyBinding{
    {Key: "k", Species: "Kiwi", CallType: "Male"},
    },
    want: "k=Kiwi/Male",
    },
    {
    name: "multiple bindings sorted a-z then 0-9",
    bindings: []calls.KeyBinding{
    {Key: "9", Species: "Nine"},
    {Key: "a", Species: "Alpha"},
    {Key: "1", Species: "One"},
    {Key: "b", Species: "Beta"},
    },
    want: "a=Alpha b=Beta 1=One 9=Nine",
    },
    }
    for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
    got := buildBindingsHelp(tt.bindings)
    if got != tt.want {
    t.Errorf("buildBindingsHelp() = %q, want %q", got, tt.want)
    }
    })
    }
    }
    func TestHandleCommentKeyCode(t *testing.T) {
    tests := []struct {
    name string
    initialText string
    initialCursor int
    keyCode rune
    wantText string
    wantCursor int
    wantHandled bool
    }{
    {
    name: "left arrow moves cursor",
    initialText: "hello",
    initialCursor: 3,
    keyCode: tea.KeyLeft,
    wantText: "hello",
    wantCursor: 2,
    wantHandled: true,
    },
    {
    name: "left arrow at start does nothing",
    initialText: "hello",
    initialCursor: 0,
    keyCode: tea.KeyLeft,
    wantText: "hello",
    wantCursor: 0,
    wantHandled: true,
    },
    {
    name: "right arrow moves cursor",
    initialText: "hello",
    initialCursor: 2,
    keyCode: tea.KeyRight,
    wantText: "hello",
    wantCursor: 3,
    wantHandled: true,
    },
    {
    name: "right arrow at end does nothing",
    initialText: "hello",
    initialCursor: 5,
    keyCode: tea.KeyRight,
    wantText: "hello",
    wantCursor: 5,
    wantHandled: true,
    },
    {
    name: "backspace deletes char before cursor",
    initialText: "hello",
    initialCursor: 3,
    keyCode: tea.KeyBackspace,
    wantText: "helo",
    wantCursor: 2,
    wantHandled: true,
    },
    {
    name: "backspace at start does nothing",
    initialText: "hello",
    initialCursor: 0,
    keyCode: tea.KeyBackspace,
    wantText: "hello",
    wantCursor: 0,
    wantHandled: true,
    },
    {
    name: "delete removes char at cursor",
    initialText: "hello",
    initialCursor: 2,
    keyCode: tea.KeyDelete,
    wantText: "helo",
    wantCursor: 2,
    wantHandled: true,
    },
    {
    name: "delete at end does nothing",
    initialText: "hello",
    initialCursor: 5,
    keyCode: tea.KeyDelete,
    wantText: "hello",
    wantCursor: 5,
    wantHandled: true,
    },
    {
    name: "space inserts space",
    initialText: "hello",
    initialCursor: 2,
    keyCode: tea.KeySpace,
    wantText: "he llo",
    wantCursor: 3,
    wantHandled: true,
    },
    {
    name: "space respects max length",
    initialText: string(make([]byte, maxCommentLength)),
    initialCursor: 10,
    keyCode: tea.KeySpace,
    wantText: string(make([]byte, maxCommentLength)),
    wantCursor: 10,
    wantHandled: true,
    },
    }
    for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
    m := &Model{
    commentText: tt.initialText,
    commentCursor: tt.initialCursor,
    }
    key := tea.Key{Code: tt.keyCode}
    gotHandled := m.handleCommentKeyCode(key)
    if gotHandled != tt.wantHandled {
    t.Errorf("handleCommentKeyCode() handled = %v, want %v", gotHandled, tt.wantHandled)
    }
    if m.commentText != tt.wantText {
    t.Errorf("commentText = %q, want %q", m.commentText, tt.wantText)
    }
    if m.commentCursor != tt.wantCursor {
    t.Errorf("commentCursor = %d, want %d", m.commentCursor, tt.wantCursor)
    }
    })
    }
    }
    func TestHandleCommentCtrl(t *testing.T) {
    tests := []struct {
    name string
    initialText string
    initialCursor int
    input string
    wantText string
    wantCursor int
    wantHandled bool
    }{
    {
    name: "ctrl+u clears text",
    initialText: "hello world",
    initialCursor: 5,
    input: "ctrl+u",
    wantText: "",
    wantCursor: 0,
    wantHandled: true,
    },
    {
    name: "ctrl+a moves to start",
    initialText: "hello",
    initialCursor: 3,
    input: "ctrl+a",
    wantText: "hello",
    wantCursor: 0,
    wantHandled: true,
    },
    {
    name: "ctrl+e moves to end",
    initialText: "hello",
    initialCursor: 2,
    input: "ctrl+e",
    wantText: "hello",
    wantCursor: 5,
    wantHandled: true,
    },
    {
    name: "unknown ctrl combo not handled",
    initialText: "hello",
    initialCursor: 2,
    input: "ctrl+x",
    wantText: "hello",
    wantCursor: 2,
    wantHandled: false,
    },
    }
    for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
    m := &Model{
    commentText: tt.initialText,
    commentCursor: tt.initialCursor,
    }
    gotHandled := m.handleCommentCtrl(tt.input)
    if gotHandled != tt.wantHandled {
    t.Errorf("handleCommentCtrl() handled = %v, want %v", gotHandled, tt.wantHandled)
    }
    if m.commentText != tt.wantText {
    t.Errorf("commentText = %q, want %q", m.commentText, tt.wantText)
    }
    if m.commentCursor != tt.wantCursor {
    t.Errorf("commentCursor = %d, want %d", m.commentCursor, tt.wantCursor)
    }
    })
    }
    }
    func TestMaxCommentLength(t *testing.T) {
    if maxCommentLength != 140 {
    t.Errorf("maxCommentLength = %d, want 140", maxCommentLength)
    }
    }
    func TestMaxClipPrefixLength(t *testing.T) {
    if maxClipPrefixLength != 64 {
    t.Errorf("maxClipPrefixLength = %d, want 64", maxClipPrefixLength)
    }
    }