calls_classify.go
package cmd
import (
"fmt"
"os"
"strconv"
"strings"
tea "charm.land/bubbletea/v2"
"skraak/config"
"skraak/datafile"
"skraak/tools/calls"
"skraak/tui"
"skraak/utils"
)
// reservedClassifyKeys are single-character keys the classify TUI handles
// itself (see tui/classify.go). User bindings to these keys would be silently
// overridden by the TUI, so we reject them at config-load time.
var reservedClassifyKeys = map[string]string{
",": "previous segment",
".": "next segment",
"0": "confirm label at certainty 100",
" ": "open comment dialog",
}
func printClassifyUsage() {
fmt.Fprintf(os.Stderr, "Usage: skraak calls classify [options]\n\n")
fmt.Fprintf(os.Stderr, "Interactive TUI for reviewing and classifying bird call segments.\n")
fmt.Fprintf(os.Stderr, "Reads .data files (AviaNZ format) and presents segments for labelling\n")
fmt.Fprintf(os.Stderr, "with spectrogram display and audio playback.\n\n")
fmt.Fprintf(os.Stderr, "Options:\n")
fmt.Fprintf(os.Stderr, " --folder <path> Path to folder containing .data files (required, or --file)\n")
fmt.Fprintf(os.Stderr, " --file <path> Path to a single .data file (required, or --folder)\n")
fmt.Fprintf(os.Stderr, " --filter <name> Filter name to scope which segments to review (optional)\n")
fmt.Fprintf(os.Stderr, " --species <name> Scope to species, optionally with calltype (e.g. Kiwi, Kiwi+Duet, Kiwi+_)\n")
fmt.Fprintf(os.Stderr, " Use +_ to match only calls with no calltype\n")
fmt.Fprintf(os.Stderr, " --certainty <int> Scope to certainty value (0-100, optional)\n")
fmt.Fprintf(os.Stderr, " --sample <1-100> Randomly sample N%% of filtered calls (requires --certainty; 100 = no-op)\n")
fmt.Fprintf(os.Stderr, " --goto <filename> Start at this .data file (basename match, optional)\n")
fmt.Fprintf(os.Stderr, " --night Only review solar-night recordings (requires --location)\n")
fmt.Fprintf(os.Stderr, " --day Only review solar-day recordings (requires --location)\n")
fmt.Fprintf(os.Stderr, " --size <int> Spectrogram image size in pixels (224-896, default: config img_dims or 448)\n")
fmt.Fprintf(os.Stderr, " --location <lat,lng[,tz]> GPS coordinates and optional IANA timezone\n")
fmt.Fprintf(os.Stderr, " e.g. --location \"-36.85,174.76\" or --location \"-36.85,174.76,Pacific/Auckland\"\n")
fmt.Fprintf(os.Stderr, " Required with --night or --day. Timezone defaults to UTC.\n")
fmt.Fprintf(os.Stderr, " Not needed for AudioMoth data (UTC from WAV comment).\n")
fmt.Fprintf(os.Stderr, " --bandpass <low-high> Bandpass filter frequency range in Hz (e.g. 8000-24000)\n")
fmt.Fprintf(os.Stderr, " Filters audio to the specified range before display/playback.\n")
fmt.Fprintf(os.Stderr, " Useful for high sample rate recordings targeting specific\n")
fmt.Fprintf(os.Stderr, " frequency bands (e.g. Rock Wren at 8-24kHz from 250kHz audio).\n")
fmt.Fprintf(os.Stderr, "\nConfig (required): ~/.skraak/config.json\n")
fmt.Fprintf(os.Stderr, " Provides reviewer, keybindings, and display flags (color/sixel/iterm/img_dims).\n")
fmt.Fprintf(os.Stderr, " Example:\n")
fmt.Fprintf(os.Stderr, " {\n")
fmt.Fprintf(os.Stderr, " \"classify\": {\n")
fmt.Fprintf(os.Stderr, " \"reviewer\": \"David\",\n")
fmt.Fprintf(os.Stderr, " \"color\": true,\n")
fmt.Fprintf(os.Stderr, " \"bindings\": {\n")
fmt.Fprintf(os.Stderr, " \"k\": \"Kiwi\",\n")
fmt.Fprintf(os.Stderr, " \"1\": \"Kiwi+Duet\",\n")
fmt.Fprintf(os.Stderr, " \"x\": \"Noise\"\n")
fmt.Fprintf(os.Stderr, " }\n")
fmt.Fprintf(os.Stderr, " }\n")
fmt.Fprintf(os.Stderr, " }\n")
fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " skraak calls classify --folder /path/to/data\n")
fmt.Fprintf(os.Stderr, " skraak calls classify --file /path/to/file.data --filter opensoundscape-kiwi-1.2\n")
fmt.Fprintf(os.Stderr, " skraak calls classify --folder /path/to/data --species Kiwi+Duet\n")
}
// classifyArgs holds parsed CLI arguments for the classify subcommand.
type classifyArgs struct {
folder string
file string
filter string
species string
gotoFile string
certainty int
sample int
size int
night bool
day bool
location string
bandpass string // "8000-24000" format
}
// classifyFlagSet maps a flag name to a setter function.
// The setter receives the value string and the current args state, returning the updated state.
// isBool flags take no value argument.
type classifyFlag struct {
set func(val string, a *classifyArgs)
isBool bool
}
// classifyFlags defines the flag dispatch table for parseClassifyArgs.
var classifyFlags = map[string]classifyFlag{
"--folder": {func(v string, a *classifyArgs) { a.folder = v }, false},
"--file": {func(v string, a *classifyArgs) { a.file = v }, false},
"--filter": {func(v string, a *classifyArgs) { a.filter = classifyUniqueSet(v, "--filter", a.filter) }, false},
"--species": {func(v string, a *classifyArgs) { a.species = classifyUniqueSet(v, "--species", a.species) }, false},
"--certainty": {func(v string, a *classifyArgs) { a.certainty = classifyIntValue(v, "--certainty", 0, 100) }, false},
"--sample": {func(v string, a *classifyArgs) { a.sample = classifyIntValue(v, "--sample", 1, 100) }, false},
"--goto": {func(v string, a *classifyArgs) { a.gotoFile = v }, false},
"--location": {func(v string, a *classifyArgs) { a.location = classifyUniqueSet(v, "--location", a.location) }, false},
"--bandpass": {func(v string, a *classifyArgs) { a.bandpass = classifyUniqueSet(v, "--bandpass", a.bandpass) }, false},
"--night": {func(_ string, a *classifyArgs) { a.night = true }, true},
"--day": {func(_ string, a *classifyArgs) { a.day = true }, true},
}
// classifyUniqueSet sets a string field, exiting if already set.
func classifyUniqueSet(val, flag, current string) string {
if current != "" {
fmt.Fprintf(os.Stderr, "Error: %s can only be specified once\n", flag)
os.Exit(1)
}
return val
}
// classifyIntValue parses and validates an integer flag value.
func classifyIntValue(val, flag string, lo, hi int) int {
v, err := strconv.Atoi(val)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %s must be an integer\n", flag)
os.Exit(1)
}
if v < lo || v > hi {
fmt.Fprintf(os.Stderr, "Error: %s must be between %d and %d\n", flag, lo, hi)
os.Exit(1)
}
return v
}
// parseClassifyArgs parses the argument slice and returns classified args.
func parseClassifyArgs(args []string) classifyArgs {
a := classifyArgs{certainty: -1, sample: -1, size: 0}
i := 0
for i < len(args) {
arg := args[i]
>>>>>>>>>>>>>> i += 2
case "--size":
a.size = a.requireIntRange(args, i, "--size", 224, 896)
<<<<<<<======= if arg == "--help" || arg == "-h" {
<<<<<<< printClassifyUsage()
os.Exit(0)
}
fl, ok := classifyFlags[arg]
if !ok {
fmt.Fprintf(os.Stderr, "Error: unknown flag: %s\n\n", arg)
printClassifyUsage()
os.Exit(1)
}
if fl.isBool {
fl.set("", &a)
i++
} else {
fl.set(mustValue(args, &i, arg), &a)
}
}
return a
}
// validate checks cross-flag constraints after parsing.
func (a classifyArgs) validate() error {
if err := a.validateSampleCertainty(); err != nil {
return err
}
if err := a.validateSource(); err != nil {
return err
}
if err := a.validateBandpass(); err != nil {
return err
}
return a.validateDayNight()
}
func (a classifyArgs) validateSampleCertainty() error {
if a.sample > 0 && a.sample < 100 && a.certainty < 0 {
return fmt.Errorf("--sample requires --certainty to be set")
}
return nil
}
func (a classifyArgs) validateSource() error {
if a.folder == "" && a.file == "" {
printClassifyUsage()
return fmt.Errorf("missing required flag: --folder or --file")
}
return nil
}
func (a classifyArgs) validateDayNight() error {
if a.night && a.day {
printClassifyUsage()
return fmt.Errorf("--night and --day are mutually exclusive")
}
if (a.night || a.day) && a.location == "" {
printClassifyUsage()
return fmt.Errorf("--night/--day requires --location")
}
return nil
}
func (a classifyArgs) validateBandpass() error {
if a.bandpass == "" {
return nil
}
_, _, err := parseBandpass(a.bandpass)
return err
}
// parseBandpass parses a bandpass string like "8000-24000" into low and high frequencies.
func parseBandpass(s string) (float64, float64, error) {
parts := strings.SplitN(s, "-", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("--bandpass format must be low-high (e.g. 8000-24000)")
}
low, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return 0, 0, fmt.Errorf("--bandpass low frequency must be a number")
}
high, err := strconv.ParseFloat(parts[1], 64)
if err != nil {
return 0, 0, fmt.Errorf("--bandpass high frequency must be a number")
}
if low < 0 {
return 0, 0, fmt.Errorf("--bandpass low frequency must be >= 0")
}
if high <= low {
return 0, 0, fmt.Errorf("--bandpass high frequency must be > low frequency")
}
return low, high, nil
}
// validateBindings checks config bindings and secondary_bindings, returning
// the converted []tools.KeyBinding slice. Returns an error on validation errors.
func validateBindings(cfg *config.Config, cfgPath string) ([]calls.KeyBinding, error) {
// Convert config bindings map -> []tools.KeyBinding via existing parseBind.
bindings := make([]calls.KeyBinding, 0, len(cfg.Classify.Bindings))
for key, value := range cfg.Classify.Bindings {
if len(key) != 1 {
return nil, fmt.Errorf("binding key %q in %s must be a single character", key, cfgPath)
}
if purpose, reserved := reservedClassifyKeys[key]; reserved {
return nil, fmt.Errorf("binding key %q in %s is reserved by the TUI for %s — pick a different key",
key, cfgPath, purpose)
}
bindings = append(bindings, parseBind(key+"="+value))
}
// Validate secondary_bindings: each outer key must exist in bindings,
// each inner key must be a single non-reserved char, values non-empty.
for primaryKey, inner := range cfg.Classify.SecondaryBindings {
if _, ok := cfg.Classify.Bindings[primaryKey]; !ok {
return nil, fmt.Errorf("secondary_bindings key %q in %s has no matching primary binding",
primaryKey, cfgPath)
}
for k, v := range inner {
if len(k) != 1 {
return nil, fmt.Errorf("secondary_bindings[%q] key %q in %s must be a single character",
primaryKey, k, cfgPath)
}
if purpose, reserved := reservedClassifyKeys[k]; reserved {
return nil, fmt.Errorf("secondary_bindings[%q] key %q in %s is reserved by the TUI for %s — pick a different key",
primaryKey, k, cfgPath, purpose)
}
if v == "" {
return nil, fmt.Errorf("secondary_bindings[%q][%q] in %s has empty calltype",
primaryKey, k, cfgPath)
}
}
}
return bindings, nil
}
>>>>>>>// classifyImageSize returns the effective image size: CLI flag overrides config.
// Zero from both means use the default (448, handled downstream).
func classifyImageSize(cliSize, configSize int) int {
if cliSize > 0 {
return cliSize
}
return configSize
}
// RunCallsClassify handles the "calls classify" subcommand
<<<<<<<
// loadClassifyConfig loads and validates the classify config from disk.
func loadClassifyConfig() (config.Config, string, []calls.KeyBinding, error) {
cfg, cfgPath, err := config.LoadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Create %s with a \"classify\" section; run `skraak calls classify --help` for an example.\n", cfgPath)
return cfg, cfgPath, nil, fmt.Errorf("loading config: %w", err)
}
if cfg.Classify.Reviewer == "" {
return cfg, cfgPath, nil, fmt.Errorf("%s is missing \"classify.reviewer\"", cfgPath)
}
if len(cfg.Classify.Bindings) == 0 {
return cfg, cfgPath, nil, fmt.Errorf("%s is missing \"classify.bindings\" (need at least one key)", cfgPath)
}
bindings, err := validateBindings(&cfg, cfgPath)
if err != nil {
return cfg, cfgPath, nil, err
}
return cfg, cfgPath, bindings, nil
}
// buildClassifyConfig constructs the ClassifyConfig from parsed args and loaded config.
func buildClassifyConfig(a classifyArgs, cfg config.Config, bindings []calls.KeyBinding) (calls.ClassifyConfig, error) {
speciesName, callType := datafile.ParseSpeciesCallType(a.species)
var lat, lng float64
var timezone string
if a.location != "" {
var err error
lat, lng, timezone, err = utils.ParseLocation(a.location)
if err != nil {
return calls.ClassifyConfig{}, fmt.Errorf("parsing location: %w", err)
}
}
var bandpassLow, bandpassHigh float64
if a.bandpass != "" {
var err error
bandpassLow, bandpassHigh, err = parseBandpass(a.bandpass)
if err != nil {
return calls.ClassifyConfig{}, err
}
}
return calls.ClassifyConfig{
Folder: a.folder,
File: a.file,
Filter: a.filter,
Species: speciesName,
CallType: callType,
Certainty: a.certainty,
Sample: a.sample,
Goto: a.gotoFile,
Reviewer: cfg.Classify.Reviewer,
Color: cfg.Classify.Color,
ImageSize: classifyImageSize(a.size, cfg.Classify.ImgDims),
Sixel: cfg.Classify.Sixel,
ITerm: cfg.Classify.ITerm,
Bindings: bindings,
SecondaryBindings: cfg.Classify.SecondaryBindings,
BandpassLow: bandpassLow,
BandpassHigh: bandpassHigh,
Night: a.night,
Day: a.day,
Lat: lat,
Lng: lng,
Timezone: timezone,
}, nil
}
// RunCallsClassify handles the "calls classify" subcommand
func RunCallsClassify(args []string) error {
a := parseClassifyArgs(args)
if err := a.validate(); err != nil {
return err
}
cfg, _, bindings, err := loadClassifyConfig()
if err != nil {
return err
}
config, err := buildClassifyConfig(a, cfg, bindings)
if err != nil {
return err
}
// Load data files
state, err := calls.LoadDataFiles(config)
if err != nil {
return fmt.Errorf("loading data files: %w", err)
}
// Show filtered counts (files with no matching segments are already pruned)
if state.TimeFilteredCount > 0 {
label := "daytime"
if config.Day {
label = "nighttime"
}
fmt.Fprintf(os.Stderr, "Skipped %d %s files\n", state.TimeFilteredCount, label)
}
fmt.Fprintf(os.Stderr, "Loaded %d files with %d matching segments\n",
len(state.DataFiles), state.TotalSegments())
if config.BandpassLow > 0 || config.BandpassHigh > 0 {
fmt.Fprintf(os.Stderr, "Bandpass: %.0f-%.0f Hz\n", config.BandpassLow, config.BandpassHigh)
}
if state.TotalSegments() == 0 {
fmt.Fprintf(os.Stderr, "No segments to review.\n")
return nil
}
// Launch TUI (alt screen for clean kitty image rendering)
p := tea.NewProgram(tui.New(state))
if _, err := p.Run(); err != nil {
return fmt.Errorf("TUI error: %w", err)
}
return nil
}
// parseBind parses "k=Kiwi" or "d=Kiwi+Duet" format
func parseBind(s string) calls.KeyBinding {
parts := strings.SplitN(s, "=", 2)
if len(parts) != 2 {
panic(fmt.Sprintf("invalid bind format: %s (expected key=value)", s))
}
key := parts[0]
value := parts[1]
// Check for Species+CallType format
if strings.Contains(value, "+") {
valueParts := strings.SplitN(value, "+", 2)
return calls.KeyBinding{
Key: key,
Species: valueParts[0],
CallType: valueParts[1],
}
}
// Species only
return calls.KeyBinding{
Key: key,
Species: value,
}
}