calls_clip.go
package cmd
import (
"encoding/json"
"flag"
"fmt"
"os"
"skraak/tools/calls"
)
// clipFlags holds the parsed CLI flags for calls clip
type clipFlags struct {
file string
folder string
output string
prefix string
filter string
species string
size int
certainty int
color bool
night bool
day bool
location string
}
// parseClipArgs parses CLI arguments into clip flags using flag.FlagSet.
// Returns the parsed flags and the FlagSet (whose Usage is invoked by the caller on error).
func parseClipArgs(args []string) (clipFlags, *flag.FlagSet) {
fs := flag.NewFlagSet("calls clip", flag.ExitOnError)
file := fs.String("file", "", "Path to .data file (required if no --folder)")
folder := fs.String("folder", "", "Path to folder containing .data files (required if no --file)")
output := fs.String("output", "", "Output folder for generated clips (required)")
prefix := fs.String("prefix", "", "Prefix for output filenames (required)")
filter := fs.String("filter", "", "Filter by ML model name")
species := fs.String("species", "", "Filter by species, optionally with calltype (e.g. Kiwi, Kiwi+Duet)")
certainty := fs.Int("certainty", -1, "Filter by certainty value (0-100)")
size := fs.Int("size", 0, "Spectrogram image size in pixels (224-896, default 224)")
color := fs.Bool("color", false, "Apply L4 colormap to spectrogram (default: grayscale)")
night := fs.Bool("night", false, "Only clip recordings made during solar night (requires --location)")
day := fs.Bool("day", false, "Only clip recordings made during solar day (requires --location)")
location := fs.String("location", "", "GPS coordinates and optional IANA timezone, e.g. \"-36.85,174.76\" or \"-36.85,174.76,Pacific/Auckland\". Required with --night/--day. Not needed for AudioMoth data.")
fs.Usage = usagePrinter(fs,
"skraak calls clip [options]",
"Generate audio clips and spectrogram images from .data file segments.\n\n"+
"Output files:\n"+
" <prefix>_<basename>_<start>_<end>.png # spectrogram image\n"+
" <prefix>_<basename>_<start>_<end>.wav # audio clip (16kHz if downsampled)",
"# Clip all segments from a single file",
"skraak calls clip --file recording.data --output ./clips --prefix train",
"",
"# Clip only Kiwi segments with color spectrograms at 448px",
"skraak calls clip --folder ./data --output ./clips --prefix kiwi \\",
" --filter opensoundscape-kiwi-1.2 --species Kiwi --size 448 --color",
"",
"# Clip Kiwi Duet calls",
"skraak calls clip --folder ./data --output ./clips --prefix duet \\",
" --filter opensoundscape-kiwi-1.2 --species Kiwi+Duet",
)
_ = fs.Parse(args)
if *certainty < -1 || *certainty > 100 {
fmt.Fprintf(os.Stderr, "Error: --certainty must be between 0 and 100\n")
*certainty = -1 // sentinel: will be filtered by tools layer
}
return clipFlags{
file: *file,
folder: *folder,
output: *output,
prefix: *prefix,
filter: *filter,
species: *species,
size: *size,
certainty: *certainty,
color: *color,
night: *night,
day: *day,
location: *location,
}, fs
}
// validateClipFlags checks required flags and flag combinations.
func validateClipFlags(fs *flag.FlagSet, f clipFlags) error {
if f.file == "" && f.folder == "" {
fs.Usage()
return fmt.Errorf("missing required flags: [--file or --folder]")
}
if err := requireFlags(fs, map[string]any{"--output": f.output, "--prefix": f.prefix}); err != nil {
return err
}
if f.night && f.day {
fs.Usage()
return fmt.Errorf("--night and --day are mutually exclusive")
}
if (f.night || f.day) && f.location == "" {
fs.Usage()
return fmt.Errorf("--night/--day requires --location")
}
return nil
}
// RunCallsClip handles the "calls clip" subcommand
//
// JSON output schema:
//
// {
// "files_processed": int, // .data files processed
// "segments_clipped": int, // Segments that generated clips
// "night_skipped": int, // Segments skipped (--night, omitted if 0)
// "day_skipped": int, // Segments skipped (--day, omitted if 0)
// "output_files": [string], // Paths to generated clip files (.wav/.png)
// "errors": [string] // Error messages (omitted if empty)
// }
func RunCallsClip(args []string) error {
f, fs := parseClipArgs(args)
if err := validateClipFlags(fs, f); err != nil {
return err
}
// Build input
input := calls.CallsClipInput{
File: f.file,
Folder: f.folder,
Output: f.output,
Prefix: f.prefix,
Filter: f.filter,
Species: f.species,
Certainty: f.certainty,
Size: f.size,
Color: f.color,
Night: f.night,
Day: f.day,
Location: f.location,
}
// Execute
result, err := calls.CallsClip(input)
if err != nil {
// Print partial result as JSON (may contain useful info)
data, _ := json.Marshal(result)
fmt.Println(string(data))
return fmt.Errorf("clip: %w", err)
}
// Output JSON
data, _ := json.Marshal(result)
fmt.Println(string(data))
return nil
}