Fork channel

Create a new channel as a copy of main.

Rename channel

Rename main to:

Delete channel

Delete main? This cannot be undone.

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
}