A modern, dark-themed desktop tool for downmixing multichannel WAV files (RME Durec format) to stereo — with per-track volume, pan, live waveform preview, and in-app audio playback.
- Load one or multiple multichannel WAV files and batch-process them
- Automatically reads iXML metadata embedded in the WAV file to identify track names and channel order
- Per-track controls: index, mixdown toggle, volume slider (-60 dB – +6 dB), pan slider (L – R)
- Double-click volume slider to reset to 0 dB (unity); double-click pan to reset to centre
- Volume at or below -60 dB is treated as silence (no noise floor amplification)
- Per-track playback — click ▶ on any row to audition that channel in isolation, peak-normalised to -1 dBFS; click again to stop
- Listen Mix ▶ / ■ Stop — preview the full stereo mix with the current loudness-normalisation setting applied, without rendering a file; click again to stop
- Waveform preview — renders a mini amplitude plot for every channel; y-axis is fixed (−1 to +1) and amplitude is scaled by the current volume fader so tracks are visually comparable
- Loudness normalisation: none / -1 dBFS peak (default) / -12 dB LUFS
- Automatic BPM detection (librosa) — embedded in the output filename
- Phase correction — detects and fixes phase inversion during the export pipeline
- Output format: MP3 or WAV
- Batch export — processes every loaded file with a progress dialog
- Persists channel settings in MixConf.json and restores them on next load
- Compatible with RME Durec multichannel recorder format
- Python ≥ 3.13
- uv package manager
# 1. Clone the repo
git clone https://github.com/MacBuchi/MultiChannelWavMixer.git
cd MultiChannelWavMixer
# 2. Create the virtual environment and install all dependencies
uv syncThat's it — uv sync reads pyproject.toml, creates .venv, and installs every dependency (including audioop-lts for Python 3.13 compatibility).
uv run MultiChannelWavMixer.pyOr activate the venv and run directly:
source .venv/bin/activate
python MultiChannelWavMixer.pyNever run with the system Python (
/usr/bin/python3or/opt/homebrew/bin/python3) — dependencies are only installed inside.venv.
| Control | Description |
|---|---|
| Load WAV | Open one or more multichannel WAV files; iXML metadata is parsed automatically |
| Waveforms | Render a mini waveform thumbnail for every track |
| Output Folder | Choose the export destination (auto-set to source folder on load) |
| Loudness | Select the normalisation target: none, -1dBFS, or -12dB LUFS |
| Format | Toggle between MP3 and WAV export |
| Listen Mix ▶ | Preview the current mix in real time (uses the selected loudness setting) |
| Mix to Stereo ▶ | Export all loaded files to disk |
| Column | Description |
|---|---|
| # | Channel index (1-based, editable) |
| Mix | Checkbox — include this track in the mixdown |
| Track Name | Name read from iXML |
| Volume | -60 dB – +6 dB gain slider; double-click to reset to 0 dB; live dB readout |
| Pan | L (0) – R (1) slider; live numeric readout |
| ▶ | Play this channel in isolation (peak-normalised to -1 dBFS); click again to stop. Starting playback on another track or Listen Mix stops this one automatically. |
| Waveform | Mini amplitude plot (appears after clicking Waveforms) |
Displays the currently selected output folder.
# Run the full test suite
uv run pytest -v
# With coverage report
uv run pytest --cov=mixer_utils --cov-report=term-missingAll 71 tests are GUI-free and live in tests/test_mixer_utils.py.
MultiChannelWavMixer/
├── MultiChannelWavMixer.py # GUI application (customtkinter)
├── mixer_utils.py # Pure audio logic — no GUI dependency
├── MixConf.json # Persisted channel configuration
├── pyproject.toml # UV project & dependency declaration
├── .python-version # Pins Python 3.13
├── requirements.txt # Legacy reference (use uv sync instead)
├── tests/
│ └── test_mixer_utils.py # Unit tests for mixer_utils
└── doc/
└── Preview.png
| Function | Description |
|---|---|
clean_xml(data) |
Strip junk before <?xml and remove non-printable chars |
parse_tracks_from_ixml(ixml_str) |
Parse iXML string → list of plain-dict track descriptors |
load_raw_config(path) |
Load MixConf.json as plain Python dicts |
save_raw_config(config, path) |
Persist channel config to JSON |
build_stereo_mix(data, tracks) |
Downmix multichannel numpy array to stereo |
process_audio(wav_in, ...) |
Phase check, normalise, strip silence, apply fades |
extract_bpm(y, sr) |
Estimate tempo via librosa |
db_to_linear(db, floor_db) |
Convert dB value to linear gain; returns 0.0 at or below floor |
build_track_preview(data, ch) |
Extract one channel as peak-normalised stereo float32 |
build_mix_preview(data, tracks, sr, mode) |
Build normalised stereo preview mix |
play_audio(data, sr, on_finished) |
Non-blocking playback via sd.OutputStream; only one stream open at a time; stop is signalled via callback event (no Pa_StopStream, no AUHAL -50 on macOS) |
stop_playback() |
Signal the active stream's callback to stop; thread-safe no-op when idle |
graph TD
A[MultiChannelWavMixer.py\nGUI layer] -->|imports| B[mixer_utils.py\nPure logic]
A --> C[MixConf.json\nChannel config]
subgraph GUI
A1[Toolbar] --> A2[Load WAV]
A1 --> A3[Listen Mix]
A1 --> A4[Mix to Stereo]
A5[Track rows] --> A6[Per-track ▶ button]
A5 --> A7[Volume / Pan sliders]
A5 --> A8[Waveform preview]
end
subgraph mixer_utils
B1[iXML parsing] --> B2[clean_xml]
B1 --> B3[parse_tracks_from_ixml]
B4[Config I/O] --> B5[load_raw_config]
B4 --> B6[save_raw_config]
B7[Audio engine] --> B8[build_stereo_mix]
B7 --> B9[process_audio]
B7 --> B10[extract_bpm]
B11[Playback] --> B12[build_track_preview]
B11 --> B13[build_mix_preview]
B11 --> B14[play_audio / stop_playback]
end
subgraph tests
T[test_mixer_utils.py] -->|tests| B
end
| Date | Change |
|---|---|
| 2026-02-20 | Volume fader changed to dB scale (-60 – +6 dB); -60 dB treated as silence; double-click resets to 0 dB |
| 2026-02-20 | Waveform y-axis fixed (-1 to +1); amplitude scaled by volume fader for visual comparability |
| 2026-02-20 | Playback rewritten with sd.OutputStream callback + per-stream stop Event; Pa_StopStream never called → AUHAL error -50 eliminated on macOS |
| 2026-02-20 | _active_stream module-level ref prevents GC-induced segfault when interacting with GUI during playback |
| 2026-02-20 | Per-stream on_finished closures capture their own button ref; finishing old stream no longer resets the new stream's ■ button |
| 2026-02-20 | Generation counter + launch thread: starting a new track while one is playing is deadlock-free |
| 2026-02-19 | Migrate to uv + pyproject.toml; upgrade to Python 3.13; add audioop-lts |
| 2026-02-19 | Full UI rewrite with customtkinter (dark mode, sliders with live readout, segmented format button, status bar) |
| 2026-02-19 | Extract pure logic into mixer_utils.py; add 71 unit tests |
| 2026-02-19 | Per-track ▶ playback buttons; Listen Mix ▶ toolbar button for live mix preview |
| 2025-02-13 | Add librosa BPM detection; close waveform figures after creation |
| 2025-02-09 | Add loudness normalisation: -1 dBFS peak, -12 dB LUFS, none |
| 2025-02-05 | Double-click sliders to reset; waveform amplitude preview |