moved TUI-adjacent code from calls_classify.go to dedicated files within the same package

quietlight
May 19, 2026, 2:56 AM
4L2IWSLSDFB5BZWQYBHQCHGCMOKLB2QVONUYJ4OEJJG5N6XVYUPQC

Dependencies

  • [2] V2HX6HEB claude going nuts all over the place
  • [3] 3DVPQOKB big tidy up of tools/
  • [4] P4CJMBYK added first version of --bandpass flag to calls classify, work to do
  • [5] XU7FTYK3 third phase of utils refactor, wav/
  • [6] N57PNZPF second phase of utils refactor, audio/
  • [7] 3ETJ6KPI refactor of tui/ second iteration
  • [8] PXQDGTR5 fourth phase of utils refactor, spectrogram/

Change contents

  • file addition: classify_labels.go (----------)
    [3.67281]
    package calls
    import (
    "skraak/datafile"
    )
    // SetComment sets the comment on the current segment's filter label.
    // Returns the previous comment (for undo) or empty string if none.
    func (s *ClassifyState) SetComment(comment string) string {
    seg := s.CurrentSegment()
    if seg == nil {
    return ""
    }
    df := s.CurrentFile()
    if df == nil {
    return ""
    }
    // Set reviewer
    df.Meta.Reviewer = s.Config.Reviewer
    // Get labels matching filter
    filterLabels := seg.GetFilterLabels(s.Config.Filter)
    var oldComment string
    if len(filterLabels) == 0 {
    // No matching labels, add new one with comment
    label := &datafile.Label{
    Species: "Don't Know",
    Certainty: 0,
    Filter: s.Config.Filter,
    Comment: comment,
    }
    seg.Labels = append(seg.Labels, label)
    } else {
    // Set comment on first matching label
    oldComment = filterLabels[0].Comment
    filterLabels[0].Comment = comment
    }
    s.Dirty = true
    return oldComment
    }
    // GetCurrentComment returns the comment on the current segment's filter label.
    func (s *ClassifyState) GetCurrentComment() string {
    seg := s.CurrentSegment()
    if seg == nil {
    return ""
    }
    filterLabels := seg.GetFilterLabels(s.Config.Filter)
    if len(filterLabels) == 0 {
    return ""
    }
    return filterLabels[0].Comment
    }
    // ConfirmLabel upgrades the current segment's existing filter label certainty
    // to 100. Returns true if a write is needed (label existed and was below 100).
    // Returns false for Don't Know (certainty=0) — confirming a Don't Know is a no-op;
    // the caller should just advance to the next segment.
    func (s *ClassifyState) ConfirmLabel() bool {
    seg := s.CurrentSegment()
    if seg == nil {
    return false
    }
    filterLabels := seg.GetFilterLabels(s.Config.Filter)
    if len(filterLabels) == 0 {
    return false
    }
    if filterLabels[0].Certainty == 0 {
    return false
    }
    if filterLabels[0].Certainty == 100 {
    return false
    }
    df := s.CurrentFile()
    if df == nil {
    return false
    }
    df.Meta.Reviewer = s.Config.Reviewer
    filterLabels[0].Certainty = 100
    s.Dirty = true
    return true
    }
  • file addition: classify_io.go (----------)
    [3.67281]
    package calls
    import (
    "fmt"
    "skraak/audio"
    "skraak/datafile"
    "skraak/spectrogram"
    "skraak/wav"
    )
    // LoadFilteredSegment reads, bandpass-shifts, and downsamples a segment's audio.
    // Returns samples and effective sample rate after any filtering/downsampling.
    // This is a TUI operation for audio playback.
    func (s *ClassifyState) LoadFilteredSegment(df *datafile.DataFile, seg *datafile.Segment) ([]float64, int, error) {
    wavPath := df.FilePath[:len(df.FilePath)-5] // strip ".data"
    segSamples, sampleRate, err := wav.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 s.Config.BandpassLow > 0 || s.Config.BandpassHigh > 0 {
    segSamples, sampleRate = audio.BandpassShiftFilter(segSamples, sampleRate, s.Config.BandpassLow, s.Config.BandpassHigh)
    return segSamples, sampleRate, nil
    }
    // No bandpass: downsample if sample rate exceeds default
    if sampleRate > audio.DefaultMaxSampleRate {
    segSamples = audio.ResampleRate(segSamples, sampleRate, audio.DefaultMaxSampleRate)
    sampleRate = audio.DefaultMaxSampleRate
    }
    return segSamples, sampleRate, nil
    }
    // SaveClip saves a spectrogram PNG and WAV of the current segment to outputDir.
    // The prefix is prepended to the filename.
    // This is a TUI operation for exporting clips.
    func (s *ClassifyState) SaveClip(outputDir, prefix string) ([]string, error) {
    df := s.CurrentFile()
    seg := s.CurrentSegment()
    if df == nil || seg == nil {
    return nil, fmt.Errorf("no segment selected")
    }
    basename := spectrogram.WAVBasename(df.FilePath)
    pngPath, wavPath, err := spectrogram.ClipPaths(outputDir, prefix, basename, seg.StartTime, seg.EndTime)
    if err != nil {
    return nil, err
    }
    segSamples, sampleRate, err := s.LoadFilteredSegment(df, seg)
    if err != nil {
    return nil, err
    }
    // Generate spectrogram image (always color, 224px for clips)
    img := spectrogram.SpectrogramImageFromSamples(segSamples, sampleRate, true, 224)
    if img == nil {
    return nil, fmt.Errorf("failed to generate spectrogram")
    }
    if err := spectrogram.WritePNGFile(pngPath, img); err != nil {
    return nil, err
    }
    if err := wav.WriteWAVFile(wavPath, segSamples, sampleRate); err != nil {
    return nil, fmt.Errorf("failed to write WAV: %w", err)
    }
    return []string{pngPath, wavPath}, nil
    }
    // PlaySegmentAtSpeed loads and plays the current segment's audio at the given speed.
    // speed=1.0 is normal, speed=0.5 is half speed.
    // Returns an error message string, or empty string on success.
    // This is a TUI operation for audio playback.
    func (s *ClassifyState) PlaySegmentAtSpeed(speed float64) string {
    df := s.CurrentFile()
    seg := s.CurrentSegment()
    if df == nil || seg == nil {
    return ""
    }
    segSamples, playSampleRate, err := s.LoadFilteredSegment(df, seg)
    if err != nil {
    return fmt.Sprintf("audio: %v", err)
    }
    // Initialize player lazily on first play
    if s.Player == nil {
    player, err := audio.NewAudioPlayer(playSampleRate)
    if err != nil {
    return fmt.Sprintf("audio init: %v", err)
    }
    s.Player = player
    }
    if len(segSamples) > 0 {
    s.PlaybackSpeed = speed
    s.Player.PlayAtSpeed(segSamples, playSampleRate, speed)
    }
    return ""
    }
  • file addition: classify_format.go (----------)
    [3.67281]
    package calls
    import (
    "fmt"
    "strings"
    "skraak/datafile"
    )
    // FormatLabels formats labels for display in TUI.
    // If filter is non-empty, only labels matching that filter are shown.
    func FormatLabels(labels []*datafile.Label, filter string) string {
    var parts []string
    for _, l := range labels {
    if filter != "" && l.Filter != filter {
    continue
    }
    part := l.Species
    if l.CallType != "" {
    part += "/" + l.CallType
    }
    part += fmt.Sprintf(" (%d%%)", l.Certainty)
    if l.Filter != "" {
    part += " [" + l.Filter + "]"
    }
    if l.Comment != "" {
    part += fmt.Sprintf(" \"%s\"", l.Comment)
    }
    parts = append(parts, part)
    }
    return strings.Join(parts, ", ")
    }
  • file addition: classify_bookmarks.go (----------)
    [3.67281]
    package calls
    import (
    "skraak/datafile"
    )
    // getFilterLabel returns the label matching the current filter, or first label if no filter.
    func (s *ClassifyState) getFilterLabel(seg *datafile.Segment) *datafile.Label {
    if s.Config.Filter == "" {
    if len(seg.Labels) > 0 {
    return seg.Labels[0]
    }
    return nil
    }
    for _, label := range seg.Labels {
    if label.Filter == s.Config.Filter {
    return label
    }
    }
    return nil
    }
    // getOrCreateFilterLabel gets existing label or creates new one for the current filter.
    func (s *ClassifyState) getOrCreateFilterLabel(seg *datafile.Segment) *datafile.Label {
    label := s.getFilterLabel(seg)
    if label != nil {
    return label
    }
    // Create new label
    label = &datafile.Label{
    Species: "Don't Know",
    Certainty: 0,
    Filter: s.Config.Filter,
    }
    seg.Labels = append(seg.Labels, label)
    s.Dirty = true
    return label
    }
    // HasBookmark returns true if current segment has a bookmark on the filter label.
    func (s *ClassifyState) HasBookmark() bool {
    seg := s.CurrentSegment()
    if seg == nil {
    return false
    }
    label := s.getFilterLabel(seg)
    return label != nil && label.Bookmark
    }
    // ToggleBookmark toggles the bookmark on the current segment's filter label.
    func (s *ClassifyState) ToggleBookmark() {
    seg := s.CurrentSegment()
    if seg == nil {
    return
    }
    df := s.CurrentFile()
    if df == nil {
    return
    }
    // Set reviewer
    df.Meta.Reviewer = s.Config.Reviewer
    label := s.getOrCreateFilterLabel(seg)
    label.Bookmark = !label.Bookmark
    s.Dirty = true
    }
    // NextBookmark navigates to the next bookmark, wrapping around if needed.
    // Returns false if no bookmarks found (back at start position).
    func (s *ClassifyState) NextBookmark() bool {
    startFile := s.FileIdx
    startSeg := s.SegmentIdx
    first := true
    for {
    // Advance to next segment
    if !s.NextSegment() {
    // Wrap to start of folder
    s.FileIdx = 0
    s.SegmentIdx = 0
    }
    // Check if we've looped back to start
    if !first && s.FileIdx == startFile && s.SegmentIdx == startSeg {
    return false // full circle, no bookmark found
    }
    first = false
    // Check if current segment has bookmark
    if s.hasFilterBookmark() {
    return true
    }
    }
    }
    // PrevBookmark navigates to the previous bookmark, wrapping around if needed.
    // Returns false if no bookmarks found (back at start position).
    func (s *ClassifyState) PrevBookmark() bool {
    startFile := s.FileIdx
    startSeg := s.SegmentIdx
    first := true
    for {
    // Move to previous segment
    if !s.PrevSegment() {
    // Wrap to end of folder
    s.FileIdx = len(s.DataFiles) - 1
    segs := s.filteredSegs[s.FileIdx]
    s.SegmentIdx = max(len(segs)-1, 0)
    }
    // Check if we've looped back to start
    if !first && s.FileIdx == startFile && s.SegmentIdx == startSeg {
    return false // full circle, no bookmark found
    }
    first = false
    // Check if current segment has bookmark
    if s.hasFilterBookmark() {
    return true
    }
    }
    }
    // hasFilterBookmark checks if current segment has bookmark on filter-matching label.
    func (s *ClassifyState) hasFilterBookmark() bool {
    seg := s.CurrentSegment()
    if seg == nil {
    return false
    }
    label := s.getFilterLabel(seg)
    return label != nil && label.Bookmark
    }
  • file addition: classify_bindings.go (----------)
    [3.67281]
    package calls
    import (
    "slices"
    "sort"
    "skraak/datafile"
    )
    // KeyBinding maps a key to a species/calltype for TUI classification.
    type KeyBinding struct {
    Key string // single char: "k", "n", "p"
    Species string // "Kiwi", "Don't Know", "Morepork"
    CallType string // "Duet", "Female", "Male" (optional)
    }
    // BindingResult represents parsed key result for TUI classification.
    type BindingResult struct {
    Species string
    CallType string // empty string = remove calltype
    }
    // ParseKeyBuffer parses a single key into binding result.
    // Returns nil if no matching binding is found.
    func (s *ClassifyState) ParseKeyBuffer(key string) *BindingResult {
    for _, b := range s.Config.Bindings {
    if b.Key == key {
    return &BindingResult{
    Species: b.Species,
    CallType: b.CallType,
    }
    }
    }
    return nil
    }
    // ApplyBinding applies a binding result to the current segment.
    // This is a TUI operation that modifies the segment's labels.
    func (s *ClassifyState) ApplyBinding(result *BindingResult) {
    seg := s.CurrentSegment()
    if seg == nil {
    return
    }
    df := s.CurrentFile()
    if df == nil {
    return
    }
    // Set reviewer
    df.Meta.Reviewer = s.Config.Reviewer
    // Get labels matching filter
    filterLabels := seg.GetFilterLabels(s.Config.Filter)
    // Determine certainty: 0 for Don't Know, 100 for others
    certainty := 100
    if result.Species == "Don't Know" {
    certainty = 0
    }
    if len(filterLabels) == 0 {
    // No matching labels, add new one
    seg.Labels = append(seg.Labels, &datafile.Label{
    Species: result.Species,
    Certainty: certainty,
    Filter: s.Config.Filter,
    CallType: result.CallType,
    })
    } else {
    // Edit first matching label, remove rest
    filterLabels[0].Species = result.Species
    filterLabels[0].Certainty = certainty
    filterLabels[0].CallType = result.CallType // always set (empty = remove)
    // Remove extra matching labels
    if len(filterLabels) > 1 {
    var newLabels []*datafile.Label
    for _, l := range seg.Labels {
    keep := !slices.Contains(filterLabels[1:], l)
    if keep {
    newLabels = append(newLabels, l)
    }
    }
    seg.Labels = newLabels
    }
    }
    // Re-sort labels
    sort.Slice(seg.Labels, func(i, j int) bool {
    return seg.Labels[i].Species < seg.Labels[j].Species
    })
    s.Dirty = true
    }
    // ApplyCallTypeOnly sets the CallType on the current segment's first
    // filter-matching label. Used after a Shift+primary keypress labeled the
    // species and we now receive the secondary key for the calltype.
    // No-op if there is no matching label to update.
    func (s *ClassifyState) ApplyCallTypeOnly(callType string) {
    seg := s.CurrentSegment()
    if seg == nil {
    return
    }
    df := s.CurrentFile()
    if df == nil {
    return
    }
    filterLabels := seg.GetFilterLabels(s.Config.Filter)
    if len(filterLabels) == 0 {
    return
    }
    df.Meta.Reviewer = s.Config.Reviewer
    filterLabels[0].CallType = callType
    s.Dirty = true
    }
    // HasSecondary reports whether the given primary key has any secondary
    // (calltype) bindings configured.
    func (s *ClassifyState) HasSecondary(primaryKey string) bool {
    return len(s.Config.SecondaryBindings[primaryKey]) > 0
    }
  • edit in tools/calls/calls_classify.go at line 8
    [3.292135][3.292135:292145]()
    "slices"
  • edit in tools/calls/calls_classify.go at line 14
    [2.80733][3.49681:49703](),[3.25836][3.49681:49703](),[3.292189][3.61827:61841]()
    "skraak/spectrogram"
    "skraak/wav"
  • replacement in tools/calls/calls_classify.go at line 16
    [3.292192][3.292192:292422]()
    // KeyBinding maps a key to a species/calltype
    type KeyBinding struct {
    Key string // single char: "k", "n", "p"
    Species string // "Kiwi", "Don't Know", "Morepork"
    CallType string // "Duet", "Female", "Male" (optional)
    }
    [3.292192]
    [3.292422]
    // KeyBinding maps a key to a species/calltype for TUI classification.
    // See classify_bindings.go for KeyBinding type and related methods.
  • replacement in tools/calls/calls_classify.go at line 19
    [3.292423][3.292423:292484]()
    // ClassifyConfig holds the configuration for classification
    [3.292423]
    [3.292484]
    // ClassifyConfig holds the configuration for classification.
    //
    // TUI-only fields (used only by the classify TUI, not CLI tools):
    // - Color: enable color output for spectrograms
    // - Sixel: use Sixel graphics protocol
    // - ITerm: use iTerm2 inline image protocol
    // - ImageSize: spectrogram display size in pixels
    // - Bindings: keybindings for species/calltype assignment
    // - SecondaryBindings: secondary keybindings for calltype after species
    // - BandpassLow/BandpassHigh: bandpass filter for audio playback
    // - Night/Day: time-of-day filters for file selection
    // - Lat/Lng/Timezone: location data for astronomical calculations
    //
    // These fields are ignored by CLI tools that use ClassifyConfig purely
    // for file/segment filtering (Filter, Species, CallType, Certainty, Sample, Goto).
  • edit in tools/calls/calls_classify.go at line 74
    [3.294039][3.294039:294184]()
    }
    // BindingResult represents parsed key result
    type BindingResult struct {
    Species string
    CallType string // empty string = remove calltype
  • edit in tools/calls/calls_classify.go at line 355
    [3.302124][3.302124:302973](),[3.302973][2.82236:82264](),[2.82264][3.302998:303633](),[3.302998][3.302998:303633]()
    }
    // ParseKeyBuffer parses a single key into binding result
    func (s *ClassifyState) ParseKeyBuffer(key string) *BindingResult {
    for _, b := range s.Config.Bindings {
    if b.Key == key {
    return &BindingResult{
    Species: b.Species,
    CallType: b.CallType,
    }
    }
    }
    return nil
    }
    // SetComment sets the comment on the current segment's filter label.
    // Returns the previous comment (for undo) or empty string if none.
    func (s *ClassifyState) SetComment(comment string) string {
    seg := s.CurrentSegment()
    if seg == nil {
    return ""
    }
    df := s.CurrentFile()
    if df == nil {
    return ""
    }
    // Set reviewer
    df.Meta.Reviewer = s.Config.Reviewer
    // Get labels matching filter
    filterLabels := seg.GetFilterLabels(s.Config.Filter)
    var oldComment string
    if len(filterLabels) == 0 {
    // No matching labels, add new one with comment
    label := &datafile.Label{
    Species: "Don't Know",
    Certainty: 0,
    Filter: s.Config.Filter,
    Comment: comment,
    }
    seg.Labels = append(seg.Labels, label)
    } else {
    // Set comment on first matching label
    oldComment = filterLabels[0].Comment
    filterLabels[0].Comment = comment
    }
    s.Dirty = true
    return oldComment
    }
    // GetCurrentComment returns the comment on the current segment's filter label.
    func (s *ClassifyState) GetCurrentComment() string {
    seg := s.CurrentSegment()
    if seg == nil {
    return ""
    }
    filterLabels := seg.GetFilterLabels(s.Config.Filter)
    if len(filterLabels) == 0 {
    return ""
    }
    return filterLabels[0].Comment
  • edit in tools/calls/calls_classify.go at line 357
    [3.303636][3.303636:304212](),[3.304212][2.82265:82316](),[2.82316][3.304260:304659](),[3.304260][3.304260:304659](),[3.304659][2.82317:82352](),[2.82352][3.304691:306643](),[3.304691][3.304691:306643]()
    // ApplyBinding applies a binding result to the current segment
    func (s *ClassifyState) ApplyBinding(result *BindingResult) {
    seg := s.CurrentSegment()
    if seg == nil {
    return
    }
    df := s.CurrentFile()
    if df == nil {
    return
    }
    // Set reviewer
    df.Meta.Reviewer = s.Config.Reviewer
    // Get labels matching filter
    filterLabels := seg.GetFilterLabels(s.Config.Filter)
    // Determine certainty: 0 for Don't Know, 100 for others
    certainty := 100
    if result.Species == "Don't Know" {
    certainty = 0
    }
    if len(filterLabels) == 0 {
    // No matching labels, add new one
    seg.Labels = append(seg.Labels, &datafile.Label{
    Species: result.Species,
    Certainty: certainty,
    Filter: s.Config.Filter,
    CallType: result.CallType,
    })
    } else {
    // Edit first matching label, remove rest
    filterLabels[0].Species = result.Species
    filterLabels[0].Certainty = certainty
    filterLabels[0].CallType = result.CallType // always set (empty = remove)
    // Remove extra matching labels
    if len(filterLabels) > 1 {
    var newLabels []*datafile.Label
    for _, l := range seg.Labels {
    keep := !slices.Contains(filterLabels[1:], l)
    if keep {
    newLabels = append(newLabels, l)
    }
    }
    seg.Labels = newLabels
    }
    }
    // Re-sort labels
    sort.Slice(seg.Labels, func(i, j int) bool {
    return seg.Labels[i].Species < seg.Labels[j].Species
    })
    s.Dirty = true
    }
    // ApplyCallTypeOnly sets the CallType on the current segment's first
    // filter-matching label. Used after a Shift+primary keypress labeled the
    // species and we now receive the secondary key for the calltype.
    // No-op if there is no matching label to update.
    func (s *ClassifyState) ApplyCallTypeOnly(callType string) {
    seg := s.CurrentSegment()
    if seg == nil {
    return
    }
    df := s.CurrentFile()
    if df == nil {
    return
    }
    filterLabels := seg.GetFilterLabels(s.Config.Filter)
    if len(filterLabels) == 0 {
    return
    }
    df.Meta.Reviewer = s.Config.Reviewer
    filterLabels[0].CallType = callType
    s.Dirty = true
    }
    // HasSecondary reports whether the given primary key has any secondary
    // (calltype) bindings configured.
    func (s *ClassifyState) HasSecondary(primaryKey string) bool {
    return len(s.Config.SecondaryBindings[primaryKey]) > 0
    }
    // ConfirmLabel upgrades the current segment's existing filter label certainty
    // to 100. Returns true if a write is needed (label existed and was below 100).
    // Returns false for Don't Know (certainty=0) — confirming a Don't Know is a no-op;
    // the caller should just advance to the next segment.
    func (s *ClassifyState) ConfirmLabel() bool {
    seg := s.CurrentSegment()
    if seg == nil {
    return false
    }
    filterLabels := seg.GetFilterLabels(s.Config.Filter)
    if len(filterLabels) == 0 {
    return false
    }
    if filterLabels[0].Certainty == 0 {
    return false
    }
    if filterLabels[0].Certainty == 100 {
    return false
    }
    df := s.CurrentFile()
    if df == nil {
    return false
    }
    df.Meta.Reviewer = s.Config.Reviewer
    filterLabels[0].Certainty = 100
    s.Dirty = true
    return true
    }
  • edit in tools/calls/calls_classify.go at line 374
    [3.306882][3.306882:306991](),[3.306991][2.82353:82433](),[2.82433][3.307065:307262](),[3.307065][3.307065:307262]()
    return nil
    }
    // getFilterLabel returns the label matching the current filter, or first label if no filter.
    func (s *ClassifyState) getFilterLabel(seg *datafile.Segment) *datafile.Label {
    if s.Config.Filter == "" {
    if len(seg.Labels) > 0 {
    return seg.Labels[0]
    }
    return nil
    }
    for _, label := range seg.Labels {
    if label.Filter == s.Config.Filter {
    return label
    }
    }
  • edit in tools/calls/calls_classify.go at line 376
    [3.307276][3.307276:307366](),[3.307366][2.82434:82522](),[2.82522][3.307448:307538](),[3.307448][3.307448:307538](),[3.307538][2.82523:82549](),[2.82549][3.307561:310068](),[3.307561][3.307561:310068](),[3.310068][2.82550:82618](),[2.82618][3.310133:310561](),[3.310133][3.310133:310561](),[3.310561][3.9938:10100](),[3.10100][2.82619:82735](),[2.82735][3.10210:10272](),[3.10210][3.10210:10272](),[3.10272][3.61842:61937](),[3.61937][3.10369:10642](),[3.10369][3.10369:10642](),[3.10642][3.25876:25998](),[3.25998][3.10764:10864](),[3.10764][3.10764:10864](),[3.10864][3.25999:26173](),[3.26173][3.11038:11416](),[3.11038][3.11038:11416](),[3.11416][3.49704:49859](),[3.49859][3.11559:11763](),[3.11559][3.11559:11763](),[3.11763][3.49860:49943](),[3.49943][3.11840:11920](),[3.11840][3.11840:11920](),[3.11920][3.49944:50008](),[3.50008][3.11978:11999](),[3.11978][3.11978:11999](),[3.11999][3.61938:62013](),[3.62013][3.12076:12734](),[3.12076][3.12076:12734](),[3.12734][3.26174:26228](),[3.26228][3.12788:13005](),[3.12788][3.12788:13005]()
    // getOrCreateFilterLabel gets existing label or creates new one for the current filter.
    func (s *ClassifyState) getOrCreateFilterLabel(seg *datafile.Segment) *datafile.Label {
    label := s.getFilterLabel(seg)
    if label != nil {
    return label
    }
    // Create new label
    label = &datafile.Label{
    Species: "Don't Know",
    Certainty: 0,
    Filter: s.Config.Filter,
    }
    seg.Labels = append(seg.Labels, label)
    s.Dirty = true
    return label
    }
    // HasBookmark returns true if current segment has a bookmark on the filter label.
    func (s *ClassifyState) HasBookmark() bool {
    seg := s.CurrentSegment()
    if seg == nil {
    return false
    }
    label := s.getFilterLabel(seg)
    return label != nil && label.Bookmark
    }
    // ToggleBookmark toggles the bookmark on the current segment's filter label.
    func (s *ClassifyState) ToggleBookmark() {
    seg := s.CurrentSegment()
    if seg == nil {
    return
    }
    df := s.CurrentFile()
    if df == nil {
    return
    }
    // Set reviewer
    df.Meta.Reviewer = s.Config.Reviewer
    label := s.getOrCreateFilterLabel(seg)
    label.Bookmark = !label.Bookmark
    s.Dirty = true
    }
    // NextBookmark navigates to the next bookmark, wrapping around if needed.
    // Returns false if no bookmarks found (back at start position).
    func (s *ClassifyState) NextBookmark() bool {
    startFile := s.FileIdx
    startSeg := s.SegmentIdx
    first := true
    for {
    // Advance to next segment
    if !s.NextSegment() {
    // Wrap to start of folder
    s.FileIdx = 0
    s.SegmentIdx = 0
    }
    // Check if we've looped back to start
    if !first && s.FileIdx == startFile && s.SegmentIdx == startSeg {
    return false // full circle, no bookmark found
    }
    first = false
    // Check if current segment has bookmark
    if s.hasFilterBookmark() {
    return true
    }
    }
    }
    // PrevBookmark navigates to the previous bookmark, wrapping around if needed.
    // Returns false if no bookmarks found (back at start position).
    func (s *ClassifyState) PrevBookmark() bool {
    startFile := s.FileIdx
    startSeg := s.SegmentIdx
    first := true
    for {
    // Move to previous segment
    if !s.PrevSegment() {
    // Wrap to end of folder
    s.FileIdx = len(s.DataFiles) - 1
    segs := s.filteredSegs[s.FileIdx]
    s.SegmentIdx = max(len(segs)-1, 0)
    }
    // Check if we've looped back to start
    if !first && s.FileIdx == startFile && s.SegmentIdx == startSeg {
    return false // full circle, no bookmark found
    }
    first = false
    // Check if current segment has bookmark
    if s.hasFilterBookmark() {
    return true
    }
    }
    }
    // hasFilterBookmark checks if current segment has bookmark on filter-matching label.
    func (s *ClassifyState) hasFilterBookmark() bool {
    seg := s.CurrentSegment()
    if seg == nil {
    return false
    }
    label := s.getFilterLabel(seg)
    return label != nil && label.Bookmark
    }
    // FormatLabels formats labels for display
    func FormatLabels(labels []*datafile.Label, filter string) string {
    var parts []string
    for _, l := range labels {
    if filter != "" && l.Filter != filter {
    continue
    }
    part := l.Species
    if l.CallType != "" {
    part += "/" + l.CallType
    }
    part += fmt.Sprintf(" (%d%%)", l.Certainty)
    if l.Filter != "" {
    part += " [" + l.Filter + "]"
    }
    if l.Comment != "" {
    part += fmt.Sprintf(" \"%s\"", l.Comment)
    }
    parts = append(parts, part)
    }
    return strings.Join(parts, ", ")
    }
    // LoadFilteredSegment reads, bandpass-shifts, and downsamples a segment's audio.
    // Returns samples and effective sample rate after any filtering/downsampling.
    func (s *ClassifyState) LoadFilteredSegment(df *datafile.DataFile, seg *datafile.Segment) ([]float64, int, error) {
    wavPath := df.FilePath[:len(df.FilePath)-5] // strip ".data"
    segSamples, sampleRate, err := wav.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 s.Config.BandpassLow > 0 || s.Config.BandpassHigh > 0 {
    segSamples, sampleRate = audio.BandpassShiftFilter(segSamples, sampleRate, s.Config.BandpassLow, s.Config.BandpassHigh)
    return segSamples, sampleRate, nil
    }
    // No bandpass: downsample if sample rate exceeds default
    if sampleRate > audio.DefaultMaxSampleRate {
    segSamples = audio.ResampleRate(segSamples, sampleRate, audio.DefaultMaxSampleRate)
    sampleRate = audio.DefaultMaxSampleRate
    }
    return segSamples, sampleRate, nil
    }
    // SaveClip saves a spectrogram PNG and WAV of the current segment to outputDir.
    // The prefix is prepended to the filename.
    func (s *ClassifyState) SaveClip(outputDir, prefix string) ([]string, error) {
    df := s.CurrentFile()
    seg := s.CurrentSegment()
    if df == nil || seg == nil {
    return nil, fmt.Errorf("no segment selected")
    }
    basename := spectrogram.WAVBasename(df.FilePath)
    pngPath, wavPath, err := spectrogram.ClipPaths(outputDir, prefix, basename, seg.StartTime, seg.EndTime)
    if err != nil {
    return nil, err
    }
    segSamples, sampleRate, err := s.LoadFilteredSegment(df, seg)
    if err != nil {
    return nil, err
    }
    // Generate spectrogram image (always color, 224px for clips)
    img := spectrogram.SpectrogramImageFromSamples(segSamples, sampleRate, true, 224)
    if img == nil {
    return nil, fmt.Errorf("failed to generate spectrogram")
    }
    if err := spectrogram.WritePNGFile(pngPath, img); err != nil {
    return nil, err
    }
    if err := wav.WriteWAVFile(wavPath, segSamples, sampleRate); err != nil {
    return nil, fmt.Errorf("failed to write WAV: %w", err)
    }
    return []string{pngPath, wavPath}, nil
    }
    // PlaySegmentAtSpeed loads and plays the current segment's audio at the given speed.
    // speed=1.0 is normal, speed=0.5 is half speed.
    // Returns an error message string, or empty string on success.
    func (s *ClassifyState) PlaySegmentAtSpeed(speed float64) string {
    df := s.CurrentFile()
    seg := s.CurrentSegment()
    if df == nil || seg == nil {
    return ""
    }
    segSamples, playSampleRate, err := s.LoadFilteredSegment(df, seg)
    if err != nil {
    return fmt.Sprintf("audio: %v", err)
    }
    // Initialize player lazily on first play
    if s.Player == nil {
    player, err := audio.NewAudioPlayer(playSampleRate)
    if err != nil {
    return fmt.Sprintf("audio init: %v", err)
    }
    s.Player = player
    }
    if len(segSamples) > 0 {
    s.PlaybackSpeed = speed
    s.Player.PlayAtSpeed(segSamples, playSampleRate, speed)
    }
    return ""
    }