cyclo 21+

quietlight
May 4, 2026, 7:29 PM
KLUEQ6X5CXVBV3KLJKEHWQYHIU6AYPP2WT4PWKM2QZJ7SNACCJ6QC

Dependencies

  • [2] KZKLAINJ run out of space on nest, cleaned out
  • [3] GVOVKH5R more cyclo refactoring

Change contents

  • replacement in utils/audiomoth_parser_test.go at line 49
    [2.196030][2.196030:196262]()
    t.Run("should parse a valid structured AudioMoth comment", func(t *testing.T) {
    comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C."
    [2.196030]
    [2.196262]
    t.Run("should parse a valid structured AudioMoth comment", testParseStructuredComment)
    t.Run("should return error for invalid comments", testParseInvalidComments)
    t.Run("should handle different timezone formats", testParseTimezoneFormats)
    t.Run("should parse all gain levels", testParseAllGainLevels)
    t.Run("should handle negative temperatures", testParseNegativeTemp)
    t.Run("should fallback to legacy parsing", testParseLegacyFallback)
    }
  • replacement in utils/audiomoth_parser_test.go at line 57
    [2.196263][2.196263:196381]()
    result, err := ParseAudioMothComment(comment)
    if err != nil {
    t.Fatalf("Failed to parse comment: %v", err)
    }
    [2.196263]
    [2.196381]
    func testParseStructuredComment(t *testing.T) {
    comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C."
  • replacement in utils/audiomoth_parser_test.go at line 60
    [2.196382][2.196382:196635]()
    // Check timestamp (should be in UTC+13)
    expected := time.Date(2025, 2, 24, 21, 0, 0, 0, time.FixedZone("UTC+13", 13*3600))
    if !result.Timestamp.Equal(expected) {
    t.Errorf("Timestamp incorrect: got %v, want %v", result.Timestamp, expected)
    }
    [2.196382]
    [2.196635]
    result, err := ParseAudioMothComment(comment)
    if err != nil {
    t.Fatalf("Failed to parse comment: %v", err)
    }
  • replacement in utils/audiomoth_parser_test.go at line 65
    [2.196636][2.196636:196870]()
    // Convert to UTC and verify
    utc := result.Timestamp.UTC()
    expectedUTC := time.Date(2025, 2, 24, 8, 0, 0, 0, time.UTC)
    if !utc.Equal(expectedUTC) {
    t.Errorf("UTC timestamp incorrect: got %v, want %v", utc, expectedUTC)
    }
    [2.196636]
    [2.196870]
    expected := time.Date(2025, 2, 24, 21, 0, 0, 0, time.FixedZone("UTC+13", 13*3600))
    if !result.Timestamp.Equal(expected) {
    t.Errorf("Timestamp incorrect: got %v, want %v", result.Timestamp, expected)
    }
  • replacement in utils/audiomoth_parser_test.go at line 70
    [2.196871][2.196871:197008]()
    if result.RecorderID != "248AB50153AB0549" {
    t.Errorf("RecorderID incorrect: got %s, want 248AB50153AB0549", result.RecorderID)
    }
    [2.196871]
    [2.197008]
    utc := result.Timestamp.UTC()
    expectedUTC := time.Date(2025, 2, 24, 8, 0, 0, 0, time.UTC)
    if !utc.Equal(expectedUTC) {
    t.Errorf("UTC timestamp incorrect: got %v, want %v", utc, expectedUTC)
    }
  • replacement in utils/audiomoth_parser_test.go at line 76
    [2.197009][2.197009:197124]()
    if result.Gain != db.GainMedium {
    t.Errorf("Gain incorrect: got %s, want %s", result.Gain, db.GainMedium)
    }
    [2.197009]
    [2.197124]
    if result.RecorderID != "248AB50153AB0549" {
    t.Errorf("RecorderID incorrect: got %s, want 248AB50153AB0549", result.RecorderID)
    }
    if result.Gain != db.GainMedium {
    t.Errorf("Gain incorrect: got %s, want %s", result.Gain, db.GainMedium)
    }
    if result.BatteryV != 4.3 {
    t.Errorf("BatteryV incorrect: got %f, want 4.3", result.BatteryV)
    }
    if result.TempC != 15.8 {
    t.Errorf("TempC incorrect: got %f, want 15.8", result.TempC)
    }
    }
  • replacement in utils/audiomoth_parser_test.go at line 90
    [2.197125][2.197125:197228]()
    if result.BatteryV != 4.3 {
    t.Errorf("BatteryV incorrect: got %f, want 4.3", result.BatteryV)
    }
    [2.197125]
    [2.197228]
    func testParseInvalidComments(t *testing.T) {
    invalidComments := []string{
    "Not an AudioMoth comment",
    "Recorded at invalid time format",
    "Short comment",
    "",
    "AudioMoth without proper format",
    }
  • replacement in utils/audiomoth_parser_test.go at line 99
    [2.197229][2.197229:197321]()
    if result.TempC != 15.8 {
    t.Errorf("TempC incorrect: got %f, want 15.8", result.TempC)
    [2.197229]
    [2.197321]
    for _, comment := range invalidComments {
    _, err := ParseAudioMothComment(comment)
    if err == nil {
    t.Errorf("Expected error for invalid comment: %s", comment)
  • replacement in utils/audiomoth_parser_test.go at line 104
    [2.197325][2.197325:197329]()
    })
    [2.197325]
    [2.197329]
    }
    }
  • replacement in utils/audiomoth_parser_test.go at line 107
    [2.197330][2.197330:197571]()
    t.Run("should return error for invalid comments", func(t *testing.T) {
    invalidComments := []string{
    "Not an AudioMoth comment",
    "Recorded at invalid time format",
    "Short comment",
    "",
    "AudioMoth without proper format",
    }
    [2.197330]
    [2.197571]
    func testParseTimezoneFormats(t *testing.T) {
    commentUTCMinus := "Recorded at 10:30:45 15/06/2024 (UTC-5) by AudioMoth 123456789ABCDEF0 at high gain while battery was 3.9V and temperature was 22.1C."
  • replacement in utils/audiomoth_parser_test.go at line 110
    [2.197572][2.197572:197756]()
    for _, comment := range invalidComments {
    _, err := ParseAudioMothComment(comment)
    if err == nil {
    t.Errorf("Expected error for invalid comment: %s", comment)
    }
    }
    })
    [2.197572]
    [2.197756]
    result, err := ParseAudioMothComment(commentUTCMinus)
    if err != nil {
    t.Fatalf("Failed to parse comment: %v", err)
    }
  • replacement in utils/audiomoth_parser_test.go at line 115
    [2.197757][2.197757:198573]()
    t.Run("should handle different timezone formats", func(t *testing.T) {
    commentUTCMinus := "Recorded at 10:30:45 15/06/2024 (UTC-5) by AudioMoth 123456789ABCDEF0 at high gain while battery was 3.9V and temperature was 22.1C."
    result, err := ParseAudioMothComment(commentUTCMinus)
    if err != nil {
    t.Fatalf("Failed to parse comment: %v", err)
    }
    // Check timestamp is in UTC-5
    expected := time.Date(2024, 6, 15, 10, 30, 45, 0, time.FixedZone("UTC-5", -5*3600))
    if !result.Timestamp.Equal(expected) {
    t.Errorf("Timestamp incorrect: got %v, want %v", result.Timestamp, expected)
    }
    if result.Gain != db.GainHigh {
    t.Errorf("Gain incorrect: got %s, want %s", result.Gain, db.GainHigh)
    }
    if result.BatteryV != 3.9 {
    t.Errorf("BatteryV incorrect: got %f, want 3.9", result.BatteryV)
    }
    [2.197757]
    [2.198573]
    expected := time.Date(2024, 6, 15, 10, 30, 45, 0, time.FixedZone("UTC-5", -5*3600))
    if !result.Timestamp.Equal(expected) {
    t.Errorf("Timestamp incorrect: got %v, want %v", result.Timestamp, expected)
    }
    if result.Gain != db.GainHigh {
    t.Errorf("Gain incorrect: got %s, want %s", result.Gain, db.GainHigh)
    }
    if result.BatteryV != 3.9 {
    t.Errorf("BatteryV incorrect: got %f, want 3.9", result.BatteryV)
    }
    if result.TempC != 22.1 {
    t.Errorf("TempC incorrect: got %f, want 22.1", result.TempC)
    }
    }
  • replacement in utils/audiomoth_parser_test.go at line 130
    [2.198574][2.198574:198970]()
    if result.TempC != 22.1 {
    t.Errorf("TempC incorrect: got %f, want 22.1", result.TempC)
    }
    })
    t.Run("should parse all gain levels", func(t *testing.T) {
    testCases := []struct {
    gainStr string
    expected db.GainLevel
    }{
    {"low", db.GainLow},
    {"low-medium", db.GainLowMedium},
    {"medium", db.GainMedium},
    {"medium-high", db.GainMediumHigh},
    {"high", db.GainHigh},
    }
    [2.198574]
    [2.198970]
    func testParseAllGainLevels(t *testing.T) {
    testCases := []struct {
    gainStr string
    expected db.GainLevel
    }{
    {"low", db.GainLow},
    {"low-medium", db.GainLowMedium},
    {"medium", db.GainMedium},
    {"medium-high", db.GainMediumHigh},
    {"high", db.GainHigh},
    }
  • replacement in utils/audiomoth_parser_test.go at line 142
    [2.198971][2.198971:199690]()
    for _, tc := range testCases {
    comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at " + tc.gainStr + " gain while battery was 4.3V and temperature was 15.8C."
    result, err := ParseAudioMothComment(comment)
    if err != nil {
    t.Errorf("Failed to parse comment with gain %s: %v", tc.gainStr, err)
    continue
    }
    if result.Gain != tc.expected {
    t.Errorf("Gain incorrect for %s: got %s, want %s", tc.gainStr, result.Gain, tc.expected)
    }
    }
    })
    t.Run("should handle negative temperatures", func(t *testing.T) {
    comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was -5.2C."
    [2.198971]
    [2.199690]
    for _, tc := range testCases {
    comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at " + tc.gainStr + " gain while battery was 4.3V and temperature was 15.8C."
  • replacement in utils/audiomoth_parser_test.go at line 146
    [2.199756][2.199756:199804]()
    t.Fatalf("Failed to parse comment: %v", err)
    [2.199756]
    [2.199804]
    t.Errorf("Failed to parse comment with gain %s: %v", tc.gainStr, err)
    continue
  • replacement in utils/audiomoth_parser_test.go at line 149
    [2.199808][2.199808:199901]()
    if result.TempC != -5.2 {
    t.Errorf("TempC incorrect: got %f, want -5.2", result.TempC)
    [2.199808]
    [2.199901]
    if result.Gain != tc.expected {
    t.Errorf("Gain incorrect for %s: got %s, want %s", tc.gainStr, result.Gain, tc.expected)
  • replacement in utils/audiomoth_parser_test.go at line 152
    [2.199905][2.199905:199909]()
    })
    [2.199905]
    [2.199909]
    }
    }
  • replacement in utils/audiomoth_parser_test.go at line 155
    [2.199910][2.199910:200239]()
    t.Run("should fallback to legacy parsing", func(t *testing.T) {
    // Legacy format might not match structured regex but should be parseable
    // Test with a legacy-style comment
    comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C"
    [2.199910]
    [2.200239]
    func testParseNegativeTemp(t *testing.T) {
    comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was -5.2C."
    result, err := ParseAudioMothComment(comment)
    if err != nil {
    t.Fatalf("Failed to parse comment: %v", err)
    }
    if result.TempC != -5.2 {
    t.Errorf("TempC incorrect: got %f, want -5.2", result.TempC)
    }
    }
  • replacement in utils/audiomoth_parser_test.go at line 167
    [2.200240][2.200240:200453]()
    // Note: The legacy parser expects the exact structure, so this might fail
    // if the comment doesn't match. Adjust test as needed based on actual legacy format.
    result, err := ParseAudioMothComment(comment)
    [2.200240]
    [2.200453]
    func testParseLegacyFallback(t *testing.T) {
    comment := "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C"
  • replacement in utils/audiomoth_parser_test.go at line 170
    [2.200454][2.200454:200638]()
    // Either succeeds or fails gracefully
    if err == nil {
    // If it succeeds, verify basic fields
    if result.RecorderID == "" {
    t.Error("RecorderID should not be empty")
    }
    [2.200454]
    [2.200638]
    result, err := ParseAudioMothComment(comment)
    if err == nil {
    if result.RecorderID == "" {
    t.Error("RecorderID should not be empty")
  • replacement in utils/audiomoth_parser_test.go at line 175
    [2.200642][2.200642:200646]()
    })
    [2.200642]
    [2.200646]
    }
  • edit in tui/classify.go at line 425
    [2.237260]
    [2.237260]
    return m, nil
    }
    // Navigation and editing keys
    if handled := m.handleCommentKeyCode(key); handled {
    return m, nil
    }
    // Ctrl combos
    if handled := m.handleCommentCtrl(msg.String()); handled {
  • replacement in tui/classify.go at line 438
    [2.237280][2.237280:237340]()
    // Navigation and editing keys (check by code, not string)
    [2.237280]
    [2.237340]
    // Printable ASCII character (space handled above via KeySpace)
    s := msg.String()
    if len(s) == 1 && s[0] >= 33 && s[0] <= 126 {
    if len(m.commentText) < 140 {
    m.commentText = m.commentText[:m.commentCursor] + s + m.commentText[m.commentCursor:]
    m.commentCursor++
    }
    }
    return m, nil
    }
    // handleCommentKeyCode handles navigation and editing keys in comment mode.
    // Returns true if the key was consumed.
    func (m *Model) handleCommentKeyCode(key tea.Key) bool {
  • replacement in tui/classify.go at line 457
    [2.237430][2.237430:237446]()
    return m, nil
    [2.237430]
    [2.237446]
    return true
  • replacement in tui/classify.go at line 462
    [2.237535][2.237535:237551]()
    return m, nil
    [2.237535]
    [2.237551]
    return true
  • replacement in tui/classify.go at line 468
    [2.237719][2.237719:237735]()
    return m, nil
    [2.237719]
    [2.237735]
    return true
  • replacement in tui/classify.go at line 474
    [2.237898][2.237898:237914]()
    return m, nil
    [2.237898]
    [2.237914]
    return true
  • replacement in tui/classify.go at line 479
    [2.238070][2.238070:238086]()
    return m, nil
    [2.238070]
    [2.238086]
    return true
  • edit in tui/classify.go at line 481
    [2.238089]
    [2.238089]
    return false
    }
  • replacement in tui/classify.go at line 484
    [2.238090][2.238090:238166]()
    // Handle via string representation for ctrl combos
    switch msg.String() {
    [2.238090]
    [2.238166]
    // handleCommentCtrl handles ctrl-key combos in comment mode.
    // Returns true if the key was consumed.
    func (m *Model) handleCommentCtrl(s string) bool {
    switch s {
  • replacement in tui/classify.go at line 491
    [2.238225][2.238225:238241]()
    return m, nil
    [2.238225]
    [2.238241]
    return true
  • replacement in tui/classify.go at line 494
    [2.238279][2.238279:238295]()
    return m, nil
    [2.238279]
    [2.238295]
    return true
  • replacement in tui/classify.go at line 497
    [2.238350][2.238350:238366]()
    return m, nil
    [2.238350]
    [2.238366]
    return true
  • replacement in tui/classify.go at line 499
    [2.238369][2.238369:238726]()
    // Printable ASCII character (space handled above via KeySpace)
    s := msg.String()
    if len(s) == 1 && s[0] >= 33 && s[0] <= 126 { // 33='!', 126='~' (space=32 handled above)
    if len(m.commentText) < 140 {
    m.commentText = m.commentText[:m.commentCursor] + s + m.commentText[m.commentCursor:]
    m.commentCursor++
    }
    return m, nil
    }
    return m, nil
    [2.238369]
    [2.238726]
    return false
  • edit in tools/dataset.go at line 5
    [2.370686]
    [2.370686]
    "database/sql"
  • replacement in tools/dataset.go at line 152
    [2.375322][2.375322:375574]()
    func updateDataset(ctx context.Context, input DatasetInput) (DatasetOutput, error) {
    var output DatasetOutput
    datasetID := *input.ID
    // Validate ID format
    if err := utils.ValidateShortID(datasetID, "dataset_id"); err != nil {
    return output, err
    [2.375322]
    [2.375574]
    // validateUpdateInput validates all fields in a dataset update input.
    func validateUpdateInput(input DatasetInput) error {
    if err := utils.ValidateShortID(*input.ID, "dataset_id"); err != nil {
    return err
  • edit in tools/dataset.go at line 157
    [2.375577][2.375577:375610]()
    // Validate fields if provided
  • replacement in tools/dataset.go at line 158
    [2.375715][2.375715:375736]()
    return output, err
    [2.375715]
    [2.375736]
    return err
  • replacement in tools/dataset.go at line 161
    [2.375858][2.375858:375879]()
    return output, err
    [2.375858]
    [2.375879]
    return err
  • replacement in tools/dataset.go at line 163
    [2.375882][2.375882:376194]()
    if input.Type != nil {
    typeValue := strings.ToLower(*input.Type)
    if typeValue != "structured" && typeValue != "unstructured" && typeValue != "test" && typeValue != "train" {
    return output, fmt.Errorf("invalid dataset type: %s (must be 'structured', 'unstructured', 'test', or 'train')", *input.Type)
    }
    [2.375882]
    [2.376194]
    if err := validateDatasetType(input.Type); err != nil {
    return err
  • edit in tools/dataset.go at line 166
    [2.376197]
    [2.376197]
    return nil
    }
  • replacement in tools/dataset.go at line 169
    [2.376198][2.376198:376799]()
    // Open writable database
    database, err := db.OpenWriteableDB(dbPath)
    if err != nil {
    return output, fmt.Errorf("failed to open database: %w", err)
    }
    defer database.Close()
    // Verify dataset exists and check active status
    var exists, active bool
    err = database.QueryRow("SELECT EXISTS(SELECT 1 FROM dataset WHERE id = ?), COALESCE((SELECT active FROM dataset WHERE id = ?), false)", datasetID, datasetID).Scan(&exists, &active)
    if err != nil {
    return output, fmt.Errorf("failed to query dataset: %w", err)
    }
    if !exists {
    return output, fmt.Errorf("dataset not found: %s", datasetID)
    [2.376198]
    [2.376799]
    // validateDatasetType validates the type field if provided.
    func validateDatasetType(t *string) error {
    if t == nil {
    return nil
  • replacement in tools/dataset.go at line 174
    [2.376802][2.376802:376919]()
    if !active {
    return output, fmt.Errorf("dataset '%s' is not active (cannot update inactive datasets)", datasetID)
    [2.376802]
    [2.376919]
    typeValue := strings.ToLower(*t)
    switch typeValue {
    case "structured", "unstructured", "test", "train":
    return nil
    default:
    return fmt.Errorf("invalid dataset type: %s (must be 'structured', 'unstructured', 'test', or 'train')", *t)
  • edit in tools/dataset.go at line 181
    [2.376922]
    [2.376922]
    }
  • replacement in tools/dataset.go at line 183
    [2.376923][2.376923:376954]()
    // Build dynamic UPDATE query
    [2.376923]
    [2.376954]
    // buildUpdateQuery builds the dynamic UPDATE query and args from non-nil input fields.
    func buildUpdateQuery(input DatasetInput, datasetID string) (string, []any, error) {
  • replacement in tools/dataset.go at line 202
    [2.377364][2.377364:377424]()
    return output, fmt.Errorf("no fields provided to update")
    [2.377364]
    [2.377424]
    return "", nil, fmt.Errorf("no fields provided to update")
  • edit in tools/dataset.go at line 205
    [2.377428][2.377428:377460]()
    // Always update last_modified
  • edit in tools/dataset.go at line 209
    [2.377634]
    [2.377634]
    return query, args, nil
    }
  • edit in tools/dataset.go at line 212
    [2.377635]
    [2.377635]
    func updateDataset(ctx context.Context, input DatasetInput) (DatasetOutput, error) {
    var output DatasetOutput
    datasetID := *input.ID
    // Validate all fields
    if err := validateUpdateInput(input); err != nil {
    return output, err
    }
    // Open writable database
    database, err := db.OpenWriteableDB(dbPath)
    if err != nil {
    return output, fmt.Errorf("failed to open database: %w", err)
    }
    defer database.Close()
    // Verify dataset exists and check active status
    if err := verifyDatasetActive(database, datasetID); err != nil {
    return output, err
    }
    // Build dynamic UPDATE query
    query, args, err := buildUpdateQuery(input, datasetID)
    if err != nil {
    return output, err
    }
  • edit in tools/dataset.go at line 273
    [2.378688]
    [2.378688]
    }
    // verifyDatasetActive checks that a dataset exists and is active.
    func verifyDatasetActive(database *sql.DB, datasetID string) error {
    var exists, active bool
    err := database.QueryRow("SELECT EXISTS(SELECT 1 FROM dataset WHERE id = ?), COALESCE((SELECT active FROM dataset WHERE id = ?), false)", datasetID, datasetID).Scan(&exists, &active)
    if err != nil {
    return fmt.Errorf("failed to query dataset: %w", err)
    }
    if !exists {
    return fmt.Errorf("dataset not found: %s", datasetID)
    }
    if !active {
    return fmt.Errorf("dataset '%s' is not active (cannot update inactive datasets)", datasetID)
    }
    return nil
  • replacement in tools/calls_clip.go at line 50
    [2.575438][2.575438:575947]()
    if input.File == "" && input.Folder == "" {
    output.Errors = append(output.Errors, "either --file or --folder is required")
    return output, fmt.Errorf("missing required flag: --file or --folder")
    }
    if input.Output == "" {
    output.Errors = append(output.Errors, "--output is required")
    return output, fmt.Errorf("missing required flag: --output")
    }
    if input.Prefix == "" {
    output.Errors = append(output.Errors, "--prefix is required")
    return output, fmt.Errorf("missing required flag: --prefix")
    [2.575438]
    [2.575947]
    if err := validateClipInput(&output, input); err != nil {
    return output, err
  • replacement in tools/calls_clip.go at line 58
    [2.576075][2.576075:576373]()
    var filePaths []string
    var err error
    if input.File != "" {
    filePaths = []string{input.File}
    } else {
    filePaths, err = utils.FindDataFiles(input.Folder)
    if err != nil {
    output.Errors = append(output.Errors, fmt.Sprintf("failed to find .data files: %v", err))
    return output, err
    }
    [2.576075]
    [2.576373]
    filePaths, err := resolveClipFiles(&output, input)
    if err != nil {
    return output, err
  • edit in tools/calls_clip.go at line 63
    [2.576377][2.576377:576523]()
    if len(filePaths) == 0 {
    output.Errors = append(output.Errors, "no .data files found")
    return output, fmt.Errorf("no .data files found")
    }
  • replacement in tools/calls_clip.go at line 74
    [2.576908][2.576908:577545]()
    // Sequential for small batches
    for _, dataPath := range filePaths {
    clips, skipped, errs := processFile(dataPath, input.Output, input.Prefix, input.Filter, speciesName, callType, input.Certainty, imgSize, input.Color, input.WavOnly, input.Night, input.Day, input.Lat, input.Lng, input.Timezone)
    output.SegmentsClipped += len(clips)
    if input.Night {
    output.NightSkipped += skipped
    } else {
    output.DaySkipped += skipped
    }
    output.OutputFiles = append(output.OutputFiles, clips...)
    output.Errors = append(output.Errors, errs...)
    if len(clips) > 0 || len(errs) == 0 {
    output.FilesProcessed++
    }
    }
    [2.576908]
    [2.577545]
    processFilesSequential(&output, filePaths, input, speciesName, callType, imgSize)
  • replacement in tools/calls_clip.go at line 76
    [2.577555][2.577555:577671]()
    // Parallel file processing
    type fileResult struct {
    clips []string
    skipped int
    errs []string
    }
    [2.577555]
    [2.577671]
    processFilesParallel(&output, filePaths, input, speciesName, callType, imgSize)
    }
    return output, nil
    }
    // validateClipInput validates required flags for clip generation.
    func validateClipInput(output *CallsClipOutput, input CallsClipInput) error {
    if input.File == "" && input.Folder == "" {
    output.Errors = append(output.Errors, "either --file or --folder is required")
    return fmt.Errorf("missing required flag: --file or --folder")
    }
    if input.Output == "" {
    output.Errors = append(output.Errors, "--output is required")
    return fmt.Errorf("missing required flag: --output")
    }
    if input.Prefix == "" {
    output.Errors = append(output.Errors, "--prefix is required")
    return fmt.Errorf("missing required flag: --prefix")
    }
    return nil
    }
  • replacement in tools/calls_clip.go at line 99
    [2.577672][2.577672:577821]()
    workers := min(runtime.NumCPU(), 8, len(filePaths))
    jobs := make(chan string, len(filePaths))
    results := make(chan fileResult, len(filePaths))
    [2.577672]
    [2.577821]
    // resolveClipFiles returns the list of .data file paths from input.
    func resolveClipFiles(output *CallsClipOutput, input CallsClipInput) ([]string, error) {
    if input.File != "" {
    return []string{input.File}, nil
    }
    filePaths, err := utils.FindDataFiles(input.Folder)
    if err != nil {
    output.Errors = append(output.Errors, fmt.Sprintf("failed to find .data files: %v", err))
    return nil, err
    }
    if len(filePaths) == 0 {
    output.Errors = append(output.Errors, "no .data files found")
    return nil, fmt.Errorf("no .data files found")
    }
    return filePaths, nil
    }
  • replacement in tools/calls_clip.go at line 116
    [2.577822][2.577822:578238]()
    var wg sync.WaitGroup
    for range workers {
    wg.Go(func() {
    for dataPath := range jobs {
    clips, skipped, errs := processFile(dataPath, input.Output, input.Prefix, input.Filter, speciesName, callType, input.Certainty, imgSize, input.Color, input.WavOnly, input.Night, input.Day, input.Lat, input.Lng, input.Timezone)
    results <- fileResult{clips: clips, skipped: skipped, errs: errs}
    }
    })
    }
    [2.577822]
    [2.578238]
    // processFilesSequential processes .data files one at a time.
    func processFilesSequential(output *CallsClipOutput, filePaths []string, input CallsClipInput, speciesName, callType string, imgSize int) {
    for _, dataPath := range filePaths {
    clips, skipped, errs := processFile(dataPath, input.Output, input.Prefix, input.Filter, speciesName, callType, input.Certainty, imgSize, input.Color, input.WavOnly, input.Night, input.Day, input.Lat, input.Lng, input.Timezone)
    accumulateFileResult(output, clips, skipped, errs, input.Night)
    }
    }
  • replacement in tools/calls_clip.go at line 124
    [2.578239][2.578239:578316]()
    for _, dataPath := range filePaths {
    jobs <- dataPath
    }
    close(jobs)
    [2.578239]
    [2.578316]
    // processFilesParallel processes .data files using worker goroutines.
    func processFilesParallel(output *CallsClipOutput, filePaths []string, input CallsClipInput, speciesName, callType string, imgSize int) {
    type fileResult struct {
    clips []string
    skipped int
    errs []string
    }
  • replacement in tools/calls_clip.go at line 132
    [2.578317][2.578317:578368]()
    go func() {
    wg.Wait()
    close(results)
    }()
    [2.578317]
    [2.578368]
    workers := min(runtime.NumCPU(), 8, len(filePaths))
    jobs := make(chan string, len(filePaths))
    results := make(chan fileResult, len(filePaths))
  • replacement in tools/calls_clip.go at line 136
    [2.578369][2.578369:578735]()
    for r := range results {
    output.SegmentsClipped += len(r.clips)
    if input.Night {
    output.NightSkipped += r.skipped
    } else {
    output.DaySkipped += r.skipped
    }
    output.OutputFiles = append(output.OutputFiles, r.clips...)
    output.Errors = append(output.Errors, r.errs...)
    if len(r.clips) > 0 || len(r.errs) == 0 {
    output.FilesProcessed++
    [2.578369]
    [2.578735]
    var wg sync.WaitGroup
    for range workers {
    wg.Go(func() {
    for dataPath := range jobs {
    clips, skipped, errs := processFile(dataPath, input.Output, input.Prefix, input.Filter, speciesName, callType, input.Certainty, imgSize, input.Color, input.WavOnly, input.Night, input.Day, input.Lat, input.Lng, input.Timezone)
    results <- fileResult{clips: clips, skipped: skipped, errs: errs}
  • replacement in tools/calls_clip.go at line 143
    [2.578740][2.578740:578744]()
    }
    [2.578740]
    [2.578744]
    })
    }
    for _, dataPath := range filePaths {
    jobs <- dataPath
  • edit in tools/calls_clip.go at line 149
    [2.578747]
    [2.578747]
    close(jobs)
  • replacement in tools/calls_clip.go at line 151
    [2.578748][2.578748:578768]()
    return output, nil
    [2.578748]
    [2.578768]
    go func() {
    wg.Wait()
    close(results)
    }()
    for r := range results {
    accumulateFileResult(output, r.clips, r.skipped, r.errs, input.Night)
    }
    }
    // accumulateFileResult merges a single file's results into the output.
    func accumulateFileResult(output *CallsClipOutput, clips []string, skipped int, errs []string, night bool) {
    output.SegmentsClipped += len(clips)
    if night {
    output.NightSkipped += skipped
    } else {
    output.DaySkipped += skipped
    }
    output.OutputFiles = append(output.OutputFiles, clips...)
    output.Errors = append(output.Errors, errs...)
    if len(clips) > 0 || len(errs) == 0 {
    output.FilesProcessed++
    }
  • replacement in tools/calls_clip.go at line 194
    [2.579563][2.579563:579778]()
    var matchingSegments []*utils.Segment
    for _, seg := range dataFile.Segments {
    if seg.SegmentMatchesFilters(filter, speciesName, callType, certainty) {
    matchingSegments = append(matchingSegments, seg)
    }
    }
    [2.579563]
    [2.579778]
    matchingSegments := filterSegments(dataFile.Segments, filter, speciesName, callType, certainty)
  • replacement in tools/calls_clip.go at line 196
    [2.579811][2.579811:579860]()
    return nil, 0, nil // No matches, not an error
    [2.579811]
    [2.579860]
    return nil, 0, nil
  • edit in tools/calls_clip.go at line 200
    [2.579943][2.579943:580030]()
    // Skip recordings in the wrong time-of-day before paying the cost of ReadWAVSamples.
  • replacement in tools/calls_clip.go at line 201
    [2.580049][2.580049:580278]()
    result, err := IsNight(IsNightInput{
    FilePath: wavPath,
    Lat: lat,
    Lng: lng,
    Timezone: timezone,
    })
    if err != nil {
    fmt.Fprintf(os.Stderr, "warning: skipping %s (isnight error: %v)\n", wavPath, err)
    [2.580049]
    [2.580278]
    skipped, err := checkDayNightFilter(wavPath, night, day, lat, lng, timezone)
    if err != nil || skipped {
    if skipped {
    return nil, 1, nil
    }
  • edit in tools/calls_clip.go at line 208
    [2.580304][2.580304:580553]()
    if night && !result.SolarNight {
    fmt.Fprintf(os.Stderr, "skipped (daytime): %s\n", wavPath)
    return nil, 1, nil
    }
    if day && !result.DiurnalActive {
    fmt.Fprintf(os.Stderr, "skipped (nighttime): %s\n", wavPath)
    return nil, 1, nil
    }
  • replacement in tools/calls_clip.go at line 217
    [2.580771][2.580771:580905]()
    // Process matching segments (parallel for larger batches)
    if len(matchingSegments) <= 2 {
    for _, seg := range matchingSegments {
    [2.580771]
    [2.580905]
    // Process matching segments
    clips, errors = processSegments(matchingSegments, dataPath, samples, sampleRate, outputDir, prefix, basename, imgSize, color, wavOnly)
    return clips, 0, errors
    }
    // filterSegments returns segments matching the given filter criteria.
    func filterSegments(segments []*utils.Segment, filter, speciesName, callType string, certainty int) []*utils.Segment {
    var matching []*utils.Segment
    for _, seg := range segments {
    if seg.SegmentMatchesFilters(filter, speciesName, callType, certainty) {
    matching = append(matching, seg)
    }
    }
    return matching
    }
    // checkDayNightFilter applies day/night filtering. Returns (skipped=true, nil) if the
    // recording should be skipped, (false, nil) if it passes, or (false, err) on failure.
    func checkDayNightFilter(wavPath string, night, day bool, lat, lng float64, timezone string) (bool, error) {
    result, err := IsNight(IsNightInput{
    FilePath: wavPath,
    Lat: lat,
    Lng: lng,
    Timezone: timezone,
    })
    if err != nil {
    fmt.Fprintf(os.Stderr, "warning: skipping %s (isnight error: %v)\n", wavPath, err)
    return false, err
    }
    if night && !result.SolarNight {
    fmt.Fprintf(os.Stderr, "skipped (daytime): %s\n", wavPath)
    return true, nil
    }
    if day && !result.DiurnalActive {
    fmt.Fprintf(os.Stderr, "skipped (nighttime): %s\n", wavPath)
    return true, nil
    }
    return false, nil
    }
    // processSegments generates clips for matching segments, using parallel processing for larger batches.
    func processSegments(segments []*utils.Segment, dataPath string, samples []float64, sampleRate int, outputDir, prefix, basename string, imgSize int, color, wavOnly bool) ([]string, []string) {
    var clips []string
    var errors []string
    if len(segments) <= 2 {
    for _, seg := range segments {
  • replacement in tools/calls_clip.go at line 272
    [2.581245][2.581245:581309]()
    type segResult struct {
    clips []string
    err string
    }
    [2.581245]
    [2.581309]
    clips, errors = processSegmentsParallel(segments, dataPath, samples, sampleRate, outputDir, prefix, basename, imgSize, color, wavOnly)
    }
  • replacement in tools/calls_clip.go at line 275
    [2.581310][2.581310:581484]()
    workers := min(runtime.NumCPU(), len(matchingSegments))
    jobs := make(chan *utils.Segment, len(matchingSegments))
    results := make(chan segResult, len(matchingSegments))
    [2.581310]
    [2.581484]
    return clips, errors
    }
  • replacement in tools/calls_clip.go at line 278
    [2.581485][2.581485:581920]()
    var wg sync.WaitGroup
    for range workers {
    wg.Go(func() {
    for seg := range jobs {
    clipFiles, err := generateClip(samples, sampleRate, outputDir, prefix, basename, seg.StartTime, seg.EndTime, imgSize, color, wavOnly)
    if err != nil {
    results <- segResult{err: fmt.Sprintf("%s: segment %.0f-%.0f: %v", dataPath, seg.StartTime, seg.EndTime, err)}
    } else {
    results <- segResult{clips: clipFiles}
    }
    [2.581485]
    [2.581920]
    // processSegmentsParallel generates clips for segments using worker goroutines.
    func processSegmentsParallel(segments []*utils.Segment, dataPath string, samples []float64, sampleRate int, outputDir, prefix, basename string, imgSize int, color, wavOnly bool) ([]string, []string) {
    type segResult struct {
    clips []string
    err string
    }
    workers := min(runtime.NumCPU(), len(segments))
    jobs := make(chan *utils.Segment, len(segments))
    results := make(chan segResult, len(segments))
    var wg sync.WaitGroup
    for range workers {
    wg.Go(func() {
    for seg := range jobs {
    clipFiles, err := generateClip(samples, sampleRate, outputDir, prefix, basename, seg.StartTime, seg.EndTime, imgSize, color, wavOnly)
    if err != nil {
    results <- segResult{err: fmt.Sprintf("%s: segment %.0f-%.0f: %v", dataPath, seg.StartTime, seg.EndTime, err)}
    } else {
    results <- segResult{clips: clipFiles}
  • replacement in tools/calls_clip.go at line 299
    [2.581926][2.581926:581936]()
    })
    }
    [2.581926]
    [2.581936]
    }
    })
    }
  • replacement in tools/calls_clip.go at line 303
    [2.581937][2.581937:582011]()
    for _, seg := range matchingSegments {
    jobs <- seg
    }
    close(jobs)
    [2.581937]
    [2.582011]
    for _, seg := range segments {
    jobs <- seg
    }
    close(jobs)
  • replacement in tools/calls_clip.go at line 308
    [2.582012][2.582012:582063]()
    go func() {
    wg.Wait()
    close(results)
    }()
    [2.582012]
    [2.582063]
    go func() {
    wg.Wait()
    close(results)
    }()
  • replacement in tools/calls_clip.go at line 313
    [2.582064][2.582064:582201]()
    for r := range results {
    if r.err != "" {
    errors = append(errors, r.err)
    } else {
    clips = append(clips, r.clips...)
    }
    [2.582064]
    [2.582201]
    var clips []string
    var errors []string
    for r := range results {
    if r.err != "" {
    errors = append(errors, r.err)
    } else {
    clips = append(clips, r.clips...)
  • replacement in tools/calls_clip.go at line 322
    [2.582208][2.582208:582234]()
    return clips, 0, errors
    [2.582208]
    [2.582234]
    return clips, errors