calls_clip_labels.go
package cmd
import (
"encoding/json"
"flag"
"fmt"
"os"
"sort"
"skraak/tools/calls"
)
// runCallsClipLabels handles the "calls clip-labels" subcommand.
func runCallsClipLabels(args []string) error {
fs := flag.NewFlagSet("calls clip-labels", flag.ExitOnError)
folder := fs.String("folder", "", "Folder containing .data files (required)")
mapping := fs.String("mapping", "", "Path to mapping.json (required)")
filter := fs.String("filter", "", "Restrict to a single filter name (default: all filters)")
output := fs.String("output", "./clip_labels.csv", "Output CSV path")
clipDuration := fs.Float64("clip-duration", 4.0, "Clip duration in seconds")
clipOverlap := fs.Float64("clip-overlap", 0.5, "Clip overlap in seconds")
minLabelOverlap := fs.Float64("min-label-overlap", 0.25, "Minimum overlap (s) for an annotation to label a clip")
finalClip := fs.String("final-clip", "full", "Trailing-clip behaviour: full | remainder | extend | none")
fs.Usage = usagePrinter(fs,
"skraak calls clip-labels [options]",
"Generate an OpenSoundScape clip_labels-format CSV from .data files.\n\n"+
"Segment policy:\n"+
" - Real species → contributes mapped class to overlapping clips.\n"+
" - Mapped to __NEGATIVE__ → clip emitted, all class columns False;\n"+
" overrides positives in the same clip.\n"+
" - Mapped to __IGNORE__ → segment contributes no labels to clips.\n"+
" - Gaps → clip emitted with all class columns False.\n\n"+
"If --output exists: append. Column-set mismatch → hard error.\n"+
"Duplicate (file, start_time, end_time) row → hard error on first.",
"skraak calls clip-labels --folder ./recordings --mapping ./mapping.json",
"skraak calls clip-labels --folder ./recordings --mapping ./mapping.json \\",
" --filter opensoundscape-multi-1.0",
)
if err := fs.Parse(args); err != nil {
return fmt.Errorf("parsing arguments: %w", err)
}
if err := requireFlags(fs, map[string]any{"--folder": *folder, "--mapping": *mapping}); err != nil {
return err
}
input := calls.CallsClipLabelsInput{
Folder: *folder,
MappingPath: *mapping,
Filter: *filter,
OutputPath: *output,
ClipDuration: *clipDuration,
ClipOverlap: *clipOverlap,
MinLabelOverlap: *minLabelOverlap,
FinalClip: *finalClip,
}
fmt.Fprintf(os.Stderr, "Folder: %s\n", *folder)
fmt.Fprintf(os.Stderr, "Mapping: %s\n", *mapping)
fmt.Fprintf(os.Stderr, "Output: %s\n", *output)
fmt.Fprintf(os.Stderr, "Clip: duration=%.3fs overlap=%.3fs final=%s min-label-overlap=%.3fs\n",
*clipDuration, *clipOverlap, *finalClip, *minLabelOverlap)
if *filter != "" {
fmt.Fprintf(os.Stderr, "Filter: %s\n", *filter)
}
out, err := calls.CallsClipLabels(input)
if err != nil {
return fmt.Errorf("clip-labels: %w", err)
}
fmt.Fprintf(os.Stderr, "\nResults\n")
fmt.Fprintf(os.Stderr, " .data files parsed: %d\n", out.DataFilesParsed)
fmt.Fprintf(os.Stderr, " Segments ignored (__IGNORE__): %d\n", out.SegmentsIgnored)
fmt.Fprintf(os.Stderr, " Clips excluded (__IGNORE__): %d\n", out.ClipsIgnored)
fmt.Fprintf(os.Stderr, " Clips emitted: %d\n", out.RowsWritten)
fmt.Fprintf(os.Stderr, " negative (__NEGATIVE__): %d\n", out.ClipsNegative)
fmt.Fprintf(os.Stderr, " all-False (gap): %d\n", out.ClipsAllFalseGap)
if out.AppendedToFile {
fmt.Fprintf(os.Stderr, " Appended to file: yes (%d existing rows)\n", out.ExistingRowsFound)
}
fmt.Fprintf(os.Stderr, "\nPer-class True counts:\n")
keys := make([]string, 0, len(out.PerClassTrueCount))
for k := range out.PerClassTrueCount {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(os.Stderr, " %-30s %d\n", k+":", out.PerClassTrueCount[k])
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(out); err != nil {
return fmt.Errorf("encoding output: %w", err)
}
return nil
}