DEV Community

amekusa03
amekusa03

Posted on

Building a 5.1ch Surround Upmixer GUI with Python & FFmpeg

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:

Channel Formula Purpose
FL / FR L, R directly Front left/right
FC (Center) (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 venv\Scripts\activate + python main.py manually.


What I learned

  1. FFmpeg's filter_complex is incredibly powerful β€” you can split, transform, recombine audio streams in one pipeline with no intermediate files.
  2. CustomTkinter makes modern-looking Tk GUIs much easier β€” dark mode, rounded corners, and consistent theming out of the box.
  3. The queue.Queue + after() pattern is the right way to drive Tkinter updates from worker threads β€” cleaner than after lambdas scattered everywhere.
  4. 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

Top comments (0)