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_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,
	}
}