fixes filter on MPT-transition. Add a high quality filter wir anti-aliasing

This commit is contained in:
Bastian Bührig
2025-12-04 14:39:54 +01:00
parent 73cff7ea08
commit 074643577d
5 changed files with 532 additions and 113 deletions

281
main.go
View File

@@ -2,6 +2,7 @@ package main
import (
"bufio"
_ "embed"
"fmt"
"io"
"log"
@@ -16,6 +17,9 @@ import (
"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
@@ -132,7 +136,7 @@ func processIR(c *cli.Context, sweepData *wav.WAVData, recordedPath, outputPath
// Plot IR waveform if requested
if c.Bool("plot-ir") {
log.Printf("Plotting IR waveform...")
err := plot.PlotIR(ir, sampleRate, outputPath)
err := plot.PlotIR(ir, sampleRate, outputPath, logoData)
if err != nil {
return fmt.Errorf("failed to plot IR: %v", err)
}
@@ -142,12 +146,12 @@ func processIR(c *cli.Context, sweepData *wav.WAVData, recordedPath, outputPath
// Generate MPT IR if requested
if c.Bool("mpt") {
log.Println("Generating minimum phase transform...")
// Use the original 96kHz IR for MPT generation
// 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, recordedData.PCMData)
originalIR = convolve.Deconvolve(sweepData.PCMData, recordedFiltered)
} else {
originalIR = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedData.PCMData)
originalIR = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedFiltered)
}
originalIR = convolve.TrimSilence(originalIR, 1e-5)
mptIR := convolve.MinimumPhaseTransform(originalIR)
@@ -328,12 +332,17 @@ func processCabpack(c *cli.Context, sweepData *wav.WAVData, recordedDir, outputD
// 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
// 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)
@@ -374,6 +383,26 @@ func processCabpack(c *cli.Context, sweepData *wav.WAVData, recordedDir, outputD
// 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)
@@ -384,17 +413,13 @@ func processCabpack(c *cli.Context, sweepData *wav.WAVData, recordedDir, outputD
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)
}
// 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"
closeMicsPath := filepath.Join(formatPath, "close mics")
rawPath := filepath.Join(closeMicsPath, "RAW")
mptPath := filepath.Join(closeMicsPath, "MPT")
if err := os.MkdirAll(rawPath, 0755); err != nil {
@@ -404,26 +429,44 @@ func processCabpack(c *cli.Context, sweepData *wav.WAVData, recordedDir, outputD
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 "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 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)
// 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)
}
}
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)
}
}
}
@@ -433,6 +476,15 @@ func processCabpack(c *cli.Context, sweepData *wav.WAVData, recordedDir, outputD
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 {
@@ -481,7 +533,7 @@ func processCabpack(c *cli.Context, sweepData *wav.WAVData, recordedDir, outputD
} 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 {
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)
@@ -499,11 +551,144 @@ func processCabpack(c *cli.Context, sweepData *wav.WAVData, recordedDir, outputD
// Process selection.txt and ambient.txt files
log.Printf("\nProcessing selection and ambient file lists...")
if err := processSelectionAndAmbientLists(recordedDir, outputDir, cabpackBase, formats); err != nil {
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
}
@@ -562,10 +747,10 @@ func moveFile(src, dst string) error {
}
// processSelectionAndAmbientLists processes selection.txt and ambient.txt files
func processSelectionAndAmbientLists(recordedDir, outputDir, cabpackBase string, formats []formatSpec) error {
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 _, err := os.Stat(selectionListPath); err == nil {
if hasSelection {
log.Printf("Processing selection.txt...")
selectionFiles, err := readFileList(selectionListPath)
if err != nil {
@@ -625,7 +810,7 @@ func processSelectionAndAmbientLists(recordedDir, outputDir, cabpackBase string,
// Process ambient.txt (move files)
ambientListPath := filepath.Join(recordedDir, "ambient.txt")
if _, err := os.Stat(ambientListPath); err == nil {
if hasAmbient {
log.Printf("Processing ambient.txt...")
ambientFiles, err := readFileList(ambientListPath)
if err != nil {
@@ -696,9 +881,11 @@ func processIRForFormatWithDefaults(c *cli.Context, sweepData *wav.WAVData, reco
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)
}
@@ -735,12 +922,12 @@ func processIRForFormatWithDefaults(c *cli.Context, sweepData *wav.WAVData, reco
// Generate MPT if requested
if generateMPT {
// Use the original 96kHz IR for MPT generation
// 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, recordedData.PCMData)
originalIR = convolve.Deconvolve(sweepData.PCMData, recordedFiltered)
} else {
originalIR = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedData.PCMData)
originalIR = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedFiltered)
}
originalIR = convolve.TrimSilence(originalIR, 1e-5)
mptIR := convolve.MinimumPhaseTransform(originalIR)
@@ -819,12 +1006,12 @@ func generateIRForFormatWithDefaults(c *cli.Context, sweepData *wav.WAVData, rec
// Generate MPT if requested
if generateMPT {
// Use the original 96kHz IR for MPT generation
// 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, recordedData.PCMData)
originalIR = convolve.Deconvolve(sweepData.PCMData, recordedFiltered)
} else {
originalIR = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedData.PCMData)
originalIR = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedFiltered)
}
originalIR = convolve.TrimSilence(originalIR, 1e-5)
mptIR := convolve.MinimumPhaseTransform(originalIR)
@@ -853,7 +1040,7 @@ func main() {
app := &cli.App{
Name: "valhallir-deconvolver",
Usage: "Deconvolve sweep and recorded WAV files to create impulse responses",
Version: "v1.1.0",
Version: "v1.2.1",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "sweep",
@@ -865,7 +1052,7 @@ func main() {
},
&cli.StringFlag{
Name: "output",
Usage: "Path to the output IR WAV file or directory for batch processing. Default: IRs subfolder in recorded directory",
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",
@@ -966,8 +1153,8 @@ func main() {
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")
// 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)
@@ -982,10 +1169,10 @@ func main() {
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
// 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), "IRs")
outputPath = filepath.Join(filepath.Dir(recordedPath), "cabpack")
} else {
outputPath = filepath.Dir(recordedPath)
}