added an add and a remove command

quietlight
May 18, 2026, 1:02 AM
O45G7VX2XBX2JSKKVK42BLU4UG5K77WPZOD2IYLKK5MOTSPXFFMAC

Dependencies

  • [2] P4CJMBYK added first version of --bandpass flag to calls classify, work to do
  • [3] XO5DF6WR I tiedied up readme
  • [4] DD3LCTLZ tidy up lat lng timezone api for calls classify and push certainty
  • [5] ZCCQ4P5T reduce complexity to under 14, gocyclo but cilint test still has 3 functions over
  • [6] KZKLAINJ run out of space on nest, cleaned out
  • [*] 3DVPQOKB big tidy up of tools/

Change contents

  • file addition: calls_remove_test.go (----------)
    [8.67281]
    package calls
    import (
    "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 label
    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) != 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 remains
    df2, 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 deleted
    if _, 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 error
    result, 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 calltype
    result, 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 remains
    df2, 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 frequency
    result, 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 remains
    df2, 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 exist
    result, 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 needed
    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)
    }
    // Check that LowFreq/HighFreq are populated from the segment
    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)
    }
    }
  • file addition: calls_remove.go (----------)
    [8.67281]
    package calls
    import (
    "fmt"
    "os"
    "skraak/utils"
    )
    // CallsRemoveInput defines the input for the remove tool
    type 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 tool
    type 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 = msg
    return *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.Segment
    for _, 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.Label
    for _, 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 []string
    for _, l := range matches {
    ct := l.CallType
    if 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.Label
    for _, 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.Segment
    for _, 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, error
    if 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 provided
    if 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 = freqLow
    output.HighFreq = freqHigh
    return target, nil
    }
    // Exactly one segment matches the time range
    target := matchingSegments[0]
    output.LowFreq = target.FreqLow
    output.HighFreq = target.FreqHigh
    return 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 file
    func CallsRemove(input CallsRemoveInput) (CallsRemoveOutput, error) {
    var output CallsRemoveOutput
    if 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.File
    output.SegmentStart = startTime
    output.SegmentEnd = endTime
    output.Species = species
    output.CallType = callType
    output.Filter = input.Filter
    // File must exist
    if _, 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 segment
    targetSegment, err := resolveTargetSegment(dataFile, input, &output)
    if err != nil {
    return removeOutputError(&output, err.Error())
    }
    // Find matching labels
    matchingLabels, err := resolveTargetLabels(targetSegment, species, callType, input.Filter)
    if err != nil {
    return removeOutputError(&output, err.Error())
    }
    // Update reviewer
    dataFile.Meta.Reviewer = input.Reviewer
    // Remove the label(s)
    removeLabelFromSegment(targetSegment, matchingLabels)
    // If segment has no labels left, remove it
    if len(targetSegment.Labels) == 0 {
    removeSegmentFromDataFile(dataFile, targetSegment)
    output.Removed = "segment"
    // If no segments left, remove the .data file
    if 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
    }
  • file addition: calls_add_test.go (----------)
    [8.67281]
    package calls
    import (
    "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 * durationSec
    dataSize := numSamples * 2 // 16-bit = 2 bytes per sample
    fileSize := 36 + dataSize
    f, 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)
    }
  • file addition: calls_add.go (----------)
    [8.67281]
    package calls
    import (
    "fmt"
    "os"
    "strings"
    "skraak/utils"
    )
    // CallsAddInput defines the input for the add tool
    type 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 tool
    type 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 existing
    Error string `json:"error,omitempty"`
    }
    // addOutputError sets the error field and returns.
    func addOutputError(output *CallsAddOutput, msg string) (CallsAddOutput, error) {
    output.Error = msg
    return *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 float64
    if _, 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 float64
    if _, 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 = false
    return *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 = true
    return *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.File
    output.SegmentStart = startTime
    output.SegmentEnd = endTime
    output.Species = species
    output.CallType = callType
    output.Filter = input.Filter
    output.Certainty = input.Certainty
    // Resolve freq defaults from WAV if not provided
    if input.Frequency == "" {
    freqLow, freqHigh, err = resolveFreqDefaults(input.Frequency, input.File)
    if err != nil {
    return 0, 0, err
    }
    }
    output.LowFreq = freqLow
    output.HighFreq = freqHigh
    return freqLow, freqHigh, nil
    }
    // CallsAdd adds a segment/label to a .data file
    func CallsAdd(input CallsAddInput) (CallsAddOutput, error) {
    var output CallsAddOutput
    freqLow, 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 match
    segment := 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 float64
    duration, 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
    }
  • file addition: test_calls_add_remove.sh (---r------)
    [3.638309]
    #!/bin/bash
    # Integration tests for: skraak calls add, skraak calls remove
    # Uses test_lib.sh and generate_wav helper.
    set -euo pipefail
    SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    source "$SCRIPT_DIR/test_lib.sh"
    check_binary
    # Setup: temp dir with a WAV file and config
    WORK_DIR=$(mktemp -d)
    trap 'rm -rf "$WORK_DIR"' EXIT
    WAV_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 reviewer
    if [ ! -f "$CONFIG_FILE" ]; then
    echo "Warning: $CONFIG_FILE not found, creating minimal config for tests"
    mkdir -p "$CONFIG_DIR"
    echo '{"classify":{"reviewer":"TestUser"}}' > "$CONFIG_FILE"
    CLEANUP_CONFIG=1
    else
    CLEANUP_CONFIG=0
    fi
    # Generate a test WAV file (10 seconds, 16000 Hz)
    generate_wav "$WAV_FILE" 10 16000
    pass=0
    fail=0
    assert_json() {
    local description="$1"
    local json="$2"
    local key="$3"
    local expected="$4"
    local actual
    actual=$(echo "$json" | jq -r "$key")
    if [ "$actual" = "$expected" ]; then
    echo -e " ${GREEN}PASS${NC}: $description"
    pass=$((pass + 1))
    else
    echo -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" ]; then
    echo -e " ${GREEN}PASS${NC}: $description"
    pass=$((pass + 1))
    else
    echo -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 exists
    if [ -f "$DATA_FILE" ]; then
    echo -e " ${GREEN}PASS${NC}: .data file exists on disk"
    pass=$((pass + 1))
    else
    echo -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 segment
    LABELS=$(jq '.[1][4] | length' "$DATA_FILE")
    if [ "$LABELS" = "2" ]; then
    echo -e " ${GREEN}PASS${NC}: segment has 2 labels"
    pass=$((pass + 1))
    else
    echo -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 label
    MANUAL_EXISTS=$(jq '.[1][4] | map(select(.filter == "Manual")) | length' "$DATA_FILE")
    if [ "$MANUAL_EXISTS" = "1" ]; then
    echo -e " ${GREEN}PASS${NC}: Manual label still exists on segment"
    pass=$((pass + 1))
    else
    echo -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 element
    if [ "$SEGS" = "2" ]; then
    echo -e " ${GREEN}PASS${NC}: 2 segments remain after segment removal"
    pass=$((pass + 1))
    else
    echo -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 it
    SINGLE_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>&1
    RESULT7=$($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 deleted
    if [ ! -f "$SINGLE_DATA" ]; then
    echo -e " ${GREEN}PASS${NC}: .data file deleted after last segment removed"
    pass=$((pass + 1))
    else
    echo -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 calltypes
    AMBIG_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"}]]
    ]
    DATAEOF
    STDERR=$($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 16000
    RESULT9=$($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 it
    if [ "$CLEANUP_CONFIG" = "1" ]; then
    rm -f "$CONFIG_FILE"
    fi
    # Summary
    echo ""
    echo "=== Summary ==="
    echo -e "Passed: ${GREEN}$pass${NC}"
    if [ "$fail" -gt 0 ]; then
    echo -e "Failed: ${RED}$fail${NC}"
    exit 1
    else
    echo -e "Failed: $fail"
    fi
  • replacement in cmd/calls_test.go at line 32
    [3.724][3.724:774]()
    "classify", "clip", "modify", "push-certainty",
    [3.724]
    [3.774]
    "classify", "clip", "add", "remove", "modify", "push-certainty",
  • file addition: calls_remove.go (----------)
    [3.1037540]
    package cmd
    import (
    "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 string
    segment string
    frequency string
    species string
    filter string
    reviewer 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, true
    case "--segment":
    return func(v string) { ra.segment = v }, false, true
    case "--frequency":
    return func(v string) { ra.frequency = v }, false, true
    case "--species":
    return func(v string) { ra.species = v }, false, true
    case "--filter":
    return func(v string) { ra.filter = v }, false, true
    case "--reviewer":
    return func(v string) { ra.reviewer = v }, false, true
    default:
    return nil, false, false
    }
    }
    // parseRemoveArgs parses the command-line arguments for the remove subcommand.
    func parseRemoveArgs(args []string) removeArgs {
    var ra removeArgs
    i := 0
    for 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 defaults
    if ra.filter == "" {
    ra.filter = "Manual"
    }
    // Resolve reviewer from config if not specified
    if 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
    }
  • file addition: calls_add.go (----------)
    [3.1037540]
    package cmd
    import (
    "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 string
    segment string
    frequency string
    species string
    certainty int
    filter string
    reviewer 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, true
    case "--segment":
    return func(v string) { aa.segment = v }, false, true
    case "--frequency":
    return func(v string) { aa.frequency = v }, false, true
    case "--species":
    return func(v string) { aa.species = v }, false, true
    case "--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, true
    case "--filter":
    return func(v string) { aa.filter = v }, false, true
    case "--reviewer":
    return func(v string) { aa.reviewer = v }, false, true
    default:
    return nil, false, false
    }
    }
    // parseAddArgs parses the command-line arguments for the add subcommand.
    func parseAddArgs(args []string) addArgs {
    var aa addArgs
    i := 0
    for 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 defaults
    if aa.certainty == 0 {
    aa.certainty = 100
    }
    if aa.filter == "" {
    aa.filter = "Manual"
    }
    // Resolve reviewer from config if not specified
    if 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
    }
  • edit in cmd/calls.go at line 24
    [3.14776]
    [3.14776]
    "add": RunCallsAdd,
    "remove": RunCallsRemove,
  • edit in cmd/calls.go at line 57
    [3.1155435]
    [3.1155435]
    fmt.Fprintf(os.Stderr, " add Add a segment/label to a .data file\n")
    fmt.Fprintf(os.Stderr, " remove Remove a label from a .data file\n")
  • edit in README.md at line 65
    [3.1181358]
    [3.1181358]
    # 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
  • edit in README.md at line 264
    [3.1188846]
    [3.1188846]
    5. **Manually add or remove segments:**
  • replacement in README.md at line 267
    [3.1188847][3.1188847:1188906]()
    5. **Propagate verified classifications between filters:**
    [3.1188847]
    [3.1188906]
    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.
  • edit in README.md at line 286
    [3.1188914]
    [3.1188914]
    # 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
  • replacement in README.md at line 314
    [3.1189485][3.1091:1149]()
    6. **Export .data files to OpenSoundScape multihot CSV:**
    [3.1189485]
    [3.1189538]
    7. **Export .data files to OpenSoundScape multihot CSV:**
  • edit in CHANGELOG.md at line 4
    [3.1198010]
    [2.12928]
    ## [2026-05-18] Add `calls add` and `calls remove` CLI commands
    Two 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