package main import ( "bufio" _ "embed" "fmt" "io" "log" "os" "path/filepath" "strings" "valhallir-deconvolver/pkg/convolve" "valhallir-deconvolver/pkg/plot" "valhallir-deconvolver/pkg/wav" "github.com/urfave/cli/v2" ) //go:embed assets/logo.png var logoData []byte // processIR processes a single recorded file and generates IR(s) func processIR(c *cli.Context, sweepData *wav.WAVData, recordedPath, outputPath string) error { // Read recorded WAV file recordedData, err := wav.ReadWAVFile(recordedPath) if err != nil { return fmt.Errorf("failed to read recorded file %s: %v", recordedPath, err) } log.Printf("Processing: %s", filepath.Base(recordedPath)) log.Printf("Sweep: %d samples, %d channels", len(sweepData.PCMData), sweepData.Channels) log.Printf("Recorded: %d samples, %d channels", len(recordedData.PCMData), recordedData.Channels) // Optionally filter the recorded sweep recordedFiltered := recordedData.PCMData recSampleRate := recordedData.SampleRate highcutHz := c.Float64("highcut") lowcutHz := c.Float64("lowcut") cutSlope := c.Int("cut-slope") if cutSlope < 12 || cutSlope%12 != 0 { return fmt.Errorf("cut-slope must be a positive multiple of 12 (got %d)", cutSlope) } if lowcutHz > 0 { log.Printf("Applying low-cut (high-pass) filter to recorded sweep: %.2f Hz, slope: %d dB/oct", lowcutHz, cutSlope) recordedFiltered = convolve.CascadeLowcut(recordedFiltered, recSampleRate, lowcutHz, cutSlope) } if highcutHz > 0 { log.Printf("Applying high-cut (low-pass) filter to recorded sweep: %.2f Hz, slope: %d dB/oct", highcutHz, cutSlope) recordedFiltered = convolve.CascadeHighcut(recordedFiltered, recSampleRate, highcutHz, cutSlope) } // Force phase inversion if requested if c.Bool("force-invert-phase") { log.Printf("Forcing phase inversion of recorded sweep (manual override)") recordedFiltered = convolve.InvertPhase(recordedFiltered) } log.Println("Performing deconvolution...") var ir []float64 if c.Bool("no-phase-correction") { ir = convolve.Deconvolve(sweepData.PCMData, recordedFiltered) } else { ir = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedFiltered) } log.Printf("Deconvolution result: %d samples", len(ir)) log.Println("Trimming silence...") ir = convolve.TrimSilence(ir, 1e-5) log.Printf("After trimming: %d samples", len(ir)) log.Println("Normalizing...") ir = convolve.Normalize(ir, c.Float64("normalize")) log.Printf("Final IR: %d samples", len(ir)) // Validate output format options sampleRate := c.Int("sample-rate") bitDepth := c.Int("bit-depth") // Validate sample rate validSampleRates := []int{44100, 48000, 88200, 96000} validSampleRate := false for _, sr := range validSampleRates { if sampleRate == sr { validSampleRate = true break } } if !validSampleRate { return fmt.Errorf("invalid sample rate: %d. Valid options: %v", sampleRate, validSampleRates) } // Validate bit depth validBitDepths := []int{16, 24, 32} validBitDepth := false for _, bd := range validBitDepths { if bitDepth == bd { validBitDepth = true break } } if !validBitDepth { return fmt.Errorf("invalid bit depth: %d. Valid options: %v", bitDepth, validBitDepths) } // Resample IR to target sample rate if different from input (96kHz) targetSampleRate := sampleRate if targetSampleRate != 96000 { log.Printf("Resampling IR from 96kHz to %dHz...", targetSampleRate) ir = convolve.Resample(ir, 96000, targetSampleRate) log.Printf("Resampled IR: %d samples", len(ir)) } // Trim or pad IR to requested length if --length-ms is set lengthMs := c.Float64("length-ms") if lengthMs > 0 { targetSamples := int(float64(targetSampleRate) * lengthMs / 1000.0) log.Printf("Trimming or padding IR to %d samples (%.2f ms)...", targetSamples, lengthMs) ir = convolve.TrimOrPad(ir, targetSamples) } // Apply fade-out fadeMs := c.Float64("fade-ms") fadeSamples := int(float64(targetSampleRate) * fadeMs / 1000.0) if fadeSamples > 0 { log.Printf("Applying linear fade-out: %d samples (%.2f ms)...", fadeSamples, fadeMs) ir = convolve.FadeOutLinear(ir, fadeSamples) } // Write regular IR log.Printf("Writing IR to: %s (%dHz, %d-bit WAV)", outputPath, sampleRate, bitDepth) if err := wav.WriteWAVFileWithOptions(outputPath, ir, sampleRate, bitDepth); err != nil { return fmt.Errorf("failed to write IR file: %v", err) } // Plot IR waveform if requested if c.Bool("plot-ir") { log.Printf("Plotting IR waveform...") err := plot.PlotIR(ir, sampleRate, outputPath, logoData) if err != nil { return fmt.Errorf("failed to plot IR: %v", err) } log.Printf("IR plot saved") } // Generate MPT IR if requested if c.Bool("mpt") { log.Println("Generating minimum phase transform...") // Use the filtered recorded sweep for MPT generation (same as RAW IR) var originalIR []float64 if c.Bool("no-phase-correction") { originalIR = convolve.Deconvolve(sweepData.PCMData, recordedFiltered) } else { originalIR = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedFiltered) } originalIR = convolve.TrimSilence(originalIR, 1e-5) mptIR := convolve.MinimumPhaseTransform(originalIR) mptIR = convolve.Normalize(mptIR, c.Float64("normalize")) log.Printf("MPT IR: %d samples", len(mptIR)) // Resample MPT IR to target sample rate if different from input (96kHz) if targetSampleRate != 96000 { log.Printf("Resampling MPT IR from 96kHz to %dHz...", targetSampleRate) mptIR = convolve.Resample(mptIR, 96000, targetSampleRate) log.Printf("Resampled MPT IR: %d samples", len(mptIR)) } // Trim or pad MPT IR to requested length if --length-ms is set if lengthMs > 0 { targetSamples := int(float64(targetSampleRate) * lengthMs / 1000.0) log.Printf("Trimming or padding MPT IR to %d samples (%.2f ms)...", targetSamples, lengthMs) mptIR = convolve.TrimOrPad(mptIR, targetSamples) } // Apply fade-out to MPT IR if fadeSamples > 0 { log.Printf("Applying linear fade-out to MPT IR: %d samples (%.2f ms)...", fadeSamples, fadeMs) mptIR = convolve.FadeOutLinear(mptIR, fadeSamples) } // Generate MPT output filename mptOutputPath := outputPath if len(mptOutputPath) > 4 && mptOutputPath[len(mptOutputPath)-4:] == ".wav" { mptOutputPath = mptOutputPath[:len(mptOutputPath)-4] } mptOutputPath = mptOutputPath + "_mpt.wav" log.Printf("Writing MPT IR to: %s (%dHz, %d-bit WAV)", mptOutputPath, sampleRate, bitDepth) if err := wav.WriteWAVFileWithOptions(mptOutputPath, mptIR, sampleRate, bitDepth); err != nil { return fmt.Errorf("failed to write MPT IR file: %v", err) } log.Println("Minimum phase transform IR generated successfully!") } return nil } // processSingleFile processes a single recorded file func processSingleFile(c *cli.Context, sweepData *wav.WAVData, recordedPath, outputPath string) error { return processIR(c, sweepData, recordedPath, outputPath) } // processDirectory processes all WAV files in a directory func processDirectory(c *cli.Context, sweepData *wav.WAVData, recordedDir, outputDir string) error { // Check if output directory exists, create if not outputInfo, err := os.Stat(outputDir) if err != nil { if os.IsNotExist(err) { log.Printf("Creating output directory: %s", outputDir) if err := os.MkdirAll(outputDir, 0755); err != nil { return fmt.Errorf("failed to create output directory: %v", err) } } else { return fmt.Errorf("failed to access output directory: %v", err) } } else if !outputInfo.IsDir() { return fmt.Errorf("output path is not a directory: %s", outputDir) } // Find all WAV files in the recorded directory var wavFiles []string err = filepath.Walk(recordedDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() && strings.ToLower(filepath.Ext(path)) == ".wav" { wavFiles = append(wavFiles, path) } return nil }) if err != nil { return fmt.Errorf("failed to scan recorded directory: %v", err) } if len(wavFiles) == 0 { return fmt.Errorf("no WAV files found in directory: %s", recordedDir) } log.Printf("Found %d WAV file(s) to process", len(wavFiles)) // Process each WAV file successCount := 0 for i, recordedFile := range wavFiles { log.Printf("\n[%d/%d] Processing: %s", i+1, len(wavFiles), filepath.Base(recordedFile)) // Generate output filename based on recorded filename recordedBase := filepath.Base(recordedFile) recordedName := strings.TrimSuffix(recordedBase, filepath.Ext(recordedBase)) outputFile := filepath.Join(outputDir, recordedName+".wav") if err := processIR(c, sweepData, recordedFile, outputFile); err != nil { log.Printf("ERROR processing %s: %v", recordedFile, err) continue } successCount++ log.Printf("Successfully processed: %s -> %s", recordedBase, filepath.Base(outputFile)) } log.Printf("\nBatch processing complete: %d/%d files processed successfully", successCount, len(wavFiles)) if successCount < len(wavFiles) { return fmt.Errorf("some files failed to process (%d/%d succeeded)", successCount, len(wavFiles)) } return nil } // extractCabpackBaseName extracts the cabpack base name from a WAV filename // Example: "V2-1960STV-d-SM7B-A1.wav" -> "V2-1960STV" func extractCabpackBaseName(filename string) string { base := filepath.Base(filename) name := strings.TrimSuffix(base, filepath.Ext(base)) // Find the first occurrence of a pattern like "-d-", "-SM7B-", etc. // We look for the pattern where the first part (before the first dash after the base) is the cabpack name parts := strings.Split(name, "-") if len(parts) >= 2 { // Take the first two parts (e.g., "V2" and "1960STV") return strings.Join(parts[:2], "-") } // Fallback: return the first part or the whole name if len(parts) > 0 { return parts[0] } return name } // formatSpec represents a format specification for cabpack generation type formatSpec struct { SampleRate int BitDepth int LengthMs float64 } // getCabpackFormats returns all format specifications for cabpack generation func getCabpackFormats() []formatSpec { return []formatSpec{ {44100, 16, 170}, {44100, 24, 170}, {44100, 24, 500}, {48000, 16, 170}, {48000, 24, 170}, {48000, 24, 500}, {48000, 24, 1370}, {96000, 24, 500}, {96000, 24, 1370}, } } // formatFolderName generates the format folder name func formatFolderName(cabpackBase string, spec formatSpec) string { return fmt.Sprintf("%s %dHz-%dbit %.0fms", cabpackBase, spec.SampleRate, spec.BitDepth, spec.LengthMs) } // processCabpack processes all WAV files in a directory and creates a cabpack structure func processCabpack(c *cli.Context, sweepData *wav.WAVData, recordedDir, outputDir, sweepFileName string) error { // Apply cabpack defaults if not explicitly set // fade-ms: default 10 for cabpack (vs 5 normally) fadeMs := c.Float64("fade-ms") if !c.IsSet("fade-ms") { fadeMs = 10.0 log.Printf("Using cabpack default: fade-ms = 10") } // lowcut: default 40 for cabpack lowcutHz := c.Float64("lowcut") if !c.IsSet("lowcut") { lowcutHz = 40.0 log.Printf("Using cabpack default: lowcut = 40 Hz") } // cut-slope: default is already 12, so no change needed cutSlope := c.Int("cut-slope") // Find all WAV files in the recorded directory, excluding the sweep file and mixes folder mixesDir := filepath.Join(recordedDir, "mixes") var wavFiles []string err := filepath.Walk(recordedDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } // Skip the mixes folder - files there are processed separately if info.IsDir() && path == mixesDir { return filepath.SkipDir } if !info.IsDir() && strings.ToLower(filepath.Ext(path)) == ".wav" { // Exclude the sweep file from processing fileName := filepath.Base(path) if fileName != sweepFileName { wavFiles = append(wavFiles, path) } } return nil }) if err != nil { return fmt.Errorf("failed to scan recorded directory: %v", err) } if len(wavFiles) == 0 { return fmt.Errorf("no WAV files found in directory: %s", recordedDir) } log.Printf("Found %d WAV file(s) to process for cabpack", len(wavFiles)) // Extract cabpack base name from the first file firstFile := filepath.Base(wavFiles[0]) cabpackBase := extractCabpackBaseName(firstFile) log.Printf("Cabpack base name: %s", cabpackBase) // Verify all files have the same base name for _, wavFile := range wavFiles { base := extractCabpackBaseName(filepath.Base(wavFile)) if base != cabpackBase { log.Printf("Warning: File %s has different base name (%s), expected %s", wavFile, base, cabpackBase) } } // Create output directory if it doesn't exist if err := os.MkdirAll(outputDir, 0755); err != nil { return fmt.Errorf("failed to create output directory: %v", err) } // Get all format specifications formats := getCabpackFormats() // Check which optional files/folders exist selectionListPath := filepath.Join(recordedDir, "selection.txt") ambientListPath := filepath.Join(recordedDir, "ambient.txt") // mixesDir is already defined above in filepath.Walk hasSelection := false if _, err := os.Stat(selectionListPath); err == nil { hasSelection = true } hasAmbient := false if _, err := os.Stat(ambientListPath); err == nil { hasAmbient = true } hasMixes := false if _, err := os.Stat(mixesDir); err == nil { hasMixes = true } // Create directory structure for each format for _, format := range formats { formatFolder := formatFolderName(cabpackBase, format) formatPath := filepath.Join(outputDir, formatFolder) // Create format folder if err := os.MkdirAll(formatPath, 0755); err != nil { return fmt.Errorf("failed to create format folder %s: %v", formatPath, err) } // Always create "close mics" folder closeMicsPath := filepath.Join(formatPath, "close mics") if err := os.MkdirAll(closeMicsPath, 0755); err != nil { return fmt.Errorf("failed to create close mics folder: %v", err) } // Create RAW and MPT folders inside "close mics" rawPath := filepath.Join(closeMicsPath, "RAW") mptPath := filepath.Join(closeMicsPath, "MPT") if err := os.MkdirAll(rawPath, 0755); err != nil { return fmt.Errorf("failed to create RAW folder: %v", err) } if err := os.MkdirAll(mptPath, 0755); err != nil { return fmt.Errorf("failed to create MPT folder: %v", err) } // Create "selection" folder and subfolders only if selection.txt exists if hasSelection { selectionPath := filepath.Join(formatPath, "selection") if err := os.MkdirAll(selectionPath, 0755); err != nil { return fmt.Errorf("failed to create selection folder: %v", err) } selectionRawPath := filepath.Join(selectionPath, "RAW") selectionMptPath := filepath.Join(selectionPath, "MPT") if err := os.MkdirAll(selectionRawPath, 0755); err != nil { return fmt.Errorf("failed to create selection RAW folder: %v", err) } if err := os.MkdirAll(selectionMptPath, 0755); err != nil { return fmt.Errorf("failed to create selection MPT folder: %v", err) } } // Create "ambient mics" folder and subfolders only if ambient.txt exists if hasAmbient { ambientPath := filepath.Join(formatPath, "ambient mics") if err := os.MkdirAll(ambientPath, 0755); err != nil { return fmt.Errorf("failed to create ambient mics folder: %v", err) } ambientRawPath := filepath.Join(ambientPath, "RAW") ambientMptPath := filepath.Join(ambientPath, "MPT") if err := os.MkdirAll(ambientRawPath, 0755); err != nil { return fmt.Errorf("failed to create ambient RAW folder: %v", err) } if err := os.MkdirAll(ambientMptPath, 0755); err != nil { return fmt.Errorf("failed to create ambient MPT folder: %v", err) } } // Create "mixes" folder only if mixes folder exists in recorded directory if hasMixes { mixesPath := filepath.Join(formatPath, "mixes") if err := os.MkdirAll(mixesPath, 0755); err != nil { return fmt.Errorf("failed to create mixes folder: %v", err) } } } // Create plots folder at cabpack level plotsPath := filepath.Join(outputDir, "plots") if err := os.MkdirAll(plotsPath, 0755); err != nil { return fmt.Errorf("failed to create plots folder: %v", err) } // Create mixes subfolder in plots only if mixes folder exists var plotsMixesPath string if hasMixes { plotsMixesPath = filepath.Join(plotsPath, "mixes") if err := os.MkdirAll(plotsMixesPath, 0755); err != nil { return fmt.Errorf("failed to create plots/mixes folder: %v", err) } } // Process each WAV file successCount := 0 for i, recordedFile := range wavFiles { log.Printf("\n[%d/%d] Processing: %s", i+1, len(wavFiles), filepath.Base(recordedFile)) // Read recorded WAV file recordedData, err := wav.ReadWAVFile(recordedFile) if err != nil { log.Printf("ERROR reading %s: %v", recordedFile, err) continue } // Get base name for output files (without extension) recordedBase := filepath.Base(recordedFile) recordedName := strings.TrimSuffix(recordedBase, filepath.Ext(recordedBase)) // Process each format for _, format := range formats { log.Printf(" Processing format: %dHz, %d-bit, %.0fms", format.SampleRate, format.BitDepth, format.LengthMs) // Process IR for this format formatFolder := formatFolderName(cabpackBase, format) rawPath := filepath.Join(outputDir, formatFolder, "close mics", "RAW") mptPath := filepath.Join(outputDir, formatFolder, "close mics", "MPT") // Generate RAW IR rawOutputPath := filepath.Join(rawPath, recordedName+".wav") if err := processIRForFormatWithDefaults(c, sweepData, recordedData, rawOutputPath, format, false, fadeMs, lowcutHz, cutSlope); err != nil { log.Printf("ERROR generating RAW IR for %s in format %s: %v", recordedName, formatFolder, err) continue } // Generate MPT IR mptOutputPath := filepath.Join(mptPath, recordedName+".wav") if err := processIRForFormatWithDefaults(c, sweepData, recordedData, mptOutputPath, format, true, fadeMs, lowcutHz, cutSlope); err != nil { log.Printf("ERROR generating MPT IR for %s in format %s: %v", recordedName, formatFolder, err) continue } } // Generate plot for 96000Hz format (only once per file) plotFormat := formatSpec{96000, 24, 500} plotIR, err := generateIRForFormatWithDefaults(c, sweepData, recordedData, plotFormat, false, fadeMs, lowcutHz, cutSlope) if err != nil { log.Printf("ERROR generating IR for plot: %v", err) } else { // Pass path without .png extension - PlotIR will add it plotBasePath := filepath.Join(plotsPath, recordedName) if err := plot.PlotIR(plotIR, plotFormat.SampleRate, plotBasePath+".wav", logoData); err != nil { log.Printf("ERROR generating plot: %v", err) } else { log.Printf("Plot saved: %s.png", plotBasePath) } } successCount++ log.Printf("Successfully processed: %s", recordedBase) } log.Printf("\nCabpack generation complete: %d/%d files processed successfully", successCount, len(wavFiles)) if successCount < len(wavFiles) { return fmt.Errorf("some files failed to process (%d/%d succeeded)", successCount, len(wavFiles)) } // Process selection.txt and ambient.txt files log.Printf("\nProcessing selection and ambient file lists...") if err := processSelectionAndAmbientLists(recordedDir, outputDir, cabpackBase, formats, hasSelection, hasAmbient); err != nil { log.Printf("Warning: Error processing selection/ambient lists: %v", err) // Don't fail the entire process if this fails } // Process mixes folder if it exists if hasMixes { log.Printf("\nProcessing mixes folder...") if err := processMixesFolder(c, mixesDir, outputDir, cabpackBase, formats, plotsMixesPath, fadeMs); err != nil { log.Printf("Warning: Error processing mixes folder: %v", err) // Don't fail the entire process if this fails } } else { log.Printf("Mixes folder not found, skipping mixes processing") } return nil } // processMixesFolder processes ready-to-use IR files from the mixes folder func processMixesFolder(c *cli.Context, mixesDir, outputDir, cabpackBase string, formats []formatSpec, plotsMixesPath string, fadeMs float64) error { // Find all WAV files in the mixes directory var mixFiles []string err := filepath.Walk(mixesDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() && strings.ToLower(filepath.Ext(path)) == ".wav" { mixFiles = append(mixFiles, path) } return nil }) if err != nil { return fmt.Errorf("failed to scan mixes directory: %v", err) } if len(mixFiles) == 0 { log.Printf("No WAV files found in mixes folder") return nil } log.Printf("Found %d IR file(s) in mixes folder to convert", len(mixFiles)) // Process each mix file successCount := 0 for i, mixFile := range mixFiles { log.Printf("\n[%d/%d] Processing mix IR: %s", i+1, len(mixFiles), filepath.Base(mixFile)) // Read the IR file (it's already an IR, not a recorded sweep) irData, err := wav.ReadWAVFile(mixFile) if err != nil { log.Printf("ERROR reading %s: %v", mixFile, err) continue } // Get base name for output files (without extension) mixBase := filepath.Base(mixFile) mixName := strings.TrimSuffix(mixBase, filepath.Ext(mixBase)) // The IR is already at 96kHz after ReadWAVFile, so we can use it directly ir := irData.PCMData // Normalize the IR ir = convolve.Normalize(ir, c.Float64("normalize")) // Process each format for _, format := range formats { log.Printf(" Converting to format: %dHz, %d-bit, %.0fms", format.SampleRate, format.BitDepth, format.LengthMs) formatFolder := formatFolderName(cabpackBase, format) mixesPath := filepath.Join(outputDir, formatFolder, "mixes") // Convert IR to target format convertedIR := make([]float64, len(ir)) copy(convertedIR, ir) // Resample IR to target sample rate if different from input (96kHz) targetSampleRate := format.SampleRate if targetSampleRate != 96000 { convertedIR = convolve.Resample(convertedIR, 96000, targetSampleRate) } // Trim or pad IR to requested length targetSamples := int(float64(targetSampleRate) * format.LengthMs / 1000.0) convertedIR = convolve.TrimOrPad(convertedIR, targetSamples) // Apply fade-out fadeSamples := int(float64(targetSampleRate) * fadeMs / 1000.0) if fadeSamples > 0 { convertedIR = convolve.FadeOutLinear(convertedIR, fadeSamples) } // Write converted IR outputPath := filepath.Join(mixesPath, mixName+".wav") if err := wav.WriteWAVFileWithOptions(outputPath, convertedIR, format.SampleRate, format.BitDepth); err != nil { log.Printf("ERROR writing converted IR for %s in format %s: %v", mixName, formatFolder, err) continue } } // Generate plot for 96000Hz format (only once per file) plotFormat := formatSpec{96000, 24, 500} plotIR := make([]float64, len(ir)) copy(plotIR, ir) plotIR = convolve.Normalize(plotIR, c.Float64("normalize")) // Resample to 96kHz if needed (should already be 96kHz) if irData.SampleRate != 96000 { plotIR = convolve.Resample(plotIR, irData.SampleRate, 96000) } // Trim or pad for plot format targetSamples := int(float64(96000) * plotFormat.LengthMs / 1000.0) plotIR = convolve.TrimOrPad(plotIR, targetSamples) // Apply fade-out fadeSamples := int(float64(96000) * fadeMs / 1000.0) if fadeSamples > 0 { plotIR = convolve.FadeOutLinear(plotIR, fadeSamples) } // Generate plot plotBasePath := filepath.Join(plotsMixesPath, mixName) if err := plot.PlotIR(plotIR, plotFormat.SampleRate, plotBasePath+".wav", logoData); err != nil { log.Printf("ERROR generating plot for mix: %v", err) } else { log.Printf("Plot saved: %s.png", plotBasePath) } successCount++ log.Printf("Successfully processed mix IR: %s", mixBase) } log.Printf("\nMixes processing complete: %d/%d files processed successfully", successCount, len(mixFiles)) if successCount < len(mixFiles) { return fmt.Errorf("some mix files failed to process (%d/%d succeeded)", successCount, len(mixFiles)) } return nil } // readFileList reads a text file with one filename per line and returns the list func readFileList(filePath string) ([]string, error) { file, err := os.Open(filePath) if err != nil { return nil, err } defer file.Close() var files []string scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line != "" { files = append(files, line) } } if err := scanner.Err(); err != nil { return nil, err } return files, nil } // copyFile copies a file from source to destination func copyFile(src, dst string) error { sourceFile, err := os.Open(src) if err != nil { return err } defer sourceFile.Close() destFile, err := os.Create(dst) if err != nil { return err } defer destFile.Close() _, err = io.Copy(destFile, sourceFile) if err != nil { return err } return destFile.Sync() } // moveFile moves a file from source to destination func moveFile(src, dst string) error { if err := copyFile(src, dst); err != nil { return err } return os.Remove(src) } // processSelectionAndAmbientLists processes selection.txt and ambient.txt files func processSelectionAndAmbientLists(recordedDir, outputDir, cabpackBase string, formats []formatSpec, hasSelection, hasAmbient bool) error { // Process selection.txt first (copy files) selectionListPath := filepath.Join(recordedDir, "selection.txt") if hasSelection { log.Printf("Processing selection.txt...") selectionFiles, err := readFileList(selectionListPath) if err != nil { return fmt.Errorf("failed to read selection.txt: %v", err) } log.Printf("Found %d files in selection.txt", len(selectionFiles)) // Process each format for _, format := range formats { formatFolder := formatFolderName(cabpackBase, format) formatPath := filepath.Join(outputDir, formatFolder) closeMicsRawPath := filepath.Join(formatPath, "close mics", "RAW") closeMicsMptPath := filepath.Join(formatPath, "close mics", "MPT") selectionRawPath := filepath.Join(formatPath, "selection", "RAW") selectionMptPath := filepath.Join(formatPath, "selection", "MPT") // Copy each file from close mics to selection for _, fileName := range selectionFiles { // Ensure filename has .wav extension if !strings.HasSuffix(strings.ToLower(fileName), ".wav") { fileName = fileName + ".wav" } // Copy RAW file srcRaw := filepath.Join(closeMicsRawPath, fileName) dstRaw := filepath.Join(selectionRawPath, fileName) if _, err := os.Stat(srcRaw); err == nil { if err := copyFile(srcRaw, dstRaw); err != nil { log.Printf("Warning: Failed to copy RAW file %s: %v", fileName, err) } else { log.Printf(" Copied RAW: %s", fileName) } } else { log.Printf("Warning: RAW file not found: %s", srcRaw) } // Copy MPT file srcMpt := filepath.Join(closeMicsMptPath, fileName) dstMpt := filepath.Join(selectionMptPath, fileName) if _, err := os.Stat(srcMpt); err == nil { if err := copyFile(srcMpt, dstMpt); err != nil { log.Printf("Warning: Failed to copy MPT file %s: %v", fileName, err) } else { log.Printf(" Copied MPT: %s", fileName) } } else { log.Printf("Warning: MPT file not found: %s", srcMpt) } } } log.Printf("Selection processing complete") } else { log.Printf("selection.txt not found, skipping selection processing") } // Process ambient.txt (move files) ambientListPath := filepath.Join(recordedDir, "ambient.txt") if hasAmbient { log.Printf("Processing ambient.txt...") ambientFiles, err := readFileList(ambientListPath) if err != nil { return fmt.Errorf("failed to read ambient.txt: %v", err) } log.Printf("Found %d files in ambient.txt", len(ambientFiles)) // Process each format for _, format := range formats { formatFolder := formatFolderName(cabpackBase, format) formatPath := filepath.Join(outputDir, formatFolder) closeMicsRawPath := filepath.Join(formatPath, "close mics", "RAW") closeMicsMptPath := filepath.Join(formatPath, "close mics", "MPT") ambientRawPath := filepath.Join(formatPath, "ambient mics", "RAW") ambientMptPath := filepath.Join(formatPath, "ambient mics", "MPT") // Move each file from close mics to ambient mics for _, fileName := range ambientFiles { // Ensure filename has .wav extension if !strings.HasSuffix(strings.ToLower(fileName), ".wav") { fileName = fileName + ".wav" } // Move RAW file srcRaw := filepath.Join(closeMicsRawPath, fileName) dstRaw := filepath.Join(ambientRawPath, fileName) if _, err := os.Stat(srcRaw); err == nil { if err := moveFile(srcRaw, dstRaw); err != nil { log.Printf("Warning: Failed to move RAW file %s: %v", fileName, err) } else { log.Printf(" Moved RAW: %s", fileName) } } else { log.Printf("Warning: RAW file not found: %s", srcRaw) } // Move MPT file srcMpt := filepath.Join(closeMicsMptPath, fileName) dstMpt := filepath.Join(ambientMptPath, fileName) if _, err := os.Stat(srcMpt); err == nil { if err := moveFile(srcMpt, dstMpt); err != nil { log.Printf("Warning: Failed to move MPT file %s: %v", fileName, err) } else { log.Printf(" Moved MPT: %s", fileName) } } else { log.Printf("Warning: MPT file not found: %s", srcMpt) } } } log.Printf("Ambient processing complete") } else { log.Printf("ambient.txt not found, skipping ambient processing") } return nil } // processIRForFormatWithDefaults processes an IR with explicit defaults for cabpack mode func processIRForFormatWithDefaults(c *cli.Context, sweepData *wav.WAVData, recordedData *wav.WAVData, outputPath string, format formatSpec, generateMPT bool, fadeMs, lowcutHz float64, cutSlope int) error { // Optionally filter the recorded sweep recordedFiltered := recordedData.PCMData recSampleRate := recordedData.SampleRate highcutHz := c.Float64("highcut") if cutSlope < 12 || cutSlope%12 != 0 { return fmt.Errorf("cut-slope must be a positive multiple of 12 (got %d)", cutSlope) } if lowcutHz > 0 { log.Printf("Applying low-cut (high-pass) filter: %.2f Hz, slope: %d dB/oct", lowcutHz, cutSlope) recordedFiltered = convolve.CascadeLowcut(recordedFiltered, recSampleRate, lowcutHz, cutSlope) } if highcutHz > 0 { log.Printf("Applying high-cut (low-pass) filter: %.2f Hz, slope: %d dB/oct", highcutHz, cutSlope) recordedFiltered = convolve.CascadeHighcut(recordedFiltered, recSampleRate, highcutHz, cutSlope) } // Force phase inversion if requested if c.Bool("force-invert-phase") { recordedFiltered = convolve.InvertPhase(recordedFiltered) } var ir []float64 if c.Bool("no-phase-correction") { ir = convolve.Deconvolve(sweepData.PCMData, recordedFiltered) } else { ir = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedFiltered) } ir = convolve.TrimSilence(ir, 1e-5) ir = convolve.Normalize(ir, c.Float64("normalize")) // Resample IR to target sample rate if different from input (96kHz) targetSampleRate := format.SampleRate if targetSampleRate != 96000 { ir = convolve.Resample(ir, 96000, targetSampleRate) } // Trim or pad IR to requested length targetSamples := int(float64(targetSampleRate) * format.LengthMs / 1000.0) ir = convolve.TrimOrPad(ir, targetSamples) // Apply fade-out (using provided fadeMs parameter) fadeSamples := int(float64(targetSampleRate) * fadeMs / 1000.0) if fadeSamples > 0 { ir = convolve.FadeOutLinear(ir, fadeSamples) } // Generate MPT if requested if generateMPT { // Use the filtered recorded sweep for MPT generation (same as RAW IR) var originalIR []float64 if c.Bool("no-phase-correction") { originalIR = convolve.Deconvolve(sweepData.PCMData, recordedFiltered) } else { originalIR = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedFiltered) } originalIR = convolve.TrimSilence(originalIR, 1e-5) mptIR := convolve.MinimumPhaseTransform(originalIR) mptIR = convolve.Normalize(mptIR, c.Float64("normalize")) // Resample MPT IR to target sample rate if different from input (96kHz) if targetSampleRate != 96000 { mptIR = convolve.Resample(mptIR, 96000, targetSampleRate) } // Trim or pad MPT IR to requested length mptIR = convolve.TrimOrPad(mptIR, targetSamples) // Apply fade-out to MPT IR if fadeSamples > 0 { mptIR = convolve.FadeOutLinear(mptIR, fadeSamples) } ir = mptIR } // Write IR if err := wav.WriteWAVFileWithOptions(outputPath, ir, format.SampleRate, format.BitDepth); err != nil { return fmt.Errorf("failed to write IR file: %v", err) } return nil } // generateIRForFormatWithDefaults generates an IR with explicit defaults for cabpack mode func generateIRForFormatWithDefaults(c *cli.Context, sweepData *wav.WAVData, recordedData *wav.WAVData, format formatSpec, generateMPT bool, fadeMs, lowcutHz float64, cutSlope int) ([]float64, error) { // Optionally filter the recorded sweep recordedFiltered := recordedData.PCMData recSampleRate := recordedData.SampleRate highcutHz := c.Float64("highcut") if cutSlope < 12 || cutSlope%12 != 0 { return nil, fmt.Errorf("cut-slope must be a positive multiple of 12 (got %d)", cutSlope) } if lowcutHz > 0 { recordedFiltered = convolve.CascadeLowcut(recordedFiltered, recSampleRate, lowcutHz, cutSlope) } if highcutHz > 0 { recordedFiltered = convolve.CascadeHighcut(recordedFiltered, recSampleRate, highcutHz, cutSlope) } // Force phase inversion if requested if c.Bool("force-invert-phase") { recordedFiltered = convolve.InvertPhase(recordedFiltered) } var ir []float64 if c.Bool("no-phase-correction") { ir = convolve.Deconvolve(sweepData.PCMData, recordedFiltered) } else { ir = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedFiltered) } ir = convolve.TrimSilence(ir, 1e-5) ir = convolve.Normalize(ir, c.Float64("normalize")) // Resample IR to target sample rate if different from input (96kHz) targetSampleRate := format.SampleRate if targetSampleRate != 96000 { ir = convolve.Resample(ir, 96000, targetSampleRate) } // Trim or pad IR to requested length targetSamples := int(float64(targetSampleRate) * format.LengthMs / 1000.0) ir = convolve.TrimOrPad(ir, targetSamples) // Apply fade-out (using provided fadeMs parameter) fadeSamples := int(float64(targetSampleRate) * fadeMs / 1000.0) if fadeSamples > 0 { ir = convolve.FadeOutLinear(ir, fadeSamples) } // Generate MPT if requested if generateMPT { // Use the filtered recorded sweep for MPT generation (same as RAW IR) var originalIR []float64 if c.Bool("no-phase-correction") { originalIR = convolve.Deconvolve(sweepData.PCMData, recordedFiltered) } else { originalIR = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedFiltered) } originalIR = convolve.TrimSilence(originalIR, 1e-5) mptIR := convolve.MinimumPhaseTransform(originalIR) mptIR = convolve.Normalize(mptIR, c.Float64("normalize")) // Resample MPT IR to target sample rate if different from input (96kHz) if targetSampleRate != 96000 { mptIR = convolve.Resample(mptIR, 96000, targetSampleRate) } // Trim or pad MPT IR to requested length mptIR = convolve.TrimOrPad(mptIR, targetSamples) // Apply fade-out to MPT IR if fadeSamples > 0 { mptIR = convolve.FadeOutLinear(mptIR, fadeSamples) } ir = mptIR } return ir, nil } func main() { app := &cli.App{ Name: "valhallir-deconvolver", Usage: "Deconvolve sweep and recorded WAV files to create impulse responses", Version: "v1.2.1", Flags: []cli.Flag{ &cli.StringFlag{ Name: "sweep", Usage: "Path to the sweep WAV file (96kHz 24bit). Default: sweep.wav in current directory", }, &cli.StringFlag{ Name: "recorded", Usage: "Path to the recorded WAV file or directory containing WAV files. Default: current directory", }, &cli.StringFlag{ Name: "output", Usage: "Path to the output IR WAV file or directory for batch processing. Default: cabpack folder one directory level up from recorded directory", }, &cli.BoolFlag{ Name: "mpt", Usage: "Generate minimum phase transform IR in addition to regular IR", }, &cli.IntFlag{ Name: "sample-rate", Usage: "Output sample rate (44, 48, 88, 96 kHz)", Value: 96000, }, &cli.IntFlag{ Name: "bit-depth", Usage: "Output bit depth (16, 24, 32 bit)", Value: 24, }, &cli.Float64Flag{ Name: "normalize", Usage: "Normalize output to this peak value (0.0-1.0, default 0.95)", Value: 0.95, }, &cli.Float64Flag{ Name: "trim-threshold", Usage: "Silence threshold for trimming (0.0-1.0, default 0.001)", Value: 0.001, }, &cli.Float64Flag{ Name: "length-ms", Usage: "Optional: Output IR length in milliseconds (will trim or zero-pad as needed)", }, &cli.Float64Flag{ Name: "fade-ms", Usage: "Fade-out duration in milliseconds to apply at the end of the IR (default 5)", Value: 5.0, }, &cli.Float64Flag{ Name: "highcut", Usage: "High-cut filter (low-pass) cutoff frequency in Hz (applied to recorded sweep, optional)", }, &cli.Float64Flag{ Name: "lowcut", Usage: "Low-cut filter (high-pass) cutoff frequency in Hz (applied to recorded sweep, optional)", }, &cli.IntFlag{ Name: "cut-slope", Usage: "Cut filter slope in dB/octave (12, 24, 36, 48, ...; default 12)", Value: 12, }, &cli.BoolFlag{ Name: "plot-ir", Usage: "Plot the generated regular IR waveform to ir_plot.png", }, &cli.BoolFlag{ Name: "no-phase-correction", Usage: "Disable automatic phase correction (use if you know the phase is correct)", }, &cli.BoolFlag{ Name: "force-invert-phase", Usage: "Force inversion of the recorded sweep (for testing/manual override)", }, &cli.BoolFlag{ Name: "cabpack", Usage: "Generate a cabpack with IRs in multiple formats organized in a directory tree", }, }, Action: func(c *cli.Context) error { // Get the directory where the executable is located // This works when double-clicking the binary in Finder/Explorer execPath, err := os.Executable() if err != nil { return fmt.Errorf("failed to get executable path: %v", err) } execPath, err = filepath.EvalSymlinks(execPath) if err != nil { return fmt.Errorf("failed to resolve executable symlinks: %v", err) } execDir := filepath.Dir(execPath) execDir = filepath.Clean(execDir) // Get current working directory as fallback cwd, err := os.Getwd() if err != nil { cwd = execDir // Fallback to exec directory if Getwd fails } cwd = filepath.Clean(cwd) log.Printf("Executable directory: %s", execDir) log.Printf("Current working directory: %s", cwd) // Set defaults if flags are not provided sweepPath := c.String("sweep") recordedPath := c.String("recorded") outputPath := c.String("output") cabpackMode := c.Bool("cabpack") // If no flags provided, use default behavior (cabpack mode) // Use executable directory as default (works when double-clicking) if !c.IsSet("sweep") && !c.IsSet("recorded") && !c.IsSet("output") && !c.IsSet("cabpack") { log.Printf("No command-line options provided, using default cabpack mode") recordedPath = execDir sweepPath = filepath.Join(recordedPath, "sweep.wav") // Create cabpack folder one directory level up outputPath = filepath.Join(filepath.Dir(recordedPath), "cabpack") cabpackMode = true log.Printf("Using recorded directory: %s", recordedPath) log.Printf("Looking for sweep file: %s", sweepPath) log.Printf("Output directory: %s", outputPath) } else { // Set defaults for individual flags if not provided if !c.IsSet("recorded") { recordedPath = execDir } recordedPath = filepath.Clean(recordedPath) if !c.IsSet("sweep") { sweepPath = filepath.Join(recordedPath, "sweep.wav") } if !c.IsSet("output") { // If recorded is a directory, use cabpack folder one level up, otherwise use parent directory recordedInfo, err := os.Stat(recordedPath) if err == nil && recordedInfo.IsDir() { outputPath = filepath.Join(filepath.Dir(recordedPath), "cabpack") } else { outputPath = filepath.Dir(recordedPath) } } } // Read sweep WAV file sweepData, err := wav.ReadWAVFile(sweepPath) if err != nil { return fmt.Errorf("failed to read sweep file %s: %v", sweepPath, err) } // Check if cabpack mode is enabled if cabpackMode { log.Printf("Cabpack mode enabled") // Clean the path first to handle trailing slashes and normalize separators recordedPath = filepath.Clean(recordedPath) recordedInfo, err := os.Stat(recordedPath) if err != nil { return fmt.Errorf("failed to access recorded path %s: %v", recordedPath, err) } if !recordedInfo.IsDir() { return fmt.Errorf("cabpack mode requires --recorded to be a directory") } sweepFileName := filepath.Base(sweepPath) return processCabpack(c, sweepData, recordedPath, outputPath, sweepFileName) } // Check if recorded is a directory // Clean the path first to handle trailing slashes and normalize separators recordedPath = filepath.Clean(recordedPath) recordedInfo, err := os.Stat(recordedPath) if err != nil { return fmt.Errorf("failed to access recorded path %s: %v", recordedPath, err) } // Check if it's a directory (follow symlinks) if recordedInfo.IsDir() { // Batch processing mode log.Printf("Detected directory mode: processing all WAV files in %s", recordedPath) return processDirectory(c, sweepData, recordedPath, outputPath) } else { // Single file processing mode log.Printf("Detected single file mode: processing %s", recordedPath) return processSingleFile(c, sweepData, recordedPath, outputPath) } }, } if err := app.Run(os.Args); err != nil { log.Fatal(err) } }