fixed cyclo over 30

quietlight
May 4, 2026, 4:33 AM
2P27XV3DGJCRA4SNJENCJYZLPR2XWZMTY7CGYYSJOY4UMDVVO25AC

Dependencies

  • [2] GVOVKH5R more cyclo refactoring
  • [3] DS22DKV3 added shell script integration tests.
  • [4] KZKLAINJ run out of space on nest, cleaned out
  • [5] LQLC7S3A trying gemini: Inconsistent Standards in @utils/ refactoring

Change contents

  • edit in utils/mapping.go at line 114
    [3.92843]
    [3.92843]
    mappedSpeciesSet, mappedCalltypes := collectMappedLabels(mapping, dataCalltypes)
    // Validate species exist in DB
    if err := validateMappedSpecies(queryer, mappedSpeciesSet, &result); err != nil {
    return result, err
    }
    // Validate calltypes exist in DB
    if err := validateMappedCalltypes(queryer, mappedCalltypes, &result); err != nil {
    return result, err
    }
    return result, nil
    }
    // collectMappedLabels builds sets of mapped species and calltype labels
    func collectMappedLabels(mapping MappingFile, dataCalltypes map[string]map[string]bool) (map[string]bool, map[string]map[string]string) {
  • replacement in utils/mapping.go at line 177
    [3.93942][3.93942:93975]()
    // Validate species exist in DB
    [3.93942]
    [3.93975]
    return mappedSpeciesSet, mappedCalltypes
    }
    // validateMappedSpecies checks that all mapped species exist in the database
    func validateMappedSpecies(queryer DB, mappedSpeciesSet map[string]bool, result *MappingValidationResult) error {
  • edit in utils/mapping.go at line 187
    [3.94144]
    [3.94144]
    if len(speciesLabels) == 0 {
    return nil
    }
  • replacement in utils/mapping.go at line 192
    [3.94145][3.94145:94174](),[3.94174][3.4984:5102](),[3.5102][3.94289:94386](),[3.94289][3.94289:94386]()
    if len(speciesLabels) > 0 {
    query := `SELECT label FROM species WHERE label IN (` + db.Placeholders(len(speciesLabels)) + `) AND active = true`
    args := make([]any, len(speciesLabels))
    for i, s := range speciesLabels {
    args[i] = s
    }
    [3.94145]
    [3.94386]
    query := `SELECT label FROM species WHERE label IN (` + db.Placeholders(len(speciesLabels)) + `) AND active = true`
    args := make([]any, len(speciesLabels))
    for i, s := range speciesLabels {
    args[i] = s
    }
  • replacement in utils/mapping.go at line 198
    [3.94387][3.5103:5148](),[3.5148][3.94427:94535](),[3.94427][3.94427:94535]()
    rows, err := queryer.Query(query, args...)
    if err != nil {
    return result, fmt.Errorf("failed to query species: %w", err)
    }
    defer rows.Close()
    [3.94387]
    [3.94535]
    rows, err := queryer.Query(query, args...)
    if err != nil {
    return fmt.Errorf("failed to query species: %w", err)
    }
    defer rows.Close()
  • replacement in utils/mapping.go at line 204
    [3.94536][3.94536:94697]()
    foundSpecies := make(map[string]bool)
    for rows.Next() {
    var label string
    if err := rows.Scan(&label); err == nil {
    foundSpecies[label] = true
    }
    [3.94536]
    [3.94697]
    foundSpecies := make(map[string]bool)
    for rows.Next() {
    var label string
    if err := rows.Scan(&label); err == nil {
    foundSpecies[label] = true
  • edit in utils/mapping.go at line 210
    [3.94701]
    [3.94701]
    }
  • replacement in utils/mapping.go at line 212
    [3.94702][3.94702:94833]()
    for _, s := range speciesLabels {
    if !foundSpecies[s] {
    result.MissingDBSpecies = append(result.MissingDBSpecies, s)
    }
    [3.94702]
    [3.94833]
    for _, s := range speciesLabels {
    if !foundSpecies[s] {
    result.MissingDBSpecies = append(result.MissingDBSpecies, s)
  • edit in utils/mapping.go at line 217
    [3.94840]
    [3.94840]
    return nil
    }
  • replacement in utils/mapping.go at line 220
    [3.94841][3.94841:94876]()
    // Validate calltypes exist in DB
    [3.94841]
    [3.94876]
    // validateMappedCalltypes checks that all mapped calltypes exist in the database
    func validateMappedCalltypes(queryer DB, mappedCalltypes map[string]map[string]string, result *MappingValidationResult) error {
  • replacement in utils/mapping.go at line 246
    [3.95470][3.95470:95563]()
    return result, fmt.Errorf("failed to query calltypes for species %s: %w", dbSpecies, err)
    [3.95470]
    [3.95563]
    return fmt.Errorf("failed to query calltypes for species %s: %w", dbSpecies, err)
  • replacement in utils/mapping.go at line 266
    [3.95959][3.95959:95980]()
    return result, nil
    [3.95959]
    [3.95980]
    return nil
  • replacement in tools/cluster.go at line 67
    [3.380830][3.380830:381498]()
    if input.DatasetID == nil || strings.TrimSpace(*input.DatasetID) == "" {
    return output, fmt.Errorf("dataset_id is required when creating a cluster")
    }
    if input.LocationID == nil || strings.TrimSpace(*input.LocationID) == "" {
    return output, fmt.Errorf("location_id is required when creating a cluster")
    }
    if input.Name == nil || strings.TrimSpace(*input.Name) == "" {
    return output, fmt.Errorf("name is required when creating a cluster")
    }
    if input.SampleRate == nil {
    return output, fmt.Errorf("sample_rate is required when creating a cluster")
    }
    // Validate ID formats
    if err := utils.ValidateShortID(*input.DatasetID, "dataset_id"); err != nil {
    [3.380830]
    [3.381498]
    if err := validateCreateClusterFields(input); err != nil {
  • replacement in tools/cluster.go at line 70
    [3.381522][3.381522:381603]()
    if err := utils.ValidateShortID(*input.LocationID, "location_id"); err != nil {
    [3.381522]
    [3.381603]
    // Validate ID formats and common fields
    if err := validateCreateClusterIDs(input); err != nil {
  • edit in tools/cluster.go at line 80
    [3.381707][3.381707:381747]()
    // Validate optional pattern ID format
  • replacement in tools/cluster.go at line 103
    [3.382375][3.382375:382749]()
    var datasetExists, datasetActive bool
    var datasetName string
    err = tx.QueryRowContext(ctx,
    "SELECT EXISTS(SELECT 1 FROM dataset WHERE id = ?), COALESCE((SELECT active FROM dataset WHERE id = ?), false), COALESCE((SELECT name FROM dataset WHERE id = ?), '')",
    *input.DatasetID, *input.DatasetID, *input.DatasetID,
    ).Scan(&datasetExists, &datasetActive, &datasetName)
    [3.382375]
    [3.382749]
    datasetName, err := verifyDatasetForCluster(ctx, tx, *input.DatasetID)
  • replacement in tools/cluster.go at line 105
    [3.382766][3.382766:383062]()
    return output, fmt.Errorf("failed to verify dataset: %w", err)
    }
    if !datasetExists {
    return output, fmt.Errorf("dataset with ID '%s' does not exist", *input.DatasetID)
    }
    if !datasetActive {
    return output, fmt.Errorf("dataset '%s' (ID: %s) is not active", datasetName, *input.DatasetID)
    [3.382766]
    [3.383062]
    return output, err
  • replacement in tools/cluster.go at line 109
    [3.383142][3.383142:383659]()
    var locationExists, locationActive bool
    var locationName string
    var locationDatasetID string
    err = tx.QueryRowContext(ctx,
    "SELECT EXISTS(SELECT 1 FROM location WHERE id = ?), COALESCE((SELECT active FROM location WHERE id = ?), false), COALESCE((SELECT name FROM location WHERE id = ?), ''), COALESCE((SELECT dataset_id FROM location WHERE id = ?), '')",
    *input.LocationID, *input.LocationID, *input.LocationID, *input.LocationID,
    ).Scan(&locationExists, &locationActive, &locationName, &locationDatasetID)
    [3.383142]
    [3.383659]
    locationName, err := verifyLocationForCluster(ctx, tx, *input.LocationID, *input.DatasetID, datasetName)
  • replacement in tools/cluster.go at line 111
    [3.383676][3.383676:383742]()
    return output, fmt.Errorf("failed to verify location: %w", err)
    [3.383676]
    [3.383742]
    return output, err
  • edit in tools/cluster.go at line 113
    [3.383745][3.383745:384243]()
    if !locationExists {
    return output, fmt.Errorf("location with ID '%s' does not exist", *input.LocationID)
    }
    if !locationActive {
    return output, fmt.Errorf("location '%s' (ID: %s) is not active", locationName, *input.LocationID)
    }
    if locationDatasetID != *input.DatasetID {
    return output, fmt.Errorf("location '%s' (ID: %s) does not belong to dataset '%s' (ID: %s) - it belongs to dataset ID '%s'",
    locationName, *input.LocationID, datasetName, *input.DatasetID, locationDatasetID)
    }
  • replacement in tools/cluster.go at line 116
    [3.384396][3.384396:384829]()
    var patternExists, patternActive bool
    err = tx.QueryRowContext(ctx,
    "SELECT EXISTS(SELECT 1 FROM cyclic_recording_pattern WHERE id = ?), COALESCE((SELECT active FROM cyclic_recording_pattern WHERE id = ?), false)",
    *input.CyclicRecordingPatternID, *input.CyclicRecordingPatternID,
    ).Scan(&patternExists, &patternActive)
    if err != nil {
    return output, fmt.Errorf("failed to verify cyclic recording pattern: %w", err)
    [3.384396]
    [3.384829]
    if err := verifyPatternExists(ctx, tx, *input.CyclicRecordingPatternID); err != nil {
    return output, err
  • edit in tools/cluster.go at line 119
    [3.384833][3.384833:385120]()
    if !patternExists {
    return output, fmt.Errorf("cyclic recording pattern with ID '%s' does not exist", *input.CyclicRecordingPatternID)
    }
    if !patternActive {
    return output, fmt.Errorf("cyclic recording pattern with ID '%s' is not active", *input.CyclicRecordingPatternID)
    }
  • replacement in tools/cluster.go at line 122
    [3.385202][3.385202:385393]()
    var existingID string
    err = tx.QueryRowContext(ctx,
    "SELECT id FROM cluster WHERE location_id = ? AND name = ? AND active = true",
    *input.LocationID, *input.Name,
    ).Scan(&existingID)
    [3.385202]
    [3.385393]
    existing, err := findExistingClusterInLocation(ctx, tx, *input.LocationID, *input.Name)
  • replacement in tools/cluster.go at line 124
    [3.385410][3.385410:386066]()
    // Cluster with this name already exists in location - return existing (consistent duplicate handling)
    var cluster db.Cluster
    err = tx.QueryRowContext(ctx,
    "SELECT id, dataset_id, location_id, name, description, created_at, last_modified, active, cyclic_recording_pattern_id, sample_rate FROM cluster WHERE id = ?",
    existingID,
    ).Scan(&cluster.ID, &cluster.DatasetID, &cluster.LocationID, &cluster.Name, &cluster.Description,
    &cluster.CreatedAt, &cluster.LastModified, &cluster.Active, &cluster.CyclicRecordingPatternID, &cluster.SampleRate)
    if err != nil {
    return output, fmt.Errorf("failed to fetch existing cluster: %w", err)
    }
    [3.385410]
    [3.386066]
    // Cluster with this name already exists - return existing
  • replacement in tools/cluster.go at line 128
    [3.386177][3.386177:386362]()
    output.Cluster = cluster
    output.Message = fmt.Sprintf("Cluster '%s' already exists in location '%s' (ID: %s) - returning existing cluster", cluster.Name, locationName, cluster.ID)
    [3.386177]
    [3.386362]
    output.Cluster = existing
    output.Message = fmt.Sprintf("Cluster '%s' already exists in location '%s' (ID: %s) - returning existing cluster", existing.Name, locationName, existing.ID)
  • replacement in tools/cluster.go at line 133
    [3.386387][3.386387:386403]()
    // Generate ID
    [3.386387]
    [3.386403]
    // Generate ID and insert
  • edit in tools/cluster.go at line 139
    [3.386522][3.386522:386541]()
    // Insert cluster
  • replacement in tools/cluster.go at line 148
    [3.387040][3.387040:387480]()
    var cluster db.Cluster
    err = tx.QueryRowContext(ctx,
    "SELECT id, dataset_id, location_id, name, description, created_at, last_modified, active, cyclic_recording_pattern_id, sample_rate FROM cluster WHERE id = ?",
    id,
    ).Scan(&cluster.ID, &cluster.DatasetID, &cluster.LocationID, &cluster.Name, &cluster.Description,
    &cluster.CreatedAt, &cluster.LastModified, &cluster.Active, &cluster.CyclicRecordingPatternID, &cluster.SampleRate)
    [3.387040]
    [3.387480]
    cluster, err := fetchClusterByID(ctx, tx, id)
  • edit in tools/cluster.go at line 164
    [3.387939]
    [3.387939]
    // validateCreateClusterFields validates required fields for creating a cluster
    func validateCreateClusterFields(input ClusterInput) error {
    if input.DatasetID == nil || strings.TrimSpace(*input.DatasetID) == "" {
    return fmt.Errorf("dataset_id is required when creating a cluster")
    }
    if input.LocationID == nil || strings.TrimSpace(*input.LocationID) == "" {
    return fmt.Errorf("location_id is required when creating a cluster")
    }
    if input.Name == nil || strings.TrimSpace(*input.Name) == "" {
    return fmt.Errorf("name is required when creating a cluster")
    }
    if input.SampleRate == nil {
    return fmt.Errorf("sample_rate is required when creating a cluster")
    }
    return nil
    }
    // validateCreateClusterIDs validates ID format fields
    func validateCreateClusterIDs(input ClusterInput) error {
    if err := utils.ValidateShortID(*input.DatasetID, "dataset_id"); err != nil {
    return err
    }
    return utils.ValidateShortID(*input.LocationID, "location_id")
    }
    // verifyDatasetForCluster verifies dataset exists and is active within a transaction
    func verifyDatasetForCluster(ctx context.Context, tx *db.LoggedTx, datasetID string) (string, error) {
    var exists, active bool
    var name string
    err := tx.QueryRowContext(ctx,
    "SELECT EXISTS(SELECT 1 FROM dataset WHERE id = ?), COALESCE((SELECT active FROM dataset WHERE id = ?), false), COALESCE((SELECT name FROM dataset WHERE id = ?), '')",
    datasetID, datasetID, datasetID,
    ).Scan(&exists, &active, &name)
    if err != nil {
    return "", fmt.Errorf("failed to verify dataset: %w", err)
    }
    if !exists {
    return "", fmt.Errorf("dataset with ID '%s' does not exist", datasetID)
    }
    if !active {
    return "", fmt.Errorf("dataset '%s' (ID: %s) is not active", name, datasetID)
    }
    return name, nil
    }
    // verifyLocationForCluster verifies location exists, is active, and belongs to the dataset
    func verifyLocationForCluster(ctx context.Context, tx *db.LoggedTx, locationID, datasetID, datasetName string) (string, error) {
    var exists, active bool
    var name, locDatasetID string
    err := tx.QueryRowContext(ctx,
    "SELECT EXISTS(SELECT 1 FROM location WHERE id = ?), COALESCE((SELECT active FROM location WHERE id = ?), false), COALESCE((SELECT name FROM location WHERE id = ?), ''), COALESCE((SELECT dataset_id FROM location WHERE id = ?), '')",
    locationID, locationID, locationID, locationID,
    ).Scan(&exists, &active, &name, &locDatasetID)
    if err != nil {
    return "", fmt.Errorf("failed to verify location: %w", err)
    }
    if !exists {
    return "", fmt.Errorf("location with ID '%s' does not exist", locationID)
    }
    if !active {
    return "", fmt.Errorf("location '%s' (ID: %s) is not active", name, locationID)
    }
    if locDatasetID != datasetID {
    return "", fmt.Errorf("location '%s' (ID: %s) does not belong to dataset '%s' (ID: %s) - it belongs to dataset ID '%s'",
    name, locationID, datasetName, datasetID, locDatasetID)
    }
    return name, nil
    }
    // verifyPatternExists verifies a cyclic recording pattern exists and is active
    func verifyPatternExists(ctx context.Context, tx *db.LoggedTx, patternID string) error {
    var exists, active bool
    err := tx.QueryRowContext(ctx,
    "SELECT EXISTS(SELECT 1 FROM cyclic_recording_pattern WHERE id = ?), COALESCE((SELECT active FROM cyclic_recording_pattern WHERE id = ?), false)",
    patternID, patternID,
    ).Scan(&exists, &active)
    if err != nil {
    return fmt.Errorf("failed to verify cyclic recording pattern: %w", err)
    }
    if !exists {
    return fmt.Errorf("cyclic recording pattern with ID '%s' does not exist", patternID)
    }
    if !active {
    return fmt.Errorf("cyclic recording pattern with ID '%s' is not active", patternID)
    }
    return nil
    }
    // findExistingClusterInLocation checks for an existing cluster with the same name in a location
    func findExistingClusterInLocation(ctx context.Context, tx *db.LoggedTx, locationID, name string) (db.Cluster, error) {
    var existingID string
    err := tx.QueryRowContext(ctx,
    "SELECT id FROM cluster WHERE location_id = ? AND name = ? AND active = true",
    locationID, name,
    ).Scan(&existingID)
    if err != nil {
    return db.Cluster{}, err
    }
    return fetchClusterByID(ctx, tx, existingID)
    }
    // fetchClusterByID fetches a cluster row by ID
    func fetchClusterByID(ctx context.Context, tx *db.LoggedTx, id string) (db.Cluster, error) {
    var c db.Cluster
    err := tx.QueryRowContext(ctx,
    "SELECT id, dataset_id, location_id, name, description, created_at, last_modified, active, cyclic_recording_pattern_id, sample_rate FROM cluster WHERE id = ?",
    id,
    ).Scan(&c.ID, &c.DatasetID, &c.LocationID, &c.Name, &c.Description,
    &c.CreatedAt, &c.LastModified, &c.Active, &c.CyclicRecordingPatternID, &c.SampleRate)
    return c, err
    }
  • edit in tools/calls_summarise.go at line 93
    [3.395632]
    [3.395632]
    summariseFiles(filePaths, input, &output, operatorSet, reviewerSet)
  • replacement in tools/calls_summarise.go at line 96
    [3.395633][3.395633:395655]()
    // Process each file
    [3.395633]
    [3.395655]
    // Count segments for total
    if input.Brief {
    for _, fs := range output.Filters {
    output.TotalSegments += fs.Segments
    }
    } else {
    output.TotalSegments = len(output.Segments)
    }
    finaliseSummary(&output, operatorSet, reviewerSet, input.Brief)
    return output, nil
    }
    // summariseFiles processes all data files, populating output stats
    func summariseFiles(filePaths []string, input CallsSummariseInput, output *CallsSummariseOutput, operatorSet, reviewerSet map[string]bool) {
  • edit in tools/calls_summarise.go at line 115
    [3.395746][3.395746:395795]()
    // Extract just the filename for skipped list
  • replacement in tools/calls_summarise.go at line 120
    [3.395904][3.395904:396118]()
    // Track operator and reviewer
    if df.Meta != nil {
    if df.Meta.Operator != "" {
    operatorSet[df.Meta.Operator] = true
    }
    if df.Meta.Reviewer != "" {
    reviewerSet[df.Meta.Reviewer] = true
    }
    }
    [3.395904]
    [3.396118]
    trackMeta(df.Meta, operatorSet, reviewerSet)
  • edit in tools/calls_summarise.go at line 122
    [3.396119][3.396119:396190]()
    // Extract relative filename for segments (only needed if not brief)
  • edit in tools/calls_summarise.go at line 127
    [3.396289][3.396289:396311]()
    // Process segments
  • replacement in tools/calls_summarise.go at line 128
    [3.396347][3.396347:396645]()
    // Filter labels if --filter is specified
    var filteredLabels []*utils.Label
    for _, l := range seg.Labels {
    if input.Filter == "" || l.Filter == input.Filter {
    filteredLabels = append(filteredLabels, l)
    }
    }
    // Skip segments with no matching labels when filter is active
    [3.396347]
    [3.396645]
    filteredLabels := filterLabels(seg.Labels, input.Filter)
  • replacement in tools/calls_summarise.go at line 133
    [3.396719][3.396719:396796]()
    // Build label summaries (only if not brief)
    var labels []LabelSummary
    [3.396719]
    [3.396796]
    updateStatsFromLabels(filteredLabels, output)
  • replacement in tools/calls_summarise.go at line 136
    [3.396817][3.396817:397242]()
    for _, l := range filteredLabels {
    labelSummary := LabelSummary{
    Filter: l.Filter,
    Certainty: l.Certainty,
    Species: l.Species,
    }
    if l.CallType != "" {
    labelSummary.CallType = l.CallType
    }
    if l.Comment != "" {
    labelSummary.Comment = l.Comment
    }
    if l.Bookmark {
    labelSummary.Bookmark = true
    }
    labels = append(labels, labelSummary)
    }
    [3.396817]
    [3.397242]
    output.Segments = append(output.Segments, SegmentSummary{
    File: relPath,
    StartTime: seg.StartTime,
    EndTime: seg.EndTime,
    Labels: buildLabelSummaries(filteredLabels),
    })
  • edit in tools/calls_summarise.go at line 143
    [3.397247]
    [3.397247]
    }
    }
    }
  • replacement in tools/calls_summarise.go at line 147
    [3.397248][3.397248:397632]()
    // Update filter stats and review status (using filtered labels)
    for _, l := range filteredLabels {
    // Update filter stats
    fs, exists := output.Filters[l.Filter]
    if !exists {
    fs = FilterStats{
    Segments: 0,
    Species: make(map[string]int),
    Calltypes: make(map[string]map[string]int),
    }
    }
    fs.Segments++
    fs.Species[l.Species]++
    [3.397248]
    [3.397632]
    // trackMeta records operator and reviewer from file metadata
    func trackMeta(meta *utils.DataMeta, operatorSet, reviewerSet map[string]bool) {
    if meta == nil {
    return
    }
    if meta.Operator != "" {
    operatorSet[meta.Operator] = true
    }
    if meta.Reviewer != "" {
    reviewerSet[meta.Reviewer] = true
    }
    }
  • replacement in tools/calls_summarise.go at line 160
    [3.397633][3.397633:397877]()
    // Track calltypes if present
    if l.CallType != "" {
    if fs.Calltypes[l.Species] == nil {
    fs.Calltypes[l.Species] = make(map[string]int)
    }
    fs.Calltypes[l.Species][l.CallType]++
    }
    output.Filters[l.Filter] = fs
    [3.397633]
    [3.397877]
    // filterLabels returns labels matching the filter, or all labels if filter is empty
    func filterLabels(labels []*utils.Label, filter string) []*utils.Label {
    if filter == "" {
    return labels
    }
    var filtered []*utils.Label
    for _, l := range labels {
    if l.Filter == filter {
    filtered = append(filtered, l)
    }
    }
    return filtered
    }
  • replacement in tools/calls_summarise.go at line 174
    [3.397878][3.397878:398087]()
    // Update review status
    switch l.Certainty {
    case 100:
    output.ReviewStatus.Confirmed++
    case 0:
    output.ReviewStatus.DontKnow++
    default:
    output.ReviewStatus.Unreviewed++
    }
    [3.397878]
    [3.398087]
    // buildLabelSummaries converts labels to label summaries
    func buildLabelSummaries(labels []*utils.Label) []LabelSummary {
    var summaries []LabelSummary
    for _, l := range labels {
    ls := LabelSummary{
    Filter: l.Filter,
    Certainty: l.Certainty,
    Species: l.Species,
    }
    if l.CallType != "" {
    ls.CallType = l.CallType
    }
    if l.Comment != "" {
    ls.Comment = l.Comment
    }
    if l.Bookmark {
    ls.Bookmark = true
    }
    summaries = append(summaries, ls)
    }
    return summaries
    }
  • replacement in tools/calls_summarise.go at line 197
    [3.398088][3.398088:398300]()
    if l.CallType != "" {
    output.ReviewStatus.WithCallType++
    }
    if l.Comment != "" {
    output.ReviewStatus.WithComments++
    }
    if l.Bookmark {
    output.ReviewStatus.Bookmarked++
    }
    }
    [3.398088]
    [3.398300]
    // updateStatsFromLabels updates filter stats and review status from a set of labels
    func updateStatsFromLabels(labels []*utils.Label, output *CallsSummariseOutput) {
    for _, l := range labels {
    updateFilterStats(l, output)
    updateReviewStatus(l, output)
    }
    }
  • replacement in tools/calls_summarise.go at line 205
    [3.398301][3.398301:398581]()
    // Create segment summary only if not brief
    if !input.Brief {
    segSummary := SegmentSummary{
    File: relPath,
    StartTime: seg.StartTime,
    EndTime: seg.EndTime,
    Labels: labels,
    }
    output.Segments = append(output.Segments, segSummary)
    }
    [3.398301]
    [3.398581]
    // updateFilterStats increments filter-level statistics for a single label
    func updateFilterStats(l *utils.Label, output *CallsSummariseOutput) {
    fs, exists := output.Filters[l.Filter]
    if !exists {
    fs = FilterStats{
    Segments: 0,
    Species: make(map[string]int),
    Calltypes: make(map[string]map[string]int),
  • edit in tools/calls_summarise.go at line 215
    [3.398588]
    [3.398588]
    fs.Segments++
    fs.Species[l.Species]++
  • replacement in tools/calls_summarise.go at line 218
    [3.398589][3.398589:398775]()
    // Count segments for total
    if input.Brief {
    // Recount from filter stats since we didn't track segments
    for _, fs := range output.Filters {
    output.TotalSegments += fs.Segments
    [3.398589]
    [3.398775]
    if l.CallType != "" {
    if fs.Calltypes[l.Species] == nil {
    fs.Calltypes[l.Species] = make(map[string]int)
  • replacement in tools/calls_summarise.go at line 222
    [3.398779][3.398779:398835]()
    } else {
    output.TotalSegments = len(output.Segments)
    [3.398779]
    [3.398835]
    fs.Calltypes[l.Species][l.CallType]++
    }
    output.Filters[l.Filter] = fs
    }
    // updateReviewStatus increments review status counters for a single label
    func updateReviewStatus(l *utils.Label, output *CallsSummariseOutput) {
    switch l.Certainty {
    case 100:
    output.ReviewStatus.Confirmed++
    case 0:
    output.ReviewStatus.DontKnow++
    default:
    output.ReviewStatus.Unreviewed++
    }
    if l.CallType != "" {
    output.ReviewStatus.WithCallType++
    }
    if l.Comment != "" {
    output.ReviewStatus.WithComments++
    }
    if l.Bookmark {
    output.ReviewStatus.Bookmarked++
  • edit in tools/calls_summarise.go at line 246
    [3.398838]
    [3.398838]
    }
  • replacement in tools/calls_summarise.go at line 248
    [3.398839][3.398839:398920]()
    // Clean up empty calltypes maps (omitempty doesn't work on non-nil empty maps)
    [3.398839]
    [3.398920]
    // finaliseSummary sorts output, cleans empty maps, and converts sets to sorted slices
    func finaliseSummary(output *CallsSummariseOutput, operatorSet, reviewerSet map[string]bool, brief bool) {
    // Clean up empty calltypes maps
  • replacement in tools/calls_summarise.go at line 268
    [3.399318][3.399318:399400]()
    // Sort segments by file, then start time (only if not brief)
    if !input.Brief {
    [3.399318]
    [3.399400]
    // Sort segments by file, then start time
    if !brief {
  • edit in tools/calls_summarise.go at line 277
    [3.399655][3.399655:399676]()
    return output, nil
  • replacement in tools/calls_propagate.go at line 98
    [3.434809][3.434809:434870]()
    if input.File == "" {
    output.Error = "--file is required"
    [3.434809]
    [3.434870]
    if err := validatePropagateInput(&output, input); err != nil {
    return output, err
    }
    df, err := utils.ParseDataFile(input.File)
    if err != nil {
    output.Error = fmt.Sprintf("parse %s: %v", input.File, err)
  • replacement in tools/calls_propagate.go at line 107
    [3.434921][3.434921:435036]()
    if input.FromFilter == "" {
    output.Error = "--from is required"
    return output, fmt.Errorf("%s", output.Error)
    [3.434921]
    [3.435036]
    // Fast path: skip files that don't contain both filters at all.
    if !hasBothFilters(df, input.FromFilter, input.ToFilter) {
    output.FiltersMissing = true
    return output, nil
  • replacement in tools/calls_propagate.go at line 113
    [3.435039][3.435039:435150]()
    if input.ToFilter == "" {
    output.Error = "--to is required"
    return output, fmt.Errorf("%s", output.Error)
    [3.435039]
    [3.435150]
    sources := collectPropagateSources(df, input.FromFilter, input.Species)
    propagateTargets(df, sources, input, &output)
    if output.Propagated > 0 {
    df.Meta.Reviewer = "Skraak"
    if err := df.Write(input.File); err != nil {
    output.Error = fmt.Sprintf("write %s: %v", input.File, err)
    return output, fmt.Errorf("%s", output.Error)
    }
    }
    return output, nil
    }
    // validatePropagateInput checks required fields and file existence
    func validatePropagateInput(output *CallsPropagateOutput, input CallsPropagateInput) error {
    checks := []struct {
    val string
    msg string
    }{
    {input.File, "--file is required"},
    {input.FromFilter, "--from is required"},
    {input.ToFilter, "--to is required"},
    {input.Species, "--species is required"},
  • replacement in tools/calls_propagate.go at line 140
    [3.435153][3.435153:435268]()
    if input.Species == "" {
    output.Error = "--species is required"
    return output, fmt.Errorf("%s", output.Error)
    [3.435153]
    [3.435268]
    for _, c := range checks {
    if c.val == "" {
    output.Error = c.msg
    return fmt.Errorf("%s", c.msg)
    }
  • replacement in tools/calls_propagate.go at line 148
    [3.435359][3.435359:435407]()
    return output, fmt.Errorf("%s", output.Error)
    [3.435359]
    [3.435407]
    return fmt.Errorf("%s", output.Error)
  • edit in tools/calls_propagate.go at line 150
    [3.435410][3.435410:435411]()
  • replacement in tools/calls_propagate.go at line 152
    [3.435530][3.435530:435578]()
    return output, fmt.Errorf("%s", output.Error)
    [3.435530]
    [3.435578]
    return fmt.Errorf("%s", output.Error)
  • edit in tools/calls_propagate.go at line 154
    [3.435581]
    [3.435581]
    return nil
    }
  • replacement in tools/calls_propagate.go at line 157
    [3.435582][3.435582:435823]()
    df, err := utils.ParseDataFile(input.File)
    if err != nil {
    output.Error = fmt.Sprintf("parse %s: %v", input.File, err)
    return output, fmt.Errorf("%s", output.Error)
    }
    // Fast path: skip files that don't contain both filters at all.
    [3.435582]
    [3.435823]
    // hasBothFilters checks whether the data file contains both from and to filters
    func hasBothFilters(df *utils.DataFile, fromFilter, toFilter string) bool {
  • replacement in tools/calls_propagate.go at line 162
    [3.435925][3.435925:435964]()
    if lbl.Filter == input.FromFilter {
    [3.435925]
    [3.435964]
    if lbl.Filter == fromFilter {
  • replacement in tools/calls_propagate.go at line 165
    [3.435988][3.435988:436025]()
    if lbl.Filter == input.ToFilter {
    [3.435988]
    [3.436025]
    if lbl.Filter == toFilter {
  • replacement in tools/calls_propagate.go at line 169
    [3.436072][3.436072:436082]()
    break
    [3.436072]
    [3.436082]
    return true
  • edit in tools/calls_propagate.go at line 172
    [3.436091][3.436091:436208]()
    if hasFrom && hasTo {
    break
    }
    }
    if !hasFrom || !hasTo {
    output.FiltersMissing = true
    return output, nil
  • edit in tools/calls_propagate.go at line 173
    [3.436211]
    [3.436211]
    return false
    }
  • replacement in tools/calls_propagate.go at line 176
    [3.436212][3.436212:436284]()
    type sourceRef struct {
    seg *utils.Segment
    label *utils.Label
    }
    [3.436212]
    [3.436284]
    // sourceRef pairs a segment with its matching source label
    type sourceRef struct {
    seg *utils.Segment
    label *utils.Label
    }
    // collectPropagateSources gathers verified source labels (certainty==100) for the given filter/species
    func collectPropagateSources(df *utils.DataFile, fromFilter, species string) []sourceRef {
  • replacement in tools/calls_propagate.go at line 187
    [3.436379][3.436379:436474]()
    if lbl.Filter == input.FromFilter && lbl.Species == input.Species && lbl.Certainty == 100 {
    [3.436379]
    [3.436474]
    if lbl.Filter == fromFilter && lbl.Species == species && lbl.Certainty == 100 {
  • edit in tools/calls_propagate.go at line 193
    [3.436559]
    [3.436559]
    return sources
    }
  • replacement in tools/calls_propagate.go at line 196
    [3.436560][3.436560:436578]()
    changed := false
    [3.436560]
    [3.436578]
    // propagateTargets iterates target segments, finds overlapping sources, and applies agreed classifications
    func propagateTargets(df *utils.DataFile, sources []sourceRef, input CallsPropagateInput, output *CallsPropagateOutput) {
  • replacement in tools/calls_propagate.go at line 199
    [3.436614][3.436614:436798]()
    var toLabel *utils.Label
    for _, lbl := range tSeg.Labels {
    if lbl.Filter == input.ToFilter && (lbl.Certainty == 70 || lbl.Certainty == 0) {
    toLabel = lbl
    break
    }
    }
    [3.436614]
    [3.436798]
    toLabel := findUpdatableTargetLabel(tSeg.Labels, input.ToFilter)
  • replacement in tools/calls_propagate.go at line 205
    [3.436864][3.436864:437038]()
    var overlaps []sourceRef
    for _, s := range sources {
    if s.seg.StartTime < tSeg.EndTime && tSeg.StartTime < s.seg.EndTime {
    overlaps = append(overlaps, s)
    }
    }
    [3.436864]
    [3.437038]
    overlaps := findOverlappingSources(sources, tSeg)
  • replacement in tools/calls_propagate.go at line 211
    [3.437110][3.437110:437294]()
    agreedCallType := overlaps[0].label.CallType
    conflict := false
    for _, s := range overlaps[1:] {
    if s.label.CallType != agreedCallType {
    conflict = true
    break
    }
    }
    [3.437110]
    [3.437294]
    agreedCallType, conflict := resolveCallType(overlaps)
  • replacement in tools/calls_propagate.go at line 214
    [3.437338][3.437338:437833]()
    choices := make([]PropagateSourceChoice, 0, len(overlaps))
    for _, s := range overlaps {
    choices = append(choices, PropagateSourceChoice{
    Start: s.seg.StartTime,
    End: s.seg.EndTime,
    Species: s.label.Species,
    CallType: s.label.CallType,
    })
    }
    output.Conflicts = append(output.Conflicts, PropagateConflict{
    TargetStart: tSeg.StartTime,
    TargetEnd: tSeg.EndTime,
    TargetCallType: toLabel.CallType,
    SourceChoices: choices,
    })
    [3.437338]
    [3.437833]
    output.Conflicts = append(output.Conflicts, buildConflictRecord(tSeg, toLabel, overlaps))
  • replacement in tools/calls_propagate.go at line 218
    [3.437850][3.437850:438142]()
    change := PropagateChange{
    TargetStart: tSeg.StartTime,
    TargetEnd: tSeg.EndTime,
    PrevSpecies: toLabel.Species,
    PrevCallType: toLabel.CallType,
    PrevCertainty: toLabel.Certainty,
    NewSpecies: input.Species,
    NewCallType: agreedCallType,
    NewCertainty: 90,
    [3.437850]
    [3.438142]
    applyPropagation(toLabel, input.Species, agreedCallType, tSeg, output)
    }
    }
    // findUpdatableTargetLabel finds a target label with certainty 70 or 0 for the given filter
    func findUpdatableTargetLabel(labels []*utils.Label, toFilter string) *utils.Label {
    for _, lbl := range labels {
    if lbl.Filter == toFilter && (lbl.Certainty == 70 || lbl.Certainty == 0) {
    return lbl
    }
    }
    return nil
    }
    // findOverlappingSources returns sources whose segments overlap with the target segment
    func findOverlappingSources(sources []sourceRef, tSeg *utils.Segment) []sourceRef {
    var overlaps []sourceRef
    for _, s := range sources {
    if s.seg.StartTime < tSeg.EndTime && tSeg.StartTime < s.seg.EndTime {
    overlaps = append(overlaps, s)
  • edit in tools/calls_propagate.go at line 239
    [3.438146]
    [3.438146]
    }
    return overlaps
    }
  • replacement in tools/calls_propagate.go at line 243
    [3.438147][3.438147:438259]()
    toLabel.Species = input.Species
    toLabel.CallType = agreedCallType
    toLabel.Certainty = 90
    changed = true
    [3.438147]
    [3.438259]
    // resolveCallType checks if all overlapping sources agree on a call type.
    // Returns the agreed call type and whether there is a conflict.
    func resolveCallType(overlaps []sourceRef) (string, bool) {
    agreedCallType := overlaps[0].label.CallType
    for _, s := range overlaps[1:] {
    if s.label.CallType != agreedCallType {
    return "", true
    }
    }
    return agreedCallType, false
    }
  • replacement in tools/calls_propagate.go at line 255
    [3.438260][3.438260:438332]()
    output.Propagated++
    output.Changes = append(output.Changes, change)
    [3.438260]
    [3.438332]
    // buildConflictRecord creates a PropagateConflict from overlapping disagreeing sources
    func buildConflictRecord(tSeg *utils.Segment, toLabel *utils.Label, overlaps []sourceRef) PropagateConflict {
    choices := make([]PropagateSourceChoice, 0, len(overlaps))
    for _, s := range overlaps {
    choices = append(choices, PropagateSourceChoice{
    Start: s.seg.StartTime,
    End: s.seg.EndTime,
    Species: s.label.Species,
    CallType: s.label.CallType,
    })
    }
    return PropagateConflict{
    TargetStart: tSeg.StartTime,
    TargetEnd: tSeg.EndTime,
    TargetCallType: toLabel.CallType,
    SourceChoices: choices,
  • edit in tools/calls_propagate.go at line 272
    [3.438335]
    [3.438335]
    }
  • replacement in tools/calls_propagate.go at line 274
    [3.438336][3.438336:438543]()
    if changed {
    df.Meta.Reviewer = "Skraak"
    if err := df.Write(input.File); err != nil {
    output.Error = fmt.Sprintf("write %s: %v", input.File, err)
    return output, fmt.Errorf("%s", output.Error)
    }
    [3.438336]
    [3.438543]
    // applyPropagation updates the target label and records the change
    func applyPropagation(toLabel *utils.Label, species, callType string, tSeg *utils.Segment, output *CallsPropagateOutput) {
    change := PropagateChange{
    TargetStart: tSeg.StartTime,
    TargetEnd: tSeg.EndTime,
    PrevSpecies: toLabel.Species,
    PrevCallType: toLabel.CallType,
    PrevCertainty: toLabel.Certainty,
    NewSpecies: species,
    NewCallType: callType,
    NewCertainty: 90,
  • edit in tools/calls_propagate.go at line 286
    [3.438546]
    [3.438546]
    toLabel.Species = species
    toLabel.CallType = callType
    toLabel.Certainty = 90
  • replacement in tools/calls_propagate.go at line 291
    [3.438547][3.438547:438567]()
    return output, nil
    [3.438547]
    [3.438567]
    output.Propagated++
    output.Changes = append(output.Changes, change)
  • edit in tools/calls_from_raven.go at line 332
    [3.466785][3.466785:467244]()
    // processRavenFileCached processes a single Raven selection file using a DirCache for WAV lookup
    func processRavenFileCached(ravenFile string, cache *DirCache) ([]ClusteredCall, bool, bool, error) {
    // Open file
    file, err := os.Open(ravenFile)
    if err != nil {
    return nil, false, false, fmt.Errorf("failed to open file: %w", err)
    }
    defer func() { _ = file.Close() }()
    // Read header and selections (tab-separated)
    scanner := bufio.NewScanner(file)
  • replacement in tools/calls_from_raven.go at line 333
    [3.467245][3.467245:467509]()
    // Read header line
    if !scanner.Scan() {
    return nil, false, false, fmt.Errorf("empty file")
    }
    header := strings.Split(scanner.Text(), "\t")
    // Find column indices
    beginTimeIdx := -1
    endTimeIdx := -1
    lowFreqIdx := -1
    highFreqIdx := -1
    speciesIdx := -1
    [3.467245]
    [3.467509]
    // ravenColumnIndices holds the column index positions for a Raven file
    type ravenColumnIndices struct {
    beginTimeIdx int
    endTimeIdx int
    lowFreqIdx int
    highFreqIdx int
    speciesIdx int
    }
  • edit in tools/calls_from_raven.go at line 342
    [3.467510]
    [3.467510]
    // parseRavenHeader finds column indices from a tab-separated header line
    func parseRavenHeader(header []string) (ravenColumnIndices, error) {
    idx := ravenColumnIndices{beginTimeIdx: -1, endTimeIdx: -1, lowFreqIdx: -1, highFreqIdx: -1, speciesIdx: -1}
  • replacement in tools/calls_from_raven.go at line 348
    [3.467580][3.467580:467600]()
    beginTimeIdx = i
    [3.467580]
    [3.467600]
    idx.beginTimeIdx = i
  • replacement in tools/calls_from_raven.go at line 350
    [3.467623][3.467623:467641]()
    endTimeIdx = i
    [3.467623]
    [3.467641]
    idx.endTimeIdx = i
  • replacement in tools/calls_from_raven.go at line 352
    [3.467665][3.467665:467683]()
    lowFreqIdx = i
    [3.467665]
    [3.467683]
    idx.lowFreqIdx = i
  • replacement in tools/calls_from_raven.go at line 354
    [3.467708][3.467708:467727]()
    highFreqIdx = i
    [3.467708]
    [3.467727]
    idx.highFreqIdx = i
  • replacement in tools/calls_from_raven.go at line 356
    [3.467745][3.467745:467763]()
    speciesIdx = i
    [3.467745]
    [3.467763]
    idx.speciesIdx = i
  • replacement in tools/calls_from_raven.go at line 359
    [3.467770][3.467770:467917]()
    if beginTimeIdx == -1 || endTimeIdx == -1 || speciesIdx == -1 {
    return nil, false, false, fmt.Errorf("missing required columns in Raven file")
    [3.467770]
    [3.467917]
    if idx.beginTimeIdx == -1 || idx.endTimeIdx == -1 || idx.speciesIdx == -1 {
    return idx, fmt.Errorf("missing required columns in Raven file")
  • edit in tools/calls_from_raven.go at line 362
    [3.467920]
    [3.467920]
    return idx, nil
    }
  • replacement in tools/calls_from_raven.go at line 365
    [3.467921][3.467921:467941]()
    // Read selections
    [3.467921]
    [3.467941]
    // parseRavenSelections reads all selection rows from a scanner and returns parsed selections
    func parseRavenSelections(scanner *bufio.Scanner, idx ravenColumnIndices) ([]RavenSelection, error) {
  • replacement in tools/calls_from_raven.go at line 375
    [3.468094][3.468094:468127]()
    if len(fields) <= speciesIdx {
    [3.468094]
    [3.468127]
    if len(fields) <= idx.speciesIdx {
  • replacement in tools/calls_from_raven.go at line 379
    [3.468144][3.468144:468357]()
    var sel RavenSelection
    if _, err := fmt.Sscanf(fields[beginTimeIdx], "%f", &sel.StartTime); err != nil {
    return nil, false, false, fmt.Errorf("failed to parse begin time %q: %w", fields[beginTimeIdx], err)
    [3.468144]
    [3.468357]
    sel, err := parseRavenRow(fields, idx)
    if err != nil {
    return nil, err
  • edit in tools/calls_from_raven.go at line 383
    [3.468361][3.468361:469071]()
    if _, err := fmt.Sscanf(fields[endTimeIdx], "%f", &sel.EndTime); err != nil {
    return nil, false, false, fmt.Errorf("failed to parse end time %q: %w", fields[endTimeIdx], err)
    }
    if lowFreqIdx >= 0 && lowFreqIdx < len(fields) {
    if _, err := fmt.Sscanf(fields[lowFreqIdx], "%f", &sel.FreqLow); err != nil {
    return nil, false, false, fmt.Errorf("failed to parse low freq %q: %w", fields[lowFreqIdx], err)
    }
    }
    if highFreqIdx >= 0 && highFreqIdx < len(fields) {
    if _, err := fmt.Sscanf(fields[highFreqIdx], "%f", &sel.FreqHigh); err != nil {
    return nil, false, false, fmt.Errorf("failed to parse high freq %q: %w", fields[highFreqIdx], err)
    }
    }
    sel.Species = fields[speciesIdx]
  • edit in tools/calls_from_raven.go at line 385
    [3.469113][3.469113:469114]()
  • replacement in tools/calls_from_raven.go at line 386
    [3.469153][3.469153:469223]()
    return nil, false, false, fmt.Errorf("error reading file: %w", err)
    [3.469153]
    [3.469223]
    return nil, fmt.Errorf("error reading file: %w", err)
  • edit in tools/calls_from_raven.go at line 388
    [3.469226]
    [3.469226]
    return selections, nil
    }
  • replacement in tools/calls_from_raven.go at line 391
    [3.469227][3.469227:469308]()
    if len(selections) == 0 {
    return nil, false, true, nil // No selections, skip
    [3.469227]
    [3.469308]
    // parseRavenRow parses a single tab-separated row into a RavenSelection
    func parseRavenRow(fields []string, idx ravenColumnIndices) (RavenSelection, error) {
    var sel RavenSelection
    if _, err := fmt.Sscanf(fields[idx.beginTimeIdx], "%f", &sel.StartTime); err != nil {
    return sel, fmt.Errorf("failed to parse begin time %q: %w", fields[idx.beginTimeIdx], err)
    }
    if _, err := fmt.Sscanf(fields[idx.endTimeIdx], "%f", &sel.EndTime); err != nil {
    return sel, fmt.Errorf("failed to parse end time %q: %w", fields[idx.endTimeIdx], err)
    }
    if idx.lowFreqIdx >= 0 && idx.lowFreqIdx < len(fields) {
    if _, err := fmt.Sscanf(fields[idx.lowFreqIdx], "%f", &sel.FreqLow); err != nil {
    return sel, fmt.Errorf("failed to parse low freq %q: %w", fields[idx.lowFreqIdx], err)
    }
    }
    if idx.highFreqIdx >= 0 && idx.highFreqIdx < len(fields) {
    if _, err := fmt.Sscanf(fields[idx.highFreqIdx], "%f", &sel.FreqHigh); err != nil {
    return sel, fmt.Errorf("failed to parse high freq %q: %w", fields[idx.highFreqIdx], err)
    }
  • edit in tools/calls_from_raven.go at line 410
    [3.469311]
    [3.469311]
    sel.Species = fields[idx.speciesIdx]
    return sel, nil
    }
  • replacement in tools/calls_from_raven.go at line 414
    [3.469312][3.469312:469418]()
    // Derive WAV path from Raven filename
    // "20230610_150000.Table.1.selections.txt" -> "20230610_150000"
    [3.469312]
    [3.469418]
    // deriveWAVBaseName extracts the base WAV filename from a Raven .selections.txt filename
    func deriveWAVBaseName(ravenFile string) string {
  • edit in tools/calls_from_raven.go at line 417
    [3.469452][3.469452:469479]()
    // Remove .selections.txt
  • edit in tools/calls_from_raven.go at line 418
    [3.469545][3.469545:469586]()
    // Remove .Table.X (or similar pattern)
  • edit in tools/calls_from_raven.go at line 421
    [3.469698]
    [3.469698]
    }
    return nameWithoutSuffix
    }
    // processRavenFileCached processes a single Raven selection file using a DirCache for WAV lookup
    func processRavenFileCached(ravenFile string, cache *DirCache) ([]ClusteredCall, bool, bool, error) {
    file, err := os.Open(ravenFile)
    if err != nil {
    return nil, false, false, fmt.Errorf("failed to open file: %w", err)
  • edit in tools/calls_from_raven.go at line 431
    [3.469701]
    [3.469701]
    defer func() { _ = file.Close() }()
    scanner := bufio.NewScanner(file)
  • replacement in tools/calls_from_raven.go at line 435
    [3.469702][3.469702:469942]()
    // Find WAV file using DirCache (O(1) lookup instead of O(N) directory scan)
    var wavPath string
    if cache != nil {
    wavPath = cache.FindWAV(nameWithoutSuffix)
    } else {
    wavPath = findWAVFile(filepath.Dir(ravenFile), nameWithoutSuffix)
    [3.469702]
    [3.469942]
    if !scanner.Scan() {
    return nil, false, false, fmt.Errorf("empty file")
    }
    header := strings.Split(scanner.Text(), "\t")
    idx, err := parseRavenHeader(header)
    if err != nil {
    return nil, false, false, err
    }
    selections, err := parseRavenSelections(scanner, idx)
    if err != nil {
    return nil, false, false, err
    }
    if len(selections) == 0 {
    return nil, false, true, nil
  • edit in tools/calls_from_raven.go at line 453
    [3.469945]
    [3.469945]
    // Find WAV file
    wavPath := resolveWAVPath(ravenFile, cache)
  • replacement in tools/calls_from_raven.go at line 457
    [3.469965][3.469965:470019]()
    return nil, false, true, nil // WAV not found, skip
    [3.469965]
    [3.470019]
    return nil, false, true, nil
  • edit in tools/calls_from_raven.go at line 460
    [3.470023][3.470023:470081]()
    // Check if WAV exists (to get sample rate and duration)
  • replacement in tools/calls_from_raven.go at line 462
    [3.470165][3.470165:470232]()
    return nil, false, true, nil // Skip if WAV not found or invalid
    [3.470165]
    [3.470232]
    return nil, false, true, nil
  • edit in tools/calls_from_raven.go at line 466
    [3.470267][3.470267:470303]()
    // Convert selections to segments
  • replacement in tools/calls_from_raven.go at line 468
    [3.470360][3.470360:470446]()
    // Build metadata
    meta := AviaNZMeta{
    Operator: "Raven",
    Duration: duration,
    }
    [3.470360]
    [3.470446]
    meta := AviaNZMeta{Operator: "Raven", Duration: duration}
  • edit in tools/calls_from_raven.go at line 472
    [3.470494][3.470494:470528]()
    // Write .data file (safe write)
  • edit in tools/calls_from_raven.go at line 476
    [3.470645][3.470645:470686]()
    // Convert to ClusteredCalls for output
  • edit in tools/calls_from_raven.go at line 488
    [3.470950]
    [3.470950]
    }
    // resolveWAVPath finds the WAV file corresponding to a Raven file
    func resolveWAVPath(ravenFile string, cache *DirCache) string {
    baseName := deriveWAVBaseName(ravenFile)
    if cache != nil {
    return cache.FindWAV(baseName)
    }
    return findWAVFile(filepath.Dir(ravenFile), baseName)
  • edit in tools/calls_from_preds.go at line 83
    [3.486294]
    [3.486294]
    // predFileSpeciesKey groups detections by file and ebird code
    type predFileSpeciesKey struct {
    File string
    EbirdCode string
    }
  • edit in tools/calls_from_preds.go at line 99
    [3.486667][3.486667:486696]()
    // Filter must not be empty
  • replacement in tools/calls_from_preds.go at line 106
    [3.486897][3.486897:486952]()
    // Open CSV file
    file, err := os.Open(input.CSVPath)
    [3.486897]
    [3.486952]
    _, detections, clipDuration, err := readPredCSV(input.CSVPath)
  • replacement in tools/calls_from_preds.go at line 108
    [3.486969][3.486969:487029]()
    errMsg := fmt.Sprintf("Failed to open CSV file: %v", err)
    [3.486969]
    [3.487029]
    errMsg := err.Error()
  • replacement in tools/calls_from_preds.go at line 110
    [3.487054][3.487054:487096]()
    return output, fmt.Errorf("%s", errMsg)
    [3.487054]
    [3.487096]
    return output, err
    }
    output.ClipDuration = clipDuration
    gapMultiplier := CLUSTER_GAP_MULTIPLIER
    if input.GapMultiplier > 0 {
    gapMultiplier = input.GapMultiplier
    }
    minDetections := MIN_DETECTIONS_PER_CLUSTER
    if input.MinDetections >= 0 {
    minDetections = input.MinDetections
    }
    gapThreshold := float64(gapMultiplier) * clipDuration
    output.GapThreshold = gapThreshold
    allCalls, speciesCount := clusterDetections(detections, clipDuration, gapThreshold, minDetections)
    output.Calls = allCalls
    output.TotalCalls = len(allCalls)
    output.SpeciesCount = speciesCount
    if input.WriteDotData {
    dataFilesWritten, dataFilesSkipped, err := writeDotFiles(input.CSVPath, filter, allCalls, input.ProgressHandler)
    if err != nil {
    errMsg := fmt.Sprintf("Error writing .data files: %v", err)
    output.Error = &errMsg
    return output, fmt.Errorf("%s", errMsg)
    }
    output.DataFilesWritten = dataFilesWritten
    output.DataFilesSkipped = dataFilesSkipped
    }
    return output, nil
    }
    // readPredCSV opens and reads a predictions CSV, returning column mappings, detections, and clip duration
    func readPredCSV(csvPath string) (predCSVColumns, map[predFileSpeciesKey][]float64, float64, error) {
    file, err := os.Open(csvPath)
    if err != nil {
    return predCSVColumns{}, nil, 0, fmt.Errorf("failed to open CSV file: %w", err)
  • edit in tools/calls_from_preds.go at line 153
    [3.487137][3.487137:487150]()
    // Read CSV
  • replacement in tools/calls_from_preds.go at line 154
    [3.487181][3.487181:487247]()
    reader.ReuseRecord = true // Memory optimization for large files
    [3.487181]
    [3.487247]
    reader.ReuseRecord = true
  • edit in tools/calls_from_preds.go at line 156
    [3.487248][3.487248:487264]()
    // Read header
  • replacement in tools/calls_from_preds.go at line 158
    [3.487311][3.487311:487440]()
    errMsg := fmt.Sprintf("Failed to read CSV header: %v", err)
    output.Error = &errMsg
    return output, fmt.Errorf("%s", errMsg)
    [3.487311]
    [3.487440]
    return predCSVColumns{}, nil, 0, fmt.Errorf("failed to read CSV header: %w", err)
    }
    cols, err := findPredCSVColumns(header)
    if err != nil {
    return predCSVColumns{}, nil, 0, err
  • replacement in tools/calls_from_preds.go at line 166
    [3.487444][3.487444:487566]()
    // Find column indices
    fileIdx := -1
    startTimeIdx := -1
    endTimeIdx := -1
    var ebirdCodes []string
    var ebirdIdx []int
    [3.487444]
    [3.487566]
    detections, clipDuration, err := readPredCSVRows(reader, cols)
    if err != nil {
    return predCSVColumns{}, nil, 0, err
    }
    return cols, detections, clipDuration, nil
    }
  • replacement in tools/calls_from_preds.go at line 174
    [3.487567][3.487567:487681]()
    // Columns to ignore (not ebird codes)
    ignoredColumns := map[string]bool{
    "NotKiwi": true,
    "0.0": true,
    [3.487567]
    [3.487681]
    // predCSVColumns holds the column indices for a predictions CSV
    type predCSVColumns struct {
    fileIdx int
    startTimeIdx int
    endTimeIdx int
    ebirdCodes []string
    ebirdIdx []int
    }
    // findPredCSVColumns parses the CSV header to find column indices
    func findPredCSVColumns(header []string) (predCSVColumns, error) {
    cols := predCSVColumns{
    fileIdx: -1,
    startTimeIdx: -1,
    endTimeIdx: -1,
  • edit in tools/calls_from_preds.go at line 191
    [3.487685]
    [3.487685]
    ignoredColumns := map[string]bool{"NotKiwi": true, "0.0": true}
  • replacement in tools/calls_from_preds.go at line 196
    [3.487745][3.487745:487760]()
    fileIdx = i
    [3.487745]
    [3.487760]
    cols.fileIdx = i
  • replacement in tools/calls_from_preds.go at line 198
    [3.487781][3.487781:487801]()
    startTimeIdx = i
    [3.487781]
    [3.487801]
    cols.startTimeIdx = i
  • replacement in tools/calls_from_preds.go at line 200
    [3.487820][3.487820:487838]()
    endTimeIdx = i
    [3.487820]
    [3.487838]
    cols.endTimeIdx = i
  • edit in tools/calls_from_preds.go at line 202
    [3.487849][3.487849:487876]()
    // Skip ignored columns
  • replacement in tools/calls_from_preds.go at line 205
    [3.487922][3.487922:488036]()
    // All other columns are ebird codes
    ebirdCodes = append(ebirdCodes, col)
    ebirdIdx = append(ebirdIdx, i)
    [3.487922]
    [3.488036]
    cols.ebirdCodes = append(cols.ebirdCodes, col)
    cols.ebirdIdx = append(cols.ebirdIdx, i)
  • replacement in tools/calls_from_preds.go at line 210
    [3.488044][3.488044:488246]()
    if fileIdx == -1 || startTimeIdx == -1 || endTimeIdx == -1 {
    errMsg := "CSV must have 'file', 'start_time', and 'end_time' columns"
    output.Error = &errMsg
    return output, fmt.Errorf("%s", errMsg)
    [3.488044]
    [3.488246]
    if cols.fileIdx == -1 || cols.startTimeIdx == -1 || cols.endTimeIdx == -1 {
    return cols, fmt.Errorf("CSV must have 'file', 'start_time', and 'end_time' columns")
  • replacement in tools/calls_from_preds.go at line 213
    [3.488249][3.488249:488403]()
    if len(ebirdCodes) == 0 {
    errMsg := "CSV must have at least one ebird code column"
    output.Error = &errMsg
    return output, fmt.Errorf("%s", errMsg)
    [3.488249]
    [3.488403]
    if len(cols.ebirdCodes) == 0 {
    return cols, fmt.Errorf("CSV must have at least one ebird code column")
  • edit in tools/calls_from_preds.go at line 216
    [3.488406]
    [3.488406]
    return cols, nil
    }
  • replacement in tools/calls_from_preds.go at line 219
    [3.488407][3.488407:488630]()
    // Read all rows and organize by (file, ebird_code) -> start_times
    // Using maps for efficient grouping
    type FileEbirdKey struct {
    File string
    EbirdCode string
    }
    detections := make(map[FileEbirdKey][]float64)
    [3.488407]
    [3.488630]
    // readPredCSVRows reads all CSV data rows and returns detections grouped by file+species, plus clip duration
    func readPredCSVRows(reader *csv.Reader, cols predCSVColumns) (map[predFileSpeciesKey][]float64, float64, error) {
    detections := make(map[predFileSpeciesKey][]float64)
  • edit in tools/calls_from_preds.go at line 224
    [3.488652][3.488652:488692]()
    // Read first row to get clip duration
  • replacement in tools/calls_from_preds.go at line 225
    [3.488722][3.488722:488888]()
    if err != nil && err != io.EOF {
    errMsg := fmt.Sprintf("Failed to read first CSV row: %v", err)
    output.Error = &errMsg
    return output, fmt.Errorf("%s", errMsg)
    [3.488722]
    [3.488888]
    if err == io.EOF {
    return detections, 0, nil
    }
    if err != nil {
    return nil, 0, fmt.Errorf("failed to read first CSV row: %w", err)
  • replacement in tools/calls_from_preds.go at line 232
    [3.488892][3.488892:489108]()
    if err != io.EOF {
    startTime, _ := strconv.ParseFloat(record[startTimeIdx], 64)
    endTime, _ := strconv.ParseFloat(record[endTimeIdx], 64)
    clipDuration = endTime - startTime
    output.ClipDuration = clipDuration
    [3.488892]
    [3.489108]
    startTime, _ := strconv.ParseFloat(record[cols.startTimeIdx], 64)
    endTime, _ := strconv.ParseFloat(record[cols.endTimeIdx], 64)
    clipDuration = endTime - startTime
    addDetectionsFromRow(record, cols, startTime, detections)
  • replacement in tools/calls_from_preds.go at line 238
    [3.489109][3.489109:489350]()
    // Process first row
    fileName := record[fileIdx]
    for i, idx := range ebirdIdx {
    if record[idx] == "1" {
    key := FileEbirdKey{File: fileName, EbirdCode: ebirdCodes[i]}
    detections[key] = append(detections[key], startTime)
    }
    [3.489109]
    [3.489350]
    for {
    record, err := reader.Read()
    if err == io.EOF {
    break
    }
    if err != nil {
    return nil, 0, fmt.Errorf("failed to read CSV row: %w", err)
  • replacement in tools/calls_from_preds.go at line 247
    [3.489355][3.489355:489613]()
    // Read remaining rows
    for {
    record, err := reader.Read()
    if err == io.EOF {
    break
    }
    if err != nil {
    errMsg := fmt.Sprintf("Failed to read CSV row: %v", err)
    output.Error = &errMsg
    return output, fmt.Errorf("%s", errMsg)
    }
    [3.489355]
    [3.489613]
    startTime, _ = strconv.ParseFloat(record[cols.startTimeIdx], 64)
    addDetectionsFromRow(record, cols, startTime, detections)
    }
  • replacement in tools/calls_from_preds.go at line 251
    [3.489614][3.489614:489709]()
    startTime, _ := strconv.ParseFloat(record[startTimeIdx], 64)
    fileName := record[fileIdx]
    [3.489614]
    [3.489709]
    return detections, clipDuration, nil
    }
  • replacement in tools/calls_from_preds.go at line 254
    [3.489710][3.489710:489908]()
    for i, idx := range ebirdIdx {
    if record[idx] == "1" {
    key := FileEbirdKey{File: fileName, EbirdCode: ebirdCodes[i]}
    detections[key] = append(detections[key], startTime)
    }
    }
    [3.489710]
    [3.489908]
    // addDetectionsFromRow adds positive detections from a single CSV row
    func addDetectionsFromRow(record []string, cols predCSVColumns, startTime float64, detections map[predFileSpeciesKey][]float64) {
    fileName := record[cols.fileIdx]
    for i, idx := range cols.ebirdIdx {
    if record[idx] == "1" {
    key := predFileSpeciesKey{File: fileName, EbirdCode: cols.ebirdCodes[i]}
    detections[key] = append(detections[key], startTime)
  • edit in tools/calls_from_preds.go at line 262
    [3.489912][3.489912:490170]()
    }
    // Calculate gap threshold
    gapMultiplier := CLUSTER_GAP_MULTIPLIER
    if input.GapMultiplier > 0 {
    gapMultiplier = input.GapMultiplier
    }
    minDetections := MIN_DETECTIONS_PER_CLUSTER
    if input.MinDetections >= 0 {
    minDetections = input.MinDetections
  • replacement in tools/calls_from_preds.go at line 263
    [3.490173][3.490173:490264]()
    gapThreshold := float64(gapMultiplier) * clipDuration
    output.GapThreshold = gapThreshold
    [3.490173]
    [3.490264]
    }
  • replacement in tools/calls_from_preds.go at line 265
    [3.490265][3.490265:490310]()
    // Cluster detections by (file, ebird_code)
    [3.490265]
    [3.490310]
    // clusterDetections groups detections into clusters and produces sorted ClusteredCalls
    func clusterDetections(detections map[predFileSpeciesKey][]float64, clipDuration, gapThreshold float64, minDetections int) ([]ClusteredCall, map[string]int) {
  • edit in tools/calls_from_preds.go at line 271
    [3.490422][3.490422:490444]()
    // Sort start times
  • edit in tools/calls_from_preds.go at line 273
    [3.490473][3.490473:490509]()
    // Cluster consecutive detections
  • edit in tools/calls_from_preds.go at line 275
    [3.490568][3.490568:490599]()
    // Convert clusters to calls
  • edit in tools/calls_from_preds.go at line 292
    [3.490968][3.490968:491008]()
    // Sort calls by file, then start time
  • edit in tools/calls_from_preds.go at line 298
    [3.491205][3.491205:491302]()
    output.Calls = allCalls
    output.TotalCalls = len(allCalls)
    output.SpeciesCount = speciesCount
  • replacement in tools/calls_from_preds.go at line 299
    [3.491303][3.491303:491817]()
    // Write .data files if requested
    if input.WriteDotData {
    dataFilesWritten, dataFilesSkipped, err := writeDotFiles(input.CSVPath, filter, allCalls, input.ProgressHandler)
    if err != nil {
    // Return error - this includes clobber protection and parse errors
    errMsg := fmt.Sprintf("Error writing .data files: %v", err)
    output.Error = &errMsg
    return output, fmt.Errorf("%s", errMsg)
    }
    output.DataFilesWritten = dataFilesWritten
    output.DataFilesSkipped = dataFilesSkipped
    }
    return output, nil
    [3.491303]
    [3.491817]
    return allCalls, speciesCount
  • replacement in cmd/calls_push_certainty.go at line 32
    [3.1105663][3.1105663:1106184]()
    // runCallsPushCertainty promotes certainty=90 segments to certainty=100 for a filtered set.
    //
    // JSON output schema:
    //
    // {
    // "segments_updated": int, // Number of segments promoted from 90→100
    // "files_updated": int, // Number of .data files modified
    // "time_filtered_count": int // Files skipped by --night/--day filter
    // }
    func runCallsPushCertainty(args []string) {
    var folder, file, filter, species, timezone string
    var night, day bool
    var lat, lng float64
    var latSet, lngSet bool
    [3.1105663]
    [3.1106184]
    // pushCertaintyFlags holds the parsed CLI flags for push-certainty
    type pushCertaintyFlags struct {
    folder string
    file string
    filter string
    species string
    timezone string
    night bool
    day bool
    lat float64
    lng float64
    latSet bool
    lngSet bool
    }
  • edit in cmd/calls_push_certainty.go at line 47
    [3.1106185]
    [3.1106185]
    // parsePushCertaintyArgs parses CLI arguments into flags
    func parsePushCertaintyArgs(args []string) pushCertaintyFlags {
    var f pushCertaintyFlags
  • replacement in cmd/calls_push_certainty.go at line 55
    [3.1106265][3.1106265:1106408]()
    if i+1 >= len(args) {
    fmt.Fprintf(os.Stderr, "Error: --folder requires a value\n")
    os.Exit(1)
    }
    folder = args[i+1]
    i += 2
    [3.1106265]
    [3.1106408]
    f.folder = requireValue(arg, args, &i)
  • replacement in cmd/calls_push_certainty.go at line 57
    [3.1106425][3.1106425:1106564]()
    if i+1 >= len(args) {
    fmt.Fprintf(os.Stderr, "Error: --file requires a value\n")
    os.Exit(1)
    }
    file = args[i+1]
    i += 2
    [3.1106425]
    [3.1106564]
    f.file = requireValue(arg, args, &i)
  • replacement in cmd/calls_push_certainty.go at line 59
    [3.1106583][3.1106583:1106726]()
    if i+1 >= len(args) {
    fmt.Fprintf(os.Stderr, "Error: --filter requires a value\n")
    os.Exit(1)
    }
    filter = args[i+1]
    i += 2
    [3.1106583]
    [3.1106726]
    f.filter = requireValue(arg, args, &i)
  • replacement in cmd/calls_push_certainty.go at line 61
    [3.1106746][3.1106746:1106891]()
    if i+1 >= len(args) {
    fmt.Fprintf(os.Stderr, "Error: --species requires a value\n")
    os.Exit(1)
    }
    species = args[i+1]
    i += 2
    [3.1106746]
    [3.1106891]
    f.species = requireValue(arg, args, &i)
  • replacement in cmd/calls_push_certainty.go at line 63
    [3.1106909][3.1106909:1106925]()
    night = true
    [3.1106909]
    [3.1106925]
    f.night = true
  • edit in cmd/calls_push_certainty.go at line 65
    [3.1106932][3.1106932:1106933]()
  • replacement in cmd/calls_push_certainty.go at line 66
    [3.1106949][3.1106949:1106963]()
    day = true
    [3.1106949]
    [3.1106963]
    f.day = true
  • edit in cmd/calls_push_certainty.go at line 68
    [3.1106970][3.1106970:1106971]()
  • replacement in cmd/calls_push_certainty.go at line 69
    [3.1106987][3.1106987:1107281]()
    if i+1 >= len(args) {
    fmt.Fprintf(os.Stderr, "Error: --lat requires a value\n")
    os.Exit(1)
    }
    v, err := strconv.ParseFloat(args[i+1], 64)
    if err != nil {
    fmt.Fprintf(os.Stderr, "Error: --lat must be a number\n")
    os.Exit(1)
    }
    lat = v
    latSet = true
    i += 2
    [3.1106987]
    [3.1107281]
    f.lat = requireFloat(arg, args, &i)
    f.latSet = true
  • replacement in cmd/calls_push_certainty.go at line 72
    [3.1107297][3.1107297:1107591]()
    if i+1 >= len(args) {
    fmt.Fprintf(os.Stderr, "Error: --lng requires a value\n")
    os.Exit(1)
    }
    v, err := strconv.ParseFloat(args[i+1], 64)
    if err != nil {
    fmt.Fprintf(os.Stderr, "Error: --lng must be a number\n")
    os.Exit(1)
    }
    lng = v
    lngSet = true
    i += 2
    [3.1107297]
    [3.1107591]
    f.lng = requireFloat(arg, args, &i)
    f.lngSet = true
  • replacement in cmd/calls_push_certainty.go at line 75
    [3.1107612][3.1107612:1107759]()
    if i+1 >= len(args) {
    fmt.Fprintf(os.Stderr, "Error: --timezone requires a value\n")
    os.Exit(1)
    }
    timezone = args[i+1]
    i += 2
    [3.1107612]
    [3.1107759]
    f.timezone = requireValue(arg, args, &i)
  • edit in cmd/calls_push_certainty.go at line 79
    [3.1107825][3.1107825:1107826]()
  • edit in cmd/calls_push_certainty.go at line 85
    [3.1107949]
    [3.1107949]
    return f
    }
  • replacement in cmd/calls_push_certainty.go at line 88
    [3.1107950][3.1107950:1107983]()
    if folder == "" && file == "" {
    [3.1107950]
    [3.1107983]
    // requireValue returns the next argument after a flag, or exits with an error
    func requireValue(flag string, args []string, i *int) string {
    if *i+1 >= len(args) {
    fmt.Fprintf(os.Stderr, "Error: %s requires a value\n", flag)
    os.Exit(1)
    }
    v := args[*i+1]
    *i += 2
    return v
    }
    // requireFloat parses the next argument as a float64, or exits with an error
    func requireFloat(flag string, args []string, i *int) float64 {
    s := requireValue(flag, args, i)
    v, err := strconv.ParseFloat(s, 64)
    if err != nil {
    fmt.Fprintf(os.Stderr, "Error: %s must be a number\n", flag)
    os.Exit(1)
    }
    return v
    }
    // validatePushCertaintyFlags checks flag combinations and exits on error
    func validatePushCertaintyFlags(f pushCertaintyFlags) {
    if f.folder == "" && f.file == "" {
  • replacement in cmd/calls_push_certainty.go at line 117
    [3.1108108][3.1108108:1108127]()
    if night && day {
    [3.1108108]
    [3.1108127]
    if f.night && f.day {
  • replacement in cmd/calls_push_certainty.go at line 122
    [3.1108251][3.1108251:1108296]()
    if (night || day) && (!latSet || !lngSet) {
    [3.1108251]
    [3.1108296]
    if (f.night || f.day) && (!f.latSet || !f.lngSet) {
  • edit in cmd/calls_push_certainty.go at line 127
    [3.1108423]
    [3.1108423]
    }
  • edit in cmd/calls_push_certainty.go at line 129
    [3.1108424]
    [3.1108424]
    // runCallsPushCertainty promotes certainty=90 segments to certainty=100 for a filtered set.
    //
    // JSON output schema:
    //
    // {
    // "segments_updated": int, // Number of segments promoted from 90→100
    // "files_updated": int, // Number of .data files modified
    // "time_filtered_count": int // Files skipped by --night/--day filter
    // }
    func runCallsPushCertainty(args []string) {
    f := parsePushCertaintyArgs(args)
    validatePushCertaintyFlags(f)
  • replacement in cmd/calls_push_certainty.go at line 153
    [3.1108805][3.1108805:1108867]()
    speciesName, callType := utils.ParseSpeciesCallType(species)
    [3.1108805]
    [3.1108867]
    speciesName, callType := utils.ParseSpeciesCallType(f.species)
  • replacement in cmd/calls_push_certainty.go at line 156
    [3.1108906][3.1108906:1108964]()
    Folder: folder,
    File: file,
    Filter: filter,
    [3.1108906]
    [3.1108964]
    Folder: f.folder,
    File: f.file,
    Filter: f.filter,
  • replacement in cmd/calls_push_certainty.go at line 161
    [3.1109011][3.1109011:1109103]()
    Night: night,
    Day: day,
    Lat: lat,
    Lng: lng,
    Timezone: timezone,
    [3.1109011]
    [3.1109103]
    Night: f.night,
    Day: f.day,
    Lat: f.lat,
    Lng: f.lng,
    Timezone: f.timezone,
  • edit in cmd/calls_clip.go at line 50
    [2.16065]
    [2.16065]
    }
    // nextUniqueValue returns the next argument after the flag, or exits if already set.
    func (p *clipArgParser) nextUniqueValue(flag, current string) string {
    if current != "" {
    fmt.Fprintf(os.Stderr, "Error: %s can only be specified once\n", flag)
    os.Exit(1)
    }
    return p.nextValue(flag)
  • edit in cmd/calls_clip.go at line 96
    [3.1136301][3.1136301:1137041]()
    // 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) {
    var file, folder, output, prefix, filter, species, timezone string
    var size, certainty int
    var color, wavOnly, night, day bool
    var lat, lng float64
    var latSet, lngSet bool
  • replacement in cmd/calls_clip.go at line 97
    [3.1137042][3.1137042:1137098]()
    // Default to -1 (no certainty filter)
    certainty = -1
    [3.1137042]
    [3.1137098]
    // clipFlags holds the parsed CLI flags for calls clip
    type clipFlags struct {
    file string
    folder string
    output string
    prefix string
    filter string
    species string
    timezone string
    size int
    certainty int
    color bool
    wavOnly bool
    night bool
    day bool
    lat float64
    lng float64
    latSet bool
    lngSet bool
    }
  • replacement in cmd/calls_clip.go at line 118
    [3.1137099][3.1137099:1137119]()
    // Parse arguments
    [3.1137099]
    [2.16069]
    // parseClipArgs parses CLI arguments into clip flags
    func parseClipArgs(args []string) clipFlags {
    f := clipFlags{certainty: -1}
  • edit in cmd/calls_clip.go at line 124
    [2.16144][3.1137165:1137166](),[3.1137165][3.1137165:1137166]()
  • replacement in cmd/calls_clip.go at line 126
    [3.1137198][2.16145:16172]()
    file = p.nextValue(arg)
    [3.1137198]
    [3.1137337]
    f.file = p.nextValue(arg)
  • replacement in cmd/calls_clip.go at line 128
    [3.1137356][2.16173:16202]()
    folder = p.nextValue(arg)
    [3.1137356]
    [3.1137499]
    f.folder = p.nextValue(arg)
  • replacement in cmd/calls_clip.go at line 130
    [3.1137518][2.16203:16232]()
    output = p.nextValue(arg)
    [3.1137518]
    [3.1137661]
    f.output = p.nextValue(arg)
  • replacement in cmd/calls_clip.go at line 132
    [3.1137680][2.16233:16262]()
    prefix = p.nextValue(arg)
    [3.1137680]
    [3.1137823]
    f.prefix = p.nextValue(arg)
  • replacement in cmd/calls_clip.go at line 134
    [3.1137952][3.1137952:1138068](),[3.1138068][2.16263:16292]()
    if filter != "" {
    fmt.Fprintf(os.Stderr, "Error: --filter can only be specified once\n")
    os.Exit(1)
    }
    filter = p.nextValue(arg)
    [3.1137842]
    [3.1138101]
    f.filter = p.nextUniqueValue(arg, f.filter)
  • replacement in cmd/calls_clip.go at line 136
    [3.1138232][3.1138232:1138350](),[3.1138350][2.16293:16323]()
    if species != "" {
    fmt.Fprintf(os.Stderr, "Error: --species can only be specified once\n")
    os.Exit(1)
    }
    species = p.nextValue(arg)
    [3.1138121]
    [3.1138384]
    f.species = p.nextUniqueValue(arg, f.species)
  • replacement in cmd/calls_clip.go at line 143
    [3.1138787][3.1138787:1138804]()
    certainty = v
    [3.1138787]
    [3.1138815]
    f.certainty = v
  • replacement in cmd/calls_clip.go at line 145
    [3.1138832][2.16348:16373]()
    size = p.nextInt(arg)
    [3.1138832]
    [3.1139104]
    f.size = p.nextInt(arg)
  • replacement in cmd/calls_clip.go at line 147
    [3.1139122][3.1139122:1139138]()
    color = true
    [3.1139122]
    [2.16374]
    f.color = true
  • replacement in cmd/calls_clip.go at line 150
    [3.1139167][3.1139167:1139185]()
    wavOnly = true
    [3.1139167]
    [2.16384]
    f.wavOnly = true
  • replacement in cmd/calls_clip.go at line 153
    [3.1139211][3.1139211:1139227]()
    night = true
    [3.1139211]
    [2.16394]
    f.night = true
  • replacement in cmd/calls_clip.go at line 156
    [3.1139251][3.1139251:1139265]()
    day = true
    [3.1139251]
    [2.16404]
    f.day = true
  • replacement in cmd/calls_clip.go at line 159
    [3.1139289][2.16414:16440](),[2.16440][3.1139555:1139572](),[3.1139555][3.1139555:1139572]()
    lat = p.nextFloat(arg)
    latSet = true
    [3.1139289]
    [3.1139583]
    f.lat = p.nextFloat(arg)
    f.latSet = true
  • replacement in cmd/calls_clip.go at line 162
    [3.1139599][2.16441:16467](),[2.16467][3.1139865:1139882](),[3.1139865][3.1139865:1139882]()
    lng = p.nextFloat(arg)
    lngSet = true
    [3.1139599]
    [3.1139893]
    f.lng = p.nextFloat(arg)
    f.lngSet = true
  • replacement in cmd/calls_clip.go at line 165
    [3.1139914][2.16468:16499]()
    timezone = p.nextValue(arg)
    [3.1139914]
    [3.1140061]
    f.timezone = p.nextValue(arg)
  • edit in cmd/calls_clip.go at line 178
    [3.1140315]
    [3.1140315]
    return f
    }
  • replacement in cmd/calls_clip.go at line 181
    [3.1140316][3.1140316:1140344]()
    // Validate required flags
    [3.1140316]
    [3.1140344]
    // validateClipFlags checks required flags and flag combinations
    func validateClipFlags(f clipFlags) {
  • replacement in cmd/calls_clip.go at line 184
    [3.1140367][3.1140367:1140400]()
    if file == "" && folder == "" {
    [3.1140367]
    [3.1140400]
    if f.file == "" && f.folder == "" {
  • replacement in cmd/calls_clip.go at line 187
    [3.1140453][3.1140453:1140472]()
    if output == "" {
    [3.1140453]
    [3.1140472]
    if f.output == "" {
  • replacement in cmd/calls_clip.go at line 190
    [3.1140515][3.1140515:1140534]()
    if prefix == "" {
    [3.1140515]
    [3.1140534]
    if f.prefix == "" {
  • replacement in cmd/calls_clip.go at line 198
    [3.1140710][3.1140710:1140730]()
    if night && day {
    [3.1140710]
    [3.1140730]
    if f.night && f.day {
  • replacement in cmd/calls_clip.go at line 203
    [3.1140845][3.1140845:1140890]()
    if (night || day) && (!latSet || !lngSet) {
    [3.1140845]
    [3.1140890]
    if (f.night || f.day) && (!f.latSet || !f.lngSet) {
  • edit in cmd/calls_clip.go at line 208
    [3.1141008]
    [3.1141008]
    }
    // 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) {
    f := parseClipArgs(args)
    validateClipFlags(f)
  • replacement in cmd/calls_clip.go at line 228
    [3.1141057][3.1141057:1141364]()
    File: file,
    Folder: folder,
    Output: output,
    Prefix: prefix,
    Filter: filter,
    Species: species,
    Certainty: certainty,
    Size: size,
    Color: color,
    WavOnly: wavOnly,
    Night: night,
    Day: day,
    Lat: lat,
    Lng: lng,
    Timezone: timezone,
    [3.1141057]
    [3.1141364]
    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,
    WavOnly: f.wavOnly,
    Night: f.night,
    Day: f.day,
    Lat: f.lat,
    Lng: f.lng,
    Timezone: f.timezone,
  • edit in CHANGELOG.md at line 4
    [3.1198010]
    [2.16517]
    ## [2026-05-04] Reduce cyclomatic complexity of 8 functions over gocyclo 30
    Refactored 8 functions that exceeded cyclomatic complexity of 30 by extracting
    helper functions with clear responsibilities:
    1. **`CallsPropagate` (39→6)**: Extracted `validatePropagateInput`, `hasBothFilters`,
    `collectPropagateSources`, `propagateTargets`, `findUpdatableTargetLabel`,
    `findOverlappingSources`, `resolveCallType`, `buildConflictRecord`, `applyPropagation`.
    2. **`CallsSummarise` (38→5)**: Extracted `summariseFiles`, `trackMeta`, `filterLabels`,
    `buildLabelSummaries`, `updateStatsFromLabels`, `updateFilterStats`,
    `updateReviewStatus`, `finaliseSummary`.
    3. **`runCallsPushCertainty` (35→7)**: Extracted `parsePushCertaintyArgs`,
    `requireValue`, `requireFloat`, `validatePushCertaintyFlags`.
    4. **`RunCallsClip` (35→2)**: Extracted `parseClipArgs`, `validateClipFlags`,
    `nextUniqueValue` on clipArgParser.
    5. **`createCluster` (34→19)**: Extracted `validateCreateClusterFields`,
    `validateCreateClusterIDs`, `verifyDatasetForCluster`, `verifyLocationForCluster`,
    `verifyPatternExists`, `findExistingClusterInLocation`, `fetchClusterByID`.
    6. **`ValidateMappingAgainstDB` (32→5)**: Extracted `collectMappedLabels`,
    `validateMappedSpecies`, `validateMappedCalltypes`.
    7. **`CallsFromPreds` (32→8)**: Extracted `readPredCSV`, `findPredCSVColumns`,
    `readPredCSVRows`, `addDetectionsFromRow`, `clusterDetections`.
    8. **`processRavenFileCached` (31→10)**: Extracted `parseRavenHeader`,
    `parseRavenSelections`, `parseRavenRow`, `deriveWAVBaseName`, `resolveWAVPath`.