Building a 5.1ch Surround Upmixer GUI with Python & FFmpeg
Ever wanted to watch a movie or listen to live music with true surround sound, but only had a stereo MP3 file? I built a desktop GUI application that upmixes 2-channel stereo audio into 6-channel AC-3 (Dolby Digital 5.1) — fully customizable with slider controls and one-click presets.
🔗 GitHub: https://github.com/amekusa03/mp3-to-ac3-converter
What it does
-
Fully adjustable surround parameters via sliders:
- Center Mix Level (0× – 2×) — controls vocal/dialogue presence
- Rear/Surround Level (0× – 2×) — controls ambient depth
- Surround Delay (0–100 ms) — enhances spatial feel
- LFE Subwoofer Cutoff (50–200 Hz) & Gain — deep bass extraction
- 3 one-click presets: 🎥 Cinema, 🎵 Music/Live, 🗣️ Voice/Dialogue
- Batch conversion — drop a whole folder of files
- Dark / Light theme toggle
- Live FFmpeg log monitor with progress bar
Built with Python, CustomTkinter (modern Tk GUI), and FFmpeg under the hood.
How the upmix works
The core idea is converting stereo (L, R) into 5.1 (FL, FR, FC, LFE, SL, SR) using FFmpeg’s filter_complex. Here’s the math:
(L + R) × 0.707 × center_gain
Vocals, dialogue
LFE (Sub)
lowpass((L + R) × 0.5, cutoff_hz)
Deep bass
SL (Surround L)
(L − R) × 0.707 × rear_gain + delay
Ambient / effects
SR (Surround R)
(R − L) × 0.707 × rear_gain + delay
Ambient / effects
The phase difference (L − R) is what makes surround sound “come from behind” — it extracts stereo-only information that would be mixed out in mono, and sends it to the rear speakers.
Here’s what the generated FFmpeg filter looks like:
[0:a]asplit=5[in_flfr][in_c][in_lfe][in_sl][in_sr];
[in_flfr]pan=stereo|c0=c0|c1=c1[flfr];
[in_c]pan=1c|c0=0.707*c0+0.707*c1,volume=1.000[c];
[in_lfe]pan=1c|c0=0.5*c0+0.5*c1,lowpass=f=120,volume=1.000[lfe];
[in_sl]pan=1c|c0=0.707*c0-0.707*c1,adelay=20|20,volume=0.700[sl];
[in_sr]pan=1c|c0=-0.707*c0+0.707*c1,adelay=20|20,volume=0.700[sr];
[flfr][c][lfe][sl][sr]amerge=inputs=5,pan=5.1|FL=c0|FR=c1|FC=c2|LFE=c3|BL=c4|BR=c5[out]
Enter fullscreen mode Exit fullscreen mode
Code highlights
Thread-safe UI updates with a queue
Tkinter is single-threaded. Calling UI methods from a background FFmpeg worker thread will crash the app. The solution is a queue.Queue polled by after() on the main thread:
def _process_queue(self):
"""Called every 50ms on main thread — safely drains worker messages."""
try:
while True:
msg_type, data = self.msg_queue.get_nowait()
if msg_type == "log":
self.append_log(data)
elif msg_type == "progress":
self.progress_bar.set(data)
elif msg_type == "batch_complete":
# ... handle completion
except queue.Empty:
pass
finally:
self.after(50, self._process_queue) # reschedule
Enter fullscreen mode Exit fullscreen mode
The background worker puts messages into the queue; the main thread processes them. No locks, no crashes.
Fixing Python’s late-binding closure trap
A subtle bug lurks when defining closures inside a for loop — the variable idx gets bound by reference, so all closures end up using the last loop value:
# ❌ Bug: idx is always the final loop value
def _on_progress(p):
overall_p = ((idx - 1) + p) / total_files
# ✅ Fix: capture current value via default argument
def _on_progress(p, _idx=idx, _total=total_files):
overall_p = ((_idx - 1) + p) / _total
Enter fullscreen mode Exit fullscreen mode
Default argument values are evaluated at definition time, so each closure gets the correct snapshot of idx.
Clean preset system with dataclasses
@dataclass
class AudioPreset:
name: str
description: str
icon: str
center_gain: float # 0.0 to 2.0
rear_gain: float
rear_delay: int # ms
lfe_cutoff: int # Hz
lfe_gain: float
bitrate: str # '448k', '640k', etc.
sample_rate: int # 48000 / 44100
Enter fullscreen mode Exit fullscreen mode
Presets are just dictionaries of AudioPreset objects — trivial to add new ones, and the UI automatically reflects the values when clicked.
Quick start
# Requirements: Python 3.8+, FFmpeg in PATH
git clone https://github.com/[YourUsername]/mp3-to-ac3-converter
cd mp3-to-ac3-converter
./run.sh # auto-creates venv, installs deps, launches app
Enter fullscreen mode Exit fullscreen mode
Works on Linux and macOS. Windows users can run venvScriptsactivate + python main.py manually.
What I learned
-
FFmpeg’s
filter_complexis incredibly powerful — you can split, transform, recombine audio streams in one pipeline with no intermediate files. - CustomTkinter makes modern-looking Tk GUIs much easier — dark mode, rounded corners, and consistent theming out of the box.
-
The
queue.Queue+after()pattern is the right way to drive Tkinter updates from worker threads — cleaner thanafterlambdas scattered everywhere. - Python closure late binding is a real gotcha — always use default argument capture when building callbacks inside loops.
Feel free to open issues, submit PRs, or fork it for your own surround mix experiments. Contributions welcome!
🔗 GitHub: https://github.com/amekusa03/mp3-to-ac3-converter
📘 日本語版記事 (Qiita): https://qiita.com/amekusa03/items/c425d0870f462bfb7645