GQ2FYLG2VGDDKQ7TJTCFM2XPLJOAX2N737OZWPRCNKSFIH7YJ6EQC XEFQ73KDMQJ5YP5UBGZM3Z2GZUVRCVWMTIADZQSUIJKVDTBODP4AC IFVRAERTCCDICNTYTG3TX2WASB6RXQQEJWWXQMQZJSQDQ3HLE5OQC 5KIKDA72HM6JFIPKOWGLM2EO7D5PTSK7WEVYV3YZWGMG3M34PJXQC 7NS27QXZMVTZBK4VPMYL5IKGSTTAWR6NDG5SOVITNX44VNIRZPMAC DBOROCRFD6A5SJBMFYFEJI5S5M77X4EFEK6KDQWA5QDMQJKIHRWQC DMMB63IW75MSRMKX63LTIVRVA74FOERMGGCNYGC7BZRV76VIJURQC D4EL6RSTSZ3S3IDSETRNGLJHZKGZEE2V2OZIOKQK6LRLHQNS77JQC HHT7M27I3YKGGJOTVTMRVWXATDWUZKIVVLM7IVI7SJRB7FLT2DAQC MFURT7K56GUJH64Z6XRKWKUI4VZZQYR72U2QEQNWR4B3TSUEXPQAC XSITZX775O4AB2YYSBF7XOOYFUCB6IS25HG4NIO2PQLKFHTTJLZAC GXVVTHNXT2IZPR4OB77VMU6GXFEA5TUFZ2MHMA5ASU2DSTFPLDLQC W3A2EECCD23SVHJZN6MXPH2PAVFHH5CNFD2XHPQRRW6M4GUTG3FAC package utilsimport ("encoding/binary""fmt""os")// WriteWAVFile writes audio samples to a WAV file.// Samples should be in the range -1.0 to 1.0.// Output is mono 16-bit PCM.func WriteWAVFile(filepath string, samples []float64, sampleRate int) error {if len(samples) == 0 {return fmt.Errorf("no samples to write")}file, err := os.Create(filepath)if err != nil {return fmt.Errorf("failed to create file: %w", err)}defer file.Close()// WAV parameterschannels := 1bitsPerSample := 16bytesPerSample := bitsPerSample / 8byteRate := sampleRate * channels * bytesPerSampleblockAlign := channels * bytesPerSampledataSize := len(samples) * bytesPerSampletotalSize := 36 + dataSize // 36 = header size before data chunk// Write RIFF headerif _, err := file.WriteString("RIFF"); err != nil {return err}if err := binary.Write(file, binary.LittleEndian, uint32(totalSize)); err != nil {return err}if _, err := file.WriteString("WAVE"); err != nil {return err}// Write fmt chunkif _, err := file.WriteString("fmt "); err != nil {return err}if err := binary.Write(file, binary.LittleEndian, uint32(16)); err != nil { // chunk sizereturn err}if err := binary.Write(file, binary.LittleEndian, uint16(1)); err != nil { // PCM formatreturn err}if err := binary.Write(file, binary.LittleEndian, uint16(channels)); err != nil {return err}if err := binary.Write(file, binary.LittleEndian, uint32(sampleRate)); err != nil {return err}if err := binary.Write(file, binary.LittleEndian, uint32(byteRate)); err != nil {return err}if err := binary.Write(file, binary.LittleEndian, uint16(blockAlign)); err != nil {return err}if err := binary.Write(file, binary.LittleEndian, uint16(bitsPerSample)); err != nil {return err}// Write data chunk headerif _, err := file.WriteString("data"); err != nil {return err}if err := binary.Write(file, binary.LittleEndian, uint32(dataSize)); err != nil {return err}// Convert float64 samples to 16-bit PCM and writefor _, sample := range samples {// Clamp to [-1, 1]if sample > 1.0 {sample = 1.0} else if sample < -1.0 {sample = -1.0}// Convert to 16-bit signed integervalue := int16(sample * 32767)if err := binary.Write(file, binary.LittleEndian, value); err != nil {return err}}return nil}
// WritePNG writes an image to a writer in PNG format.func WritePNG(img image.Image, w io.Writer) error {return png.Encode(w, img)}
package toolsimport ("fmt""math""os""path/filepath""strings""skraak/utils")// CallsClipInput defines the input for the clip tooltype CallsClipInput struct {File string `json:"file" jsonschema:"Path to .data file (required if no folder)"`Folder string `json:"folder" jsonschema:"Path to folder containing .data files (required if no file)"`Output string `json:"output" jsonschema:"required,Output folder for generated clips"`Prefix string `json:"prefix" jsonschema:"required,Prefix for output filenames"`Filter string `json:"filter" jsonschema:"Filter by ML model name"`Species string `json:"species" jsonschema:"Filter by species, optionally with calltype (e.g. Kiwi, Kiwi+Duet)"`}// CallsClipOutput defines the output for the clip tooltype CallsClipOutput struct {FilesProcessed int `json:"files_processed"`SegmentsClipped int `json:"segments_clipped"`OutputFiles []string `json:"output_files"`Errors []string `json:"errors,omitempty"`}// CallsClip processes .data files and generates audio/image clips for matching segmentsfunc CallsClip(input CallsClipInput) (CallsClipOutput, error) {var output CallsClipOutput// Validate required flagsif 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")}// Parse species+calltypevar speciesName, callType stringif input.Species != "" {if strings.Contains(input.Species, "+") {parts := strings.SplitN(input.Species, "+", 2)speciesName = parts[0]callType = parts[1]} else {speciesName = input.Species}}// Get list of .data filesvar filePaths []stringvar err errorif 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}}if len(filePaths) == 0 {output.Errors = append(output.Errors, "no .data files found")return output, fmt.Errorf("no .data files found")}// Create output folder if it doesn't existif 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}// Process each .data filefor _, dataPath := range filePaths {clips, errs := processFile(dataPath, input.Output, input.Prefix, input.Filter, speciesName, callType)output.SegmentsClipped += len(clips)output.OutputFiles = append(output.OutputFiles, clips...)output.Errors = append(output.Errors, errs...)if len(clips) > 0 || len(errs) == 0 {output.FilesProcessed++}}return output, nil}// processFile processes a single .data file and returns generated clips and errorsfunc processFile(dataPath, outputDir, prefix, filter, speciesName, callType string) ([]string, []string) {var clips []stringvar errors []string// Parse .data filedataFile, err := utils.ParseDataFile(dataPath)if err != nil {errors = append(errors, fmt.Sprintf("%s: failed to parse: %v", dataPath, err))return nil, errors}// Get WAV basename (without path and .data extension)wavPath := strings.TrimSuffix(dataPath, ".data")basename := filepath.Base(wavPath)// Filter segmentsvar matchingSegments []*utils.Segmentfor _, seg := range dataFile.Segments {if segmentMatchesFilters(seg, filter, speciesName, callType) {matchingSegments = append(matchingSegments, seg)}}if len(matchingSegments) == 0 {return nil, nil // No matches, not an error}// Read WAV samples oncesamples, sampleRate, err := utils.ReadWAVSamples(wavPath)if err != nil {errors = append(errors, fmt.Sprintf("%s: failed to read WAV: %v", dataPath, err))return nil, errors}// Process each matching segmentfor _, seg := range matchingSegments {clipFiles, err := generateClip(samples, sampleRate, outputDir, prefix, basename, seg.StartTime, seg.EndTime)if err != nil {errors = append(errors, fmt.Sprintf("%s: segment %.0f-%.0f: %v", dataPath, seg.StartTime, seg.EndTime, err))continue}clips = append(clips, clipFiles...)}return clips, errors}// segmentMatchesFilters checks if a segment matches the filter criteriafunc segmentMatchesFilters(seg *utils.Segment, filter, speciesName, callType string) bool {if filter == "" && speciesName == "" {return true // No filters, match all}for _, label := range seg.Labels {filterMatch := filter == "" || label.Filter == filterspeciesMatch := speciesName == "" || label.Species == speciesNamecallTypeMatch := callType == "" || label.CallType == callTypeif filterMatch && speciesMatch && callTypeMatch {return true}}return false}// generateClip generates PNG and WAV files for a segmentfunc generateClip(samples []float64, sampleRate int, outputDir, prefix, basename string, startTime, endTime float64) ([]string, error) {var files []string// Calculate integer times for filenamestartInt := int(math.Floor(startTime))endInt := int(math.Ceil(endTime))// Build base filenamebaseName := fmt.Sprintf("%s_%s_%d_%d", prefix, basename, startInt, endInt)pngPath := filepath.Join(outputDir, baseName+".png")wavPath := filepath.Join(outputDir, baseName+".wav")// Check if files already existif _, err := os.Stat(pngPath); err == nil {return nil, fmt.Errorf("file already exists: %s", pngPath)}if _, err := os.Stat(wavPath); err == nil {return nil, fmt.Errorf("file already exists: %s", wavPath)}// Extract segment samplessegSamples := utils.ExtractSegmentSamples(samples, sampleRate, startTime, endTime)if len(segSamples) == 0 {return nil, fmt.Errorf("no samples in segment")}// Determine output sample rate (downsample if > 16kHz)outputSampleRate := sampleRateif sampleRate > utils.DefaultMaxSampleRate {segSamples = utils.ResampleRate(segSamples, sampleRate, utils.DefaultMaxSampleRate)outputSampleRate = utils.DefaultMaxSampleRate}// Generate spectrogram PNG (224x224, grayscale)// Create a temporary .data file path for GenerateSegmentSpectrogram// Actually, we need to use the lower-level functions since we already have samplesspectSampleRate := outputSampleRatespectSamples := segSamplesif outputSampleRate > utils.DefaultMaxSampleRate {// Already downsampled above}config := utils.DefaultSpectrogramConfig(spectSampleRate)spectrogram := utils.GenerateSpectrogram(spectSamples, config)if spectrogram == nil {return nil, fmt.Errorf("failed to generate spectrogram")}img := utils.CreateGrayscaleImage(spectrogram)if img == nil {return nil, fmt.Errorf("failed to create image")}resized := utils.ResizeImage(img, 224, 224)// Write PNGpngFile, err := os.Create(pngPath)if err != nil {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)}pngFile.Close()files = append(files, pngPath)// Write WAVif 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}
package cmdimport ("encoding/json""fmt""os""strings""skraak/tools")func printClipUsage() {fmt.Fprintf(os.Stderr, "Usage: skraak calls clip [options]\n\n")fmt.Fprintf(os.Stderr, "Generate audio clips and spectrogram images from .data file segments.\n\n")fmt.Fprintf(os.Stderr, "Options:\n")fmt.Fprintf(os.Stderr, " --file <path> Path to .data file (required if no --folder)\n")fmt.Fprintf(os.Stderr, " --folder <path> Path to folder containing .data files (required if no --file)\n")fmt.Fprintf(os.Stderr, " --output <path> Output folder for generated clips (required)\n")fmt.Fprintf(os.Stderr, " --prefix <name> Prefix for output filenames (required)\n")fmt.Fprintf(os.Stderr, " --filter <name> Filter by ML model name (optional)\n")fmt.Fprintf(os.Stderr, " --species <name> Filter by species, optionally with calltype (e.g. Kiwi, Kiwi+Duet)\n")fmt.Fprintf(os.Stderr, "\nOutput files:\n")fmt.Fprintf(os.Stderr, " <prefix>_<basename>_<start>_<end>.png # 224x224 spectrogram\n")fmt.Fprintf(os.Stderr, " <prefix>_<basename>_<start>_<end>.wav # audio clip (16kHz if downsampled)\n")fmt.Fprintf(os.Stderr, "\nExamples:\n")fmt.Fprintf(os.Stderr, " # Clip all segments from a single file\n")fmt.Fprintf(os.Stderr, " skraak calls clip --file recording.data --output ./clips --prefix train\n\n")fmt.Fprintf(os.Stderr, " # Clip only Kiwi segments from a filter across a folder\n")fmt.Fprintf(os.Stderr, " skraak calls clip --folder ./data --output ./clips --prefix kiwi \\\n")fmt.Fprintf(os.Stderr, " --filter opensoundscape-kiwi-1.2 --species Kiwi\n\n")fmt.Fprintf(os.Stderr, " # Clip Kiwi Duet calls\n")fmt.Fprintf(os.Stderr, " skraak calls clip --folder ./data --output ./clips --prefix duet \\\n")fmt.Fprintf(os.Stderr, " --filter opensoundscape-kiwi-1.2 --species Kiwi+Duet\n")}// RunCallsClip handles the "calls clip" subcommandfunc RunCallsClip(args []string) {var file, folder, output, prefix, filter, species string// Parse argumentsi := 0for i < len(args) {arg := args[i]switch arg {case "--file":if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --file requires a value\n")os.Exit(1)}file = args[i+1]i += 2case "--folder":if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --folder requires a value\n")os.Exit(1)}folder = args[i+1]i += 2case "--output":if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --output requires a value\n")os.Exit(1)}output = args[i+1]i += 2case "--prefix":if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --prefix requires a value\n")os.Exit(1)}prefix = args[i+1]i += 2case "--filter":if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --filter requires a value\n")os.Exit(1)}if filter != "" {fmt.Fprintf(os.Stderr, "Error: --filter can only be specified once\n")os.Exit(1)}filter = args[i+1]i += 2case "--species":if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --species requires a value\n")os.Exit(1)}if species != "" {fmt.Fprintf(os.Stderr, "Error: --species can only be specified once\n")os.Exit(1)}species = args[i+1]i += 2case "-h", "--help":printClipUsage()os.Exit(0)default:// Check for unknown flagsif strings.HasPrefix(arg, "--") {fmt.Fprintf(os.Stderr, "Error: unknown flag: %s\n\n", arg)printClipUsage()os.Exit(1)}i++}}// Validate required flagsmissing := []string{}if file == "" && folder == "" {missing = append(missing, "--file or --folder")}if output == "" {missing = append(missing, "--output")}if prefix == "" {missing = append(missing, "--prefix")}if len(missing) > 0 {fmt.Fprintf(os.Stderr, "Error: missing required flags: %v\n\n", missing)printClipUsage()os.Exit(1)}// Build inputinput := tools.CallsClipInput{File: file,Folder: folder,Output: output,Prefix: prefix,Filter: filter,Species: species,}// Executeresult, err := tools.CallsClip(input)if err != nil {// Print partial result as JSON (may contain useful info)data, _ := json.Marshal(result)fmt.Println(string(data))os.Exit(1)}// Output JSONdata, _ := json.Marshal(result)fmt.Println(string(data))}
./skraak calls clip --file recording.wav.data --prefix B01 --out-path /tmp/B01/ --species Kiwi+Duet --filter opensoundscape-multi-1.0 --size 224 (optional, default 224)./skraak calls clip --folder B01/2026-12-11/ --prefix B01 --out-path /tmp/B01/ --species Kiwi+Duet --filter opensoundscape-multi-1.0 --size 224 (optional, default 224)
## [2026-04-02] New `calls clip` commandGenerate audio clips and spectrogram images from .data file segments.Useful for extracting training data or creating datasets for ML.**Usage:**```bashskraak calls clip --file recording.data --output ./clips --prefix trainskraak calls clip --folder ./data --output ./clips --prefix kiwi \--filter opensoundscape-kiwi-1.2 --species Kiwi```**Output files:**- `<prefix>_<basename>_<start>_<end>.png` — 224x224 grayscale spectrogram- `<prefix>_<basename>_<start>_<end>.wav` — audio clip (16kHz if downsampled)**Features:**- Single file (`--file`) or batch folder (`--folder`) processing- Filter by ML model (`--filter`) and/or species (`--species`)- Species can include calltype: `Kiwi+Duet`- Error if output files already exist (no overwrite)- WAV files downsampled to 16kHz if input > 16kHz
**New utilities:**- `utils.WriteWAVFile(path, samples, sampleRate)` — write mono 16-bit PCM WAV- `utils.WritePNG(img, writer)` — write image as PNG**Changes:**- `utils/wav_writer.go` — New file, WAV writer implementation- `utils/terminal_image.go` — Added `WritePNG()` function- `tools/calls_clip.go` — New file, core clip logic- `cmd/calls_clip.go` — New file, CLI parsing- `cmd/calls.go` — Added `clip` subcommand