Files

1230 lines
41 KiB
Go

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)
}
}