diff --git a/README.md b/README.md index 5e43f72..5589d88 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ A CLI tool for processing WAV files to generate impulse responses (IR) from swee - **Multiple output formats** with configurable sample rates and bit depths - **Minimum Phase Transform (MPT)** option for reduced latency IRs - **Automatic silence trimming** and normalization +- **Batch processing:** Process entire directories of recorded files automatically +- **Cabpack generation:** Create complete cabpack structures with IRs in multiple formats organized in a directory tree - **Modular design** with separate packages for WAV I/O, convolution, and visualization - **Robust error handling** and validation @@ -32,6 +34,24 @@ go build -o valhallir-deconvolver ## Usage +### Default Mode (No Options) + +If you run the tool without any command-line options, it automatically creates a cabpack: + +```sh +./valhallir-deconvolver +``` + +This will: +- Use the current directory as the recorded directory +- Look for `sweep.wav` in the current directory as the sweep file +- Create output in an `IRs` folder one directory level up from the current directory +- Automatically enable cabpack mode +- Process all WAV files in the current directory (excluding the sweep file) +- Use `selection.txt` and `ambient.txt` from the current directory if present + +**Perfect for:** Simply placing the tool in a folder with recorded WAV files and running it to create a complete cabpack in the parent directory's `IRs` folder. + ### Basic IR Generation Generate a standard impulse response from sweep and recorded files (any WAV format): @@ -40,6 +60,105 @@ Generate a standard impulse response from sweep and recorded files (any WAV form ./valhallir-deconvolver --sweep sweep.wav --recorded recorded.wav --output ir.wav ``` +### Batch Processing (Directory Mode) + +Process all WAV files in a directory of recorded files. The tool will automatically detect if `--recorded` is a directory and process all WAV files found in it (including subdirectories): + +```sh +./valhallir-deconvolver --sweep sweep.wav --recorded ./recordings/ --output ./ir_output/ +``` + +This will: +- Find all `.wav` files in the `./recordings/` directory (recursively) +- Process each recorded file with the same sweep file +- Generate IR files in the `./ir_output/` directory +- Use the original recorded filename for each output IR (e.g., `recorded1.wav` → `recorded1.wav`) + +**Note:** If `--output` is a directory, it will be created automatically if it doesn't exist. If `--output` is a file path, single-file mode is used. + +**Example with options:** +```sh +./valhallir-deconvolver \ + --sweep sweep.wav \ + --recorded ./recordings/ \ + --output ./ir_output/ \ + --mpt \ + --length-ms 50 \ + --sample-rate 48000 \ + --bit-depth 24 +``` + +This processes all WAV files in `./recordings/` and generates both regular and MPT IRs in `./ir_output/`. + +### Cabpack Generation + +Generate a complete cabpack with IRs in multiple formats organized in a structured directory tree: + +**Simple mode (default - no options needed):** +```sh +./valhallir-deconvolver +``` +This uses: +- Current directory as recorded directory +- `sweep.wav` in current directory as sweep file +- `IRs` folder one directory level up from current directory as output +- Automatically enables cabpack mode +- Excludes the sweep file from processing + +**Explicit mode:** +```sh +./valhallir-deconvolver \ + --sweep sweep.wav \ + --recorded ./recordings/ \ + --output ./cabpack_output/ \ + --cabpack +``` + +Both modes create a cabpack structure with: +- **Format folders** named like `V2-1960STV 96000Hz-24bit 500ms` (extracted from WAV filenames) +- **9 different formats** covering various sample rates, bit depths, and lengths: + - 44100Hz, 16bit, 170ms + - 44100Hz, 24bit, 170ms + - 44100Hz, 24bit, 500ms + - 48000Hz, 16bit, 170ms + - 48000Hz, 24bit, 170ms + - 48000Hz, 24bit, 500ms + - 48000Hz, 24bit, 1370ms + - 96000Hz, 24bit, 500ms + - 96000Hz, 24bit, 1370ms +- **Subfolders** in each format: `ambient mics`, `close mics`, `mixees`, `selection` +- **RAW and MPT IRs** in `close mics/RAW` and `close mics/MPT` respectively +- **IR visualizations** in the `plots` folder (96000Hz format) +- **Automatic file organization** using `selection.txt` and `ambient.txt` files + +The cabpack base name is automatically extracted from WAV filenames. For example, `V2-1960STV-d-SM7B-A1.wav` will create a cabpack named `V2-1960STV`. + +#### Automatic File Organization + +You can automatically populate the `selection` and `ambient mics` folders by placing text files in the recorded directory: + +- **`selection.txt`**: Lists filenames (one per line) to **copy** from `close mics` to `selection` folders. Both RAW and MPT versions are copied. +- **`ambient.txt`**: Lists filenames (one per line) to **move** from `close mics` to `ambient mics` folders. Both RAW and MPT versions are moved. + +**Example `selection.txt`:** +``` +V2-1960STV-d-SM7B-A1.wav +V2-1960STV-d-SM7B-A2.wav +V2-1960STV-d-SM7B-A3.wav +``` + +**Example `ambient.txt`:** +``` +V2-1960STV-d-SM7B-B1.wav +V2-1960STV-d-SM7B-B2.wav +``` + +**Notes:** +- If `selection.txt` or `ambient.txt` are missing, those operations are skipped (no error). +- Files are processed for all format folders automatically. +- The `.wav` extension is automatically added if missing in the list files. +- The `mixees` folder is left empty for manual filling. + ### With Minimum Phase Transform Generate both regular and minimum phase IRs: @@ -183,9 +302,9 @@ Generate IRs in different sample rates and bit depths: | Flag | Description | Default | Required | |------|-------------|---------|----------| -| `--sweep` | Path to sweep WAV file (any format) | - | Yes | -| `--recorded` | Path to recorded WAV file (any format) | - | Yes | -| `--output` | Path to output IR WAV file | - | Yes | +| `--sweep` | Path to sweep WAV file (any format) | `sweep.wav` in current directory | No | +| `--recorded` | Path to recorded WAV file or directory containing WAV files | Current directory | No | +| `--output` | Path to output IR WAV file or directory for batch processing | `IRs` folder one directory level up from recorded directory | No | | `--mpt` | Generate minimum phase transform IR | false | No | | `--sample-rate` | Output sample rate (44, 48, 88, 96 kHz) | 96000 | No | | `--bit-depth` | Output bit depth (16, 24, 32 bit) | 24 | No | @@ -199,6 +318,7 @@ Generate IRs in different sample rates and bit depths: | `--plot-ir` | Generate frequency response and waveform plot | false | No | | `--no-phase-correction` | Disable automatic phase correction | false | No | | `--force-invert-phase` | Force inversion of the recorded sweep (manual override) | false | No | +| `--cabpack` | Generate a cabpack with IRs in multiple formats organized in a directory tree | false | No | ## File Requirements @@ -311,6 +431,18 @@ Generate IRs in different sample rates and bit depths: --mpt ``` +### Cabpack Generation +```sh +# Generate a complete cabpack with all formats +./valhallir-deconvolver \ + --sweep sweep.wav \ + --recorded ./recordings/ \ + --output ./cabpack_output/ \ + --cabpack +``` + +This will process all WAV files in `./recordings/` and create a complete cabpack structure with IRs in 9 different formats, organized in folders with RAW and MPT versions, plus visualization plots. + ## CI/CD & Multi-Platform Builds This project includes a Dagger pipeline for building binaries for multiple platforms: diff --git a/main.go b/main.go index ad613c7..51c22a5 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,13 @@ package main import ( + "bufio" "fmt" + "io" "log" "os" + "path/filepath" + "strings" "valhallir-deconvolver/pkg/convolve" "valhallir-deconvolver/pkg/plot" @@ -12,6 +16,839 @@ import ( "github.com/urfave/cli/v2" ) +// 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) + 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 original 96kHz IR for MPT generation + var originalIR []float64 + if c.Bool("no-phase-correction") { + originalIR = convolve.Deconvolve(sweepData.PCMData, recordedData.PCMData) + } else { + originalIR = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedData.PCMData) + } + 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 + 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" { + // 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() + + // 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) + } + + // Create subfolders: ambient mics, close mics, mixees, selection + subfolders := []string{"ambient mics", "close mics", "mixees", "selection"} + for _, subfolder := range subfolders { + subfolderPath := filepath.Join(formatPath, subfolder) + if err := os.MkdirAll(subfolderPath, 0755); err != nil { + return fmt.Errorf("failed to create subfolder %s: %v", subfolderPath, err) + } + } + + // Create RAW and MPT folders inside "close mics" + closeMicsPath := filepath.Join(formatPath, "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 RAW and MPT folders inside "selection" + selectionPath := filepath.Join(formatPath, "selection") + 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 RAW and MPT folders inside "ambient mics" + ambientPath := filepath.Join(formatPath, "ambient mics") + 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 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) + } + + // 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"); 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); err != nil { + log.Printf("Warning: Error processing selection/ambient lists: %v", err) + // Don't fail the entire process if this fails + } + + 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) error { + // Process selection.txt first (copy files) + selectionListPath := filepath.Join(recordedDir, "selection.txt") + if _, err := os.Stat(selectionListPath); err == nil { + 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 _, err := os.Stat(ambientListPath); err == nil { + 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 { + 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 original 96kHz IR for MPT generation + var originalIR []float64 + if c.Bool("no-phase-correction") { + originalIR = convolve.Deconvolve(sweepData.PCMData, recordedData.PCMData) + } else { + originalIR = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedData.PCMData) + } + 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 original 96kHz IR for MPT generation + var originalIR []float64 + if c.Bool("no-phase-correction") { + originalIR = convolve.Deconvolve(sweepData.PCMData, recordedData.PCMData) + } else { + originalIR = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedData.PCMData) + } + 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", @@ -19,19 +856,16 @@ func main() { Version: "v1.1.0", Flags: []cli.Flag{ &cli.StringFlag{ - Name: "sweep", - Usage: "Path to the sweep WAV file (96kHz 24bit)", - Required: true, + 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 (96kHz 24bit)", - Required: true, + 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 (96kHz 24bit)", - Required: true, + Name: "output", + Usage: "Path to the output IR WAV file or directory for batch processing. Default: IRs subfolder in recorded directory", }, &cli.BoolFlag{ Name: "mpt", @@ -91,185 +925,114 @@ func main() { 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 { - // Read sweep WAV file - sweepData, err := wav.ReadWAVFile(c.String("sweep")) + // 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 err + return fmt.Errorf("failed to get executable path: %v", err) } - - // Read recorded WAV file - recordedData, err := wav.ReadWAVFile(c.String("recorded")) + execPath, err = filepath.EvalSymlinks(execPath) if err != nil { - return err + return fmt.Errorf("failed to resolve executable symlinks: %v", err) } + execDir := filepath.Dir(execPath) + execDir = filepath.Clean(execDir) - log.Printf("Sweep: %d samples, %d channels", len(sweepData.PCMData), sweepData.Channels) - log.Printf("Recorded: %d samples, %d channels", len(recordedData.PCMData), recordedData.Channels) + // 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) - // 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) - } + log.Printf("Executable directory: %s", execDir) + log.Printf("Current working directory: %s", cwd) - // 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) - } + // Set defaults if flags are not provided + sweepPath := c.String("sweep") + recordedPath := c.String("recorded") + outputPath := c.String("output") + cabpackMode := c.Bool("cabpack") - log.Println("Performing deconvolution...") - var ir []float64 - if c.Bool("no-phase-correction") { - ir = convolve.Deconvolve(sweepData.PCMData, recordedFiltered) + // 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 IRs folder one directory level up + outputPath = filepath.Join(filepath.Dir(recordedPath), "IRs") + cabpackMode = true + log.Printf("Using recorded directory: %s", recordedPath) + log.Printf("Looking for sweep file: %s", sweepPath) + log.Printf("Output directory: %s", outputPath) } 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 + // 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 IRs folder one level up, otherwise use parent directory + recordedInfo, err := os.Stat(recordedPath) + if err == nil && recordedInfo.IsDir() { + outputPath = filepath.Join(filepath.Dir(recordedPath), "IRs") + } else { + outputPath = filepath.Dir(recordedPath) + } } } - if !validSampleRate { - return fmt.Errorf("invalid sample rate: %d. Valid options: %v", sampleRate, validSampleRates) + + // Read sweep WAV file + sweepData, err := wav.ReadWAVFile(sweepPath) + if err != nil { + return fmt.Errorf("failed to read sweep file %s: %v", sweepPath, err) } - // 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)", c.String("output"), sampleRate, bitDepth) - if err := wav.WriteWAVFileWithOptions(c.String("output"), ir, sampleRate, bitDepth); err != nil { - return err - } - - // Plot IR waveform if requested - if c.Bool("plot-ir") { - log.Printf("Plotting IR waveform to ir_plot.png...") - err := plot.PlotIR(ir, sampleRate, c.String("output")) + // 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 plot IR: %v", err) + return fmt.Errorf("failed to access recorded path %s: %v", recordedPath, err) } - log.Printf("IR plot saved as ir_plot.png") + 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) } - // Generate MPT IR if requested - if c.Bool("mpt") { - log.Println("Generating minimum phase transform...") - // Use the original 96kHz IR for MPT generation - var originalIR []float64 - if c.Bool("no-phase-correction") { - originalIR = convolve.Deconvolve(sweepData.PCMData, recordedData.PCMData) - } else { - originalIR = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedData.PCMData) - } - 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)) + // Check if recorded is a directory + // Clean the path first to handle trailing slashes and normalize separators + recordedPath = filepath.Clean(recordedPath) - // 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 - outputPath := c.String("output") - if len(outputPath) > 4 && outputPath[len(outputPath)-4:] == ".wav" { - outputPath = outputPath[:len(outputPath)-4] - } - mptOutputPath := outputPath + "_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 err - } - log.Println("Minimum phase transform IR generated successfully!") + recordedInfo, err := os.Stat(recordedPath) + if err != nil { + return fmt.Errorf("failed to access recorded path %s: %v", recordedPath, err) } - log.Println("Impulse response generated successfully!") - return nil + // 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) + } }, } diff --git a/pkg/plot/plot.go b/pkg/plot/plot.go index b43fc94..07c3eec 100644 --- a/pkg/plot/plot.go +++ b/pkg/plot/plot.go @@ -62,8 +62,9 @@ func PlotIR(ir []float64, sampleRate int, irFileName string) error { } } fmt.Printf("[PlotIR] minDb in plotted range: %.2f dB at %.2f Hz\n", minDb, minDbFreq) + irBaseName := filepath.Base(irFileName) p := plot.New() - p.Title.Text = "IR Frequency Response (dB, 2048-sample window)" + p.Title.Text = fmt.Sprintf("IR Frequency Response: %s", irBaseName) p.X.Label.Text = "Frequency (Hz)" p.Y.Label.Text = "Magnitude (dB)" p.X.Scale = plot.LogScale{} @@ -111,7 +112,7 @@ func PlotIR(ir []float64, sampleRate int, irFileName string) error { // --- Time-aligned waveform plot --- p2 := plot.New() - p2.Title.Text = "IR Waveform (Time Aligned)" + p2.Title.Text = fmt.Sprintf("IR Waveform: %s", irBaseName) p2.X.Label.Text = "Time (ms)" p2.Y.Label.Text = "Amplitude" // Prepare waveform data (only first 10ms) diff --git a/pkg/wav/reader.go b/pkg/wav/reader.go index 20ae256..96307a6 100644 --- a/pkg/wav/reader.go +++ b/pkg/wav/reader.go @@ -36,6 +36,15 @@ func toMono(data []float64, channels int) []float64 { // ReadWAVFile reads a WAV file and returns its PCM data as float64 (resampled to 96kHz mono) func ReadWAVFile(filePath string) (*WAVData, error) { + // Check if path is a directory + info, err := os.Stat(filePath) + if err != nil { + return nil, fmt.Errorf("failed to access file %s: %w", filePath, err) + } + if info.IsDir() { + return nil, fmt.Errorf("path %s is a directory, not a WAV file", filePath) + } + file, err := os.Open(filePath) if err != nil { return nil, fmt.Errorf("failed to open file %s: %w", filePath, err)