Inspiration
My girlfriend is a medical student. I'm training as an alternative practitioner, the regulated German profession called Heilpraktiker. We spend a lot of our time on opposite sides of the same questions, and we kept ending up in long kitchen-table conversations about what the evidence actually says, where it's thin, and where people are being handed something more confident than anyone can honestly claim.
At some point we figured those conversations would make a decent podcast.
Then we looked at our calendars. We both work full time and we're both still studying. Finding two hours to record was doable. The rest of it was the problem. An hour of the two of us talking turns into a long evening of cleanup: the false starts, the "hold on, let me say that again", the twenty seconds where one of us is looking up a paper. Every podcast we know that quietly stopped after four episodes stopped for that reason.
So the podcast went on hold and we built the tool first. A slightly ridiculous order of operations, but here we are.
What it does
Werkstern records each person's voice locally and uncompressed. While you're talking, you tag things with a single keypress: retake, error, good point, chapter, source. Those tags are the whole idea. The tedious part of editing gets done while you're still in the conversation, by the person who just heard what went wrong.
Afterwards, ChatGPT reads the finished take through WebMCP and suggests specific regions to remove, in seconds. The app lists them for you, you keep or drop each one, and only then does anything get cut. You get an audible preview before you commit and an undo that puts everything back. The original recordings stay untouched, which we verify byte for byte in the test suite.
Why we used MCP
We started from a constraint. We're recording medical conversations, including half-formed thoughts and things one of us says and then takes back. That audio stays on the machine, so any design that began with uploading the file was ruled out early.
The second thing that shaped it was accuracy. In a conversation about medicine the qualifier is often the load-bearing part of the sentence. "This helps in a narrow group of patients" and "this helps" are two different claims, and only one of them is true. We needed to see every proposed cut before it happened.
WebMCP fit both of those. The model never receives audio. It receives a deliberately thin description of the take: markers, director signals and pause windows, all in seconds. No names, no device labels, no file paths, no tokens. It reasons over that and hands back numbers, and the app does the actual cutting locally with FFmpeg, sample-accurately.
That division of labour is what made the project feel workable. The model handles the part it's good at, which is reading a conversation and having an opinion about what to trim. The app handles the part it's good at, which is being exact and reversible. The approval step in between is where most of our care went.
How we built it
The recorder came first and took most of the time.
Capture. An AudioWorklet per station at 48 kHz, 24-bit mono, with echo cancellation, noise suppression and automatic gain deliberately switched off. Two-second chunks, each carrying a CRC32, buffered in IndexedDB so a Wi-Fi dropout costs you nothing. Node, Fastify and ws on the host machine; the browser side is plain ES modules with no framework.
Clock drift. Two laptops have two crystals, and over an hour they disagree. This was the hardest audio problem here. Werkstern writes a 1 kHz stripe digitally into every raw track just after the start and again just before the stop, then locates both afterwards by cross-correlation. For each guest track the correction factor is
$$f = \frac{n_{\text{end}} - n_{\text{start}}}{N_{\text{end}} - N_{\text{start}}}$$
where $n$ are the guest's stripe positions and $N$ the host's, in samples. We report it as $(f - 1) \times 10^{6}$ ppm and correct it with asetrate plus SOXR resampling at 28-bit precision. A take is accepted only when the residual offset stays under 240 samples, which at 48 kHz works out to
$$\frac{240}{48\,000} = 5\ \text{ms}.$$
Rendering. The soundboard stays out of the microphone signal. Every pad press is stored as an event, and the final stereo mix is rebuilt afterwards from that log plus a content-addressed copy of each sound file. Replace a sound six months later and the old episode still renders exactly as it did.
The agent layer sits on top and touches none of that. Eight tools registered in the top-level document through document.modelContext.registerTool: four read-only ones, preview_edit_batch which validates and displays without saving anything, and three writing tools behind the approval dialog. Every schema is closed with additionalProperties: false and bounded lengths, and each tool carries an honest readOnlyHint so the host can tell reading from writing.
Cuts are stored as batches of source-time regions with a revision number that only ever goes up. Every write has to state the revision it believes is current, so a command built on stale information gets rejected instead of landing on a take that has moved on. The preview you hear is the complement of what was removed: for regions $R = {[s_i, e_i)}$ over a take of length $T$, it plays $[0, T) \setminus \bigcup_i [s_i, e_i)$, concatenated.
Challenges we ran into
Approval turned out to be a concurrency problem. Our worst bug had nothing to do with audio. Two approval requests could share the same dialog element: the second one overwrote the text of the first while it was still open, and one click then resolved both promises. You would have read "render a preview?" and, with that same click, authorized a permanent edit. Approvals are serialized now, and a second request arriving while one is open gets refused. Cancelling, losing the connection, leaving the dialog unanswered, and a dialog that fails to open all count as a no.
Undo had a hole in it. If you queued a preview render and then hit undo, the render was still holding the old region list and would cheerfully write the audio you had just discarded back to disk. A render now re-reads the take before it starts and gives up if the batch is gone.
We reviewed our own work adversarially, and it was uncomfortable. Before calling it finished we ran an independent audit against our own commit. It came back with one critical finding and a handful of serious ones. Then, while fixing those, I introduced a new one: my approval guard could get stuck and silently refuse every write for the rest of the session. It only surfaced because a browser test behaved oddly and I chased it instead of writing it off as flakiness. I'm glad I did.
Testing a modal dialog under browser automation was miserable. The automation pane we used never dispatches the <dialog> close event. showModal() opens it, close() sets the return value, and the listener simply never runs. Instead of calling it working on a harness we couldn't trust, we proved the flow two other ways: unit tests with a DOM stub where the event does fire, and an end-to-end test that drives the real FFmpeg binary over a synthetic take, checks the output is exactly 48 kHz stereo 16-bit at the expected length, and confirms the source file's SHA-256 is identical before and after.
Being straight about filler words. The first feature everyone asks for is automatic removal of "um" and "äh". We have no transcript and no speech recognition, so we can't do it. get_edit_capabilities says so directly in its output, to the model, in the tool response itself. Leaving that vague would have taken one sentence.
and honestly we didnt had time to make a video, so we simply asked ChatGPT to generate one :D
What we learned
The question that mattered shifted over the project. It started as how much the model could do on its own and ended up as how precisely we could stop it. One approval covers exactly one write. Anything ambiguous resolves to no. The recordings come out identical to how they went in, and we can show that rather than claim it.
We also learned how much of this rests on the unglamorous half. Nobody will ever notice the sync stripes or the checksum on every two-second chunk. Without them there'd be no trustworthy take for a model to reason about in the first place.
What's next
Local transcription with faster-whisper, running on the same machine so nothing has to leave it. That's what would unlock filler words and picking highlights by what was actually said. We left it out of this round because we wanted the smaller claim to be accurate.
After that, a preview that spans several edit batches at once.
And, eventually, the podcast.
Built With
- ai-agents
- audio-processing
- audioworklet
- chatgpt
- cloudflare-workers
- digital-signal-processing
- fastify
- ffmpeg
- indexeddb
- javascript
- json-schema
- local-first
- mdns
- model-context-protocol
- next.js
- node.js
- podcast
- react
- soxr
- tailwind-css
- typescript
- vite
- web-audio-api
- webmcp
- websockets
Log in or sign up for Devpost to join the conversation.