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_add.go
package calls

import (
	"fmt"
	"os"
	"strings"

	"skraak/datafile"
	"skraak/wav"
)

// CallsAddInput defines the input for the add tool
type CallsAddInput struct {
	File      string `json:"file"`
	Segment   string `json:"segment"`
	Frequency string `json:"frequency,omitempty"`
	Species   string `json:"species"`
	Certainty int    `json:"certainty"`
	Filter    string `json:"filter"`
	Reviewer  string `json:"reviewer"`
}

// CallsAddOutput defines the output for the add tool
type CallsAddOutput struct {
	File         string  `json:"file"`
	SegmentStart float64 `json:"segment_start"`
	SegmentEnd   float64 `json:"segment_end"`
	LowFreq      float64 `json:"low_freq"`
	HighFreq     float64 `json:"high_freq"`
	Species      string  `json:"species"`
	CallType     string  `json:"calltype,omitempty"`
	Filter       string  `json:"filter"`
	Certainty    int     `json:"certainty"`
	Created      bool    `json:"created"`           // true = new segment, false = label added to existing
	Clamped      bool    `json:"clamped,omitempty"` // true if segment end was clamped to WAV duration
	Error        string  `json:"error,omitempty"`
}

// addOutputError sets the error field and returns.
func addOutputError(output *CallsAddOutput, msg string) (CallsAddOutput, error) {
	output.Error = msg
	return *output, fmt.Errorf("%s", msg)
}

// validateAddInput checks required fields.
func validateAddInput(input CallsAddInput) error {
	if input.File == "" {
		return fmt.Errorf("--file is required")
	}
	if input.Segment == "" {
		return fmt.Errorf("--segment is required")
	}
	if input.Species == "" {
		return fmt.Errorf("--species is required")
	}
	if input.Reviewer == "" {
		return fmt.Errorf("--reviewer is required")
	}
	if input.Certainty < 0 || input.Certainty > 100 {
		return fmt.Errorf("--certainty must be between 0 and 100")
	}
	return nil
}

// parseSegmentRangeFloat parses "12.3-15.7" format into start and end floats.
func parseSegmentRangeFloat(s string) (float64, float64, error) {
	parts := strings.Split(s, "-")
	if len(parts) != 2 {
		return 0, 0, fmt.Errorf("invalid segment format: %s (expected start-end, e.g., 12.3-15.7)", s)
	}

	var start, end float64
	if _, err := fmt.Sscanf(parts[0], "%f", &start); err != nil {
		return 0, 0, fmt.Errorf("invalid start time: %s", parts[0])
	}
	if _, err := fmt.Sscanf(parts[1], "%f", &end); err != nil {
		return 0, 0, fmt.Errorf("invalid end time: %s", parts[1])
	}

	if start < 0 || end < 0 {
		return 0, 0, fmt.Errorf("times must be non-negative")
	}
	if start >= end {
		return 0, 0, fmt.Errorf("start time must be less than end time")
	}

	return start, end, nil
}

// parseFrequencyRange parses "200-4500" format into low and high floats.
// Returns (0, 0, nil) if the string is empty.
func parseFrequencyRange(s string) (float64, float64, error) {
	if s == "" {
		return 0, 0, nil
	}

	parts := strings.Split(s, "-")
	if len(parts) != 2 {
		return 0, 0, fmt.Errorf("invalid frequency format: %s (expected low-high, e.g., 200-4500)", s)
	}

	var low, high float64
	if _, err := fmt.Sscanf(parts[0], "%f", &low); err != nil {
		return 0, 0, fmt.Errorf("invalid low frequency: %s", parts[0])
	}
	if _, err := fmt.Sscanf(parts[1], "%f", &high); err != nil {
		return 0, 0, fmt.Errorf("invalid high frequency: %s", parts[1])
	}

	if low < 0 || high < 0 {
		return 0, 0, fmt.Errorf("frequencies must be non-negative")
	}
	if low >= high {
		return 0, 0, fmt.Errorf("low frequency must be less than high frequency")
	}

	return low, high, nil
}

// findExactSegment finds a segment with exact match on StartTime, EndTime, FreqLow, FreqHigh.
func findExactSegment(segments []*datafile.Segment, startTime, endTime, freqLow, freqHigh float64) *datafile.Segment {
	for _, seg := range segments {
		if seg.StartTime == startTime && seg.EndTime == endTime &&
			seg.FreqLow == freqLow && seg.FreqHigh == freqHigh {
			return seg
		}
	}
	return nil
}

// deriveWAVPath strips the .data suffix to find the companion WAV file.
func deriveWAVPath(dataPath string) string {
	return strings.TrimSuffix(dataPath, ".data")
}

// getWAVSampleRate reads the WAV header to get the sample rate.
func getWAVSampleRate(dataPath string) (int, error) {
	wavPath := deriveWAVPath(dataPath)
	if _, err := os.Stat(wavPath); os.IsNotExist(err) {
		return 0, fmt.Errorf("WAV file not found: %s (needed for default high_freq)", wavPath)
	}
	sampleRate, _, err := wav.ParseWAVHeaderMinimal(wavPath)
	if err != nil {
		return 0, fmt.Errorf("reading WAV header %s: %w", wavPath, err)
	}
	return sampleRate, nil
}

// resolveDuration returns the authoritative duration for clamping segment bounds.
// New .data files must read the WAV. Existing files prefer Meta.Duration (set at
// creation from the WAV); if absent or zero, we fall back to reading the WAV.
func resolveDuration(dataPath string, df *datafile.DataFile, newFile bool) (float64, error) {
	if !newFile && df.Meta != nil && df.Meta.Duration > 0 {
		return df.Meta.Duration, nil
	}
	return getWAVDuration(dataPath)
}

// getWAVDuration reads the WAV header to get the duration.
func getWAVDuration(dataPath string) (float64, error) {
	wavPath := deriveWAVPath(dataPath)
	if _, err := os.Stat(wavPath); os.IsNotExist(err) {
		return 0, fmt.Errorf("WAV file not found: %s", wavPath)
	}
	_, duration, err := wav.ParseWAVHeaderMinimal(wavPath)
	if err != nil {
		return 0, fmt.Errorf("reading WAV header %s: %w", wavPath, err)
	}
	return duration, nil
}

// resolveFreqDefaults sets freq defaults from WAV sample rate when --frequency is not provided.
func resolveFreqDefaults(frequency string, dataPath string) (float64, float64, error) {
	if frequency != "" {
		return 0, 0, nil // caller already parsed these
	}
	sampleRate, err := getWAVSampleRate(dataPath)
	if err != nil {
		return 0, 0, err
	}
	return 0, float64(sampleRate), nil
}

// addLabelToSegment adds a label to an existing segment, or errors if the filter already exists.
func addLabelToSegment(segment *datafile.Segment, species, callType string, input CallsAddInput, output *CallsAddOutput) (CallsAddOutput, error) {
	for _, label := range segment.Labels {
		if label.Filter == input.Filter {
			msg := fmt.Sprintf("segment %.1f-%.1f already has a label with filter '%s'", output.SegmentStart, output.SegmentEnd, input.Filter)
			return addOutputError(output, msg)
		}
	}
	segment.Labels = append(segment.Labels, &datafile.Label{
		Species:   species,
		Certainty: input.Certainty,
		Filter:    input.Filter,
		CallType:  callType,
	})
	output.Created = false
	return *output, nil
}

// createNewSegment creates a new segment with a label in the data file.
func createNewSegment(dataFile *datafile.DataFile, species, callType string, input CallsAddInput, output *CallsAddOutput) (CallsAddOutput, error) {
	newSeg := &datafile.Segment{
		StartTime: output.SegmentStart,
		EndTime:   output.SegmentEnd,
		FreqLow:   output.LowFreq,
		FreqHigh:  output.HighFreq,
		Labels: []*datafile.Label{
			{
				Species:   species,
				Certainty: input.Certainty,
				Filter:    input.Filter,
				CallType:  callType,
			},
		},
	}
	dataFile.Segments = append(dataFile.Segments, newSeg)
	output.Created = true
	return *output, nil
}

// parseAddInput parses and validates all add input fields, populating the output.
func parseAddInput(input CallsAddInput, output *CallsAddOutput) (float64, float64, error) {
	if err := validateAddInput(input); err != nil {
		return 0, 0, err
	}

	startTime, endTime, err := parseSegmentRangeFloat(input.Segment)
	if err != nil {
		return 0, 0, err
	}

	freqLow, freqHigh, err := parseFrequencyRange(input.Frequency)
	if err != nil {
		return 0, 0, err
	}

	species, callType := datafile.ParseSpeciesCallType(input.Species)
	output.File = input.File
	output.SegmentStart = startTime
	output.SegmentEnd = endTime
	output.Species = species
	output.CallType = callType
	output.Filter = input.Filter
	output.Certainty = input.Certainty

	// Resolve freq defaults from WAV if not provided
	if input.Frequency == "" {
		freqLow, freqHigh, err = resolveFreqDefaults(input.Frequency, input.File)
		if err != nil {
			return 0, 0, err
		}
	}
	output.LowFreq = freqLow
	output.HighFreq = freqHigh

	return freqLow, freqHigh, nil
}

// CallsAdd adds a segment/label to a .data file
func CallsAdd(input CallsAddInput) (CallsAddOutput, error) {
	var output CallsAddOutput

	freqLow, freqHigh, err := parseAddInput(input, &output)
	if err != nil {
		return addOutputError(&output, err.Error())
	}

	dataFile, newFile, err := loadOrCreateDataFile(input.File, input.Reviewer)
	if err != nil {
		return addOutputError(&output, err.Error())
	}

	duration, err := resolveDuration(input.File, dataFile, newFile)
	if err != nil {
		return addOutputError(&output, err.Error())
	}
	if output.SegmentStart >= duration {
		return addOutputError(&output, fmt.Sprintf("segment start %.3f >= file duration %.3f (impossible)", output.SegmentStart, duration))
	}
	if output.SegmentEnd > duration {
		output.SegmentEnd = duration
		output.Clamped = true
	}

	dataFile.Meta.Reviewer = input.Reviewer
	if newFile {
		dataFile.Meta.Duration = duration
	}

	// Look for existing segment with exact match
	segment := findExactSegment(dataFile.Segments, output.SegmentStart, output.SegmentEnd, freqLow, freqHigh)

	if segment != nil {
		output, err = addLabelToSegment(segment, output.Species, output.CallType, input, &output)
	} else {
		output, err = createNewSegment(dataFile, output.Species, output.CallType, input, &output)
	}
	if err != nil {
		return output, err
	}

	if writeErr := dataFile.Write(input.File); writeErr != nil {
		return addOutputError(&output, fmt.Sprintf("failed to save file: %v", writeErr))
	}

	return output, nil
}

// loadOrCreateDataFile loads an existing .data file or creates a new one.
// Returns the DataFile, whether it was newly created, and any error.
func loadOrCreateDataFile(path string, reviewer string) (*datafile.DataFile, bool, error) {
	if _, err := os.Stat(path); os.IsNotExist(err) {
		df := &datafile.DataFile{
			Meta: &datafile.DataMeta{
				Operator: "Manual",
				Reviewer: reviewer,
			},
			Segments: []*datafile.Segment{},
		}
		return df, true, nil // true = newly created
	}

	df, err := datafile.ParseDataFile(path)
	if err != nil {
		return nil, false, fmt.Errorf("failed to parse file: %v", err)
	}
	return df, false, nil // false = already existed
}