1230 lines
41 KiB
Go
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)
|
|
}
|
|
}
|