calls_from_birda.go
package calls
import (
"encoding/csv"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"skraak/wav"
)
// CallsFromBirdaInput defines the input for the calls-from-birda tool
type CallsFromBirdaInput struct {
Folder string `json:"folder"`
File string `json:"file"`
Delete bool `json:"delete"`
ProgressHandler ProgressHandler `json:"-"` // Optional progress callback
}
// CallsFromBirdaOutput defines the output for the calls-from-birda tool
type CallsFromBirdaOutput struct {
Calls []ClusteredCall `json:"calls"`
TotalCalls int `json:"total_calls"`
SpeciesCount map[string]int `json:"species_count"`
DataFilesWritten int `json:"data_files_written"`
DataFilesSkipped int `json:"data_files_skipped"`
FilesProcessed int `json:"files_processed"`
FilesDeleted int `json:"files_deleted"`
Filter string `json:"filter"`
Error *string `json:"error,omitempty"`
}
// birdaSource implements CallSource for BirdNET results files
type birdaSource struct{}
func (birdaSource) Name() string { return "BirdNET" }
func (birdaSource) FindFiles(folder string) ([]string, error) {
var files []string
entries, err := os.ReadDir(folder)
if err != nil {
return nil, err
}
for _, entry := range entries {
name := entry.Name()
if strings.HasSuffix(name, ".BirdNET.results.csv") {
files = append(files, filepath.Join(folder, name))
}
}
return files, nil
}
func (birdaSource) ProcessFile(birdaFile string, cache *DirCache) ([]ClusteredCall, bool, bool, error) {
return processBirdaFileCached(birdaFile, cache)
}
// CallsFromBirda processes BirdNET results files and writes .data files
func CallsFromBirda(input CallsFromBirdaInput) (CallsFromBirdaOutput, error) {
src := birdaSource{}
commonInput := CallsFromSourceInput(input)
commonOutput, err := callsFromSource(src, commonInput)
// Convert to Birda-specific output type
var output CallsFromBirdaOutput
output.Calls = commonOutput.Calls
output.TotalCalls = commonOutput.TotalCalls
output.SpeciesCount = commonOutput.SpeciesCount
output.DataFilesWritten = commonOutput.DataFilesWritten
output.DataFilesSkipped = commonOutput.DataFilesSkipped
output.FilesProcessed = commonOutput.FilesProcessed
output.FilesDeleted = commonOutput.FilesDeleted
output.Filter = commonOutput.Filter
output.Error = commonOutput.Error
return output, err
}
// BirdNETDetection represents a single BirdNET detection
type BirdNETDetection struct {
StartTime float64
EndTime float64
ScientificName string
CommonName string
Confidence float64
WAVPath string
}
// birdaColumnIndices holds the parsed column positions from a BirdNET CSV header.
type birdaColumnIndices struct {
startIdx int
endIdx int
commonNameIdx int
confidenceIdx int
fileIdx int
}
// parseBirdaCSVHeader reads the CSV header row and returns column indices.
func parseBirdaCSVHeader(reader *csv.Reader) (birdaColumnIndices, error) {
header, err := reader.Read()
if err != nil {
return birdaColumnIndices{}, fmt.Errorf("failed to read header: %w", err)
}
idx := detectBirdaColumns(header)
if err := validateBirdaColumns(idx); err != nil {
return birdaColumnIndices{}, err
}
return idx, nil
}
// detectBirdaColumns scans the header row and returns column indices.
func detectBirdaColumns(header []string) birdaColumnIndices {
idx := birdaColumnIndices{startIdx: -1, endIdx: -1, commonNameIdx: -1, confidenceIdx: -1, fileIdx: -1}
columnMap := map[string]*int{
"Start (s)": &idx.startIdx,
"End (s)": &idx.endIdx,
"Common name": &idx.commonNameIdx,
"Confidence": &idx.confidenceIdx,
"File": &idx.fileIdx,
}
for i, col := range header {
col = strings.TrimPrefix(col, "\ufeff")
if ptr, ok := columnMap[col]; ok {
*ptr = i
}
}
return idx
}
// validateBirdaColumns checks that required columns were found.
func validateBirdaColumns(idx birdaColumnIndices) error {
if idx.startIdx == -1 || idx.endIdx == -1 || idx.commonNameIdx == -1 || idx.confidenceIdx == -1 {
return fmt.Errorf("missing required columns in BirdNET file")
}
return nil
}
// readBirdaDetections reads all detection records from a BirdNET CSV.
func readBirdaDetections(reader *csv.Reader, idx birdaColumnIndices) ([]BirdNETDetection, error) {
var detections []BirdNETDetection
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("failed to read record: %w", err)
}
var det BirdNETDetection
startTime, perr := strconv.ParseFloat(record[idx.startIdx], 64)
if perr != nil {
return nil, fmt.Errorf("failed to parse start time %q: %w", record[idx.startIdx], perr)
}
det.StartTime = startTime
endTime, perr := strconv.ParseFloat(record[idx.endIdx], 64)
if perr != nil {
return nil, fmt.Errorf("failed to parse end time %q: %w", record[idx.endIdx], perr)
}
det.EndTime = endTime
det.CommonName = record[idx.commonNameIdx]
confidence, perr := strconv.ParseFloat(record[idx.confidenceIdx], 64)
if perr != nil {
return nil, fmt.Errorf("failed to parse confidence %q: %w", record[idx.confidenceIdx], perr)
}
det.Confidence = confidence
if idx.fileIdx >= 0 && idx.fileIdx < len(record) {
det.WAVPath = record[idx.fileIdx]
}
detections = append(detections, det)
}
return detections, nil
}
// resolveBirdaWAVPath finds the WAV file associated with a BirdNET results file.
func resolveBirdaWAVPath(birdaFile string, firstWAVPath string, cache *DirCache) string {
if firstWAVPath != "" {
if _, err := os.Stat(firstWAVPath); err == nil {
return firstWAVPath
}
}
dir := filepath.Dir(birdaFile)
base := filepath.Base(birdaFile)
baseName := strings.TrimSuffix(base, ".BirdNET.results.csv")
if cache != nil {
return cache.FindWAV(baseName)
}
return findWAVFile(dir, baseName)
}
// processBirdaFileCached processes a single BirdNET results file using a DirCache for WAV lookup
func processBirdaFileCached(birdaFile string, cache *DirCache) ([]ClusteredCall, bool, bool, error) {
file, err := os.Open(birdaFile)
if err != nil {
return nil, false, false, fmt.Errorf("failed to open file: %w", err)
}
defer func() { _ = file.Close() }()
reader := csv.NewReader(file)
idx, err := parseBirdaCSVHeader(reader)
if err != nil {
return nil, false, false, err
}
detections, err := readBirdaDetections(reader, idx)
if err != nil {
return nil, false, false, err
}
if len(detections) == 0 {
return nil, false, true, nil
}
wavPath := resolveBirdaWAVPath(birdaFile, detections[0].WAVPath, cache)
if wavPath == "" {
return nil, false, true, nil
}
sampleRate, duration, err := wav.ParseWAVHeaderMinimal(wavPath)
if err != nil {
return nil, false, true, nil
}
dataPath := wavPath + ".data"
segments := buildBirdNETSegments(detections, sampleRate)
meta := AviaNZMeta{Operator: "BirdNET", Duration: duration}
reviewer := "None"
meta.Reviewer = &reviewer
if err := writeDotDataFileSafe(dataPath, segments, "BirdNET", meta); err != nil {
return nil, false, false, err
}
var calls []ClusteredCall
for _, det := range detections {
calls = append(calls, ClusteredCall{
File: wavPath,
StartTime: det.StartTime,
EndTime: det.EndTime,
EbirdCode: det.CommonName,
Segments: 1,
})
}
return calls, true, false, nil
}
// buildBirdNETSegments converts BirdNET detections to AviaNZ segments
func buildBirdNETSegments(detections []BirdNETDetection, sampleRate int) []AviaNZSegment {
var segments []AviaNZSegment
for _, det := range detections {
// Convert confidence (0.0-1.0) to certainty (0-100)
certainty := min(max(int(det.Confidence*100), 0), 100)
labels := []AviaNZLabel{
{
Species: det.CommonName,
Certainty: certainty,
Filter: "BirdNET",
},
}
segment := AviaNZSegment{
det.StartTime,
det.EndTime,
0, // freq_low
sampleRate, // freq_high (full band)
labels,
}
segments = append(segments, segment)
}
return segments
}