package tools
import (
"encoding/csv"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"skraak/utils"
)
type CallsFromBirdaInput struct {
Folder string `json:"folder"`
File string `json:"file"`
Delete bool `json:"delete"`
ProgressHandler ProgressHandler `json:"-"` }
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"`
}
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)
}
func CallsFromBirda(input CallsFromBirdaInput) (CallsFromBirdaOutput, error) {
src := birdaSource{}
commonInput := CallsFromSourceInput(input)
commonOutput, err := callsFromSource(src, commonInput)
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
}
type BirdNETDetection struct {
StartTime float64
EndTime float64
ScientificName string
CommonName string
Confidence float64
WAVPath string
}
type birdaColumnIndices struct {
startIdx int
endIdx int
commonNameIdx int
confidenceIdx int
fileIdx int
}
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 := birdaColumnIndices{startIdx: -1, endIdx: -1, commonNameIdx: -1, confidenceIdx: -1, fileIdx: -1}
for i, col := range header {
col = strings.TrimPrefix(col, "\ufeff")
switch col {
case "Start (s)":
idx.startIdx = i
case "End (s)":
idx.endIdx = i
case "Common name":
idx.commonNameIdx = i
case "Confidence":
idx.confidenceIdx = i
case "File":
idx.fileIdx = i
}
}
if idx.startIdx == -1 || idx.endIdx == -1 || idx.commonNameIdx == -1 || idx.confidenceIdx == -1 {
return birdaColumnIndices{}, fmt.Errorf("missing required columns in BirdNET file")
}
return idx, nil
}
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
}
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)
}
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 := utils.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
}
func buildBirdNETSegments(detections []BirdNETDetection, sampleRate int) []AviaNZSegment {
var segments []AviaNZSegment
for _, det := range detections {
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, sampleRate, labels,
}
segments = append(segments, segment)
}
return segments
}