<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Robert Corn</title>
    <description>The latest articles on DEV Community by Robert Corn (@robert_corn_2c1ef7ffc084b).</description>
    <link>https://dev.to/robert_corn_2c1ef7ffc084b</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3923605%2F034660d2-8687-4c68-a585-2ba47eefd057.png</url>
      <title>DEV Community: Robert Corn</title>
      <link>https://dev.to/robert_corn_2c1ef7ffc084b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/robert_corn_2c1ef7ffc084b"/>
    <language>en</language>
    <item>
      <title>https://play.google.com/store/apps/details?id=app.aethercut.twa</title>
      <dc:creator>Robert Corn</dc:creator>
      <pubDate>Fri, 12 Jun 2026 23:52:09 +0000</pubDate>
      <link>https://dev.to/robert_corn_2c1ef7ffc084b/httpsplaygooglecomstoreappsdetailsidappaethercuttwa-2a1f</link>
      <guid>https://dev.to/robert_corn_2c1ef7ffc084b/httpsplaygooglecomstoreappsdetailsidappaethercuttwa-2a1f</guid>
      <description>&lt;p&gt;If you are curious go look, you will be suprised.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
      <category>python</category>
    </item>
    <item>
      <title>Why I made AetherCut...</title>
      <dc:creator>Robert Corn</dc:creator>
      <pubDate>Sun, 07 Jun 2026 02:53:18 +0000</pubDate>
      <link>https://dev.to/robert_corn_2c1ef7ffc084b/why-i-made-aethercut-468i</link>
      <guid>https://dev.to/robert_corn_2c1ef7ffc084b/why-i-made-aethercut-468i</guid>
      <description>&lt;p&gt;Most video editors require you to upload your footage to a cloud server. For confidential material — client discovery footage, patient case studies, pre-release product reveals, source interviews — that upload is the entire problem.&lt;/p&gt;

&lt;p&gt;AetherCut is the security-first video editor that solves it. The editor runs entirely in your browser. Your footage is read into memory via the File API and never traverses the network. The claim is verifiable in 30 seconds: open Chrome DevTools, switch to the Network tab, import a video, and watch zero outbound traffic carrying media data.&lt;/p&gt;

&lt;p&gt;This is the falsifiable privacy claim that no upload-based editor can make. The architecture isn't an opt-in privacy mode bolted onto a cloud product. It's the default and only mode of operation.&lt;/p&gt;

&lt;p&gt;The editor is built on standard browser APIs — File API for media import, Canvas API for the timeline preview, WebCodecs for video decode and encode, MediaRecorder as a fallback for older browsers. None of these APIs require network access. The export runs entirely on your hardware via WebCodecs.&lt;/p&gt;

&lt;p&gt;AI features that genuinely need cloud inference (Whisper auto-captions, ElevenLabs voiceover, Sora 2 B-roll generation) are opt-in per feature, clearly labelled in the UI, and disabled entirely by Privacy Mode — a one-click toggle in the header that severs every network-touching AI call.&lt;/p&gt;

&lt;p&gt;When Privacy Mode is on, the editor continues to work using on-device AI features only (background removal, scene detection, motion tracking, color match). For high-security environments where any outbound network call is a non-starter, Privacy Mode delivers the full editor experience with zero cloud dependencies.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>The security-first video editor</title>
      <dc:creator>Robert Corn</dc:creator>
      <pubDate>Sat, 06 Jun 2026 20:04:15 +0000</pubDate>
      <link>https://dev.to/robert_corn_2c1ef7ffc084b/the-security-first-video-editor-4j5</link>
      <guid>https://dev.to/robert_corn_2c1ef7ffc084b/the-security-first-video-editor-4j5</guid>
      <description>&lt;p&gt;Most video editors require you to upload your footage to a cloud server. For confidential material — client discovery footage, patient case studies, pre-release product reveals, source interviews — that upload is the entire problem.&lt;/p&gt;

&lt;p&gt;AetherCut is the security-first video editor that solves it. The editor runs entirely in your browser. Your footage is read into memory via the File API and never traverses the network. The claim is verifiable in 30 seconds: open Chrome DevTools, switch to the Network tab, import a video, and watch zero outbound traffic carrying media data.&lt;/p&gt;

&lt;p&gt;This is the falsifiable privacy claim that no upload-based editor can make. The architecture isn't an opt-in privacy mode bolted onto a cloud product. It's the default and only mode of operation.&lt;/p&gt;

&lt;p&gt;Who this editor is for&lt;br&gt;
Legal teams reviewing confidential discovery footage that can't be uploaded to a third-party processor without breaching client privilege.&lt;/p&gt;

&lt;p&gt;Medical communications teams editing patient case studies covered by HIPAA-equivalent regulations in their jurisdiction. AetherCut doesn't claim HIPAA certification — but reducing the data processing surface to zero is more defensible than any vendor BAA.&lt;/p&gt;

&lt;p&gt;Corporate communications handling unreleased product footage, M&amp;amp;A material, or internal-only content where Slack-message-level scrutiny applies to where the files live.&lt;/p&gt;

&lt;p&gt;Journalists working with confidential sources who explicitly require that their footage not be uploaded to any third-party service. AetherCut delivers this without a workaround.&lt;/p&gt;

&lt;p&gt;Any creator subject to client NDAs that restrict third-party data processing.&lt;/p&gt;

&lt;p&gt;Architecture: the why&lt;br&gt;
The editor is built on standard browser APIs — File API for media import, Canvas API for the timeline preview, WebCodecs for video decode and encode, MediaRecorder as a fallback for older browsers. None of these APIs require network access. The export runs entirely on your hardware via WebCodecs.&lt;/p&gt;

&lt;p&gt;AI features that genuinely need cloud inference (Whisper auto-captions, ElevenLabs voiceover, Sora 2 B-roll generation) are opt-in per feature, clearly labelled in the UI, and disabled entirely by Privacy Mode — a one-click toggle in the header that severs every network-touching AI call.&lt;/p&gt;

&lt;p&gt;When Privacy Mode is on, the editor continues to work using on-device AI features only (background removal, scene detection, motion tracking, color match). For high-security environments where any outbound network call is a non-starter, Privacy Mode delivers the full editor experience with zero cloud dependencies.&lt;/p&gt;

&lt;p&gt;Compliance posture&lt;br&gt;
Compliance is a property of how you deploy and use software, not a property of the software itself. AetherCut doesn't claim HIPAA, SOC 2, or GDPR certification — those certifications apply to data processors, and AetherCut isn't a data processor for your media.&lt;/p&gt;

&lt;p&gt;What AetherCut delivers is a smaller compliance surface. No Business Associate Agreement is needed for media processing because we don't process your media. No Data Processing Agreement is needed for footage because we don't receive footage. No EU data residency rider is needed because the data never leaves your jurisdiction.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://aethercut.app" rel="noopener noreferrer"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>AetherCut.app</title>
      <dc:creator>Robert Corn</dc:creator>
      <pubDate>Fri, 05 Jun 2026 23:44:13 +0000</pubDate>
      <link>https://dev.to/robert_corn_2c1ef7ffc084b/aethercutapp-4gl8</link>
      <guid>https://dev.to/robert_corn_2c1ef7ffc084b/aethercutapp-4gl8</guid>
      <description>&lt;p&gt;AetherCut is the Privacy first AI assisted video editing suite. Zero uploads, verifiable in DevTools. Runs entirely in your browser through web codecs and canvas, nothing to download. 72 creative tools, 30 of which are AI/AI assisted. Privacy toggle disables all cloud based tools. Proxy mode runs 2 clocks and export quality is never affected. Guest mode. Go check it out.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>python</category>
      <category>security</category>
    </item>
    <item>
      <title>Privacy First... Period</title>
      <dc:creator>Robert Corn</dc:creator>
      <pubDate>Sat, 30 May 2026 21:19:11 +0000</pubDate>
      <link>https://dev.to/robert_corn_2c1ef7ffc084b/privacy-first-period-8in</link>
      <guid>https://dev.to/robert_corn_2c1ef7ffc084b/privacy-first-period-8in</guid>
      <description>&lt;p&gt;&lt;strong&gt;&lt;em&gt;Load up a clip, click the privacy toggle, open DevTools and verify for yourself.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>PRIVACY FIRST. PERIOD.</title>
      <dc:creator>Robert Corn</dc:creator>
      <pubDate>Thu, 28 May 2026 00:44:50 +0000</pubDate>
      <link>https://dev.to/robert_corn_2c1ef7ffc084b/privacy-first-period-4e5</link>
      <guid>https://dev.to/robert_corn_2c1ef7ffc084b/privacy-first-period-4e5</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhgyld8orca2a0vgu6imn.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhgyld8orca2a0vgu6imn.jpg" alt=" " width="800" height="592"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fiirvvcj1eno1pvupba3u.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fiirvvcj1eno1pvupba3u.jpg" alt=" " width="800" height="449"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjeigeiqb6m38p4laqwox.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjeigeiqb6m38p4laqwox.jpg" alt=" " width="800" height="623"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft83adsglo5rhimu22fca.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft83adsglo5rhimu22fca.jpg" alt=" " width="800" height="591"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Browser Based Video Editor, 0 uploads..100% Privacy</title>
      <dc:creator>Robert Corn</dc:creator>
      <pubDate>Sun, 24 May 2026 19:20:52 +0000</pubDate>
      <link>https://dev.to/robert_corn_2c1ef7ffc084b/browser-based-video-editor-0-uploads100-privacy-5bli</link>
      <guid>https://dev.to/robert_corn_2c1ef7ffc084b/browser-based-video-editor-0-uploads100-privacy-5bli</guid>
      <description>&lt;p&gt;I built a browser based video editor that runs entirely client side, has a privacy toggle to disable the cloud based tools, still over 50 tools available to run client side with absolutle 0 uploads. Verifiable through Dev Tool panel.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Falcxjj1q6jx02wllbrqd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Falcxjj1q6jx02wllbrqd.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>javascript</category>
      <category>midnightchallenge</category>
    </item>
    <item>
      <title>Privacy First, No Uploads, Ever.</title>
      <dc:creator>Robert Corn</dc:creator>
      <pubDate>Sun, 17 May 2026 00:46:13 +0000</pubDate>
      <link>https://dev.to/robert_corn_2c1ef7ffc084b/privacy-first-no-uploads-ever-4kgm</link>
      <guid>https://dev.to/robert_corn_2c1ef7ffc084b/privacy-first-no-uploads-ever-4kgm</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftqu6igd6u6qkokhgm4u4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftqu6igd6u6qkokhgm4u4.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flgeqn0hzqoofqp1uu7t9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flgeqn0hzqoofqp1uu7t9.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flirvenrvdupstupz35kn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flirvenrvdupstupz35kn.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Femro9cue1be992iad4nc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Femro9cue1be992iad4nc.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Privacy First. No Uploads. Ever.</title>
      <dc:creator>Robert Corn</dc:creator>
      <pubDate>Fri, 15 May 2026 21:49:35 +0000</pubDate>
      <link>https://dev.to/robert_corn_2c1ef7ffc084b/privacy-first-no-uploads-ever-28en</link>
      <guid>https://dev.to/robert_corn_2c1ef7ffc084b/privacy-first-no-uploads-ever-28en</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv6hv13txt7jdvhji9wc0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv6hv13txt7jdvhji9wc0.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffo2te4v6171gubcr86t9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffo2te4v6171gubcr86t9.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhm0tb8060oueo0bdqksg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhm0tb8060oueo0bdqksg.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>TIL canvas.captureStream() is video-only — here's how I mixed voiceover + music into a MediaRecorder export" published: true</title>
      <dc:creator>Robert Corn</dc:creator>
      <pubDate>Sun, 10 May 2026 17:31:07 +0000</pubDate>
      <link>https://dev.to/robert_corn_2c1ef7ffc084b/til-canvascapturestream-is-video-only-heres-how-i-mixed-voiceover-music-into-a-1c2d</link>
      <guid>https://dev.to/robert_corn_2c1ef7ffc084b/til-canvascapturestream-is-video-only-heres-how-i-mixed-voiceover-music-into-a-1c2d</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — &lt;code&gt;canvas.captureStream()&lt;/code&gt; only carries video tracks. To get audio into your MediaRecorder export you need to build an AudioContext graph that lets every &lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element play through speakers AND tap into a &lt;code&gt;MediaStreamDestination&lt;/code&gt; simultaneously. There are at least five footguns along the way. Here's the singleton wrapper that finally got it right.&lt;/p&gt;




&lt;p&gt;I've been building a client-side video editor over the last few months (&lt;a href="https://aethercut.app/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=audio_graph" rel="noopener noreferrer"&gt;AetherCut&lt;/a&gt; — 100% in-browser, nothing uploads, no SaaS backend for the editing itself). The video pipeline was straightforward enough: HTML5 &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; elements drawn into a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; on every animation frame, then &lt;code&gt;canvas.captureStream(30)&lt;/code&gt; piped into &lt;code&gt;MediaRecorder&lt;/code&gt; for the export.&lt;/p&gt;

&lt;p&gt;Then I added voiceover. Then background music. Then I shipped it.&lt;/p&gt;

&lt;p&gt;Then a user said &lt;em&gt;"why is the exported file silent."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Turns out &lt;code&gt;canvas.captureStream()&lt;/code&gt; only carries video tracks. Cool. &lt;strong&gt;No problem,&lt;/strong&gt; I thought, &lt;strong&gt;I'll just &lt;code&gt;addTrack()&lt;/code&gt; the audio elements.&lt;/strong&gt; That's where the fun started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: &lt;code&gt;createMediaElementSource()&lt;/code&gt; can only be called ONCE per element
&lt;/h2&gt;

&lt;p&gt;The moment you call &lt;code&gt;audioContext.createMediaElementSource(audioEl)&lt;/code&gt;, the element's normal speaker output is &lt;strong&gt;rerouted through the graph&lt;/strong&gt;. Call it twice on the same element and you get an &lt;code&gt;InvalidStateError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Fine — bind once on mount. But now the element is silent in preview unless you explicitly &lt;code&gt;gainNode.connect(audioContext.destination)&lt;/code&gt;. The element's own &lt;code&gt;volume&lt;/code&gt; attribute is still respected (via the gain stage), but the speaker output literally goes through your graph now.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// First call: works, reroutes audio through the graph&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMediaElementSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audioEl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Second call on the same element: throws InvalidStateError&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMediaElementSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audioEl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 💥&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In React this means a hard rule: &lt;strong&gt;bind exactly once on mount&lt;/strong&gt;, store the source node somewhere stable (a Map outside the component is fine), and never re-create it on re-render.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: &lt;code&gt;&amp;lt;video&amp;gt;.muted = true&lt;/code&gt; silences the export, not just the speakers
&lt;/h2&gt;

&lt;p&gt;This one cost me hours.&lt;/p&gt;

&lt;p&gt;Once a &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element is bound to the AudioContext graph, setting &lt;code&gt;video.muted = true&lt;/code&gt; mutes BOTH the speaker output AND the input to the graph. So if the user toggles preview-mute (a normal "I'm at the office" gesture), their export comes out silent and they don't notice until they open it in another player.&lt;/p&gt;

&lt;p&gt;The fix isn't on the element — it's at the graph level (see Problem 3).&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: Speaker mute and export volume need to be independent
&lt;/h2&gt;

&lt;p&gt;The user must be able to silence preview without silencing the export. Solution: a &lt;strong&gt;master gain node&lt;/strong&gt; that controls speakers only. Per-source gains feed both the master gain AND any active export taps.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;video clip A&amp;gt;  ─┐
&amp;lt;video clip B&amp;gt;  ─┤── srcNode → gainNode ─┬─→ masterGain ─→ ctx.destination (speakers)
&amp;lt;audio voiceover&amp;gt;┤                       └─→ MediaStreamDestination (export, when active)
&amp;lt;audio bgMusic&amp;gt; ─┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Preview mute → &lt;code&gt;masterGain.gain.value = 0&lt;/code&gt;. Export tap → pull from per-source gains BEFORE the master gain. Both decisions are independent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: Multiple &lt;code&gt;recorder.start()&lt;/code&gt; calls = stacking taps
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;MediaStreamDestination&lt;/code&gt; instances don't auto-clean. If you call &lt;code&gt;gainNode.connect(dest)&lt;/code&gt; on every export and never &lt;code&gt;disconnect()&lt;/code&gt;, the next export gets a phantom mix from the last one (audible echoes at first, eventually silent because each gain node hits its max-fanout limit).&lt;/p&gt;

&lt;p&gt;Keep the destination reference, and on &lt;code&gt;recorder.onstop&lt;/code&gt; call &lt;code&gt;gainNode.disconnect(dest)&lt;/code&gt; for every source you connected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 5: &lt;code&gt;audioContext.resume()&lt;/code&gt; requires a user gesture
&lt;/h2&gt;

&lt;p&gt;Chrome blocks the AudioContext until a click. The export button click &lt;em&gt;is&lt;/em&gt; a gesture, but if the user has been editing for 10 minutes and the context auto-suspended, your &lt;code&gt;recorder.start()&lt;/code&gt; call fires before audio actually flows → silent first second of the export.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Resume + small grace period before starting the recorder&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;audioContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 150ms isn't theoretical jitter — it's the empirical "first chunk has audible audio" threshold across Chrome/Safari/Firefox.&lt;/p&gt;




&lt;h2&gt;
  
  
  What ended up working
&lt;/h2&gt;

&lt;p&gt;One singleton AudioContext. One &lt;code&gt;Map&amp;lt;HTMLMediaElement, gainNode&amp;gt;&lt;/code&gt;. Every &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt; in the editor binds exactly once at mount. Export taps the entire mix downstream. Preview mute is a single &lt;code&gt;masterGain.gain.value = 0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's the whole utility — about 120 lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Generalized AudioContext mixing graph for the editor's preview + export.
 *
 * Multiple HTMLMediaElements (voiceover &amp;lt;audio&amp;gt;, background music &amp;lt;audio&amp;gt;,
 * and the per-clip &amp;lt;video&amp;gt; elements in the preview pool) need to be:
 *   1. Audible through the user's speakers during preview, AND
 *   2. Tappable by a MediaStreamDestination during MediaRecorder export
 *      so all those audio sources are mixed into the exported file.
 */&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// Map of element → { srcNode, gainNode, key }&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// Master gain controls SPEAKER output only — preview mute toggles this&lt;/span&gt;
&lt;span class="c1"&gt;// so exports still capture audio. Export taps pull from each&lt;/span&gt;
&lt;span class="c1"&gt;// per-source gainNode BEFORE the master gain.&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;masterGain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ensureCtx&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AudioContext&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webkitAudioContext&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;AC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AC&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;masterGain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createGain&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;masterGain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;masterGain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Bind an HTMLMediaElement to the graph. Idempotent.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bindMediaElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mediaEl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;mediaEl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mediaEl&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ensureCtx&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;srcNode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;srcNode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMediaElementSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mediaEl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Already bound to another context (HMR / double-mount).&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[audioGraph] bind failed for "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;":`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gainNode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createGain&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;srcNode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gainNode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;gainNode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;masterGain&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mediaEl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;srcNode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gainNode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Preview mute (0 = silent speakers, 1 = full). Doesn't touch export.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setMasterGain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;masterGain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;masterGain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;unbindMediaElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mediaEl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mediaEl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcNode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gainNode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mediaEl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Mixed-audio tap for MediaRecorder. Caller MUST call disconnect()&lt;/span&gt;
&lt;span class="c1"&gt;// after recorder.onstop or the next export stacks a phantom mix.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getMixTap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMediaStreamDestination&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;connected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gainNode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;connected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gainNode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* keep connecting the rest */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;connected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;connected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resumeAudioContext&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;suspended&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasAudioGraph&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here's how the export hook uses it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;captureStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Mix every bound audio source into the recorded stream&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;resumeAudioContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getMixTap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAudioTracks&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addTrack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recorder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MediaRecorder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video/webm;codecs=vp9&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;videoBitsPerSecond&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="nx"&gt;_000_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ... drive playback so the canvas paints frames ...&lt;/span&gt;

&lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onstop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// CRITICAL: release the tap or the next export stacks a phantom mix&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The one rule I'd nail to the wall
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Bind every media element to your graph ONCE on mount, even if it doesn't currently have audio. Adding it later is a footgun because by then it's already played sound through &lt;code&gt;ctx.destination&lt;/code&gt; and the second &lt;code&gt;createMediaElementSource()&lt;/code&gt; call will throw.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  On WebCodecs vs MediaRecorder
&lt;/h2&gt;

&lt;p&gt;WebCodecs is faster (1.5–3× realtime in my testing for video-only exports) but in current browsers it's painful to mux audio + video into one container without writing your own ISOBMFF muxer. So my codepath is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Has any audio source bound? → MediaRecorder path with the audio graph tap&lt;/li&gt;
&lt;li&gt;Pure-video export? → WebCodecs (GPU-accelerated)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most users get the slower-but-correct path. Power users with silent timelines get the fast path. That trade-off has held up in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;p&gt;React 18, Web Audio API, &lt;code&gt;canvas.captureStream&lt;/code&gt;, MediaRecorder, WebCodecs (where available), FastAPI backend (only for auth + Stripe — zero video data). No FFmpeg, no server-side rendering. ~17k lines of frontend.&lt;/p&gt;

&lt;p&gt;If anyone wants to see this graph in a working app, &lt;a href="https://aethercut.app/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=audio_graph" rel="noopener noreferrer"&gt;give it a spin&lt;/a&gt; — drop your video on the page and try the export. No signup needed for the editor itself.&lt;/p&gt;

&lt;p&gt;Happy to nerd out in comments. 🎚️&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>react</category>
      <category>webaudio</category>
    </item>
  </channel>
</rss>
