YVFPP5VJJSR4EVGOMB5565IZFYAVFNRH37L6AXEUFU5AP6CL7DJAC NUOFNUIQIKWOBFHMJXY2K4AJGIROGCPFK5NJHR6Y6HYHHCOJPVCAC KZKLAINJJWZ64T5MUZT34LJVQIKBTKZ6EJGD7C7TTSSDGCHEDPMAC 3DVPQOKB6BX63XSBIYYCPWBL2RBG3LXZS3XPQBANJP2FWVRAOVZQC GVOVKH5R27K75VXGSZCP3X62FGNCSMDVFEKLR3LFXERFB54CHTUQC ZCCQ4P5T2AMJAPBDWZVHXIUKLI5U2E5GNDXRCWXEOJQRWPSJJFEQC KLUEQ6X5CXVBV3KLJKEHWQYHIU6AYPP2WT4PWKM2QZJ7SNACCJ6QC JAT3DXOLENZZGXE2NYFF3TVQAQIXMMNYO234ETKQGC2CRHJVZERQC P4CJMBYKB6LTJASFYF5FX4MHQW7TH7D6KLDLWBBLFKDHTWK5SZ6AC ZOSYO3IBH5SCB27UP642O2SGCWQ7ZTQJSZ3PXKZVFQP72S34U3IQC package tuiimport ("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 playingtype playbackTickMsg struct{}// Stylesvar (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 []stringfor _, line := range lines {if len(line) <= maxWidth {result = append(result, line)continue}// Wrap at word boundarieswords := strings.Fields(line)var currentLine stringfor _, 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 itif 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 statetype Model struct {err stringquitting boolbindingsHelp string // pre-computed bindings text// Comment dialog statecommentMode bool // true when comment dialog is opencommentText string // current input textcommentCursor int // cursor position in comment text// Clip dialog stateclipMode bool // true when clip dialog is openclipInput 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 0case c >= 'A' && c <= 'Z':return 1case c >= '0' && c <= '9':return 2default: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 []stringfor _, 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 modelfunc (m Model) Init() tea.Cmd {return inlineImageCmd(m.state, m.protocol(), *m.imageGen, m.imageGen)}// Update handles messagesfunc (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.imageGenreturn 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.awaitingSecondaryForm.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.0if 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 = truereturn true, m, tea.Quit}if key.Code == tea.KeySpace {m.commentText = m.state.GetCurrentComment()m.commentCursor = len(m.commentText)m.commentMode = truereturn true, m, nil}if msg.String() == "ctrl+s" {m.clipInput = ""m.clipMode = truereturn true, m, nil}switch msg.String() {case "ctrl+c":m.stopPlayer()m.quitting = truereturn m, tea.Quitcase ",", "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 := skey := 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 = lowerreturn 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 = truereturn m, tea.Quit}}// handleCommentKey handles key presses in comment modefunc (m Model) handleCommentKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {key := msg.Key()// Enter: save commentif key.Code == tea.KeyEnter {m.state.SetComment(m.commentText)if err := m.state.Save(); err != nil {m.err = err.Error()}m.commentMode = falsereturn m, nil}// Escape: cancelif key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {m.commentMode = falsereturn 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 truecase tea.KeyRight:if m.commentCursor < len(m.commentText) {m.commentCursor++}return truecase tea.KeySpace:if len(m.commentText) < 140 {m.commentText = m.commentText[:m.commentCursor] + " " + m.commentText[m.commentCursor:]m.commentCursor++}return truecase tea.KeyBackspace:if m.commentCursor > 0 {m.commentText = m.commentText[:m.commentCursor-1] + m.commentText[m.commentCursor:]m.commentCursor--}return truecase 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 = 0return truecase "ctrl+a":m.commentCursor = 0return truecase "ctrl+e":m.commentCursor = len(m.commentText)return true}return false}// handleClipKey handles key presses in clip modefunc (m Model) handleClipKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {key := msg.Key()// Enter: save clipif key.Code == tea.KeyEnter {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}// Escape: cancelif key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {m.clipMode = falsereturn m, nil}// Backspace: remove last characterif key.Code == tea.KeyBackspace {if len(m.clipInput) > 0 {m.clipInput = m.clipInput[:len(m.clipInput)-1]}return m, nil}// Printable characters: append to inputs := msg.String()if len(s) == 1 && s[0] >= 32 && s[0] <= 126 { // printable ASCIIif 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 directorydf := 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 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}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 defaultif sampleRate > utils.DefaultMaxSampleRate {segSamples = utils.ResampleRate(segSamples, sampleRate, utils.DefaultMaxSampleRate)sampleRate = utils.DefaultMaxSampleRatesegSamples, 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 playif 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 TUIfunc (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.Builderdf := 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 = 80b.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 := 30filled := 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 dialogfunc (m Model) renderCommentDialog(b *strings.Builder) {// Build input line with cursor at correct positionbefore := m.commentText[:m.commentCursor]after := m.commentText[m.commentCursor:]inputLine := before + "█" + aftercharCount := fmt.Sprintf("%d/140", len(m.commentText))helpLine := "[enter]save [esc]cancel [←→]move [ctrl+u]clear [ctrl+a]start [ctrl+e]end"// Render boxcontent := fmt.Sprintf("Comment:\n%s\n%s\n%s", inputLine, charCount, helpLine)b.WriteString(commentBoxStyle.Render(content))}// renderClipDialog renders the clip prefix input dialogfunc (m Model) renderClipDialog(b *strings.Builder) {inputLine := m.clipInput + "█"helpLine := "[enter]save [esc]cancel"// Render boxcontent := 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.ImageSizeif 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 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}}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.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)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 segmentfunc (m Model) renderLabels(b *strings.Builder, seg *utils.Segment) {}// renderSegmentInfo renders segment timing and playback statusfunc (m Model) renderSegmentInfo(b *strings.Builder, df *utils.DataFile, seg *utils.Segment) {}df := m.state.CurrentFile()// renderProgressBar renders the progress bar and file titlefunc (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())}// Errorif m.err != "" {b.WriteString(errorStyle.Render(m.err))}v := tea.NewView(b.String())v.AltScreen = truereturn v}// renderHeader renders the keybindings help textfunc (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 errsegSamples, outputSampleRate, err := loadFilteredSegment(state, df, seg)func saveClip(state *calls.ClassifyState, prefix string) error {return false}return m, nil}// Navigation and editing keysif handled := m.handleCommentKeyCode(key); handled {return m, nil}// Ctrl combosif 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"
package tuiimport ("fmt""strings"tea "charm.land/bubbletea/v2""skraak/tools/calls""skraak/utils")// View renders the TUIfunc (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.Builderdf := 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())}// Errorif m.err != "" {b.WriteString(errorStyle.Render(m.err))}v := tea.NewView(b.String())v.AltScreen = truereturn v}// renderHeader renders the keybindings help textfunc (m Model) renderHeader(b *strings.Builder) {const wrapWidth = 80b.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 titlefunc (m Model) renderProgressBar(b *strings.Builder, current, total int) {progress := float64(current) / float64(total)barWidth := 30filled := 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 statusfunc (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 segmentfunc (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 dialogfunc (m Model) renderCommentDialog(b *strings.Builder) {// Build input line with cursor at correct positionbefore := m.commentText[:m.commentCursor]after := m.commentText[m.commentCursor:]inputLine := before + "█" + aftercharCount := fmt.Sprintf("%d/%d", len(m.commentText), maxCommentLength)// Render boxcontent := fmt.Sprintf("Comment:\n%s\n%s\n%s", inputLine, charCount, commentHelpText)b.WriteString(commentBoxStyle.Render(content))}// renderClipDialog renders the clip prefix input dialogfunc (m Model) renderClipDialog(b *strings.Builder) {inputLine := m.clipInput + "█"// Render boxcontent := fmt.Sprintf("Clip prefix:\n%s\n%s", inputLine, clipHelpText)b.WriteString(commentBoxStyle.Render(content))}
package tuiimport ("strings""time"tea "charm.land/bubbletea/v2""skraak/tools/calls")// Update handles messagesfunc (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.imageGenreturn 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.awaitingSecondaryForm.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.0if 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 = truereturn true, m, tea.Quit}if key.Code == tea.KeySpace {m.commentText = m.state.GetCurrentComment()m.commentCursor = len(m.commentText)m.commentMode = truereturn true, m, nil}if msg.String() == "ctrl+s" {m.clipInput = ""m.clipMode = truereturn 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 = truereturn m, tea.Quitcase ",", "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 := skey := 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 = lowerreturn 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 = truereturn m, tea.Quit}return m, m.segmentChangeCmd()}// handleCommentKey handles key presses in comment modefunc (m Model) handleCommentKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {key := msg.Key()// Enter: save commentif key.Code == tea.KeyEnter {m.state.SetComment(m.commentText)if err := m.state.Save(); err != nil {m.err = err.Error()}m.commentMode = falsereturn m, nil}// Escape: cancelif key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {m.commentMode = falsereturn m, nil}// Navigation and editing keysif handled := m.handleCommentKeyCode(key); handled {return m, nil}// Ctrl combosif 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 truecase tea.KeyRight:if m.commentCursor < len(m.commentText) {m.commentCursor++}return truecase tea.KeySpace:if len(m.commentText) < maxCommentLength {m.commentText = m.commentText[:m.commentCursor] + " " + m.commentText[m.commentCursor:]m.commentCursor++}return truecase tea.KeyBackspace:if m.commentCursor > 0 {m.commentText = m.commentText[:m.commentCursor-1] + m.commentText[m.commentCursor:]m.commentCursor--}return truecase 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 = 0return truecase "ctrl+a":m.commentCursor = 0return truecase "ctrl+e":m.commentCursor = len(m.commentText)return true}return false}// handleClipKey handles key presses in clip modefunc (m Model) handleClipKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {key := msg.Key()// Enter: save clipif key.Code == tea.KeyEnter {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}// Escape: cancelif key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {m.clipMode = falsereturn m, nil}// Backspace: remove last characterif key.Code == tea.KeyBackspace {if len(m.clipInput) > 0 {m.clipInput = m.clipInput[:len(m.clipInput)-1]}return m, nil}// Printable characters: append to inputs := msg.String()if len(s) == 1 && s[0] >= 32 && s[0] <= 126 { // printable ASCIIif 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{}})}
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""sort""strings"tea "charm.land/bubbletea/v2""charm.land/lipgloss/v2""skraak/tools/calls""skraak/utils")// playbackTickMsg is sent every 50ms while audio is playingtype playbackTickMsg struct{}// Stylesvar (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 statetype Model struct {state *calls.ClassifyStateerr stringquitting boolbindingsHelp string // pre-computed bindings text// Comment dialog statecommentMode bool // true when comment dialog is opencommentText string // current input textcommentCursor int // cursor position in comment text// Clip dialog stateclipMode bool // true when clip dialog is openclipInput 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 modelfunc 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 []stringfor _, 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 0case c >= 'A' && c <= 'Z':return 1case c >= '0' && c <= '9':return 2default: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 modelfunc (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 []stringfor _, line := range lines {if len(line) <= maxWidth {result = append(result, line)continue}// Wrap at word boundarieswords := strings.Fields(line)var currentLine stringfor _, 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 itif len(word) > maxWidth {result = append(result, word[:maxWidth])word = word[maxWidth:]}currentLine = word}}if currentLine != "" {result = append(result, currentLine)}}return strings.Join(result, "\n")}
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 = 140maxClipPrefixLength = 64)
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 ""}
package tuiimport ("testing"tea "charm.land/bubbletea/v2""skraak/tools/calls")func TestWrapText(t *testing.T) {tests := []struct {name stringinput stringmaxWidth intwant 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 stringwant 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 stringbindings []calls.KeyBindingwant 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 stringinitialText stringinitialCursor intkeyCode runewantText stringwantCursor intwantHandled 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 stringinitialText stringinitialCursor intinput stringwantText stringwantCursor intwantHandled 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)}}