Repository navigation
Description
The gap
docs/architecture/studio.md lists this under Known gaps:
A podcast is WAV. The design encodes MP3 with a bundled ffmpeg, which is not built.
Why it matters
A generated episode is a single WAV joined from audio.cpp's output for each turn, so a twenty-minute podcast is a few hundred megabytes sitting in the user's data directory. Everything downstream pays for that: the artifact file, the download button, and anything the user does with the file afterwards. MP3 at the same length is an order of magnitude smaller, and it is what someone expects when they download a podcast rather than an audio asset.
Nothing else in the app is blocked by this, which is why it has sat open — but it is the difference between a Studio podcast being shareable and being a file people re-encode by hand.
Where
surfsense_local/backend/modules/llm/providers/audiocpp/speech.py—synthesize()ends atSynthesizedAudio(joined_wav(voiced), "audio/wav"). This is where the joined audio exists as bytes and where an encode would sit.surfsense_local/backend/worker/studio/media/audio/podcast/pipeline.py—_EXTENSIONSalready mapsaudio/mpegtomp3, so the pipeline needs no change if the provider returns that media type. Reuse that rather than branching on format.surfsense_local/backend/worker/studio/shared/persist.py— its MIME-to-extension map decides the stored filename.surfsense_local/electron/scripts/audiocpp/stage.mjsandscripts/sdcpp/stage.mjs— the pattern to copy for staging a native binary: a pinned version, a checked SHA-256, a smoke run from the staged folder before it is swapped into place, and the upstream licence text carried alongside.surfsense_local/electron/electron-builder.yml— theextraResourceslist the staged folder has to join, andelectron/src/main/sidecars/— how the main process tells the backend where a staged binary lives, throughSidecarContext.
What done looks like
- A podcast artifact is
audio/mpeg, stored as.mp3, and the player and download in the artifact panel still work. - A development checkout with nothing staged still produces a podcast. The other native runtimes degrade rather than fail (
stage.mjswrites an empty folder and says why, and the app runs without that feature); WAV is the honest fallback here, so decide deliberately what happens when ffmpeg is absent and say so in the PR. - The
docs/architecture/packaging.mdextraResourcestable gains its row, and the Studio doc's line "There is no ElectronprintToPDFand no ffmpeg insurfsense_local" is corrected along with the Voicing a podcast section. Delete the Known gaps line in the same PR.
What not to change
Settle the binary with a maintainer before you write the staging script. ffmpeg's licence depends on how it was configured — an LGPL build and a GPL build are different obligations for a distributed desktop app, and it is tens of megabytes on every installer for all three platforms. Which build, which pin, and whether it ships at all is a maintainer's call, not a contributor's; the code is the easy half. Comment here with what you propose before starting.
Do not reach for ffmpeg on the user's PATH as a shortcut. The app is offline-first and self-contained by design, and a feature that works only on machines that happen to have ffmpeg installed is worse than one that does not exist.
How to verify
surfsense_local/backend/tests/unit/worker/test_studio_podcast.py drives the pipeline with a fake voice provider, and tests/unit/llm/providers/audiocpp/test_speech.py covers synthesize() and the WAV joining. tests/packaging/test_audiocpp_voicing.py is the pattern for a test that needs the staged binary. Write the failing test first: a synthesized episode whose media type is audio/mpeg.
cd surfsense_local/backend
uv run pytest -m unit
uv run pytest -m integration
uv run ruff check .
cd ../.. && python scripts/check_docs.pyBefore you start
Read CONTRIBUTING.md, the Studio doc and the packaging doc, which describes how every other native runtime is staged and frozen. Open your PR against dev, not main. Comment here to claim this.
Activity
Cedric921 commentedon Oct 6, 2026
I'd like to take this one. As asked, here is the binary question before any staging script, with a proposal that may avoid staging ffmpeg at all.
Where the encode sits: right after
joined_wav(voiced)inaudiocpp/speech.py, asencoded_or_wav(pcm) -> SynthesizedAudio. The podcast pipeline's_EXTENSIONSandpersist.pyalready mapaudio/mpegto.mp3, so nothing downstream changes. The remote voice path injoined_replies.pyalready returns MP3.The options, smallest first:
lameencas a backend dependency (my proposal).joined_wavalready has, with no subprocess, no new staged binary, and no change toelectron-builder.ymlorSidecarContext.soundfile: its wheels bundle an LGPL libsndfile 1.2.x built with LAME and mpg123, which can write MP3. About 1.5 MB, and PyInstaller has to collect its data folder.--enable-libmp3lame(about 3–8 MB per platform), pinned and SHA-checked per platform, smoke-run with-version, and given anffmpegrow inextraResources. Generic prebuilt binaries are mostly GPL and 70–90 MB, so I would not take one of those.Fallback: with no encoder importable, a dev checkout that skipped the dependency or a failed import, the podcast stays WAV. That is deliberate and tested: one test pins
audio/mpegwith the encoder, one pinsaudio/wavwithout it. Nothing on the user'sPATHis used.Which build ships is your call: lameenc, soundfile, or a staged LGPL ffmpeg. With option 1 or 2, the
packaging.mdchange is a dependency line rather than anextraResourcesrow. I'll update the Studio doc's ffmpeg sentence and Voicing a podcast section, and delete the Known gaps line, in the same PR.feat(local): encode a podcast episode as MP3
MODSetter commentedon Oct 15, 2026
Fixed by #2183, merged into
dev, by @Cedric921. The Known gaps line this issue quoted is deleted in the same PR.Closing by hand: GitHub only auto-closes on a merge to the default branch, and we merge through
dev.