moved TUI-adjacent code from calls_classify.go to dedicated files within the same package
Dependencies
- [2]
V2HX6HEBclaude going nuts all over the place - [3]
3DVPQOKBbig tidy up of tools/ - [4]
P4CJMBYKadded first version of --bandpass flag to calls classify, work to do - [5]
XU7FTYK3third phase of utils refactor, wav/ - [6]
N57PNZPFsecond phase of utils refactor, audio/ - [7]
3ETJ6KPIrefactor of tui/ second iteration - [8]
PXQDGTR5fourth phase of utils refactor, spectrogram/
Change contents
- file addition: classify_labels.go[3.67281]
package callsimport ("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 reviewerdf.Meta.Reviewer = s.Config.Reviewer// Get labels matching filterfilterLabels := seg.GetFilterLabels(s.Config.Filter)var oldComment stringif len(filterLabels) == 0 {// No matching labels, add new one with commentlabel := &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 labeloldComment = filterLabels[0].CommentfilterLabels[0].Comment = comment}s.Dirty = truereturn 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.ReviewerfilterLabels[0].Certainty = 100s.Dirty = truereturn true} - file addition: classify_io.go[3.67281]
package callsimport ("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 configuredif 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 defaultif 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 playif 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 = speeds.Player.PlayAtSpeed(segSamples, playSampleRate, speed)}return ""} - file addition: classify_format.go[3.67281]
package callsimport ("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 []stringfor _, l := range labels {if filter != "" && l.Filter != filter {continue}part := l.Speciesif 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 callsimport ("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 labellabel = &datafile.Label{Species: "Don't Know",Certainty: 0,Filter: s.Config.Filter,}seg.Labels = append(seg.Labels, label)s.Dirty = truereturn 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 reviewerdf.Meta.Reviewer = s.Config.Reviewerlabel := s.getOrCreateFilterLabel(seg)label.Bookmark = !label.Bookmarks.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.FileIdxstartSeg := s.SegmentIdxfirst := truefor {// Advance to next segmentif !s.NextSegment() {// Wrap to start of folders.FileIdx = 0s.SegmentIdx = 0}// Check if we've looped back to startif !first && s.FileIdx == startFile && s.SegmentIdx == startSeg {return false // full circle, no bookmark found}first = false// Check if current segment has bookmarkif 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.FileIdxstartSeg := s.SegmentIdxfirst := truefor {// Move to previous segmentif !s.PrevSegment() {// Wrap to end of folders.FileIdx = len(s.DataFiles) - 1segs := s.filteredSegs[s.FileIdx]s.SegmentIdx = max(len(segs)-1, 0)}// Check if we've looped back to startif !first && s.FileIdx == startFile && s.SegmentIdx == startSeg {return false // full circle, no bookmark found}first = false// Check if current segment has bookmarkif 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 callsimport ("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 stringCallType 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 reviewerdf.Meta.Reviewer = s.Config.Reviewer// Get labels matching filterfilterLabels := seg.GetFilterLabels(s.Config.Filter)// Determine certainty: 0 for Don't Know, 100 for otherscertainty := 100if result.Species == "Don't Know" {certainty = 0}if len(filterLabels) == 0 {// No matching labels, add new oneseg.Labels = append(seg.Labels, &datafile.Label{Species: result.Species,Certainty: certainty,Filter: s.Config.Filter,CallType: result.CallType,})} else {// Edit first matching label, remove restfilterLabels[0].Species = result.SpeciesfilterLabels[0].Certainty = certaintyfilterLabels[0].CallType = result.CallType // always set (empty = remove)// Remove extra matching labelsif len(filterLabels) > 1 {var newLabels []*datafile.Labelfor _, l := range seg.Labels {keep := !slices.Contains(filterLabels[1:], l)if keep {newLabels = append(newLabels, l)}}seg.Labels = newLabels}}// Re-sort labelssort.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.ReviewerfilterLabels[0].CallType = callTypes.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
"slices" - edit in tools/calls/calls_classify.go at line 14
"skraak/spectrogram""skraak/wav" - replacement in tools/calls/calls_classify.go at line 16
// KeyBinding maps a key to a species/calltypetype KeyBinding struct {Key string // single char: "k", "n", "p"Species string // "Kiwi", "Don't Know", "Morepork"CallType string // "Duet", "Female", "Male" (optional)}// 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
// ClassifyConfig holds the configuration for classification// 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
}// BindingResult represents parsed key resulttype BindingResult struct {Species stringCallType 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 resultfunc (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 reviewerdf.Meta.Reviewer = s.Config.Reviewer// Get labels matching filterfilterLabels := seg.GetFilterLabels(s.Config.Filter)var oldComment stringif len(filterLabels) == 0 {// No matching labels, add new one with commentlabel := &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 labeloldComment = filterLabels[0].CommentfilterLabels[0].Comment = comment}s.Dirty = truereturn 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 segmentfunc (s *ClassifyState) ApplyBinding(result *BindingResult) {seg := s.CurrentSegment()if seg == nil {return}df := s.CurrentFile()if df == nil {return}// Set reviewerdf.Meta.Reviewer = s.Config.Reviewer// Get labels matching filterfilterLabels := seg.GetFilterLabels(s.Config.Filter)// Determine certainty: 0 for Don't Know, 100 for otherscertainty := 100if result.Species == "Don't Know" {certainty = 0}if len(filterLabels) == 0 {// No matching labels, add new oneseg.Labels = append(seg.Labels, &datafile.Label{Species: result.Species,Certainty: certainty,Filter: s.Config.Filter,CallType: result.CallType,})} else {// Edit first matching label, remove restfilterLabels[0].Species = result.SpeciesfilterLabels[0].Certainty = certaintyfilterLabels[0].CallType = result.CallType // always set (empty = remove)// Remove extra matching labelsif len(filterLabels) > 1 {var newLabels []*datafile.Labelfor _, l := range seg.Labels {keep := !slices.Contains(filterLabels[1:], l)if keep {newLabels = append(newLabels, l)}}seg.Labels = newLabels}}// Re-sort labelssort.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.ReviewerfilterLabels[0].CallType = callTypes.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.ReviewerfilterLabels[0].Certainty = 100s.Dirty = truereturn 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 labellabel = &datafile.Label{Species: "Don't Know",Certainty: 0,Filter: s.Config.Filter,}seg.Labels = append(seg.Labels, label)s.Dirty = truereturn 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 reviewerdf.Meta.Reviewer = s.Config.Reviewerlabel := s.getOrCreateFilterLabel(seg)label.Bookmark = !label.Bookmarks.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.FileIdxstartSeg := s.SegmentIdxfirst := truefor {// Advance to next segmentif !s.NextSegment() {// Wrap to start of folders.FileIdx = 0s.SegmentIdx = 0}// Check if we've looped back to startif !first && s.FileIdx == startFile && s.SegmentIdx == startSeg {return false // full circle, no bookmark found}first = false// Check if current segment has bookmarkif 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.FileIdxstartSeg := s.SegmentIdxfirst := truefor {// Move to previous segmentif !s.PrevSegment() {// Wrap to end of folders.FileIdx = len(s.DataFiles) - 1segs := s.filteredSegs[s.FileIdx]s.SegmentIdx = max(len(segs)-1, 0)}// Check if we've looped back to startif !first && s.FileIdx == startFile && s.SegmentIdx == startSeg {return false // full circle, no bookmark found}first = false// Check if current segment has bookmarkif 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 displayfunc FormatLabels(labels []*datafile.Label, filter string) string {var parts []stringfor _, l := range labels {if filter != "" && l.Filter != filter {continue}part := l.Speciesif 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 configuredif 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 defaultif 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 playif 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 = speeds.Player.PlayAtSpeed(segSamples, playSampleRate, speed)}return ""}