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
}