O45G7VX2XBX2JSKKVK42BLU4UG5K77WPZOD2IYLKK5MOTSPXFFMAC package callsimport ("os""path/filepath""testing""skraak/utils")func TestCallsRemoveLabel(t *testing.T) {tmpDir := t.TempDir()dataPath := filepath.Join(tmpDir, "test.data")df := &utils.DataFile{Meta: &utils.DataMeta{Operator: "Manual", Duration: 60, Reviewer: "AI"},Segments: []*utils.Segment{{StartTime: 2,EndTime: 5,FreqLow: 0,FreqHigh: 16000,Labels: []*utils.Label{{Species: "Kiwi", Certainty: 80, Filter: "Manual"},{Species: "Tui", Certainty: 90, Filter: "BirdNET"},},},},}if err := df.Write(dataPath); err != nil {t.Fatalf("failed to write test file: %v", err)}result, err := CallsRemove(CallsRemoveInput{File: dataPath,Segment: "2-5",Species: "Kiwi",Filter: "Manual",Reviewer: "David",})if err != nil {t.Fatalf("unexpected error: %v", err)}if result.Removed != "label" {t.Errorf("expected removed=label, got %s", result.Removed)}if result.Species != "Kiwi" {t.Errorf("expected species=Kiwi, got %s", result.Species)}// Verify: segment should still exist with BirdNET labeldf2, err := utils.ParseDataFile(dataPath)if err != nil {t.Fatalf("failed to parse file: %v", err)}if len(df2.Segments) != 1 {t.Fatalf("expected 1 segment, got %d", len(df2.Segments))}if len(df2.Segments[0].Labels) != 1 {t.Errorf("expected 1 label remaining, got %d", len(df2.Segments[0].Labels))}if df2.Segments[0].Labels[0].Species != "Tui" {t.Errorf("expected remaining label=Tui, got %s", df2.Segments[0].Labels[0].Species)}if df2.Meta.Reviewer != "David" {t.Errorf("expected Reviewer=David, got %s", df2.Meta.Reviewer)}}func TestCallsRemoveSegment(t *testing.T) {tmpDir := t.TempDir()dataPath := filepath.Join(tmpDir, "test.data")df := &utils.DataFile{Meta: &utils.DataMeta{Operator: "Manual", Duration: 60},Segments: []*utils.Segment{{StartTime: 2,EndTime: 5,FreqLow: 0,FreqHigh: 16000,Labels: []*utils.Label{{Species: "Kiwi", Certainty: 80, Filter: "Manual"},},},{StartTime: 10,EndTime: 15,FreqLow: 0,FreqHigh: 16000,Labels: []*utils.Label{{Species: "Tui", Certainty: 90, Filter: "Manual"},},},},}if err := df.Write(dataPath); err != nil {t.Fatalf("failed to write test file: %v", err)}result, err := CallsRemove(CallsRemoveInput{File: dataPath,Segment: "2-5",Species: "Kiwi",Filter: "Manual",Reviewer: "David",})if err != nil {t.Fatalf("unexpected error: %v", err)}if result.Removed != "segment" {t.Errorf("expected removed=segment, got %s", result.Removed)}// Verify: only one segment remainsdf2, err := utils.ParseDataFile(dataPath)if err != nil {t.Fatalf("failed to parse file: %v", err)}if len(df2.Segments) != 1 {t.Errorf("expected 1 segment, got %d", len(df2.Segments))}if df2.Segments[0].Labels[0].Species != "Tui" {t.Errorf("expected remaining segment with Tui, got %s", df2.Segments[0].Labels[0].Species)}}func TestCallsRemoveFile(t *testing.T) {tmpDir := t.TempDir()dataPath := filepath.Join(tmpDir, "test.data")df := &utils.DataFile{Meta: &utils.DataMeta{Operator: "Manual", Duration: 60},Segments: []*utils.Segment{{StartTime: 2,EndTime: 5,FreqLow: 0,FreqHigh: 16000,Labels: []*utils.Label{{Species: "Kiwi", Certainty: 80, Filter: "Manual"},},},},}if err := df.Write(dataPath); err != nil {t.Fatalf("failed to write test file: %v", err)}result, err := CallsRemove(CallsRemoveInput{File: dataPath,Segment: "2-5",Species: "Kiwi",Filter: "Manual",Reviewer: "David",})if err != nil {t.Fatalf("unexpected error: %v", err)}if result.Removed != "file" {t.Errorf("expected removed=file, got %s", result.Removed)}// Verify: file should be deletedif _, err := os.Stat(dataPath); !os.IsNotExist(err) {t.Error("expected .data file to be deleted")}}func TestCallsRemoveAmbiguousCallType(t *testing.T) {tmpDir := t.TempDir()dataPath := filepath.Join(tmpDir, "test.data")df := &utils.DataFile{Meta: &utils.DataMeta{Operator: "Manual", Duration: 60},Segments: []*utils.Segment{{StartTime: 2,EndTime: 5,FreqLow: 0,FreqHigh: 16000,Labels: []*utils.Label{{Species: "Kiwi", Certainty: 80, Filter: "Manual", CallType: "Duet"},{Species: "Kiwi", Certainty: 90, Filter: "Manual", CallType: "Alarm"},},},},}if err := df.Write(dataPath); err != nil {t.Fatalf("failed to write test file: %v", err)}// Remove without calltype should errorresult, err := CallsRemove(CallsRemoveInput{File: dataPath,Segment: "2-5",Species: "Kiwi",Filter: "Manual",Reviewer: "David",})if err == nil {t.Error("expected error for ambiguous calltype, got nil")}if result.Error == "" {t.Error("expected error message in output")}}func TestCallsRemoveWithCallType(t *testing.T) {tmpDir := t.TempDir()dataPath := filepath.Join(tmpDir, "test.data")df := &utils.DataFile{Meta: &utils.DataMeta{Operator: "Manual", Duration: 60},Segments: []*utils.Segment{{StartTime: 2,EndTime: 5,FreqLow: 0,FreqHigh: 16000,Labels: []*utils.Label{{Species: "Kiwi", Certainty: 80, Filter: "Manual", CallType: "Duet"},{Species: "Kiwi", Certainty: 90, Filter: "Manual", CallType: "Alarm"},},},},}if err := df.Write(dataPath); err != nil {t.Fatalf("failed to write test file: %v", err)}// Remove with specific calltyperesult, err := CallsRemove(CallsRemoveInput{File: dataPath,Segment: "2-5",Species: "Kiwi+Duet",Filter: "Manual",Reviewer: "David",})if err != nil {t.Fatalf("unexpected error: %v", err)}if result.Removed != "label" {t.Errorf("expected removed=label, got %s", result.Removed)}// Verify: one label remainsdf2, err := utils.ParseDataFile(dataPath)if err != nil {t.Fatalf("failed to parse file: %v", err)}if len(df2.Segments[0].Labels) != 1 {t.Errorf("expected 1 label remaining, got %d", len(df2.Segments[0].Labels))}if df2.Segments[0].Labels[0].CallType != "Alarm" {t.Errorf("expected remaining calltype=Alarm, got %s", df2.Segments[0].Labels[0].CallType)}}func TestCallsRemoveAmbiguousFreq(t *testing.T) {tmpDir := t.TempDir()dataPath := filepath.Join(tmpDir, "test.data")df := &utils.DataFile{Meta: &utils.DataMeta{Operator: "Manual", Duration: 60},Segments: []*utils.Segment{{StartTime: 2,EndTime: 5,FreqLow: 0,FreqHigh: 8000,Labels: []*utils.Label{{Species: "Kiwi", Certainty: 80, Filter: "Manual"},},},{StartTime: 2,EndTime: 5,FreqLow: 200,FreqHigh: 4500,Labels: []*utils.Label{{Species: "Kiwi", Certainty: 90, Filter: "Manual"},},},},}if err := df.Write(dataPath); err != nil {t.Fatalf("failed to write test file: %v", err)}// Remove without frequency should error (ambiguous)result, err := CallsRemove(CallsRemoveInput{File: dataPath,Segment: "2-5",Species: "Kiwi",Filter: "Manual",Reviewer: "David",})if err == nil {t.Error("expected error for ambiguous frequency, got nil")}if result.Error == "" {t.Error("expected error message in output")}}func TestCallsRemoveWithFrequency(t *testing.T) {tmpDir := t.TempDir()dataPath := filepath.Join(tmpDir, "test.data")df := &utils.DataFile{Meta: &utils.DataMeta{Operator: "Manual", Duration: 60},Segments: []*utils.Segment{{StartTime: 2,EndTime: 5,FreqLow: 0,FreqHigh: 8000,Labels: []*utils.Label{{Species: "Kiwi", Certainty: 80, Filter: "Manual"},},},{StartTime: 2,EndTime: 5,FreqLow: 200,FreqHigh: 4500,Labels: []*utils.Label{{Species: "Tui", Certainty: 90, Filter: "Manual"},},},},}if err := df.Write(dataPath); err != nil {t.Fatalf("failed to write test file: %v", err)}// Remove with specific frequencyresult, err := CallsRemove(CallsRemoveInput{File: dataPath,Segment: "2-5",Frequency: "200-4500",Species: "Tui",Filter: "Manual",Reviewer: "David",})if err != nil {t.Fatalf("unexpected error: %v", err)}if result.Removed != "segment" {t.Errorf("expected removed=segment, got %s", result.Removed)}// Verify: one segment remainsdf2, err := utils.ParseDataFile(dataPath)if err != nil {t.Fatalf("failed to parse file: %v", err)}if len(df2.Segments) != 1 {t.Errorf("expected 1 segment, got %d", len(df2.Segments))}if df2.Segments[0].Labels[0].Species != "Kiwi" {t.Errorf("expected remaining species=Kiwi, got %s", df2.Segments[0].Labels[0].Species)}}func TestCallsRemoveFileNotFound(t *testing.T) {result, err := CallsRemove(CallsRemoveInput{File: "/tmp/nonexistent_xyz.data",Segment: "2-5",Species: "Kiwi",Filter: "Manual",Reviewer: "David",})if err == nil {t.Error("expected error for missing file, got nil")}if result.Error == "" {t.Error("expected error message in output")}}func TestCallsRemoveNoMatchingLabel(t *testing.T) {tmpDir := t.TempDir()dataPath := filepath.Join(tmpDir, "test.data")df := &utils.DataFile{Meta: &utils.DataMeta{Operator: "Manual", Duration: 60},Segments: []*utils.Segment{{StartTime: 2,EndTime: 5,FreqLow: 0,FreqHigh: 16000,Labels: []*utils.Label{{Species: "Kiwi", Certainty: 80, Filter: "Manual"},},},},}if err := df.Write(dataPath); err != nil {t.Fatalf("failed to write test file: %v", err)}// Try to remove a species that doesn't existresult, err := CallsRemove(CallsRemoveInput{File: dataPath,Segment: "2-5",Species: "Tui",Filter: "Manual",Reviewer: "David",})if err == nil {t.Error("expected error for no matching label, got nil")}if result.Error == "" {t.Error("expected error message in output")}}func TestCallsRemoveSingleSegmentNoFreqNeeded(t *testing.T) {tmpDir := t.TempDir()dataPath := filepath.Join(tmpDir, "test.data")df := &utils.DataFile{Meta: &utils.DataMeta{Operator: "Manual", Duration: 60},Segments: []*utils.Segment{{StartTime: 2,EndTime: 5,FreqLow: 200,FreqHigh: 4500,Labels: []*utils.Label{{Species: "Kiwi", Certainty: 80, Filter: "Manual"},},},},}if err := df.Write(dataPath); err != nil {t.Fatalf("failed to write test file: %v", err)}// Only one segment matches 2-5, so frequency not neededresult, err := CallsRemove(CallsRemoveInput{File: dataPath,Segment: "2-5",Species: "Kiwi",Filter: "Manual",Reviewer: "David",})if err != nil {t.Fatalf("unexpected error: %v", err)}if result.Removed != "file" {t.Errorf("expected removed=file, got %s", result.Removed)}// Check that LowFreq/HighFreq are populated from the segmentif result.LowFreq != 200 {t.Errorf("expected low_freq=200, got %f", result.LowFreq)}if result.HighFreq != 4500 {t.Errorf("expected high_freq=4500, got %f", result.HighFreq)}}
package callsimport ("fmt""os""skraak/utils")// CallsRemoveInput defines the input for the remove tooltype CallsRemoveInput struct {File string `json:"file"`Segment string `json:"segment"`Frequency string `json:"frequency,omitempty"`Species string `json:"species"`Filter string `json:"filter"`Reviewer string `json:"reviewer"`}// CallsRemoveOutput defines the output for the remove tooltype CallsRemoveOutput struct {File string `json:"file"`SegmentStart float64 `json:"segment_start"`SegmentEnd float64 `json:"segment_end"`LowFreq float64 `json:"low_freq,omitempty"`HighFreq float64 `json:"high_freq,omitempty"`Species string `json:"species"`CallType string `json:"calltype,omitempty"`Filter string `json:"filter"`Removed string `json:"removed"` // "label", "segment", or "file"Error string `json:"error,omitempty"`}// validateRemoveInput checks required fields.func validateRemoveInput(input CallsRemoveInput) error {if input.File == "" {return fmt.Errorf("--file is required")}if input.Segment == "" {return fmt.Errorf("--segment is required")}if input.Species == "" {return fmt.Errorf("--species is required")}if input.Reviewer == "" {return fmt.Errorf("--reviewer is required")}return nil}// removeOutputError sets the error field and returns.func removeOutputError(output *CallsRemoveOutput, msg string) (CallsRemoveOutput, error) {output.Error = msgreturn *output, fmt.Errorf("%s", msg)}// findSegmentsByTimeRange finds all segments matching the given start/end times.func findSegmentsByTimeRange(segments []*utils.Segment, startTime, endTime float64) []*utils.Segment {var matches []*utils.Segmentfor _, seg := range segments {if seg.StartTime == startTime && seg.EndTime == endTime {matches = append(matches, seg)}}return matches}// findMatchingLabels finds labels in a segment that match species (and optionally calltype).// If callType is empty, matches any calltype for that species.// Returns matched labels and whether the match was ambiguous.func findMatchingLabels(segment *utils.Segment, species, callType, filter string) ([]*utils.Label, string) {var matches []*utils.Labelfor _, label := range segment.Labels {if label.Species != species || label.Filter != filter {continue}if callType != "" && label.CallType != callType {continue}matches = append(matches, label)}if len(matches) == 0 {return nil, ""}if callType == "" && len(matches) > 1 {var callTypes []stringfor _, l := range matches {ct := l.CallTypeif ct == "" {ct = "(none)"}callTypes = append(callTypes, ct)}return nil, fmt.Sprintf("multiple labels match species '%s' with filter '%s', specify --calltype to disambiguate (calltypes: %v)", species, filter, callTypes)}return matches, ""}// removeLabelFromSegment removes specific labels from a segment.func removeLabelFromSegment(segment *utils.Segment, toRemove []*utils.Label) {removeSet := make(map[*utils.Label]bool)for _, l := range toRemove {removeSet[l] = true}var remaining []*utils.Labelfor _, label := range segment.Labels {if !removeSet[label] {remaining = append(remaining, label)}}segment.Labels = remaining}// removeSegmentFromDataFile removes a segment from the data file.func removeSegmentFromDataFile(df *utils.DataFile, seg *utils.Segment) {var remaining []*utils.Segmentfor _, s := range df.Segments {if s != seg {remaining = append(remaining, s)}}df.Segments = remaining}// resolveTargetSegment finds the target segment for removal, handling frequency ambiguity.func resolveTargetSegment(dataFile *utils.DataFile, input CallsRemoveInput, output *CallsRemoveOutput) (*utils.Segment, error) {matchingSegments := findSegmentsByTimeRange(dataFile.Segments, output.SegmentStart, output.SegmentEnd)if len(matchingSegments) == 0 {return nil, fmt.Errorf("no segment found matching time range %.1f-%.1f", output.SegmentStart, output.SegmentEnd)}// If multiple segments match and frequency not specified, errorif len(matchingSegments) > 1 && input.Frequency == "" {return nil, fmt.Errorf("multiple segments match time range %.1f-%.1f, specify --frequency to disambiguate", output.SegmentStart, output.SegmentEnd)}// Parse frequency if providedif input.Frequency != "" {freqLow, freqHigh, err := parseFrequencyRange(input.Frequency)if err != nil {return nil, err}target := findExactSegment(dataFile.Segments, output.SegmentStart, output.SegmentEnd, freqLow, freqHigh)if target == nil {return nil, fmt.Errorf("no segment found matching time range %.1f-%.1f and frequency %.0f-%.0f", output.SegmentStart, output.SegmentEnd, freqLow, freqHigh)}output.LowFreq = freqLowoutput.HighFreq = freqHighreturn target, nil}// Exactly one segment matches the time rangetarget := matchingSegments[0]output.LowFreq = target.FreqLowoutput.HighFreq = target.FreqHighreturn target, nil}// resolveTargetLabels finds matching labels in the segment, handling calltype ambiguity.func resolveTargetLabels(segment *utils.Segment, species, callType, filter string) ([]*utils.Label, error) {matchingLabels, ambiguityErr := findMatchingLabels(segment, species, callType, filter)if ambiguityErr != "" {return nil, fmt.Errorf("%s", ambiguityErr)}if len(matchingLabels) == 0 {return nil, fmt.Errorf("no label found matching species '%s' with filter '%s'", species, filter)}return matchingLabels, nil}// CallsRemove removes a label/segment from a .data filefunc CallsRemove(input CallsRemoveInput) (CallsRemoveOutput, error) {var output CallsRemoveOutputif err := validateRemoveInput(input); err != nil {return removeOutputError(&output, err.Error())}startTime, endTime, err := parseSegmentRangeFloat(input.Segment)if err != nil {return removeOutputError(&output, err.Error())}species, callType := utils.ParseSpeciesCallType(input.Species)output.File = input.Fileoutput.SegmentStart = startTimeoutput.SegmentEnd = endTimeoutput.Species = speciesoutput.CallType = callTypeoutput.Filter = input.Filter// File must existif _, err := os.Stat(input.File); os.IsNotExist(err) {return removeOutputError(&output, fmt.Sprintf("file not found: %s", input.File))}dataFile, err := utils.ParseDataFile(input.File)if err != nil {return removeOutputError(&output, fmt.Sprintf("failed to parse file: %v", err))}// Find target segmenttargetSegment, err := resolveTargetSegment(dataFile, input, &output)if err != nil {return removeOutputError(&output, err.Error())}// Find matching labelsmatchingLabels, err := resolveTargetLabels(targetSegment, species, callType, input.Filter)if err != nil {return removeOutputError(&output, err.Error())}// Update reviewerdataFile.Meta.Reviewer = input.Reviewer// Remove the label(s)removeLabelFromSegment(targetSegment, matchingLabels)// If segment has no labels left, remove itif len(targetSegment.Labels) == 0 {removeSegmentFromDataFile(dataFile, targetSegment)output.Removed = "segment"// If no segments left, remove the .data fileif len(dataFile.Segments) == 0 {if err := os.Remove(input.File); err != nil {return removeOutputError(&output, fmt.Sprintf("failed to remove file: %v", err))}output.Removed = "file"return output, nil}} else {output.Removed = "label"}if writeErr := dataFile.Write(input.File); writeErr != nil {return removeOutputError(&output, fmt.Sprintf("failed to save file: %v", writeErr))}return output, nil}
package callsimport ("os""path/filepath""testing""skraak/utils")func TestCallsAddNewFile(t *testing.T) {tmpDir := t.TempDir()wavPath := filepath.Join(tmpDir, "test.wav")dataPath := wavPath + ".data"createTestWAV(t, wavPath, 16000, 10)result, err := CallsAdd(CallsAddInput{File: dataPath,Segment: "2-5",Species: "Kiwi",Certainty: 100,Filter: "Manual",Reviewer: "David",})if err != nil {t.Fatalf("unexpected error: %v", err)}if !result.Created {t.Errorf("expected Created=true, got false")}if result.SegmentStart != 2 {t.Errorf("expected segment_start=2, got %f", result.SegmentStart)}if result.SegmentEnd != 5 {t.Errorf("expected segment_end=5, got %f", result.SegmentEnd)}if result.LowFreq != 0 {t.Errorf("expected low_freq=0, got %f", result.LowFreq)}if result.HighFreq != 16000 {t.Errorf("expected high_freq=16000, got %f", result.HighFreq)}if result.Species != "Kiwi" {t.Errorf("expected species=Kiwi, got %s", result.Species)}if result.Filter != "Manual" {t.Errorf("expected filter=Manual, got %s", result.Filter)}}func TestCallsAddNewFileMetadata(t *testing.T) {tmpDir := t.TempDir()wavPath := filepath.Join(tmpDir, "test.wav")dataPath := wavPath + ".data"createTestWAV(t, wavPath, 16000, 10)_, err := CallsAdd(CallsAddInput{File: dataPath,Segment: "2-5",Species: "Kiwi",Certainty: 100,Filter: "Manual",Reviewer: "David",})if err != nil {t.Fatalf("unexpected error: %v", err)}if _, err := os.Stat(dataPath); os.IsNotExist(err) {t.Fatal("expected .data file to be created")}df, err := utils.ParseDataFile(dataPath)if err != nil {t.Fatalf("failed to parse created file: %v", err)}if df.Meta.Operator != "Manual" {t.Errorf("expected Operator=Manual, got %s", df.Meta.Operator)}if df.Meta.Reviewer != "David" {t.Errorf("expected Reviewer=David, got %s", df.Meta.Reviewer)}if len(df.Segments) != 1 {t.Fatalf("expected 1 segment, got %d", len(df.Segments))}seg := df.Segments[0]if seg.StartTime != 2 || seg.EndTime != 5 {t.Errorf("expected segment 2-5, got %.1f-%.1f", seg.StartTime, seg.EndTime)}if len(seg.Labels) != 1 {t.Fatalf("expected 1 label, got %d", len(seg.Labels))}if seg.Labels[0].Species != "Kiwi" {t.Errorf("expected species=Kiwi, got %s", seg.Labels[0].Species)}}func TestCallsAddToExistingFile(t *testing.T) {tmpDir := t.TempDir()wavPath := filepath.Join(tmpDir, "test.wav")dataPath := wavPath + ".data"createTestWAV(t, wavPath, 16000, 10)df := &utils.DataFile{Meta: &utils.DataMeta{Operator: "BirdNET", Duration: 10, Reviewer: "AI"},Segments: []*utils.Segment{{StartTime: 2,EndTime: 5,FreqLow: 0,FreqHigh: 16000,Labels: []*utils.Label{{Species: "Tui", Certainty: 80, Filter: "BirdNET"},},},},}if err := df.Write(dataPath); err != nil {t.Fatalf("failed to write test file: %v", err)}result, err := CallsAdd(CallsAddInput{File: dataPath,Segment: "2-5",Species: "Kiwi",Certainty: 100,Filter: "Manual",Reviewer: "David",})if err != nil {t.Fatalf("unexpected error: %v", err)}if result.Created {t.Errorf("expected Created=false (label added to existing segment), got true")}df2, err := utils.ParseDataFile(dataPath)if err != nil {t.Fatalf("failed to parse file: %v", err)}if len(df2.Segments) != 1 {t.Fatalf("expected 1 segment, got %d", len(df2.Segments))}if len(df2.Segments[0].Labels) != 2 {t.Errorf("expected 2 labels, got %d", len(df2.Segments[0].Labels))}if df2.Meta.Reviewer != "David" {t.Errorf("expected Reviewer=David, got %s", df2.Meta.Reviewer)}if df2.Meta.Operator != "BirdNET" {t.Errorf("expected Operator=BirdNET (unchanged), got %s", df2.Meta.Operator)}}func TestCallsAddDuplicateFilter(t *testing.T) {tmpDir := t.TempDir()dataPath := filepath.Join(tmpDir, "test.data")df := &utils.DataFile{Meta: &utils.DataMeta{Operator: "Manual", Duration: 60},Segments: []*utils.Segment{{StartTime: 2,EndTime: 5,FreqLow: 0,FreqHigh: 16000,Labels: []*utils.Label{{Species: "Kiwi", Certainty: 80, Filter: "Manual"},},},},}if err := df.Write(dataPath); err != nil {t.Fatalf("failed to write test file: %v", err)}result, err := CallsAdd(CallsAddInput{File: dataPath,Segment: "2-5",Species: "Tui",Certainty: 100,Filter: "Manual",Reviewer: "David",})if err == nil {t.Error("expected error for duplicate filter, got nil")}if result.Error == "" {t.Error("expected error message in output")}}func TestCallsAddNewSegment(t *testing.T) {tmpDir := t.TempDir()dataPath := filepath.Join(tmpDir, "test.data")df := &utils.DataFile{Meta: &utils.DataMeta{Operator: "Manual", Duration: 60},Segments: []*utils.Segment{{StartTime: 2,EndTime: 5,FreqLow: 0,FreqHigh: 16000,Labels: []*utils.Label{{Species: "Kiwi", Certainty: 80, Filter: "Manual"},},},},}if err := df.Write(dataPath); err != nil {t.Fatalf("failed to write test file: %v", err)}result, err := CallsAdd(CallsAddInput{File: dataPath,Segment: "10-15",Frequency: "200-4500",Species: "Tui",Certainty: 90,Filter: "Manual",Reviewer: "David",})if err != nil {t.Fatalf("unexpected error: %v", err)}if !result.Created {t.Errorf("expected Created=true, got false")}if result.LowFreq != 200 {t.Errorf("expected low_freq=200, got %f", result.LowFreq)}if result.HighFreq != 4500 {t.Errorf("expected high_freq=4500, got %f", result.HighFreq)}df2, err := utils.ParseDataFile(dataPath)if err != nil {t.Fatalf("failed to parse file: %v", err)}if len(df2.Segments) != 2 {t.Errorf("expected 2 segments, got %d", len(df2.Segments))}}func TestCallsAddWithCallType(t *testing.T) {tmpDir := t.TempDir()wavPath := filepath.Join(tmpDir, "test.wav")dataPath := wavPath + ".data"createTestWAV(t, wavPath, 16000, 10)result, err := CallsAdd(CallsAddInput{File: dataPath,Segment: "2-5",Species: "Kiwi+Duet",Certainty: 100,Filter: "Manual",Reviewer: "David",})if err != nil {t.Fatalf("unexpected error: %v", err)}if result.Species != "Kiwi" {t.Errorf("expected species=Kiwi, got %s", result.Species)}if result.CallType != "Duet" {t.Errorf("expected calltype=Duet, got %s", result.CallType)}}func TestCallsAddMissingWAV(t *testing.T) {tmpDir := t.TempDir()dataPath := filepath.Join(tmpDir, "nonexistent.wav.data")result, err := CallsAdd(CallsAddInput{File: dataPath,Segment: "2-5",Species: "Kiwi",Certainty: 100,Filter: "Manual",Reviewer: "David",})if err == nil {t.Error("expected error when WAV file missing, got nil")}if result.Error == "" {t.Error("expected error message in output")}}func TestCallsAddMissingSpecies(t *testing.T) {result, err := CallsAdd(CallsAddInput{File: "test.data",Segment: "2-5",Reviewer: "David",})if err == nil {t.Error("expected error for missing species, got nil")}if result.Error == "" {t.Error("expected error message in output")}}// createTestWAV creates a minimal valid WAV file for testing.func createTestWAV(t *testing.T, path string, sampleRate int, durationSec int) {t.Helper()numSamples := sampleRate * durationSecdataSize := numSamples * 2 // 16-bit = 2 bytes per samplefileSize := 36 + dataSizef, err := os.Create(path)if err != nil {t.Fatalf("failed to create WAV file: %v", err)}defer f.Close()f.Write([]byte("RIFF"))writeUint32(f, uint32(fileSize))f.Write([]byte("WAVE"))f.Write([]byte("fmt "))writeUint32(f, 16)writeUint16(f, 1)writeUint16(f, 1)writeUint32(f, uint32(sampleRate))writeUint32(f, uint32(sampleRate*2))writeUint16(f, 2)writeUint16(f, 16)f.Write([]byte("data"))writeUint32(f, uint32(dataSize))silence := make([]byte, dataSize)f.Write(silence)}func writeUint32(f *os.File, v uint32) {buf := make([]byte, 4)buf[0] = byte(v)buf[1] = byte(v >> 8)buf[2] = byte(v >> 16)buf[3] = byte(v >> 24)f.Write(buf)}func writeUint16(f *os.File, v uint16) {buf := make([]byte, 2)buf[0] = byte(v)buf[1] = byte(v >> 8)f.Write(buf)}
package callsimport ("fmt""os""strings""skraak/utils")// CallsAddInput defines the input for the add tooltype CallsAddInput struct {File string `json:"file"`Segment string `json:"segment"`Frequency string `json:"frequency,omitempty"`Species string `json:"species"`Certainty int `json:"certainty"`Filter string `json:"filter"`Reviewer string `json:"reviewer"`}// CallsAddOutput defines the output for the add tooltype CallsAddOutput struct {File string `json:"file"`SegmentStart float64 `json:"segment_start"`SegmentEnd float64 `json:"segment_end"`LowFreq float64 `json:"low_freq"`HighFreq float64 `json:"high_freq"`Species string `json:"species"`CallType string `json:"calltype,omitempty"`Filter string `json:"filter"`Certainty int `json:"certainty"`Created bool `json:"created"` // true = new segment, false = label added to existingError string `json:"error,omitempty"`}// addOutputError sets the error field and returns.func addOutputError(output *CallsAddOutput, msg string) (CallsAddOutput, error) {output.Error = msgreturn *output, fmt.Errorf("%s", msg)}// validateAddInput checks required fields.func validateAddInput(input CallsAddInput) error {if input.File == "" {return fmt.Errorf("--file is required")}if input.Segment == "" {return fmt.Errorf("--segment is required")}if input.Species == "" {return fmt.Errorf("--species is required")}if input.Reviewer == "" {return fmt.Errorf("--reviewer is required")}if input.Certainty < 0 || input.Certainty > 100 {return fmt.Errorf("--certainty must be between 0 and 100")}return nil}// parseSegmentRangeFloat parses "12.3-15.7" format into start and end floats.func parseSegmentRangeFloat(s string) (float64, float64, error) {parts := strings.Split(s, "-")if len(parts) != 2 {return 0, 0, fmt.Errorf("invalid segment format: %s (expected start-end, e.g., 12.3-15.7)", s)}var start, end float64if _, err := fmt.Sscanf(parts[0], "%f", &start); err != nil {return 0, 0, fmt.Errorf("invalid start time: %s", parts[0])}if _, err := fmt.Sscanf(parts[1], "%f", &end); err != nil {return 0, 0, fmt.Errorf("invalid end time: %s", parts[1])}if start < 0 || end < 0 {return 0, 0, fmt.Errorf("times must be non-negative")}if start >= end {return 0, 0, fmt.Errorf("start time must be less than end time")}return start, end, nil}// parseFrequencyRange parses "200-4500" format into low and high floats.// Returns (0, 0, nil) if the string is empty.func parseFrequencyRange(s string) (float64, float64, error) {if s == "" {return 0, 0, nil}parts := strings.Split(s, "-")if len(parts) != 2 {return 0, 0, fmt.Errorf("invalid frequency format: %s (expected low-high, e.g., 200-4500)", s)}var low, high float64if _, err := fmt.Sscanf(parts[0], "%f", &low); err != nil {return 0, 0, fmt.Errorf("invalid low frequency: %s", parts[0])}if _, err := fmt.Sscanf(parts[1], "%f", &high); err != nil {return 0, 0, fmt.Errorf("invalid high frequency: %s", parts[1])}if low < 0 || high < 0 {return 0, 0, fmt.Errorf("frequencies must be non-negative")}if low >= high {return 0, 0, fmt.Errorf("low frequency must be less than high frequency")}return low, high, nil}// findExactSegment finds a segment with exact match on StartTime, EndTime, FreqLow, FreqHigh.func findExactSegment(segments []*utils.Segment, startTime, endTime, freqLow, freqHigh float64) *utils.Segment {for _, seg := range segments {if seg.StartTime == startTime && seg.EndTime == endTime &&seg.FreqLow == freqLow && seg.FreqHigh == freqHigh {return seg}}return nil}// deriveWAVPath strips the .data suffix to find the companion WAV file.func deriveWAVPath(dataPath string) string {return strings.TrimSuffix(dataPath, ".data")}// getWAVSampleRate reads the WAV header to get the sample rate.func getWAVSampleRate(dataPath string) (int, error) {wavPath := deriveWAVPath(dataPath)if _, err := os.Stat(wavPath); os.IsNotExist(err) {return 0, fmt.Errorf("WAV file not found: %s (needed for default high_freq)", wavPath)}sampleRate, _, err := utils.ParseWAVHeaderMinimal(wavPath)if err != nil {return 0, fmt.Errorf("reading WAV header %s: %w", wavPath, err)}return sampleRate, nil}// getWAVDuration reads the WAV header to get the duration.func getWAVDuration(dataPath string) (float64, error) {wavPath := deriveWAVPath(dataPath)if _, err := os.Stat(wavPath); os.IsNotExist(err) {return 0, fmt.Errorf("WAV file not found: %s", wavPath)}_, duration, err := utils.ParseWAVHeaderMinimal(wavPath)if err != nil {return 0, fmt.Errorf("reading WAV header %s: %w", wavPath, err)}return duration, nil}// resolveFreqDefaults sets freq defaults from WAV sample rate when --frequency is not provided.func resolveFreqDefaults(frequency string, dataPath string) (float64, float64, error) {if frequency != "" {return 0, 0, nil // caller already parsed these}sampleRate, err := getWAVSampleRate(dataPath)if err != nil {return 0, 0, err}return 0, float64(sampleRate), nil}// addLabelToSegment adds a label to an existing segment, or errors if the filter already exists.func addLabelToSegment(segment *utils.Segment, species, callType string, input CallsAddInput, output *CallsAddOutput) (CallsAddOutput, error) {for _, label := range segment.Labels {if label.Filter == input.Filter {msg := fmt.Sprintf("segment %.1f-%.1f already has a label with filter '%s'", output.SegmentStart, output.SegmentEnd, input.Filter)return addOutputError(output, msg)}}segment.Labels = append(segment.Labels, &utils.Label{Species: species,Certainty: input.Certainty,Filter: input.Filter,CallType: callType,})output.Created = falsereturn *output, nil}// createNewSegment creates a new segment with a label in the data file.func createNewSegment(dataFile *utils.DataFile, species, callType string, input CallsAddInput, output *CallsAddOutput) (CallsAddOutput, error) {newSeg := &utils.Segment{StartTime: output.SegmentStart,EndTime: output.SegmentEnd,FreqLow: output.LowFreq,FreqHigh: output.HighFreq,Labels: []*utils.Label{{Species: species,Certainty: input.Certainty,Filter: input.Filter,CallType: callType,},},}dataFile.Segments = append(dataFile.Segments, newSeg)output.Created = truereturn *output, nil}// parseAddInput parses and validates all add input fields, populating the output.func parseAddInput(input CallsAddInput, output *CallsAddOutput) (float64, float64, error) {if err := validateAddInput(input); err != nil {return 0, 0, err}startTime, endTime, err := parseSegmentRangeFloat(input.Segment)if err != nil {return 0, 0, err}freqLow, freqHigh, err := parseFrequencyRange(input.Frequency)if err != nil {return 0, 0, err}species, callType := utils.ParseSpeciesCallType(input.Species)output.File = input.Fileoutput.SegmentStart = startTimeoutput.SegmentEnd = endTimeoutput.Species = speciesoutput.CallType = callTypeoutput.Filter = input.Filteroutput.Certainty = input.Certainty// Resolve freq defaults from WAV if not providedif input.Frequency == "" {freqLow, freqHigh, err = resolveFreqDefaults(input.Frequency, input.File)if err != nil {return 0, 0, err}}output.LowFreq = freqLowoutput.HighFreq = freqHighreturn freqLow, freqHigh, nil}// CallsAdd adds a segment/label to a .data filefunc CallsAdd(input CallsAddInput) (CallsAddOutput, error) {var output CallsAddOutputfreqLow, freqHigh, err := parseAddInput(input, &output)if err != nil {return addOutputError(&output, err.Error())}dataFile, newFile, err := loadOrCreateDataFile(input.File, input.Reviewer)if err != nil {return addOutputError(&output, err.Error())}dataFile.Meta.Reviewer = input.Reviewer// Look for existing segment with exact matchsegment := findExactSegment(dataFile.Segments, output.SegmentStart, output.SegmentEnd, freqLow, freqHigh)if segment != nil {output, err = addLabelToSegment(segment, output.Species, output.CallType, input, &output)} else {output, err = createNewSegment(dataFile, output.Species, output.CallType, input, &output)if err == nil && newFile {var duration float64duration, err = getWAVDuration(input.File)if err != nil {return addOutputError(&output, err.Error())}dataFile.Meta.Duration = duration}}if err != nil {return output, err}if writeErr := dataFile.Write(input.File); writeErr != nil {return addOutputError(&output, fmt.Sprintf("failed to save file: %v", writeErr))}return output, nil}// loadOrCreateDataFile loads an existing .data file or creates a new one.// Returns the DataFile, whether it was newly created, and any error.func loadOrCreateDataFile(path string, reviewer string) (*utils.DataFile, bool, error) {if _, err := os.Stat(path); os.IsNotExist(err) {df := &utils.DataFile{Meta: &utils.DataMeta{Operator: "Manual",Reviewer: reviewer,},Segments: []*utils.Segment{},}return df, true, nil // true = newly created}df, err := utils.ParseDataFile(path)if err != nil {return nil, false, fmt.Errorf("failed to parse file: %v", err)}return df, false, nil // false = already existed}
#!/bin/bash# Integration tests for: skraak calls add, skraak calls remove# Uses test_lib.sh and generate_wav helper.set -euo pipefailSCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"source "$SCRIPT_DIR/test_lib.sh"check_binary# Setup: temp dir with a WAV file and configWORK_DIR=$(mktemp -d)trap 'rm -rf "$WORK_DIR"' EXITWAV_FILE="$WORK_DIR/test.wav"DATA_FILE="$WORK_DIR/test.wav.data"CONFIG_DIR="$HOME/.skraak"CONFIG_FILE="$CONFIG_DIR/config.json"# Ensure config exists for reviewerif [ ! -f "$CONFIG_FILE" ]; thenecho "Warning: $CONFIG_FILE not found, creating minimal config for tests"mkdir -p "$CONFIG_DIR"echo '{"classify":{"reviewer":"TestUser"}}' > "$CONFIG_FILE"CLEANUP_CONFIG=1elseCLEANUP_CONFIG=0fi# Generate a test WAV file (10 seconds, 16000 Hz)generate_wav "$WAV_FILE" 10 16000pass=0fail=0assert_json() {local description="$1"local json="$2"local key="$3"local expected="$4"local actualactual=$(echo "$json" | jq -r "$key")if [ "$actual" = "$expected" ]; thenecho -e " ${GREEN}PASS${NC}: $description"pass=$((pass + 1))elseecho -e " ${RED}FAIL${NC}: $description (expected $key=$expected, got $actual)"fail=$((fail + 1))fi}assert_error() {local description="$1"local stderr="$2"if [ -n "$stderr" ]; thenecho -e " ${GREEN}PASS${NC}: $description"pass=$((pass + 1))elseecho -e " ${RED}FAIL${NC}: $description (expected error on stderr)"fail=$((fail + 1))fi}# ============================================================echo "=== Test: calls add (new file) ==="# ============================================================RESULT=$($PROJECT_DIR/skraak calls add \--file "$DATA_FILE" \--segment "2-5" \--species "Kiwi" \--reviewer "David" 2>/dev/null)assert_json "file created" "$RESULT" ".file" "$DATA_FILE"assert_json "segment_start" "$RESULT" ".segment_start" "2"assert_json "segment_end" "$RESULT" ".segment_end" "5"assert_json "species" "$RESULT" ".species" "Kiwi"assert_json "filter" "$RESULT" ".filter" "Manual"assert_json "certainty" "$RESULT" ".certainty" "100"assert_json "created" "$RESULT" ".created" "true"assert_json "low_freq default 0" "$RESULT" ".low_freq" "0"assert_json "high_freq default sample_rate" "$RESULT" ".high_freq" "16000"# Verify .data file existsif [ -f "$DATA_FILE" ]; thenecho -e " ${GREEN}PASS${NC}: .data file exists on disk"pass=$((pass + 1))elseecho -e " ${RED}FAIL${NC}: .data file missing on disk"fail=$((fail + 1))fi# Verify metadata via SQL-like check (parse with jq)META=$(jq '.[0]' "$DATA_FILE")assert_json "operator is Manual" "$META" ".Operator" "Manual"assert_json "reviewer is David" "$META" ".Reviewer" "David"assert_json "duration from WAV" "$META" ".Duration" "10"# ============================================================echo "=== Test: calls add (label to existing segment, different filter) ==="# ============================================================RESULT2=$($PROJECT_DIR/skraak calls add \--file "$DATA_FILE" \--segment "2-5" \--species "Tui" \--filter "BirdNET" \--certainty 80 \--reviewer "AI" 2>/dev/null)assert_json "created=false (label added)" "$RESULT2" ".created" "false"assert_json "species" "$RESULT2" ".species" "Tui"assert_json "filter" "$RESULT2" ".filter" "BirdNET"assert_json "certainty" "$RESULT2" ".certainty" "80"# Verify two labels on segmentLABELS=$(jq '.[1][4] | length' "$DATA_FILE")if [ "$LABELS" = "2" ]; thenecho -e " ${GREEN}PASS${NC}: segment has 2 labels"pass=$((pass + 1))elseecho -e " ${RED}FAIL${NC}: expected 2 labels, got $LABELS"fail=$((fail + 1))fi# ============================================================echo "=== Test: calls add (duplicate filter error) ==="# ============================================================STDERR=$($PROJECT_DIR/skraak calls add \--file "$DATA_FILE" \--segment "2-5" \--species "Kea" \--filter "Manual" \--reviewer "David" 2>&1 >/dev/null || true)assert_error "duplicate filter errors" "$STDERR"# ============================================================echo "=== Test: calls add (new segment with frequency) ==="# ============================================================RESULT3=$($PROJECT_DIR/skraak calls add \--file "$DATA_FILE" \--segment "6-8" \--frequency "200-4500" \--species "Kea+Alarm" \--certainty 90 \--reviewer "David" 2>/dev/null)assert_json "created=true" "$RESULT3" ".created" "true"assert_json "low_freq" "$RESULT3" ".low_freq" "200"assert_json "high_freq" "$RESULT3" ".high_freq" "4500"assert_json "species" "$RESULT3" ".species" "Kea"assert_json "calltype" "$RESULT3" ".calltype" "Alarm"assert_json "certainty" "$RESULT3" ".certainty" "90"# ============================================================echo "=== Test: calls add (reviewer from config) ==="# ============================================================# Add without --reviewer flag (should use config)RESULT4=$($PROJECT_DIR/skraak calls add \--file "$DATA_FILE" \--segment "8-9" \--species "Robin" \2>/dev/null)assert_json "reviewer from config" "$RESULT4" ".species" "Robin"# ============================================================echo "=== Test: calls remove (label only) ==="# ============================================================# Remove the BirdNET label from segment 2-5 (Manual label should remain)RESULT5=$($PROJECT_DIR/skraak calls remove \--file "$DATA_FILE" \--segment "2-5" \--species "Tui" \--filter "BirdNET" \--reviewer "David" 2>/dev/null)assert_json "removed=label" "$RESULT5" ".removed" "label"assert_json "species" "$RESULT5" ".species" "Tui"# Verify segment still has Manual labelMANUAL_EXISTS=$(jq '.[1][4] | map(select(.filter == "Manual")) | length' "$DATA_FILE")if [ "$MANUAL_EXISTS" = "1" ]; thenecho -e " ${GREEN}PASS${NC}: Manual label still exists on segment"pass=$((pass + 1))elseecho -e " ${RED}FAIL${NC}: Manual label missing from segment"fail=$((fail + 1))fi# ============================================================echo "=== Test: calls remove (segment - last label removed) ==="# ============================================================# Remove the Manual label from segment 2-5 (no labels left → segment removed)RESULT6=$($PROJECT_DIR/skraak calls remove \--file "$DATA_FILE" \--segment "2-5" \--species "Kiwi" \--filter "Manual" \--reviewer "David" 2>/dev/null)assert_json "removed=segment" "$RESULT6" ".removed" "segment"# Verify only 2 segments remain (6-8, 8-9)SEGS=$(jq 'length - 1' "$DATA_FILE") # subtract metadata elementif [ "$SEGS" = "2" ]; thenecho -e " ${GREEN}PASS${NC}: 2 segments remain after segment removal"pass=$((pass + 1))elseecho -e " ${RED}FAIL${NC}: expected 2 segments, got $SEGS"fail=$((fail + 1))fi# ============================================================echo "=== Test: calls remove (file - last segment removed) ==="# ============================================================# Create a new file with one segment, then remove itSINGLE_WAV="$WORK_DIR/single.wav"SINGLE_DATA="$SINGLE_WAV.data"generate_wav "$SINGLE_WAV" 5 16000$PROJECT_DIR/skraak calls add \--file "$SINGLE_DATA" \--segment "1-3" \--species "Kiwi" \--reviewer "David" >/dev/null 2>&1RESULT7=$($PROJECT_DIR/skraak calls remove \--file "$SINGLE_DATA" \--segment "1-3" \--species "Kiwi" \--reviewer "David" 2>/dev/null)assert_json "removed=file" "$RESULT7" ".removed" "file"# Verify .data file is deletedif [ ! -f "$SINGLE_DATA" ]; thenecho -e " ${GREEN}PASS${NC}: .data file deleted after last segment removed"pass=$((pass + 1))elseecho -e " ${RED}FAIL${NC}: .data file still exists"fail=$((fail + 1))fi# ============================================================echo "=== Test: calls remove (ambiguous calltype error) ==="# ============================================================# Setup: two labels with same species+filter, different calltypesAMBIG_WAV="$WORK_DIR/ambig.wav"AMBIG_DATA="$AMBIG_WAV.data"generate_wav "$AMBIG_WAV" 5 16000# Create .data file with two labels with same species+filter, different calltypes# (add won't allow same filter on same segment, so we create manually)cat > "$AMBIG_DATA" << 'DATAEOF'[{"Operator": "Manual", "Duration": 5, "Reviewer": "David"},[1, 3, 0, 16000, [{"species": "Kiwi", "certainty": 80, "filter": "Manual", "calltype": "Duet"}, {"species": "Kiwi", "certainty": 90, "filter": "Manual", "calltype": "Alarm"}]]]DATAEOFSTDERR=$($PROJECT_DIR/skraak calls remove \--file "$AMBIG_DATA" \--segment "1-3" \--species "Kiwi" \--reviewer "David" 2>&1 >/dev/null || true)assert_error "ambiguous calltype errors" "$STDERR"# ============================================================echo "=== Test: calls remove (specific calltype succeeds) ==="# ============================================================RESULT8=$($PROJECT_DIR/skraak calls remove \--file "$AMBIG_DATA" \--segment "1-3" \--species "Kiwi+Duet" \--reviewer "David" 2>/dev/null)assert_json "removed=label with calltype" "$RESULT8" ".removed" "label"# ============================================================echo "=== Test: calls remove (file not found error) ==="# ============================================================STDERR=$($PROJECT_DIR/skraak calls remove \--file "/tmp/nonexistent_xyz.data" \--segment "1-3" \--species "Kiwi" \--reviewer "David" 2>&1 >/dev/null || true)assert_error "file not found errors" "$STDERR"# ============================================================echo "=== Test: calls add (missing WAV for freq default) ==="# ============================================================STDERR=$($PROJECT_DIR/skraak calls add \--file "/tmp/nonexistent_xyz.wav.data" \--segment "1-3" \--species "Kiwi" \--reviewer "David" 2>&1 >/dev/null || true)assert_error "missing WAV errors" "$STDERR"# ============================================================echo "=== Test: calls add (float segment values) ==="# ============================================================FLOAT_WAV="$WORK_DIR/float.wav"FLOAT_DATA="$FLOAT_WAV.data"generate_wav "$FLOAT_WAV" 10 16000RESULT9=$($PROJECT_DIR/skraak calls add \--file "$FLOAT_DATA" \--segment "1.5-3.7" \--species "Morepork" \--reviewer "David" 2>/dev/null)assert_json "float segment_start" "$RESULT9" ".segment_start" "1.5"assert_json "float segment_end" "$RESULT9" ".segment_end" "3.7"# Cleanup config if we created itif [ "$CLEANUP_CONFIG" = "1" ]; thenrm -f "$CONFIG_FILE"fi# Summaryecho ""echo "=== Summary ==="echo -e "Passed: ${GREEN}$pass${NC}"if [ "$fail" -gt 0 ]; thenecho -e "Failed: ${RED}$fail${NC}"exit 1elseecho -e "Failed: $fail"fi
package cmdimport ("encoding/json""fmt""os""strings""skraak/tools/calls""skraak/utils")func printRemoveUsage() {fmt.Fprintf(os.Stderr, "Usage: skraak calls remove [options]\n\n")fmt.Fprintf(os.Stderr, "Remove a label from a .data file.\n\n")fmt.Fprintf(os.Stderr, "Options:\n")fmt.Fprintf(os.Stderr, " --file <path> Path to .data file (required)\n")fmt.Fprintf(os.Stderr, " --segment <st-en> Time range, e.g., 12.3-15.7 or 12-15 (required)\n")fmt.Fprintf(os.Stderr, " --frequency <lo-hi> Frequency range, e.g., 200-4500 (optional; required if ambiguous)\n")fmt.Fprintf(os.Stderr, " --species <name> Species, optionally with calltype (required, e.g., Kiwi, Kiwi+Duet)\n")fmt.Fprintf(os.Stderr, " --filter <name> Filter name (default: Manual)\n")fmt.Fprintf(os.Stderr, " --reviewer <name> Reviewer name (default: from config)\n")fmt.Fprintf(os.Stderr, "\nBehavior:\n")fmt.Fprintf(os.Stderr, " Removes the matching label. If the segment has no labels left, removes the segment.\n")fmt.Fprintf(os.Stderr, " If the .data file has no segments left, removes the .data file.\n")fmt.Fprintf(os.Stderr, " If --calltype is omitted, matches any calltype (errors if ambiguous).\n")fmt.Fprintf(os.Stderr, " If --frequency is omitted, matches by time only (errors if multiple segments match).\n")fmt.Fprintf(os.Stderr, "\nExamples:\n")fmt.Fprintf(os.Stderr, " # Remove a Kiwi label\n")fmt.Fprintf(os.Stderr, " skraak calls remove --file recording.wav.data --segment 12-15 --species Kiwi\n\n")fmt.Fprintf(os.Stderr, " # Remove a specific calltype\n")fmt.Fprintf(os.Stderr, " skraak calls remove --file recording.wav.data --segment 12-15 --species Kiwi+Duet\n\n")fmt.Fprintf(os.Stderr, " # Remove with frequency to disambiguate\n")fmt.Fprintf(os.Stderr, " skraak calls remove --file recording.wav.data --segment 12-15 --frequency 200-4500 --species Kiwi\n")}// removeArgs holds parsed CLI arguments for the remove command.type removeArgs struct {file stringsegment stringfrequency stringspecies stringfilter stringreviewer string}// removeFlagHandler returns a flag handler for removeArgs.func removeFlagHandler(arg string, ra *removeArgs) (func(string), bool, bool) {switch arg {case "--file":return func(v string) { ra.file = v }, false, truecase "--segment":return func(v string) { ra.segment = v }, false, truecase "--frequency":return func(v string) { ra.frequency = v }, false, truecase "--species":return func(v string) { ra.species = v }, false, truecase "--filter":return func(v string) { ra.filter = v }, false, truecase "--reviewer":return func(v string) { ra.reviewer = v }, false, truedefault:return nil, false, false}}// parseRemoveArgs parses the command-line arguments for the remove subcommand.func parseRemoveArgs(args []string) removeArgs {var ra removeArgsi := 0for i < len(args) {arg := args[i]if arg == "-h" || arg == "--help" {printRemoveUsage()os.Exit(0)}handler, isBool, handled := removeFlagHandler(arg, &ra)if !handled {handleUnknownFlag(arg)i++continue}if isBool {i++} else {handler(mustValue(args, &i, arg))}}return ra}// validateRemoveArgs checks required flags and resolves defaults.func validateRemoveArgs(ra *removeArgs) error {missing := []string{}if ra.file == "" {missing = append(missing, "--file")}if ra.segment == "" {missing = append(missing, "--segment")}if ra.species == "" {missing = append(missing, "--species")}if len(missing) > 0 {printRemoveUsage()return fmt.Errorf("missing required flags: %s", strings.Join(missing, ", "))}// Apply defaultsif ra.filter == "" {ra.filter = "Manual"}// Resolve reviewer from config if not specifiedif ra.reviewer == "" {cfg, cfgPath, err := utils.LoadConfig()if err != nil {return fmt.Errorf("--reviewer not provided and config not found: %s", cfgPath)}if cfg.Classify.Reviewer == "" {return fmt.Errorf("--reviewer not provided and %s missing classify.reviewer", cfgPath)}ra.reviewer = cfg.Classify.Reviewer}return nil}// RunCallsRemove handles the "calls remove" subcommand//// JSON output schema://// {// "file": string,// "segment_start": float,// "segment_end": float,// "low_freq": float,// "high_freq": float,// "species": string,// "calltype": string,// "filter": string,// "removed": string, // "label", "segment", or "file"// "error": string // omitted if no error// }func RunCallsRemove(args []string) error {ra := parseRemoveArgs(args)if err := validateRemoveArgs(&ra); err != nil {return err}input := calls.CallsRemoveInput{File: ra.file,Segment: ra.segment,Frequency: ra.frequency,Species: ra.species,Filter: ra.filter,Reviewer: ra.reviewer,}result, err := calls.CallsRemove(input)if err != nil {return fmt.Errorf("remove: %s", result.Error)}data, _ := json.Marshal(result)fmt.Println(string(data))return nil}
package cmdimport ("encoding/json""fmt""os""strconv""strings""skraak/tools/calls""skraak/utils")func printAddUsage() {fmt.Fprintf(os.Stderr, "Usage: skraak calls add [options]\n\n")fmt.Fprintf(os.Stderr, "Add a segment/label to a .data file.\n\n")fmt.Fprintf(os.Stderr, "Options:\n")fmt.Fprintf(os.Stderr, " --file <path> Path to .data file (required)\n")fmt.Fprintf(os.Stderr, " --segment <st-en> Time range, e.g., 12.3-15.7 or 12-15 (required)\n")fmt.Fprintf(os.Stderr, " --frequency <lo-hi> Frequency range, e.g., 200-4500 (optional; default: 0-sample_rate)\n")fmt.Fprintf(os.Stderr, " --species <name> Species, optionally with calltype (required, e.g., Kiwi, Kiwi+Duet)\n")fmt.Fprintf(os.Stderr, " --certainty <int> Certainty 0-100 (default: 100)\n")fmt.Fprintf(os.Stderr, " --filter <name> Filter name (default: Manual)\n")fmt.Fprintf(os.Stderr, " --reviewer <name> Reviewer name (default: from config)\n")fmt.Fprintf(os.Stderr, "\nBehavior:\n")fmt.Fprintf(os.Stderr, " If .data file does not exist, creates it with Operator=Manual, Duration from WAV.\n")fmt.Fprintf(os.Stderr, " If segment already exists with same time+freq, adds label (errors if same filter).\n")fmt.Fprintf(os.Stderr, " If --frequency is omitted, low_freq=0, high_freq=WAV sample rate.\n")fmt.Fprintf(os.Stderr, "\nExamples:\n")fmt.Fprintf(os.Stderr, " # Add a Kiwi segment\n")fmt.Fprintf(os.Stderr, " skraak calls add --file recording.wav.data --segment 12-15 --species Kiwi\n\n")fmt.Fprintf(os.Stderr, " # Add with calltype and custom certainty\n")fmt.Fprintf(os.Stderr, " skraak calls add --file recording.wav.data --segment 12.3-15.7 --species Kiwi+Duet --certainty 90\n\n")fmt.Fprintf(os.Stderr, " # Add with frequency range\n")fmt.Fprintf(os.Stderr, " skraak calls add --file recording.wav.data --segment 12-15 --frequency 200-4500 --species Kiwi\n")}// addArgs holds parsed CLI arguments for the add command.type addArgs struct {file stringsegment stringfrequency stringspecies stringcertainty intfilter stringreviewer string}// addFlagHandler returns a flag handler for addArgs.func addFlagHandler(arg string, aa *addArgs) (func(string), bool, bool) {switch arg {case "--file":return func(v string) { aa.file = v }, false, truecase "--segment":return func(v string) { aa.segment = v }, false, truecase "--frequency":return func(v string) { aa.frequency = v }, false, truecase "--species":return func(v string) { aa.species = v }, false, truecase "--certainty":return func(v string) {n, err := strconv.Atoi(v)if err != nil {fmt.Fprintf(os.Stderr, "Error: --certainty must be an integer\n")os.Exit(1)}aa.certainty = n}, false, truecase "--filter":return func(v string) { aa.filter = v }, false, truecase "--reviewer":return func(v string) { aa.reviewer = v }, false, truedefault:return nil, false, false}}// parseAddArgs parses the command-line arguments for the add subcommand.func parseAddArgs(args []string) addArgs {var aa addArgsi := 0for i < len(args) {arg := args[i]if arg == "-h" || arg == "--help" {printAddUsage()os.Exit(0)}handler, isBool, handled := addFlagHandler(arg, &aa)if !handled {handleUnknownFlag(arg)i++continue}if isBool {i++} else {handler(mustValue(args, &i, arg))}}return aa}// validateAddArgs checks required flags and resolves defaults.func validateAddArgs(aa *addArgs) error {missing := []string{}if aa.file == "" {missing = append(missing, "--file")}if aa.segment == "" {missing = append(missing, "--segment")}if aa.species == "" {missing = append(missing, "--species")}if len(missing) > 0 {printAddUsage()return fmt.Errorf("missing required flags: %s", strings.Join(missing, ", "))}// Apply defaultsif aa.certainty == 0 {aa.certainty = 100}if aa.filter == "" {aa.filter = "Manual"}// Resolve reviewer from config if not specifiedif aa.reviewer == "" {cfg, cfgPath, err := utils.LoadConfig()if err != nil {return fmt.Errorf("--reviewer not provided and config not found: %s", cfgPath)}if cfg.Classify.Reviewer == "" {return fmt.Errorf("--reviewer not provided and %s missing classify.reviewer", cfgPath)}aa.reviewer = cfg.Classify.Reviewer}return nil}// RunCallsAdd handles the "calls add" subcommand//// JSON output schema://// {// "file": string,// "segment_start": float,// "segment_end": float,// "low_freq": float,// "high_freq": float,// "species": string,// "calltype": string,// "filter": string,// "certainty": int,// "created": bool, // true = new segment, false = label added to existing// "error": string // omitted if no error// }func RunCallsAdd(args []string) error {aa := parseAddArgs(args)if err := validateAddArgs(&aa); err != nil {return err}input := calls.CallsAddInput{File: aa.file,Segment: aa.segment,Frequency: aa.frequency,Species: aa.species,Certainty: aa.certainty,Filter: aa.filter,Reviewer: aa.reviewer,}result, err := calls.CallsAdd(input)if err != nil {return fmt.Errorf("add: %s", result.Error)}data, _ := json.Marshal(result)fmt.Println(string(data))return nil}
# Manually add a segment./skraak calls add --file recording.wav.data --segment 12-15 --species Kiwi./skraak calls add --file recording.wav.data --segment 12.3-15.7 --species Kiwi+Duet --certainty 90./skraak calls add --file recording.wav.data --segment 12-15 --frequency 200-4500 --species Kiwi# Manually remove a label./skraak calls remove --file recording.wav.data --segment 12-15 --species Kiwi./skraak calls remove --file recording.wav.data --segment 12-15 --species Kiwi+Duet./skraak calls remove --file recording.wav.data --segment 12-15 --frequency 200-4500 --species Kiwi
5. **Propagate verified classifications between filters:**
Add a labelled segment to a .data file. If the file doesn't exist, creates it (Operator=Manual, Duration from WAV). Reviewer defaults from `~/.skraak/config.json`.```bash# Add a Kiwi segment (freq defaults to 0–WAV sample rate)./skraak calls add --file recording.wav.data --segment 12-15 --species Kiwi# Add with calltype and custom certainty./skraak calls add --file recording.wav.data --segment 12.3-15.7 --species Kiwi+Duet --certainty 90# Add with explicit frequency range./skraak calls add --file recording.wav.data --segment 12-15 --frequency 200-4500 --species Kiwi# Add a label from a different filter to an existing segment./skraak calls add --file recording.wav.data --segment 12-15 --species Tui --filter BirdNET --certainty 80```Remove a label from a .data file. Cascading removal: if the segment has no labels left, the segment is removed; if the file has no segments left, the file is deleted.
# Remove a Kiwi label (filter defaults to Manual)./skraak calls remove --file recording.wav.data --segment 12-15 --species Kiwi# Remove a specific calltype./skraak calls remove --file recording.wav.data --segment 12-15 --species Kiwi+Duet# Remove with frequency (required when multiple segments share the same time range)./skraak calls remove --file recording.wav.data --segment 12-15 --frequency 200-4500 --species Kiwi```**Error handling:**- `add`: errors if a label with the same filter already exists on the segment- `remove`: errors if calltype is ambiguous (multiple labels match species+filter with different calltypes — specify `Species+CallType`)- `remove`: errors if frequency is ambiguous (multiple segments match the time range — specify `--frequency`)6. **Propagate verified classifications between filters:**```bash
## [2026-05-18] Add `calls add` and `calls remove` CLI commandsTwo new subcommands for manually adding and removing segments/labels in.data files, complementing the existing `calls modify` command.### Added- `skraak calls add`: Add a segment/label to a .data file- `--segment st-en` (float range, e.g., 12.3-15.7 or 12-15)- `--frequency lo-hi` (optional; defaults to 0–WAV sample rate)- `--species` (required, supports Species+CallType syntax)- `--certainty` (default 100)- `--filter` (default "Manual")- `--reviewer` (default from ~/.skraak/config.json)- Creates new .data file if missing (Operator=Manual, Duration from WAV)- Adds label to existing segment if time+freq match, errors if same filter- `skraak calls remove`: Remove a label from a .data file- Same segment/frequency/species/filter/reviewer flags as add- Removes label → segment (if empty) → file (if empty)- Errors on ambiguous calltype (when omitted and multiple match)- Errors on ambiguous frequency (when omitted and multiple segments match)- `tools/calls/calls_add.go`: Core add logic with `CallsAdd` function- `tools/calls/calls_remove.go`: Core remove logic with `CallsRemove` function- `cmd/calls_add.go`: CLI handler for `calls add`- `cmd/calls_remove.go`: CLI handler for `calls remove`- `tools/calls/calls_add_test.go`: Unit tests for add- `tools/calls/calls_remove_test.go`: Unit tests for remove- `shell_scripts/test_calls_add_remove.sh`: End-to-end integration tests (39 cases)### Changed- `cmd/calls.go`: Registered `add` and `remove` subcommands- `cmd/calls_test.go`: Updated expected subcommand count from 12 to 14