calls.go
package cmd
import (
"encoding/json"
"flag"
"fmt"
"os"
"skraak/tools/calls"
)
// ErrHelpRequested is returned when --help is used, signalling normal exit.
var ErrHelpRequested = fmt.Errorf("help requested")
// RunCalls handles the "calls" command
// callsSubcommands maps subcommand names to their handler functions.
var callsSubcommands = map[string]func([]string) error{
"from-preds": runCallsFromPreds,
"from-birda": runCallsFromBirda,
"from-raven": runCallsFromRaven,
"show-images": runCallsShowImages,
"classify": RunCallsClassify,
"clip": RunCallsClip,
"add": RunCallsAdd,
"remove": RunCallsRemove,
"modify": RunCallsModify,
"push-certainty": runCallsPushCertainty,
"detect-anomalies": runCallsDetectAnomalies,
"propagate": runCallsPropagate,
"summarise": runCallsSummarise,
"clip-labels": runCallsClipLabels,
}
func RunCalls(args []string) error {
if len(args) < 1 {
printCallsUsage()
return fmt.Errorf("calls subcommand required")
}
handler, ok := callsSubcommands[args[0]]
if !ok {
printCallsUsage()
return fmt.Errorf("unknown calls subcommand: %s", args[0])
}
return handler(args[1:])
}
func printCallsUsage() {
fmt.Fprintf(os.Stderr, "Usage: skraak calls <subcommand> [options]\n\n")
fmt.Fprintf(os.Stderr, "Subcommands:\n")
fmt.Fprintf(os.Stderr, " from-preds Extract clustered calls from ML predictions CSV\n")
fmt.Fprintf(os.Stderr, " from-birda Import BirdNET results to .data files\n")
fmt.Fprintf(os.Stderr, " from-raven Import Raven selections to .data files\n")
fmt.Fprintf(os.Stderr, " show-images Display spectrogram images from .data file\n")
fmt.Fprintf(os.Stderr, " classify Review and classify segments in .data files\n")
fmt.Fprintf(os.Stderr, " clip Generate audio/image clips from .data files\n")
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")
fmt.Fprintf(os.Stderr, " modify Modify a label in a .data file\n")
fmt.Fprintf(os.Stderr, " push-certainty Promote certainty=90 segments to 100 for a filtered set\n")
fmt.Fprintf(os.Stderr, " detect-anomalies Flag label/certainty disagreements across ML model filters\n")
fmt.Fprintf(os.Stderr, " propagate Propagate verified classifications between filters in a .data file\n")
fmt.Fprintf(os.Stderr, " summarise Summarise all .data files in a folder\n")
fmt.Fprintf(os.Stderr, " clip-labels Export OpenSoundScape clip_labels-format multihot CSV\n")
fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " skraak calls from-preds --csv predictions.csv\n")
fmt.Fprintf(os.Stderr, " skraak calls from-birda --folder ./recordings\n")
fmt.Fprintf(os.Stderr, " skraak calls from-raven --folder ./recordings --delete\n")
fmt.Fprintf(os.Stderr, " skraak calls show-images --file recording.wav.data\n")
fmt.Fprintf(os.Stderr, " skraak calls classify --folder ./data --reviewer David --bind k=Kiwi\n")
fmt.Fprintf(os.Stderr, " skraak calls classify --folder ./data --reviewer David --bind k=Kiwi --filter mymodel --species Kiwi+Duet\n")
fmt.Fprintf(os.Stderr, " skraak calls clip --folder ./data --output ./clips --prefix train --filter mymodel --species Kiwi\n")
fmt.Fprintf(os.Stderr, " skraak calls modify --file recording.data --reviewer GLM-5 --filter mymodel --segment 12-15 --species Kiwi\n")
fmt.Fprintf(os.Stderr, " skraak calls summarise --folder ./recordings > summary.json\n")
}
// runCallsFromPreds handles the "calls from-preds" subcommand
//
// JSON output schema:
//
// {
// "calls": [ // Clustered call groups
// {
// "file": string, // WAV filename
// "start_time": float, // Cluster start time (seconds)
// "end_time": float, // Cluster end time (seconds)
// "ebird_code": string, // eBird species code
// "segments": int // Number of detections in cluster
// }
// ],
// "total_calls": int, // Total clustered calls
// "clip_duration": float, // Clip duration in seconds
// "gap_threshold": float, // Gap threshold used for clustering
// "species_count": {string: int}, // Species ebird code -> detection count
// "data_files_written": int, // .data files successfully written
// "data_files_skipped": int, // .data files skipped (already exist)
// "filter": string, // Filter name used
// "error": string // Error message (omitted if nil)
// }
func runCallsFromPreds(args []string) error {
fs := flag.NewFlagSet("calls from-preds", flag.ExitOnError)
csvPath := fs.String("csv", "", "Path to predictions CSV file (required)")
filter := fs.String("filter", "", "Filter name for .data files (default: parse from CSV filename)")
dotData := fs.Bool("dot-data", true, "Write .data files alongside audio files (default: true)")
gapMultiplier := fs.Int("gap-multiplier", 0, "Gap threshold multiplier (default: 2, e.g. 3 for kiwi)")
minDetections := fs.Int("min-detections", -1, "Min detections per cluster, filters out small clusters (default: 0 = no filtering)")
fs.Usage = usagePrinter(fs,
"skraak calls from-preds [options]",
"Extract clustered bird calls from ML predictions CSV.\n"+
"Reads prediction CSV with columns: file, start_time, end_time, <ebird_codes...>\n"+
"Each row is a clip with 1=present, 0=absent for each species.\n\n"+
"Output:\n"+
" With --dot-data=true (default): Writes .data files alongside audio files, outputs JSON summary\n"+
" With --dot-data=false: Outputs JSON with clustered calls only (no .data files)\n\n"+
"Filter name:\n"+
" If --filter is provided, uses that value.\n"+
" Otherwise, parses from CSV filename: prefix_filter_date.csv -> filter\n"+
" Example: predsST_opensoundscape-kiwi-1.2_2025-11-12.csv -> opensoundscape-kiwi-1.2",
"# Write .data files (default)",
"skraak calls from-preds --csv predictions.csv",
"",
"# JSON output only (no .data files)",
"skraak calls from-preds --csv predictions.csv --dot-data=false > calls.json",
"",
"# Override filter name",
"skraak calls from-preds --csv preds.csv --filter my-custom-filter",
)
if err := fs.Parse(args); err != nil {
return fmt.Errorf("parsing arguments: %w", err)
}
if err := requireFlags(fs, map[string]any{"--csv": *csvPath}); err != nil {
return err
}
// Determine filter name
filterName := *filter
if filterName == "" {
filterName = calls.ParseFilterFromFilename(*csvPath)
if filterName == "" {
return fmt.Errorf("could not parse filter from filename. Use --filter flag. Expected format: prefix_filter_date.csv (e.g., predsST_opensoundscape-kiwi-1.2_2025-11-12.csv)")
}
}
input := calls.CallsFromPredsInput{
CSVPath: *csvPath,
Filter: filterName,
WriteDotData: *dotData,
GapMultiplier: *gapMultiplier,
MinDetections: *minDetections,
ProgressHandler: func(processed, total int, message string) {
if total > 0 {
percent := float64(processed) / float64(total) * 100
fmt.Fprintf(os.Stderr, "\rProcessing WAV files: %d/%d (%.0f%%)", processed, total, percent)
if processed == total {
fmt.Fprintf(os.Stderr, "\n")
}
}
},
}
if *dotData {
fmt.Fprintf(os.Stderr, "Extracting calls from predictions: %s\n", *csvPath)
fmt.Fprintf(os.Stderr, "Filter: %s\n", filterName)
fmt.Fprintf(os.Stderr, "Writing .data files: enabled\n")
} else {
fmt.Fprintf(os.Stderr, "Extracting calls from predictions: %s\n", *csvPath)
fmt.Fprintf(os.Stderr, "Filter: %s\n", filterName)
fmt.Fprintf(os.Stderr, "Writing .data files: disabled (--dot-data=false)\n")
}
output, err := calls.CallsFromPreds(input)
if err != nil {
return fmt.Errorf("from-preds: %w", err)
}
fmt.Fprintf(os.Stderr, "Found %d clustered calls across %d species\n",
output.TotalCalls, len(output.SpeciesCount))
fmt.Fprintf(os.Stderr, "Clip duration: %.1fs, Gap threshold: %.1fs\n",
output.ClipDuration, output.GapThreshold)
if *dotData {
fmt.Fprintf(os.Stderr, "Data files written: %d, skipped: %d\n",
output.DataFilesWritten, output.DataFilesSkipped)
}
// Output JSON to stdout
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(output); err != nil {
return fmt.Errorf("encoding output: %w", err)
}
return nil
}
// runCallsShowImages handles the "calls show-images" subcommand
func runCallsShowImages(args []string) error {
fs := flag.NewFlagSet("calls show-images", flag.ExitOnError)
filePath := fs.String("file", "", "Path to .data file (required)")
color := fs.Bool("color", false, "Apply L4 colormap (default: false, grayscale)")
imgDims := fs.Int("img-dims", 0, "Spectrogram size in pixels (224-448, default 448)")
sixel := fs.Bool("sixel", false, "Use sixel graphics protocol (default: kitty)")
iterm := fs.Bool("iterm", false, "Use iTerm2 inline image protocol")
fs.Usage = usagePrinter(fs,
"skraak calls show-images [options]",
"Display spectrogram images for each segment in a .data file.\n"+
"Images are output using the Kitty graphics protocol (or Sixel with --sixel, iTerm2 with --iterm).",
"skraak calls show-images --file recording.wav.data",
"skraak calls show-images --file recording.wav.data --color",
)
if err := fs.Parse(args); err != nil {
return fmt.Errorf("parsing arguments: %w", err)
}
if err := requireFlags(fs, map[string]any{"--file": *filePath}); err != nil {
return err
}
input := calls.CallsShowImagesInput{
DataFilePath: *filePath,
Color: *color,
ImageSize: *imgDims,
Sixel: *sixel,
ITerm: *iterm,
}
fmt.Fprintf(os.Stderr, "Showing spectrogram images for: %s\n", *filePath)
if *color {
fmt.Fprintf(os.Stderr, "Color: L4 colormap (Black-Red-Yellow)\n")
}
output, err := calls.CallsShowImages(input)
if err != nil {
return fmt.Errorf("show-images: %w", err)
}
fmt.Fprintf(os.Stderr, "Displayed %d segment(s) from %s\n", output.SegmentsShown, output.WavFile)
return nil
}
// runCallsFromBirda handles the "calls from-birda" subcommand
//
// JSON output schema:
//
// {
// "calls": [ // Clustered call groups
// {
// "file": string, // WAV filename
// "start_time": float, // Cluster start time (seconds)
// "end_time": float, // Cluster end time (seconds)
// "ebird_code": string, // Species code
// "segments": int // Number of detections in cluster
// }
// ],
// "total_calls": int, // Total clustered calls
// "species_count": {string: int}, // Species -> detection count
// "data_files_written": int, // .data files successfully written
// "data_files_skipped": int, // .data files skipped
// "files_processed": int, // BirdNET files processed
// "files_deleted": int, // BirdNET files deleted (--delete)
// "filter": string, // Always "BirdNET"
// "error": string // Error message (omitted if nil)
// }
func runCallsFromBirda(args []string) error {
fs := flag.NewFlagSet("calls from-birda", flag.ExitOnError)
folder := fs.String("folder", "", "Folder containing BirdNET results files")
file := fs.String("file", "", "Single BirdNET results file to process")
delete := fs.Bool("delete", false, "Delete BirdNET files after processing")
fs.Usage = usagePrinter(fs,
"skraak calls from-birda [options]",
"Import BirdNET results to .data files.\n"+
"Reads *.BirdNET.results.csv files and creates/merges .data files.\n\n"+
"Behavior:\n"+
" - Filter is always 'BirdNET' (parsed from filename)\n"+
" - If .data file exists with BirdNET filter: error (refuses to clobber)\n"+
" - If .data file exists with different filter: merge segments\n"+
" - Confidence (0.0-1.0) converted to certainty (0-100)",
"skraak calls from-birda --folder ./recordings",
"skraak calls from-birda --file recording.BirdNET.results.csv",
"skraak calls from-birda --folder ./recordings --delete",
)
if err := fs.Parse(args); err != nil {
return fmt.Errorf("parsing arguments: %w", err)
}
// Validate that either folder or file is specified
if *folder == "" && *file == "" {
fs.Usage()
return fmt.Errorf("either --folder or --file is required")
}
input := calls.CallsFromBirdaInput{
Folder: *folder,
File: *file,
Delete: *delete,
ProgressHandler: func(processed, total int, message string) {
if total > 0 {
percent := float64(processed) / float64(total) * 100
fmt.Fprintf(os.Stderr, "\rProcessing BirdNET files: %d/%d (%.0f%%)", processed, total, percent)
if processed == total {
fmt.Fprintf(os.Stderr, "\n")
}
}
},
}
fmt.Fprintf(os.Stderr, "Importing BirdNET results\n")
if *folder != "" {
fmt.Fprintf(os.Stderr, "Folder: %s\n", *folder)
} else {
fmt.Fprintf(os.Stderr, "File: %s\n", *file)
}
if *delete {
fmt.Fprintf(os.Stderr, "Delete source files: enabled\n")
}
output, err := calls.CallsFromBirda(input)
if err != nil {
return fmt.Errorf("from-birda: %w", err)
}
fmt.Fprintf(os.Stderr, "Processed %d BirdNET files\n", output.FilesProcessed)
fmt.Fprintf(os.Stderr, "Found %d calls across %d species\n",
output.TotalCalls, len(output.SpeciesCount))
fmt.Fprintf(os.Stderr, "Data files written: %d, skipped: %d\n",
output.DataFilesWritten, output.DataFilesSkipped)
if *delete {
fmt.Fprintf(os.Stderr, "Files deleted: %d\n", output.FilesDeleted)
}
// Output JSON to stdout
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(output); err != nil {
return fmt.Errorf("encoding output: %w", err)
}
return nil
}
// runCallsFromRaven handles the "calls from-raven" subcommand
//
// JSON output schema:
//
// {
// "calls": [ // Clustered call groups
// {
// "file": string, // WAV filename
// "start_time": float, // Cluster start time (seconds)
// "end_time": float, // Cluster end time (seconds)
// "ebird_code": string, // Species code
// "segments": int // Number of detections in cluster
// }
// ],
// "total_calls": int, // Total clustered calls
// "species_count": {string: int}, // Species -> detection count
// "data_files_written": int, // .data files successfully written
// "data_files_skipped": int, // .data files skipped
// "files_processed": int, // Raven files processed
// "files_deleted": int, // Raven files deleted (--delete)
// "filter": string, // Always "Raven"
// "error": string // Error message (omitted if nil)
// }
func runCallsFromRaven(args []string) error {
fs := flag.NewFlagSet("calls from-raven", flag.ExitOnError)
folder := fs.String("folder", "", "Folder containing Raven selection files")
file := fs.String("file", "", "Single Raven selection file to process")
delete := fs.Bool("delete", false, "Delete Raven files after processing")
fs.Usage = usagePrinter(fs,
"skraak calls from-raven [options]",
"Import Raven selections to .data files.\n"+
"Reads *.selections.txt files and creates/merges .data files.\n\n"+
"Behavior:\n"+
" - Filter is always 'Raven' (parsed from filename)\n"+
" - If .data file exists with Raven filter: error (refuses to clobber)\n"+
" - If .data file exists with different filter: merge segments\n"+
" - Frequency range preserved from Raven selections\n"+
" - Certainty defaults to 70 (no confidence metric in Raven)",
"skraak calls from-raven --folder ./recordings",
"skraak calls from-raven --file recording.Table.1.selections.txt",
"skraak calls from-raven --folder ./recordings --delete",
)
if err := fs.Parse(args); err != nil {
return fmt.Errorf("parsing arguments: %w", err)
}
// Validate that either folder or file is specified
if *folder == "" && *file == "" {
fs.Usage()
return fmt.Errorf("either --folder or --file is required")
}
input := calls.CallsFromRavenInput{
Folder: *folder,
File: *file,
Delete: *delete,
ProgressHandler: func(processed, total int, message string) {
if total > 0 {
percent := float64(processed) / float64(total) * 100
fmt.Fprintf(os.Stderr, "\rProcessing Raven files: %d/%d (%.0f%%)", processed, total, percent)
if processed == total {
fmt.Fprintf(os.Stderr, "\n")
}
}
},
}
fmt.Fprintf(os.Stderr, "Importing Raven selections\n")
if *folder != "" {
fmt.Fprintf(os.Stderr, "Folder: %s\n", *folder)
} else {
fmt.Fprintf(os.Stderr, "File: %s\n", *file)
}
if *delete {
fmt.Fprintf(os.Stderr, "Delete source files: enabled\n")
}
output, err := calls.CallsFromRaven(input)
if err != nil {
return fmt.Errorf("from-raven: %w", err)
}
fmt.Fprintf(os.Stderr, "Processed %d Raven files\n", output.FilesProcessed)
fmt.Fprintf(os.Stderr, "Found %d calls across %d species\n",
output.TotalCalls, len(output.SpeciesCount))
fmt.Fprintf(os.Stderr, "Data files written: %d, skipped: %d\n",
output.DataFilesWritten, output.DataFilesSkipped)
if *delete {
fmt.Fprintf(os.Stderr, "Files deleted: %d\n", output.FilesDeleted)
}
// Output JSON to stdout
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(output); err != nil {
return fmt.Errorf("encoding output: %w", err)
}
return nil
}
// runCallsSummarise handles the "calls summarise" subcommand
//
// JSON output schema:
//
// {
// "segments": [ // All segments (omitted with --brief)
// {
// "file": string, // .data file path
// "start_time": float, // Segment start time (seconds)
// "end_time": float, // Segment end time (seconds)
// "labels": [
// {
// "filter": string, // Filter name
// "certainty": int, // Certainty level (0-100)
// "species": string, // Species name
// "calltype": string, // Call type (omitted if empty)
// "comment": string, // Comment (omitted if empty)
// "bookmark": bool // Bookmark flag (omitted if false)
// }
// ]
// }
// ],
// "folder": string, // Folder path
// "data_files_read": int, // Successfully parsed .data files
// "data_files_skipped": [string], // Files that failed to parse
// "total_segments": int, // Total number of segments
// "filters": { // Per-filter statistics
// string: {
// "segments": int, // Segment count for this filter
// "species": {string: int}, // Species -> count
// "calltypes": {string: {string: int}} // Species -> calltype -> count (omitted if empty)
// }
// },
// "review_status": {
// "unreviewed": int, // certainty < 100
// "confirmed": int, // certainty = 100
// "dont_know": int, // certainty = 0
// "with_calltype": int, // Labels with call type
// "with_comments": int // Labels with comments
// },
// "operators": [string], // Unique operator names
// "reviewers": [string], // Unique reviewer names
// "error": string // Error message (omitted if nil)
// }
func runCallsSummarise(args []string) error {
fs := flag.NewFlagSet("calls summarise", flag.ExitOnError)
folder := fs.String("folder", "", "Folder containing .data files (required)")
brief := fs.Bool("brief", false, "Exclude segments array from output (summary stats only)")
filter := fs.String("filter", "", "Restrict output to a single filter name (default: all filters)")
fs.Usage = usagePrinter(fs,
"skraak calls summarise [options]",
"Summarise all .data files in a folder.\n"+
"Outputs JSON with segments array and summary statistics.\n\n"+
"Output includes:\n"+
" - segments: array of all segments with labels (omitted with --brief)\n"+
" - data_files_read: count of successfully parsed .data files\n"+
" - data_files_skipped: list of files that failed to parse\n"+
" - total_segments: total number of segments\n"+
" - filters: per-filter statistics (segments, species counts)\n"+
" - review_status: unreviewed/confirmed/dont_know counts\n"+
" - operators/reviewers: unique values found",
"skraak calls summarise --folder ./recordings > summary.json",
"skraak calls summarise --folder ./recordings --brief > summary.json # summary only",
"skraak calls summarise --folder ./recordings --filter opensoundscape-kiwi-1.2 --brief",
)
if err := fs.Parse(args); err != nil {
return fmt.Errorf("parsing arguments: %w", err)
}
if err := requireFlags(fs, map[string]any{"--folder": *folder}); err != nil {
return err
}
input := calls.CallsSummariseInput{
Folder: *folder,
Brief: *brief,
Filter: *filter,
}
fmt.Fprintf(os.Stderr, "Summarising .data files in: %s\n", *folder)
if *filter != "" {
fmt.Fprintf(os.Stderr, "Filter: %s\n", *filter)
}
output, err := calls.CallsSummarise(input)
if err != nil {
return fmt.Errorf("summarise: %w", err)
}
fmt.Fprintf(os.Stderr, "Read %d .data files, skipped %d\n",
output.DataFilesRead, len(output.DataFilesSkipped))
fmt.Fprintf(os.Stderr, "Total segments: %d\n", output.TotalSegments)
fmt.Fprintf(os.Stderr, "Filters: %d\n", len(output.Filters))
fmt.Fprintf(os.Stderr, "Review status: %d unreviewed, %d confirmed, %d don't know\n",
output.ReviewStatus.Unreviewed, output.ReviewStatus.Confirmed, output.ReviewStatus.DontKnow)
// Output JSON to stdout
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(output); err != nil {
return fmt.Errorf("encoding output: %w", err)
}
return nil
}