package tools
import (
"fmt"
"image"
"math"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"skraak/utils"
)
type CallsClipInput struct {
File string `json:"file"`
Folder string `json:"folder"`
Output string `json:"output"`
Prefix string `json:"prefix"`
Filter string `json:"filter"`
Species string `json:"species"`
Certainty int `json:"certainty"`
Size int `json:"size"`
Color bool `json:"color"`
Night bool `json:"night"`
Day bool `json:"day"`
Location string `json:"location,omitempty"`
}
type CallsClipOutput struct {
FilesProcessed int `json:"files_processed"`
SegmentsClipped int `json:"segments_clipped"`
NightSkipped int `json:"night_skipped,omitempty"`
DaySkipped int `json:"day_skipped,omitempty"`
OutputFiles []string `json:"output_files"`
Errors []string `json:"errors,omitempty"`
}
func CallsClip(input CallsClipInput) (CallsClipOutput, error) {
var output CallsClipOutput
if err := validateClipInput(&output, input); err != nil {
return output, err
}
speciesName, callType := utils.ParseSpeciesCallType(input.Species)
filePaths, err := resolveClipFiles(&output, input)
if err != nil {
return output, err
}
if err := os.MkdirAll(input.Output, 0755); err != nil {
output.Errors = append(output.Errors, fmt.Sprintf("failed to create output folder: %v", err))
return output, err
}
imgSize := utils.ClampImageSize(input.Size)
var lat, lng float64
var timezone string
if input.Location != "" {
var err error
lat, lng, timezone, err = utils.ParseLocation(input.Location)
if err != nil {
output.Errors = append(output.Errors, err.Error())
return output, err
}
}
if len(filePaths) <= 2 {
processFilesSequential(&output, filePaths, input, speciesName, callType, imgSize, lat, lng, timezone)
} else {
processFilesParallel(&output, filePaths, input, speciesName, callType, imgSize, lat, lng, timezone)
}
return output, nil
}
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
}
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
}
func processFilesSequential(output *CallsClipOutput, filePaths []string, input CallsClipInput, speciesName, callType string, imgSize int, lat, lng float64, timezone string) {
for _, dataPath := range filePaths {
clips, skipped, errs := processFile(dataPath, input.Output, input.Prefix, input.Filter, speciesName, callType, input.Certainty, imgSize, input.Color, input.Night, input.Day, lat, lng, timezone)
accumulateFileResult(output, clips, skipped, errs, input.Night)
}
}
func processFilesParallel(output *CallsClipOutput, filePaths []string, input CallsClipInput, speciesName, callType string, imgSize int, lat, lng float64, timezone string) {
type fileResult struct {
clips []string
skipped int
errs []string
}
workers := min(runtime.NumCPU(), 8, len(filePaths))
jobs := make(chan string, len(filePaths))
results := make(chan fileResult, len(filePaths))
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.Night, input.Day, lat, lng, timezone)
results <- fileResult{clips: clips, skipped: skipped, errs: errs}
}
})
}
for _, dataPath := range filePaths {
jobs <- dataPath
}
close(jobs)
go func() {
wg.Wait()
close(results)
}()
for r := range results {
accumulateFileResult(output, r.clips, r.skipped, r.errs, input.Night)
}
}
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++
}
}
func processFile(dataPath, outputDir, prefix, filter, speciesName, callType string, certainty, imgSize int, color, night, day bool, lat, lng float64, timezone string) ([]string, int, []string) {
var clips []string
var errors []string
dataFile, err := utils.ParseDataFile(dataPath)
if err != nil {
errors = append(errors, fmt.Sprintf("%s: failed to parse: %v", dataPath, err))
return nil, 0, errors
}
wavPath := filepath.Clean(strings.TrimSuffix(dataPath, ".data"))
basename := filepath.Base(wavPath)
basename = strings.TrimSuffix(basename, filepath.Ext(basename))
matchingSegments := filterSegments(dataFile.Segments, filter, speciesName, callType, certainty)
if len(matchingSegments) == 0 {
return nil, 0, nil
}
if night || day {
skipped, err := checkDayNightFilter(wavPath, night, day, lat, lng, timezone)
if err != nil || skipped {
if skipped {
return nil, 1, nil
}
return nil, 0, nil
}
}
samples, sampleRate, err := utils.ReadWAVSamples(wavPath)
if err != nil {
errors = append(errors, fmt.Sprintf("%s: failed to read WAV: %v", dataPath, err))
return nil, 0, errors
}
clips, errors = processSegments(matchingSegments, dataPath, samples, sampleRate, outputDir, prefix, basename, imgSize, color)
return clips, 0, errors
}
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
}
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
}
func processSegments(segments []*utils.Segment, dataPath string, samples []float64, sampleRate int, outputDir, prefix, basename string, imgSize int, color bool) ([]string, []string) {
var clips []string
var errors []string
if len(segments) <= 2 {
for _, seg := range segments {
clipFiles, err := generateClip(samples, sampleRate, outputDir, prefix, basename, seg.StartTime, seg.EndTime, imgSize, color)
if err != nil {
errors = append(errors, fmt.Sprintf("%s: segment %.0f-%.0f: %v", dataPath, seg.StartTime, seg.EndTime, err))
continue
}
clips = append(clips, clipFiles...)
}
} else {
clips, errors = processSegmentsParallel(segments, dataPath, samples, sampleRate, outputDir, prefix, basename, imgSize, color)
}
return clips, errors
}
func processSegmentsParallel(segments []*utils.Segment, dataPath string, samples []float64, sampleRate int, outputDir, prefix, basename string, imgSize int, color 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)
if err != nil {
results <- segResult{err: fmt.Sprintf("%s: segment %.0f-%.0f: %v", dataPath, seg.StartTime, seg.EndTime, err)}
} else {
results <- segResult{clips: clipFiles}
}
}
})
}
for _, seg := range segments {
jobs <- seg
}
close(jobs)
go func() {
wg.Wait()
close(results)
}()
var clips []string
var errors []string
for r := range results {
if r.err != "" {
errors = append(errors, r.err)
} else {
clips = append(clips, r.clips...)
}
}
return clips, errors
}
func generateClip(samples []float64, sampleRate int, outputDir, prefix, basename string, startTime, endTime float64, imgSize int, color bool) ([]string, error) {
var files []string
startInt := int(math.Floor(startTime))
endInt := int(math.Ceil(endTime))
baseName := fmt.Sprintf("%s_%s_%d_%d", prefix, basename, startInt, endInt)
wavPath := filepath.Join(outputDir, baseName+".wav")
segSamples := utils.ExtractSegmentSamples(samples, sampleRate, startTime, endTime)
if len(segSamples) == 0 {
return nil, fmt.Errorf("no samples in segment")
}
outputSampleRate := sampleRate
if sampleRate > utils.DefaultMaxSampleRate {
segSamples = utils.ResampleRate(segSamples, sampleRate, utils.DefaultMaxSampleRate)
outputSampleRate = utils.DefaultMaxSampleRate
}
pngPath := filepath.Join(outputDir, baseName+".png")
spectSampleRate := outputSampleRate
config := utils.DefaultSpectrogramConfig(spectSampleRate)
spectrogram := utils.GenerateSpectrogram(segSamples, config)
if spectrogram == nil {
return nil, fmt.Errorf("failed to generate spectrogram")
}
var img image.Image
if color {
colorData := utils.ApplyL4Colormap(spectrogram)
img = utils.CreateRGBImage(colorData)
} else {
img = utils.CreateGrayscaleImage(spectrogram)
}
if img == nil {
return nil, fmt.Errorf("failed to create image")
}
resized := utils.ResizeImage(img, imgSize, imgSize)
pngFile, err := os.OpenFile(pngPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if err != nil {
if os.IsExist(err) {
return nil, fmt.Errorf("file already exists: %s", pngPath)
}
return nil, fmt.Errorf("failed to create PNG: %w", err)
}
if err := utils.WritePNG(resized, pngFile); err != nil {
_ = pngFile.Close()
return nil, fmt.Errorf("failed to write PNG: %w", err)
}
if err := pngFile.Close(); err != nil {
return nil, fmt.Errorf("failed to close PNG: %w", err)
}
files = append(files, pngPath)
if err := utils.WriteWAVFile(wavPath, segSamples, outputSampleRate); err != nil {
return nil, fmt.Errorf("failed to write WAV: %w", err)
}
files = append(files, wavPath)
return files, nil
}