DEV Community

Cover image for I built a standalone ambient music generator with Python and Electron, here's what went wrong (and right)
Will Garrido
Will Garrido

Posted on

I built a standalone ambient music generator with Python and Electron, here's what went wrong (and right)

Last summer I started building Reverie, a desktop app that turns any audio file into long, evolving ambient soundscapes. Think: drop in a 30-second piano loop, get back 30 minutes of slowly shifting texture. I've been working on it solo for about 8 months and figured some of the technical bits might be interesting to other devs.

Why

I make ambient music as a hobby. The existing options are either "learn Ableton + 15 plugins" or basic looping apps. I just wanted something simple: file in, soundscape out. So I built it.

Stack: Python for all audio DSP, Electron + React + Vite for the UI, and a stdin/stdout IPC bridge gluing them together.

The audio processing

The app has 38 audio effect modules that get chained together. Each style (dark, luminous, cosmic, aquatic...) picks a chain of modules and randomizes their parameters.

The big one is Paulstretch, an algorithm by Nasca Octavian Paul that stretches audio up to 100x without the chipmunk-or-slomo artifacts you'd normally get. It works by taking windowed FFTs, randomizing the phase of each frequency bin while keeping the magnitudes, and overlap-adding the result. A 30-second clip becomes 30 minutes of slowly evolving texture. It's kind of magic.

Other fun ones: spectral blur smooths across frequency bins in the FFT domain (like reverb but on the spectrum itself), shimmer reverb pitch-shifts each reflection up an octave so you get this cathedral wash effect, and I have a stochastic synthesis module that uses Markov chains to make effects evolve over time instead of staying static.

Each module gets its own isolated RNG seeded from the main seed + module name + chain position. Same seed + same file = exact same output, always. This took a while to get right but users love sharing seeds.

for module_name in chain:
    rng = create_module_rng(seed, module_name, position)
    audio, params = module.process(audio, sr, params=params, rng=rng)
Enter fullscreen mode Exit fullscreen mode

Where things got painful

Memory

Oh god, memory. A 120-minute stereo file at 48kHz is 345 million samples × 2 channels × 8 bytes = 5.5 GB of float64. I was originally targeting 2-hour outputs but kept getting killed by the OOM killer. I switched to block-based processing and capped output at 30 minutes, which honestly turned out to be the right creative choice anyway, most ambient listeners loop or move on after that. The annoying part is maintaining filter state between blocks, you need sosfilt with the zi parameter for continuity, and not all scipy operations support that cleanly.

The Python/Electron bridge

The architecture is: Electron spawns a Python process, sends JSON commands over stdin, reads JSON responses from stdout. Sounds simple in theory. In practice I had to build a whole bridge layer with request IDs, timeouts, buffering partial JSON lines, handling stderr separately, auto-restart on crash, and a warmup handshake so the renderer knows when Python is ready.

The timeout system alone went through multiple iterations. Audio processing can take minutes per step, so a fixed timeout would kill long generations. I ended up resetting the timeout on every progress message from Python. The bridge tracks which request triggered the last activity so it only resets for the active request.

When the user quits mid-generation, you need a graceful shutdown sequence. Send a cancel signal, wait up to 3 seconds, then force kill. If you don't handle this, the Python process becomes an orphan eating CPU in the background.

And EPIPE errors. If you restart the app too fast after killing it, the previous Python process hasn't fully terminated yet. The new one can't bind properly. I had to add a mandatory 5 second wait after killing processes before restarting in dev mode.

Audio playback in Electron

You can't just give an HTML audio element a local file path. Electron needs a custom protocol. I registered audio:// as a privileged scheme with streaming support, then built a handler that reads files from disk and serves them as responses.

On Windows, file paths broke everything. C:\Users\... gets interpreted as a URL where C: is the hostname. I had to switch to query parameters: audio://file?path=C:/Users/... instead of putting the path in the URL directly. That one bug took an embarrassing amount of time to figure out.

I also had to convert the protocol handler to async because synchronous file reads were blocking the main process during playback.

Paulstretch duration

You'd think "stretch by 8x" means the output is 8x longer. Nope. Windowing and overlap mess with the math. I had to add a post-stretch correction pass that measures the actual output length and trims or pads to hit the target. Not elegant, but it works.

macOS code signing and notarization

Don't get me started. Apple requires notarization now, you submit your app to Apple's servers, they scan it, and give you a ticket. Without it, Gatekeeper blocks the app on launch. Fine, I paid the $99/year Developer account and set up the signing pipeline.

But then Apple rejected the notarization because of unsigned .dylib and .so files bundled inside the Python environment. Turns out every native library (numpy's .so files, scipy's compiled extensions, etc.) needs to be individually codesigned with codesign --force --sign before you package the app. I had to write a script that crawls through the entire venv, finds every .dylib and .so, signs them one by one, THEN package, THEN notarize.

My app also bundles rubberband, a C++ library for time stretching. Same problem, had to codesign it separately before packaging.

Then my GitHub Actions CI kept failing. My Apple signing identity contains parentheses in the name. Bash was interpreting them as syntax. I tried single quotes, double quotes, escaping, passing as argument, nothing worked. The fix was defining it as an environment variable in the YAML env block so bash never touches it. Four commits just for that.

Windows is a second full-time job

Install location: the default was AppData, but my Python engine needed to write config files and hit PermissionError in certain setups. Moved to Program Files, which introduced a different set of permission issues.

Copy and paste: stopped working entirely in the packaged app. In Electron, if you don't explicitly create an Edit menu with cut/copy/paste accelerators, those shortcuts just don't work. There's no built-in fallback. I didn't notice until a user reported it because it works fine in dev mode.

Title bar: on Windows the app name shows in the native title bar, so I had to hide the custom title I was rendering in the app to avoid showing it twice. Small thing but it looks broken if you don't catch it.

Upgrading Electron

I went v28 to v33 to v39. Each upgrade changed something in the build pipeline. v33 required different electron-builder configs. v39 changed some defaults. The package-lock diffs were thousands of lines each time. But you have to do it. Security patches, macOS compatibility, and the renderer sandbox getting stricter with every version.

GPU and performance

My UI had CSS animations and framer-motion transitions. Looked great in dev. On some machines, the app was eating GPU for breakfast just to render a blurred background. I ended up removing framer-motion entirely and replacing CSS animations with static styles. I also force the integrated GPU on Intel Macs with a command line switch to avoid draining the battery.

Temp file cleanup

Every generation creates temp WAV files. If the user generates 20 times and doesn't save, that's gigabytes of audio sitting in the temp directory. I had to build a session manager that tracks temp files and cleans them up. Plus a startup cleanup that removes leftover sessions from crashes or force-quits.

Random things I learned the hard way

scipy.signal.fftconvolve is O(n log n), signal.convolve is O(n²). On a 2-hour file that's the difference between "done" and "heat death of the universe."

sosfilt (forward-only) does half the work of filtfilt (forward + backward). For ambient music where phase precision doesn't matter? Good enough. Saved me a ton of processing time.

If you're doing anything with long audio, design for block-based processing from day one. I didn't, and retrofitting it was painful.

macOS wants your app icon content at about 80% of the canvas with transparent padding so it looks right in the Dock. I went through three iterations of sips commands before it matched the other icons. Then you need .icns for Mac and .ico for Windows from the same source.

What actually worked well

Vite + electron-vite for the dev experience. Hot reload on the renderer, fast builds.

PyInstaller in onedir mode for bundling Python. Slower startup than onefile but no extraction step.

The custom audio:// protocol. Once it worked, it worked reliably.

Preload scripts for security. Keeps the renderer sandboxed while exposing a clean API through contextBridge.

The app

Reverie is available for Mac and Windows. There's a free version if you want to mess around with it: https://reverie.parallel-minds.studio

Top comments (0)