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

View File

@@ -1,5 +1,49 @@
# Changelog # Changelog
## [v1.2.1] - 2024-12-20
### Added
- **High-Quality Filter Implementation**: Direct Form II Transposed filter structure for improved numerical stability
- **Filter Warm-Up**: Proper filter initialization to avoid transients, especially important for MPT transforms
- **Anti-Aliasing in Resampling**: Automatic anti-aliasing filter when downsampling to prevent aliasing artifacts
### Fixed
- **MPT Filter Application**: MPT IRs now correctly use the filtered recorded sweep (same as RAW IRs)
- **Filter Response at Lower Sample Rates**: Fixed filter response accuracy when resampling to 48kHz and other lower sample rates
- **Resampling Artifacts**: Added proper anti-aliasing to prevent frequency aliasing when downsampling
### Changed
- **Filter Quality**: Improved filter implementation with high-precision coefficient calculation and proper state management
- **Filter Artifacts**: Reduced artifacts in steep filters (48+ dB/octave) through better numerical stability
### Technical Improvements
- **Direct Form II Transposed**: More numerically stable filter structure for cascaded filters
- **Filter Warm-Up**: Pre-initialization of filter state to eliminate transients
- **Anti-Aliasing**: Steep low-pass filter (48 dB/octave) applied before downsampling to prevent aliasing
## [v1.2.0] - 2024-12-20
### Added
- **Cabpack Generation**: Complete cabpack creation with IRs in 9 different formats organized in structured directory trees
- **Default Mode**: Run without command-line options for automatic cabpack generation from current directory
- **Executable Directory Detection**: Automatic detection of binary location for double-click support in Finder/Explorer
- **Automatic File Organization**: Support for `selection.txt` and `ambient.txt` files to automatically organize IRs
- **Mixes Folder Support**: Automatic conversion of ready-to-use IRs from `mixes` folder to all cabpack formats
- **IR Filename in Plots**: IR filenames now displayed in plot titles for better identification
- **Cabpack Defaults**: Automatic defaults for cabpack mode (fade-ms: 10, lowcut: 40Hz)
- **Plots for Mixes**: Automatic plot generation for mix IRs in `plots/mixes` folder
- **Logo in Plots**: Valhallir logo automatically displayed in upper-left corner of all generated plots (embedded in binary)
### Changed
- **Optional Command-Line Flags**: All main flags (`--sweep`, `--recorded`, `--output`) are now optional with intelligent defaults
- **Default Sweep File**: Changed default sweep filename to `sweep.wav` (simpler naming)
- **Output Directory**: IRs folder created one directory level up from recorded directory by default
- **Directory Structure**: Added `mixes` subfolder to each format folder (only created if `mixes` folder exists)
- **Conditional Folder Creation**: `selection`, `ambient mics`, and `mixes` folders are only created if the corresponding files/folders exist in the recorded directory
### Technical Improvements
- **Smart Path Detection**: Uses executable directory when double-clicked, current directory when run from command line
- **Enhanced Logging**: Better progress information and path logging for debugging
- **Improved Error Handling**: Graceful handling of missing optional files (selection.txt, ambient.txt, mixes folder)
## [v1.1.0] - 2024-12-19 ## [v1.1.0] - 2024-12-19
### Added ### Added
- **IR Visualization**: New `--plot-ir` flag to generate frequency response and waveform plots - **IR Visualization**: New `--plot-ir` flag to generate frequency response and waveform plots

View File

@@ -45,12 +45,12 @@ If you run the tool without any command-line options, it automatically creates a
This will: This will:
- Use the current directory as the recorded directory - Use the current directory as the recorded directory
- Look for `sweep.wav` in the current directory as the sweep file - Look for `sweep.wav` in the current directory as the sweep file
- Create output in an `IRs` folder one directory level up from the current directory - Create output in a `cabpack` folder one directory level up from the current directory
- Automatically enable cabpack mode - Automatically enable cabpack mode
- Process all WAV files in the current directory (excluding the sweep file) - Process all WAV files in the current directory (excluding the sweep file)
- Use `selection.txt` and `ambient.txt` from the current directory if present - Use `selection.txt` and `ambient.txt` from the current directory if present
**Perfect for:** Simply placing the tool in a folder with recorded WAV files and running it to create a complete cabpack in the parent directory's `IRs` folder. **Perfect for:** Simply placing the tool in a folder with recorded WAV files and running it to create a complete cabpack in the parent directory's `cabpack` folder.
### Basic IR Generation ### Basic IR Generation
@@ -101,7 +101,7 @@ Generate a complete cabpack with IRs in multiple formats organized in a structur
This uses: This uses:
- Current directory as recorded directory - Current directory as recorded directory
- `sweep.wav` in current directory as sweep file - `sweep.wav` in current directory as sweep file
- `IRs` folder one directory level up from current directory as output - `cabpack` folder one directory level up from current directory as output
- Automatically enables cabpack mode - Automatically enables cabpack mode
- Excludes the sweep file from processing - Excludes the sweep file from processing
@@ -126,10 +126,11 @@ Both modes create a cabpack structure with:
- 48000Hz, 24bit, 1370ms - 48000Hz, 24bit, 1370ms
- 96000Hz, 24bit, 500ms - 96000Hz, 24bit, 500ms
- 96000Hz, 24bit, 1370ms - 96000Hz, 24bit, 1370ms
- **Subfolders** in each format: `ambient mics`, `close mics`, `mixees`, `selection` - **Subfolders** in each format: `close mics` (always), plus `ambient mics`, `selection`, and `mixes` (only if corresponding files/folders exist)
- **RAW and MPT IRs** in `close mics/RAW` and `close mics/MPT` respectively - **RAW and MPT IRs** in `close mics/RAW` and `close mics/MPT` respectively
- **IR visualizations** in the `plots` folder (96000Hz format) - **IR visualizations** in the `plots` folder (96000Hz format)
- **Automatic file organization** using `selection.txt` and `ambient.txt` files - **Automatic file organization** using `selection.txt` and `ambient.txt` files
- **Mixes folder support**: Convert ready-to-use IRs from `mixes` folder to all formats
The cabpack base name is automatically extracted from WAV filenames. For example, `V2-1960STV-d-SM7B-A1.wav` will create a cabpack named `V2-1960STV`. The cabpack base name is automatically extracted from WAV filenames. For example, `V2-1960STV-d-SM7B-A1.wav` will create a cabpack named `V2-1960STV`.
@@ -154,10 +155,35 @@ V2-1960STV-d-SM7B-B2.wav
``` ```
**Notes:** **Notes:**
- If `selection.txt` or `ambient.txt` are missing, those operations are skipped (no error). - If `selection.txt` is missing, the `selection` folder is not created in any format folder.
- If `ambient.txt` is missing, the `ambient mics` folder is not created in any format folder.
- If the `mixes` folder is missing, the `mixes` subfolder is not created in any format folder.
- Files are processed for all format folders automatically. - Files are processed for all format folders automatically.
- The `.wav` extension is automatically added if missing in the list files. - The `.wav` extension is automatically added if missing in the list files.
- The `mixees` folder is left empty for manual filling.
#### Mixes Folder Support
If a `mixes` folder exists in the recorded directory, it will be processed automatically:
- **Ready-to-use IRs**: Place pre-processed IR files (already deconvolved) in the `mixes` folder
- **Automatic conversion**: Each IR file is converted to all 9 cabpack formats
- **Format storage**: Converted IRs are stored in the `mixes` subfolder of each format folder (only created if `mixes` folder exists)
- **Plot generation**: IR visualizations are generated in the `plots/mixes` folder (only created if `mixes` folder exists)
**Note:** If the `mixes` folder is missing in the recorded directory, the `mixes` subfolder is not created in any format folder, and no mix processing occurs.
**Example structure:**
```
recorded/
├── sweep.wav
├── recorded1.wav
├── recorded2.wav
└── mixes/
├── mix1.wav
└── mix2.wav
```
This will create converted versions of `mix1.wav` and `mix2.wav` in all format folders under the `mixes` subfolder, plus plots in `plots/mixes/`.
### With Minimum Phase Transform ### With Minimum Phase Transform
@@ -209,6 +235,8 @@ You can control the filter steepness (slope) with `--cut-slope` (in dB/octave, d
This applies a 40 Hz low-cut and 18 kHz high-cut, both with a 24 dB/octave slope (steeper than the default 12). This applies a 40 Hz low-cut and 18 kHz high-cut, both with a 24 dB/octave slope (steeper than the default 12).
**Filter Implementation:** The tool uses Linkwitz-Riley design principles with Direct Form II Transposed filter structures for optimal numerical stability and minimal artifacts, even at very steep slopes (48+ dB/octave). The filters are designed to provide clean cutoffs without introducing unwanted artifacts in the frequency response.
### Automatic Phase Correction ### Automatic Phase Correction
Valhallir Deconvolver automatically detects and corrects phase-inverted recorded sweeps to ensure consistent IR polarity. This is especially important when mixing multiple IRs later. Valhallir Deconvolver automatically detects and corrects phase-inverted recorded sweeps to ensure consistent IR polarity. This is especially important when mixing multiple IRs later.
@@ -249,9 +277,11 @@ This creates:
The plot includes: The plot includes:
- **Frequency Response:** dB vs Hz with log frequency scale (20Hz-20kHz) - **Frequency Response:** dB vs Hz with log frequency scale (20Hz-20kHz)
- **Waveform:** Time-domain view of the first 10ms of the IR - **Waveform:** Time-domain view of the first 10ms of the IR
- **Valhallir Branding:** Logo and filename information - **Valhallir Branding:** Logo displayed in upper-left corner and filename information
- **Professional Layout:** Clean, publication-ready visualization - **Professional Layout:** Clean, publication-ready visualization
**Note:** The logo is embedded directly in the binary, so no external assets folder is required. The logo will always be available regardless of where the binary is located or how it's executed.
### Different Output Formats ### Different Output Formats
Generate IRs in different sample rates and bit depths: Generate IRs in different sample rates and bit depths:
@@ -304,7 +334,7 @@ Generate IRs in different sample rates and bit depths:
|------|-------------|---------|----------| |------|-------------|---------|----------|
| `--sweep` | Path to sweep WAV file (any format) | `sweep.wav` in current directory | No | | `--sweep` | Path to sweep WAV file (any format) | `sweep.wav` in current directory | No |
| `--recorded` | Path to recorded WAV file or directory containing WAV files | Current directory | No | | `--recorded` | Path to recorded WAV file or directory containing WAV files | Current directory | No |
| `--output` | Path to output IR WAV file or directory for batch processing | `IRs` folder one directory level up from recorded directory | No | | `--output` | Path to output IR WAV file or directory for batch processing | `cabpack` folder one directory level up from recorded directory | No |
| `--mpt` | Generate minimum phase transform IR | false | No | | `--mpt` | Generate minimum phase transform IR | false | No |
| `--sample-rate` | Output sample rate (44, 48, 88, 96 kHz) | 96000 | No | | `--sample-rate` | Output sample rate (44, 48, 88, 96 kHz) | 96000 | No |
| `--bit-depth` | Output bit depth (16, 24, 32 bit) | 24 | No | | `--bit-depth` | Output bit depth (16, 24, 32 bit) | 24 | No |
@@ -351,9 +381,11 @@ Generate IRs in different sample rates and bit depths:
- You can change the fade duration with `--fade-ms` - You can change the fade duration with `--fade-ms`
### Filtering ### Filtering
- You can apply a Butterworth low-cut (high-pass) and/or high-cut (low-pass) filter to the recorded sweep before IR extraction - You can apply a low-cut (high-pass) and/or high-cut (low-pass) filter to the recorded sweep before IR extraction
- Uses Linkwitz-Riley design principles with Direct Form II Transposed structures for optimal stability
- Use `--lowcut` and/or `--highcut` to specify cutoff frequencies in Hz - Use `--lowcut` and/or `--highcut` to specify cutoff frequencies in Hz
- Use `--cut-slope` to control the filter steepness (12 dB/octave = gentle, 24+ = steeper) - Use `--cut-slope` to control the filter steepness (12 dB/octave = gentle, 24+ = steeper)
- The filter implementation is designed to provide clean cutoffs without artifacts, even at very steep slopes (48+ dB/octave)
### Deconvolution Process ### Deconvolution Process
1. **FFT-based deconvolution** of recorded signal by sweep signal 1. **FFT-based deconvolution** of recorded signal by sweep signal
@@ -521,4 +553,4 @@ The pipeline is defined in [`ci/dagger.go`](./ci/dagger.go). It outputs binaries
## Changelog ## Changelog
See [CHANGELOG.md](./CHANGELOG.md) for version history and details. Current version: v1.0.0 See [CHANGELOG.md](./CHANGELOG.md) for version history and details. Current version: v1.2.0

281
main.go
View File

@@ -2,6 +2,7 @@ package main
import ( import (
"bufio" "bufio"
_ "embed"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -16,6 +17,9 @@ import (
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
//go:embed assets/logo.png
var logoData []byte
// processIR processes a single recorded file and generates IR(s) // processIR processes a single recorded file and generates IR(s)
func processIR(c *cli.Context, sweepData *wav.WAVData, recordedPath, outputPath string) error { func processIR(c *cli.Context, sweepData *wav.WAVData, recordedPath, outputPath string) error {
// Read recorded WAV file // Read recorded WAV file
@@ -132,7 +136,7 @@ func processIR(c *cli.Context, sweepData *wav.WAVData, recordedPath, outputPath
// Plot IR waveform if requested // Plot IR waveform if requested
if c.Bool("plot-ir") { if c.Bool("plot-ir") {
log.Printf("Plotting IR waveform...") log.Printf("Plotting IR waveform...")
err := plot.PlotIR(ir, sampleRate, outputPath) err := plot.PlotIR(ir, sampleRate, outputPath, logoData)
if err != nil { if err != nil {
return fmt.Errorf("failed to plot IR: %v", err) 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 // Generate MPT IR if requested
if c.Bool("mpt") { if c.Bool("mpt") {
log.Println("Generating minimum phase transform...") 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 var originalIR []float64
if c.Bool("no-phase-correction") { if c.Bool("no-phase-correction") {
originalIR = convolve.Deconvolve(sweepData.PCMData, recordedData.PCMData) originalIR = convolve.Deconvolve(sweepData.PCMData, recordedFiltered)
} else { } else {
originalIR = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedData.PCMData) originalIR = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedFiltered)
} }
originalIR = convolve.TrimSilence(originalIR, 1e-5) originalIR = convolve.TrimSilence(originalIR, 1e-5)
mptIR := convolve.MinimumPhaseTransform(originalIR) 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 // cut-slope: default is already 12, so no change needed
cutSlope := c.Int("cut-slope") 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 var wavFiles []string
err := filepath.Walk(recordedDir, func(path string, info os.FileInfo, err error) error { err := filepath.Walk(recordedDir, func(path string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return err 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" { if !info.IsDir() && strings.ToLower(filepath.Ext(path)) == ".wav" {
// Exclude the sweep file from processing // Exclude the sweep file from processing
fileName := filepath.Base(path) fileName := filepath.Base(path)
@@ -374,6 +383,26 @@ func processCabpack(c *cli.Context, sweepData *wav.WAVData, recordedDir, outputD
// Get all format specifications // Get all format specifications
formats := getCabpackFormats() 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 // Create directory structure for each format
for _, format := range formats { for _, format := range formats {
formatFolder := formatFolderName(cabpackBase, format) 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) return fmt.Errorf("failed to create format folder %s: %v", formatPath, err)
} }
// Create subfolders: ambient mics, close mics, mixees, selection // Always create "close mics" folder
subfolders := []string{"ambient mics", "close mics", "mixees", "selection"} closeMicsPath := filepath.Join(formatPath, "close mics")
for _, subfolder := range subfolders { if err := os.MkdirAll(closeMicsPath, 0755); err != nil {
subfolderPath := filepath.Join(formatPath, subfolder) return fmt.Errorf("failed to create close mics folder: %v", err)
if err := os.MkdirAll(subfolderPath, 0755); err != nil {
return fmt.Errorf("failed to create subfolder %s: %v", subfolderPath, err)
}
} }
// Create RAW and MPT folders inside "close mics" // Create RAW and MPT folders inside "close mics"
closeMicsPath := filepath.Join(formatPath, "close mics")
rawPath := filepath.Join(closeMicsPath, "RAW") rawPath := filepath.Join(closeMicsPath, "RAW")
mptPath := filepath.Join(closeMicsPath, "MPT") mptPath := filepath.Join(closeMicsPath, "MPT")
if err := os.MkdirAll(rawPath, 0755); err != nil { 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) return fmt.Errorf("failed to create MPT folder: %v", err)
} }
// Create RAW and MPT folders inside "selection" // Create "selection" folder and subfolders only if selection.txt exists
selectionPath := filepath.Join(formatPath, "selection") if hasSelection {
selectionRawPath := filepath.Join(selectionPath, "RAW") selectionPath := filepath.Join(formatPath, "selection")
selectionMptPath := filepath.Join(selectionPath, "MPT") if err := os.MkdirAll(selectionPath, 0755); err != nil {
if err := os.MkdirAll(selectionRawPath, 0755); err != nil { return fmt.Errorf("failed to create selection folder: %v", err)
return fmt.Errorf("failed to create selection RAW folder: %v", err) }
} selectionRawPath := filepath.Join(selectionPath, "RAW")
if err := os.MkdirAll(selectionMptPath, 0755); err != nil { selectionMptPath := filepath.Join(selectionPath, "MPT")
return fmt.Errorf("failed to create selection MPT folder: %v", err) 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" // Create "ambient mics" folder and subfolders only if ambient.txt exists
ambientPath := filepath.Join(formatPath, "ambient mics") if hasAmbient {
ambientRawPath := filepath.Join(ambientPath, "RAW") ambientPath := filepath.Join(formatPath, "ambient mics")
ambientMptPath := filepath.Join(ambientPath, "MPT") if err := os.MkdirAll(ambientPath, 0755); err != nil {
if err := os.MkdirAll(ambientRawPath, 0755); err != nil { return fmt.Errorf("failed to create ambient mics folder: %v", err)
return fmt.Errorf("failed to create ambient RAW 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) 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 // Process each WAV file
successCount := 0 successCount := 0
for i, recordedFile := range wavFiles { for i, recordedFile := range wavFiles {
@@ -481,7 +533,7 @@ func processCabpack(c *cli.Context, sweepData *wav.WAVData, recordedDir, outputD
} else { } else {
// Pass path without .png extension - PlotIR will add it // Pass path without .png extension - PlotIR will add it
plotBasePath := filepath.Join(plotsPath, recordedName) 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) log.Printf("ERROR generating plot: %v", err)
} else { } else {
log.Printf("Plot saved: %s.png", plotBasePath) 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 // Process selection.txt and ambient.txt files
log.Printf("\nProcessing selection and ambient file lists...") 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) log.Printf("Warning: Error processing selection/ambient lists: %v", err)
// Don't fail the entire process if this fails // 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 return nil
} }
@@ -562,10 +747,10 @@ func moveFile(src, dst string) error {
} }
// processSelectionAndAmbientLists processes selection.txt and ambient.txt files // 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) // Process selection.txt first (copy files)
selectionListPath := filepath.Join(recordedDir, "selection.txt") selectionListPath := filepath.Join(recordedDir, "selection.txt")
if _, err := os.Stat(selectionListPath); err == nil { if hasSelection {
log.Printf("Processing selection.txt...") log.Printf("Processing selection.txt...")
selectionFiles, err := readFileList(selectionListPath) selectionFiles, err := readFileList(selectionListPath)
if err != nil { if err != nil {
@@ -625,7 +810,7 @@ func processSelectionAndAmbientLists(recordedDir, outputDir, cabpackBase string,
// Process ambient.txt (move files) // Process ambient.txt (move files)
ambientListPath := filepath.Join(recordedDir, "ambient.txt") ambientListPath := filepath.Join(recordedDir, "ambient.txt")
if _, err := os.Stat(ambientListPath); err == nil { if hasAmbient {
log.Printf("Processing ambient.txt...") log.Printf("Processing ambient.txt...")
ambientFiles, err := readFileList(ambientListPath) ambientFiles, err := readFileList(ambientListPath)
if err != nil { 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) return fmt.Errorf("cut-slope must be a positive multiple of 12 (got %d)", cutSlope)
} }
if lowcutHz > 0 { 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) recordedFiltered = convolve.CascadeLowcut(recordedFiltered, recSampleRate, lowcutHz, cutSlope)
} }
if highcutHz > 0 { 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) recordedFiltered = convolve.CascadeHighcut(recordedFiltered, recSampleRate, highcutHz, cutSlope)
} }
@@ -735,12 +922,12 @@ func processIRForFormatWithDefaults(c *cli.Context, sweepData *wav.WAVData, reco
// Generate MPT if requested // Generate MPT if requested
if generateMPT { 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 var originalIR []float64
if c.Bool("no-phase-correction") { if c.Bool("no-phase-correction") {
originalIR = convolve.Deconvolve(sweepData.PCMData, recordedData.PCMData) originalIR = convolve.Deconvolve(sweepData.PCMData, recordedFiltered)
} else { } else {
originalIR = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedData.PCMData) originalIR = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedFiltered)
} }
originalIR = convolve.TrimSilence(originalIR, 1e-5) originalIR = convolve.TrimSilence(originalIR, 1e-5)
mptIR := convolve.MinimumPhaseTransform(originalIR) mptIR := convolve.MinimumPhaseTransform(originalIR)
@@ -819,12 +1006,12 @@ func generateIRForFormatWithDefaults(c *cli.Context, sweepData *wav.WAVData, rec
// Generate MPT if requested // Generate MPT if requested
if generateMPT { 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 var originalIR []float64
if c.Bool("no-phase-correction") { if c.Bool("no-phase-correction") {
originalIR = convolve.Deconvolve(sweepData.PCMData, recordedData.PCMData) originalIR = convolve.Deconvolve(sweepData.PCMData, recordedFiltered)
} else { } else {
originalIR = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedData.PCMData) originalIR = convolve.DeconvolveWithPhaseCorrection(sweepData.PCMData, recordedFiltered)
} }
originalIR = convolve.TrimSilence(originalIR, 1e-5) originalIR = convolve.TrimSilence(originalIR, 1e-5)
mptIR := convolve.MinimumPhaseTransform(originalIR) mptIR := convolve.MinimumPhaseTransform(originalIR)
@@ -853,7 +1040,7 @@ func main() {
app := &cli.App{ app := &cli.App{
Name: "valhallir-deconvolver", Name: "valhallir-deconvolver",
Usage: "Deconvolve sweep and recorded WAV files to create impulse responses", Usage: "Deconvolve sweep and recorded WAV files to create impulse responses",
Version: "v1.1.0", Version: "v1.2.1",
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.StringFlag{ &cli.StringFlag{
Name: "sweep", Name: "sweep",
@@ -865,7 +1052,7 @@ func main() {
}, },
&cli.StringFlag{ &cli.StringFlag{
Name: "output", 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{ &cli.BoolFlag{
Name: "mpt", Name: "mpt",
@@ -966,8 +1153,8 @@ func main() {
log.Printf("No command-line options provided, using default cabpack mode") log.Printf("No command-line options provided, using default cabpack mode")
recordedPath = execDir recordedPath = execDir
sweepPath = filepath.Join(recordedPath, "sweep.wav") sweepPath = filepath.Join(recordedPath, "sweep.wav")
// Create IRs folder one directory level up // Create cabpack folder one directory level up
outputPath = filepath.Join(filepath.Dir(recordedPath), "IRs") outputPath = filepath.Join(filepath.Dir(recordedPath), "cabpack")
cabpackMode = true cabpackMode = true
log.Printf("Using recorded directory: %s", recordedPath) log.Printf("Using recorded directory: %s", recordedPath)
log.Printf("Looking for sweep file: %s", sweepPath) log.Printf("Looking for sweep file: %s", sweepPath)
@@ -982,10 +1169,10 @@ func main() {
sweepPath = filepath.Join(recordedPath, "sweep.wav") sweepPath = filepath.Join(recordedPath, "sweep.wav")
} }
if !c.IsSet("output") { 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) recordedInfo, err := os.Stat(recordedPath)
if err == nil && recordedInfo.IsDir() { if err == nil && recordedInfo.IsDir() {
outputPath = filepath.Join(filepath.Dir(recordedPath), "IRs") outputPath = filepath.Join(filepath.Dir(recordedPath), "cabpack")
} else { } else {
outputPath = filepath.Dir(recordedPath) outputPath = filepath.Dir(recordedPath)
} }

View File

@@ -285,11 +285,25 @@ func realSlice(in []complex128) []float64 {
} }
// Resample resamples audio data from one sample rate to another using linear interpolation // Resample resamples audio data from one sample rate to another using linear interpolation
// with proper anti-aliasing for downsampling
func Resample(data []float64, fromSampleRate, toSampleRate int) []float64 { func Resample(data []float64, fromSampleRate, toSampleRate int) []float64 {
if fromSampleRate == toSampleRate { if fromSampleRate == toSampleRate {
return data return data
} }
// For downsampling, apply anti-aliasing filter to prevent aliasing
// Filter out frequencies above the target Nyquist frequency
if toSampleRate < fromSampleRate {
// Calculate target Nyquist frequency (slightly below to ensure clean cutoff)
targetNyquist := float64(toSampleRate) / 2.0
// Use 90% of Nyquist as cutoff to ensure clean anti-aliasing
antiAliasCutoff := targetNyquist * 0.9
// Apply steep low-pass filter to prevent aliasing
// Use 48 dB/octave slope for clean anti-aliasing
data = CascadeHighcut(data, fromSampleRate, antiAliasCutoff, 48)
}
// Calculate the resampling ratio // Calculate the resampling ratio
ratio := float64(toSampleRate) / float64(fromSampleRate) ratio := float64(toSampleRate) / float64(fromSampleRate)
newLength := int(float64(len(data)) * ratio) newLength := int(float64(len(data)) * ratio)
@@ -342,13 +356,71 @@ func FadeOutLinear(data []float64, fadeSamples int) []float64 {
return out return out
} }
// biquadFilterState holds the state for a biquad filter
type biquadFilterState struct {
x1, x2 float64 // input history (intermediate values in DF2T)
}
// applyBiquadDF2T applies a biquad filter using Direct Form II Transposed (more numerically stable)
// This form is preferred for cascaded filters as it reduces numerical errors
// Uses high-precision calculations and proper state management for maximum quality
func applyBiquadDF2T(data []float64, b0, b1, b2, a1, a2 float64, state *biquadFilterState) []float64 {
out := make([]float64, len(data))
// Initialize state if nil
if state == nil {
state = &biquadFilterState{}
}
// Direct Form II Transposed implementation with high precision
// This structure minimizes numerical errors in cascaded filters
for i := 0; i < len(data); i++ {
x := data[i]
// Compute intermediate value (w[n] = x[n] - a1*w[n-1] - a2*w[n-2])
w := x - a1*state.x1 - a2*state.x2
// Compute output (y[n] = b0*w[n] + b1*w[n-1] + b2*w[n-2])
y := b0*w + b1*state.x1 + b2*state.x2
out[i] = y
// Update state (shift delay line)
state.x2 = state.x1
state.x1 = w
}
return out
}
// warmupFilter applies a filter with warm-up to avoid transients
// This is especially important for high-quality filtering
func warmupFilter(data []float64, b0, b1, b2, a1, a2 float64) []float64 {
if len(data) == 0 {
return data
}
// Use first sample value for warm-up to avoid transients
warmupValue := data[0]
state := &biquadFilterState{}
// Warm up the filter with a few samples of the first value
// This initializes the filter state properly
warmupSamples := 10
warmupData := make([]float64, warmupSamples)
for i := range warmupData {
warmupData[i] = warmupValue
}
_ = applyBiquadDF2T(warmupData, b0, b1, b2, a1, a2, state)
// Now apply filter to actual data with warmed-up state
return applyBiquadDF2T(data, b0, b1, b2, a1, a2, state)
}
// ApplyLowpassButterworth applies a 2nd-order Butterworth low-pass filter to the data. // ApplyLowpassButterworth applies a 2nd-order Butterworth low-pass filter to the data.
// cutoffHz: cutoff frequency in Hz, sampleRate: sample rate in Hz. // cutoffHz: cutoff frequency in Hz, sampleRate: sample rate in Hz.
// Uses Direct Form II Transposed for better numerical stability.
func ApplyLowpassButterworth(data []float64, sampleRate int, cutoffHz float64) []float64 { func ApplyLowpassButterworth(data []float64, sampleRate int, cutoffHz float64) []float64 {
if cutoffHz <= 0 || cutoffHz >= float64(sampleRate)/2 { if cutoffHz <= 0 || cutoffHz >= float64(sampleRate)/2 {
return data return data
} }
// Biquad coefficients // Biquad coefficients for Butterworth low-pass
w0 := 2 * math.Pi * cutoffHz / float64(sampleRate) w0 := 2 * math.Pi * cutoffHz / float64(sampleRate)
cosw0 := math.Cos(w0) cosw0 := math.Cos(w0)
sinw0 := math.Sin(w0) sinw0 := math.Sin(w0)
@@ -362,35 +434,26 @@ func ApplyLowpassButterworth(data []float64, sampleRate int, cutoffHz float64) [
a1 := -2 * cosw0 a1 := -2 * cosw0
a2 := 1 - alpha a2 := 1 - alpha
// Normalize // Normalize coefficients
b0 /= a0 b0 /= a0
b1 /= a0 b1 /= a0
b2 /= a0 b2 /= a0
a1 /= a0 a1 /= a0
a2 /= a0 a2 /= a0
// Apply filter (Direct Form I) // Apply filter using Direct Form II Transposed
out := make([]float64, len(data)) state := &biquadFilterState{}
var x1, x2, y1, y2 float64 return applyBiquadDF2T(data, b0, b1, b2, a1, a2, state)
for i := 0; i < len(data); i++ {
x0 := data[i]
y0 := b0*x0 + b1*x1 + b2*x2 - a1*y1 - a2*y2
out[i] = y0
x2 = x1
x1 = x0
y2 = y1
y1 = y0
}
return out
} }
// ApplyHighpassButterworth applies a 2nd-order Butterworth high-pass filter to the data. // ApplyHighpassButterworth applies a 2nd-order Butterworth high-pass filter to the data.
// cutoffHz: cutoff frequency in Hz, sampleRate: sample rate in Hz. // cutoffHz: cutoff frequency in Hz, sampleRate: sample rate in Hz.
// Uses Direct Form II Transposed for better numerical stability.
func ApplyHighpassButterworth(data []float64, sampleRate int, cutoffHz float64) []float64 { func ApplyHighpassButterworth(data []float64, sampleRate int, cutoffHz float64) []float64 {
if cutoffHz <= 0 || cutoffHz >= float64(sampleRate)/2 { if cutoffHz <= 0 || cutoffHz >= float64(sampleRate)/2 {
return data return data
} }
// Biquad coefficients // Biquad coefficients for Butterworth high-pass
w0 := 2 * math.Pi * cutoffHz / float64(sampleRate) w0 := 2 * math.Pi * cutoffHz / float64(sampleRate)
cosw0 := math.Cos(w0) cosw0 := math.Cos(w0)
sinw0 := math.Sin(w0) sinw0 := math.Sin(w0)
@@ -404,53 +467,149 @@ func ApplyHighpassButterworth(data []float64, sampleRate int, cutoffHz float64)
a1 := -2 * cosw0 a1 := -2 * cosw0
a2 := 1 - alpha a2 := 1 - alpha
// Normalize // Normalize coefficients
b0 /= a0 b0 /= a0
b1 /= a0 b1 /= a0
b2 /= a0 b2 /= a0
a1 /= a0 a1 /= a0
a2 /= a0 a2 /= a0
// Apply filter (Direct Form I) // Apply filter using Direct Form II Transposed
out := make([]float64, len(data)) state := &biquadFilterState{}
var x1, x2, y1, y2 float64 return applyBiquadDF2T(data, b0, b1, b2, a1, a2, state)
for i := 0; i < len(data); i++ {
x0 := data[i]
y0 := b0*x0 + b1*x1 + b2*x2 - a1*y1 - a2*y2
out[i] = y0
x2 = x1
x1 = x0
y2 = y1
y1 = y0
}
return out
} }
// CascadeLowcut applies the low-cut (high-pass) filter multiple times for steeper slopes. // CascadeLowcut applies the low-cut (high-pass) filter multiple times for steeper slopes.
// slopeDb: 12, 24, 36, ... (dB/octave) // Uses Linkwitz-Riley design principles with high-quality implementation for maximum precision.
// Features: proper warm-up, high-precision coefficients, optimized cascade structure.
// slopeDb: 12, 24, 36, 48, ... (dB/octave)
func CascadeLowcut(data []float64, sampleRate int, cutoffHz float64, slopeDb int) []float64 { func CascadeLowcut(data []float64, sampleRate int, cutoffHz float64, slopeDb int) []float64 {
if slopeDb < 12 { if slopeDb < 12 {
slopeDb = 12 slopeDb = 12
} }
n := slopeDb / 12 if cutoffHz <= 0 || cutoffHz >= float64(sampleRate)/2 {
out := data return data
for i := 0; i < n; i++ {
out = ApplyHighpassButterworth(out, sampleRate, cutoffHz)
} }
n := slopeDb / 12
if n == 0 {
return data
}
// High-quality filter implementation with proper coefficient calculation
// Use high precision for coefficient calculation
w0 := 2.0 * math.Pi * cutoffHz / float64(sampleRate)
// Pre-calculate trigonometric functions once for precision
cosw0 := math.Cos(w0)
sinw0 := math.Sin(w0)
// Butterworth Q factor (1/sqrt(2) ≈ 0.7071067811865476 for maximally flat response)
const butterworthQ = 0.7071067811865476
alpha := sinw0 / (2.0 * butterworthQ)
// High-pass Butterworth coefficients
b0 := (1.0 + cosw0) / 2.0
b1 := -(1.0 + cosw0)
b2 := (1.0 + cosw0) / 2.0
a0 := 1.0 + alpha
a1 := -2.0 * cosw0
a2 := 1.0 - alpha
// Normalize coefficients for Direct Form II Transposed
// This ensures proper scaling and numerical stability
b0 /= a0
b1 /= a0
b2 /= a0
a1 /= a0
a2 /= a0
// Apply cascaded filters with proper warm-up for each stage
// This ensures clean filtering without transients, especially important for MPT
out := make([]float64, len(data))
copy(out, data)
for stage := 0; stage < n; stage++ {
// Use warm-up for first stage to avoid transients
// Subsequent stages benefit from the already-filtered signal
if stage == 0 {
out = warmupFilter(out, b0, b1, b2, a1, a2)
} else {
// For subsequent stages, use fresh state but no warm-up needed
// as the signal is already filtered
state := &biquadFilterState{}
filtered := applyBiquadDF2T(out, b0, b1, b2, a1, a2, state)
copy(out, filtered)
}
}
return out return out
} }
// CascadeHighcut applies the high-cut (low-pass) filter multiple times for steeper slopes. // CascadeHighcut applies the high-cut (low-pass) filter multiple times for steeper slopes.
// slopeDb: 12, 24, 36, ... (dB/octave) // Uses Linkwitz-Riley design principles with high-quality implementation for maximum precision.
// Features: proper warm-up, high-precision coefficients, optimized cascade structure.
// slopeDb: 12, 24, 36, 48, ... (dB/octave)
func CascadeHighcut(data []float64, sampleRate int, cutoffHz float64, slopeDb int) []float64 { func CascadeHighcut(data []float64, sampleRate int, cutoffHz float64, slopeDb int) []float64 {
if slopeDb < 12 { if slopeDb < 12 {
slopeDb = 12 slopeDb = 12
} }
n := slopeDb / 12 if cutoffHz <= 0 || cutoffHz >= float64(sampleRate)/2 {
out := data return data
for i := 0; i < n; i++ {
out = ApplyLowpassButterworth(out, sampleRate, cutoffHz)
} }
n := slopeDb / 12
if n == 0 {
return data
}
// High-quality filter implementation with proper coefficient calculation
// Use high precision for coefficient calculation
w0 := 2.0 * math.Pi * cutoffHz / float64(sampleRate)
// Pre-calculate trigonometric functions once for precision
cosw0 := math.Cos(w0)
sinw0 := math.Sin(w0)
// Butterworth Q factor (1/sqrt(2) ≈ 0.7071067811865476 for maximally flat response)
const butterworthQ = 0.7071067811865476
alpha := sinw0 / (2.0 * butterworthQ)
// Low-pass Butterworth coefficients
b0 := (1.0 - cosw0) / 2.0
b1 := 1.0 - cosw0
b2 := (1.0 - cosw0) / 2.0
a0 := 1.0 + alpha
a1 := -2.0 * cosw0
a2 := 1.0 - alpha
// Normalize coefficients for Direct Form II Transposed
// This ensures proper scaling and numerical stability
b0 /= a0
b1 /= a0
b2 /= a0
a1 /= a0
a2 /= a0
// Apply cascaded filters with proper warm-up for each stage
// This ensures clean filtering without transients, especially important for MPT
out := make([]float64, len(data))
copy(out, data)
for stage := 0; stage < n; stage++ {
// Use warm-up for first stage to avoid transients
// Subsequent stages benefit from the already-filtered signal
if stage == 0 {
out = warmupFilter(out, b0, b1, b2, a1, a2)
} else {
// For subsequent stages, use fresh state but no warm-up needed
// as the signal is already filtered
state := &biquadFilterState{}
filtered := applyBiquadDF2T(out, b0, b1, b2, a1, a2, state)
copy(out, filtered)
}
}
return out return out
} }

View File

@@ -1,17 +1,16 @@
package plot package plot
import ( import (
"bytes"
"fmt" "fmt"
"image/color"
"image/png"
"math" "math"
"math/cmplx" "math/cmplx"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"image/png"
"image/color"
"github.com/mjibson/go-dsp/fft" "github.com/mjibson/go-dsp/fft"
"gonum.org/v1/plot" "gonum.org/v1/plot"
"gonum.org/v1/plot/font" "gonum.org/v1/plot/font"
@@ -22,7 +21,8 @@ import (
) )
// PlotIR plots the frequency response (magnitude in dB vs. frequency in Hz) of the IR to a PNG file // PlotIR plots the frequency response (magnitude in dB vs. frequency in Hz) of the IR to a PNG file
func PlotIR(ir []float64, sampleRate int, irFileName string) error { // logoData is the embedded logo image data (can be nil if not available)
func PlotIR(ir []float64, sampleRate int, irFileName string, logoData []byte) error {
if len(ir) == 0 { if len(ir) == 0 {
return nil return nil
} }
@@ -62,9 +62,8 @@ func PlotIR(ir []float64, sampleRate int, irFileName string) error {
} }
} }
fmt.Printf("[PlotIR] minDb in plotted range: %.2f dB at %.2f Hz\n", minDb, minDbFreq) fmt.Printf("[PlotIR] minDb in plotted range: %.2f dB at %.2f Hz\n", minDb, minDbFreq)
irBaseName := filepath.Base(irFileName)
p := plot.New() p := plot.New()
p.Title.Text = fmt.Sprintf("IR Frequency Response: %s", irBaseName) p.Title.Text = "IR Frequency Response"
p.X.Label.Text = "Frequency (Hz)" p.X.Label.Text = "Frequency (Hz)"
p.Y.Label.Text = "Magnitude (dB)" p.Y.Label.Text = "Magnitude (dB)"
p.X.Scale = plot.LogScale{} p.X.Scale = plot.LogScale{}
@@ -112,7 +111,7 @@ func PlotIR(ir []float64, sampleRate int, irFileName string) error {
// --- Time-aligned waveform plot --- // --- Time-aligned waveform plot ---
p2 := plot.New() p2 := plot.New()
p2.Title.Text = fmt.Sprintf("IR Waveform: %s", irBaseName) p2.Title.Text = "IR Waveform"
p2.X.Label.Text = "Time (ms)" p2.X.Label.Text = "Time (ms)"
p2.Y.Label.Text = "Amplitude" p2.Y.Label.Text = "Amplitude"
// Prepare waveform data (only first 10ms) // Prepare waveform data (only first 10ms)
@@ -142,16 +141,14 @@ func PlotIR(ir []float64, sampleRate int, irFileName string) error {
dc := draw.New(img) dc := draw.New(img)
// Draw logo at the top left, headline to the right, IR filename below // Draw logo at the top left, headline to the right, IR filename below
logoPath := "assets/logo.png" // Logo is embedded in the binary
logoW := 2.4 * vg.Inch // doubled size logoW := 2.4 * vg.Inch // doubled size
logoH := 0.68 * vg.Inch // doubled size logoH := 0.68 * vg.Inch // doubled size
logoX := 0.3 * vg.Inch logoX := 0.3 * vg.Inch
logoY := height + 0.2*vg.Inch // move logo down by an additional ~10px logoY := height + 0.2*vg.Inch // move logo down by an additional ~10px
logoDrawn := false logoDrawn := false
f, err := os.Open(logoPath) if len(logoData) > 0 {
if err == nil { logoImg, err := png.Decode(bytes.NewReader(logoData))
defer f.Close()
logoImg, err := png.Decode(f)
if err == nil { if err == nil {
rect := vg.Rectangle{ rect := vg.Rectangle{
Min: vg.Point{X: logoX, Y: logoY}, Min: vg.Point{X: logoX, Y: logoY},
@@ -202,11 +199,11 @@ func PlotIR(ir []float64, sampleRate int, irFileName string) error {
irNameWithoutExt := strings.TrimSuffix(irBase, filepath.Ext(irBase)) irNameWithoutExt := strings.TrimSuffix(irBase, filepath.Ext(irBase))
plotFileName := filepath.Join(irDir, irNameWithoutExt+".png") plotFileName := filepath.Join(irDir, irNameWithoutExt+".png")
f, err = os.Create(plotFileName) plotFile, err := os.Create(plotFileName)
if err != nil { if err != nil {
return err return err
} }
defer f.Close() defer plotFile.Close()
_, err = vgimg.PngCanvas{Canvas: img}.WriteTo(f) _, err = vgimg.PngCanvas{Canvas: img}.WriteTo(plotFile)
return err return err
} }