Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision fbacddc02f08c6a7ed8e09f98fdd30b318feb63c)
+++ src/routes/posts.js	(revision b4af6f3a028053e65a6bdb83c3202bd7efa3e641)
@@ -822,4 +822,31 @@
 }
 
+// De muziek van een betaalde post op de poortpagina zelf.
+//
+// WAAROM DIE DAAR HOORT. Zodra een nummer `fedi_open` is, federeert het als
+// eigen Audio-object en speelt het bij iedereen die de post in een hub of in
+// Mastodon tegenkomt. Toonde de poort het dan NIET, dan was de muziek overal
+// beschikbaar behalve op de site die hem uitbrengt -- en dat is de verkeerde
+// kant op (Robin, 24-8). De muur staat om de tekst.
+//
+// ALLES OF NIETS. Alleen als ELK nummer waar de post naar wijst open staat.
+// Een shortcode rendert zijn hele lijst, dus bij een half-open bandje zou de
+// speler ook de gesloten nummers krijgen -- en /audio/stream laat een
+// gelijke-oorsprong-fetch door, dus dat is geen theoretisch lek maar een echt.
+// Half open is hier dus dicht.
+//
+// De TEKST komt hier niet langs: we geven renderPostBodyHtml een post mee die
+// alleen uit de audio-shortcodes bestaat. Wat niet meegegeven wordt kan ook
+// niet lekken -- dezelfde regel als bij readerItems.
+export function paidOpenAudioHtml(site, post, req) {
+  if (!postAudioFediOpen(site.id, post.content)) return '';
+  const codes = String(post.content || '').match(/\[\[(?:track|album|playlist):[^\]]+\]\]/gi) || [];
+  if (!codes.length) return '';
+  const alleenMuziek = codes.join('\n');
+  try {
+    return renderPostBodyHtml(site, { ...post, content: alleenMuziek, content_rendered: alleenMuziek }, req);
+  } catch { return ''; /* geen speler is geen kapotte poort */ }
+}
+
 function postNeighbors(site, post) {
   const ordered = db.prepare(`
@@ -1573,10 +1600,12 @@
   if (post.paid && !canEditThis && !_unlocked) {
     const { newerPost, olderPost } = postNeighbors(site, post);
+    const pgAudio = paidOpenAudioHtml(site, post, req);
     return renderPage(req, res, 'pages/paid-gate', {
-    pageJs: 'paid-gate',
+    pageJs: 'paid-gate' + (pgAudio ? ' tape' : ''),
       pageTitle: post.title || 'Voor supporters',
       bodyClass: 'on-special',
       pgTitle: post.title || '',
       pgTeaser: paidTeaser(post),
+      pgAudio,
       pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
       pgSlug: post.slug,
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision fbacddc02f08c6a7ed8e09f98fdd30b318feb63c)
+++ src/services/ActivityPubService.js	(revision b4af6f3a028053e65a6bdb83c3202bd7efa3e641)
@@ -496,4 +496,59 @@
 }
 
+// fedi_open tracks → real AS2 Audio attachments (the actual file URL, served ungated) so
+// EVERY client incl. the Mastodon apps plays them inline natively. Gated tracks (default)
+// stay link/card-only — the file is never exposed for them. Resolve from post.content so a
+// later body mutation can't affect it.
+//
+// Staat apart en niet meer midden in buildNote, omdat een BETAALDE post hem ook
+// nodig heeft: daar staat de muur om de TEKST en niet om de muziek.
+function openAudioAttachments(base, site, post) {
+  const openAudio = [];
+  if (!/\[\[(track|album|playlist):/i.test(post.content || '')) return openAudio;
+  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
+  const seenA = new Set();
+  const addRow = (r) => {
+    const fn = r.filename || (r.storage_path || '').split('/').pop();
+    if (!fn || seenA.has(fn)) return; seenA.add(fn);
+    const a = { type: 'Audio', mediaType: r.mime_type || 'audio/mpeg', url: `${base}/audio/stream/${encodeURIComponent(fn)}`, name: r.title || 'Audio' };
+    // Cover art on the Audio attachment (AS2 `icon`): track cover, else the post cover.
+    // Mastodon renders it as the artwork thumbnail on its native audio player.
+    const art = abs(r.cover_url || post.cover_image_url || null);
+    if (art) a.icon = { type: 'Image', mediaType: guessMediaType(art), url: art };
+    openAudio.push(a);
+  };
+  const SEL = 'SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM audio_tracks t JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND ';
+  try {
+    for (const mm of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare(SEL + 't.id = ?').get(mm[1]); if (r) addRow(r); }
+    for (const mm of (post.content || '').matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare(SEL + 't.site_id = ? AND t.album = ? ORDER BY t.rowid').all(site.id, mm[1].trim())) addRow(r);
+    for (const mm of (post.content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND pt.playlist_id = ? ORDER BY pt.position').all(mm[1])) addRow(r);
+  } catch { /* non-fatal */ }
+  return openAudio;
+}
+
+// HET BANDJE OP DE DRAAD. Zonder dit stuk bestaat `Mixtape` alleen in onze
+// eigen code: de playlist-collectie blijft namelijk een OrderedCollection
+// (dat moet, anders verliest een lezer die `type` als tekst uitpakt het hele
+// object), en dan zegt niets naar buiten toe ooit dat dit een cassette is.
+// Gemeten op 21-8: in de Note van een mixtape-post kwam het woord Mixtape
+// niet voor, en de hub gooide zo'n bandje daarom stil weg.
+//
+// Als bijlage en niet als het object zelf: de post blijft een Note, zodat
+// Mastodon en alles wat `Mixtape` niet kent gewoon een bericht met audio
+// ziet. Wie het type wel kent, vindt het bandje als geheel.
+//
+// Het bandje draagt alleen wat al open staat: playlistOpenTracks filtert op
+// fedi_open. Daarom is het veilig om hem ook aan een betaalde teaser te hangen.
+function mixtapeAttachment(base, site, post) {
+  try {
+    const soort = postMusicType(post.content || '', site.id);
+    if (!soort || soort.type !== 'mixtape' || !soort.collectie || !soort.collectie.id) return null;
+    const pl = db.prepare('SELECT * FROM playlists WHERE id = ? AND site_id = ?')
+      .get(soort.collectie.id, site.id);
+    if (!pl) return null;
+    return buildMixtapeObject(base, site, { ...pl, _post: post }, playlistOpenTracks(pl.id)) || null;
+  } catch { return null; /* een bandje minder is geen kapotte post */ }
+}
+
 // A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery).
 export function buildNote(base, site, post, opts = {}) {
@@ -565,5 +620,15 @@
 
   // Paid post (klonkt-demo-aki): federate a PUBLIC teaser + link, never the full
-  // content, so nothing leaks past the paywall. No media attachments either.
+  // content, so nothing leaks past the paywall. Images stay home too.
+  //
+  // MAAR DE OPENGEZETTE AUDIO REIST WEL MEE (Robin, 24-8, naar aanleiding van
+  // boiert.eu/introducing-this-machine). De muur staat om de TEKST. `fedi_open`
+  // is een aparte, eenrichtings, per nummer bewust gezette vlag van de eigenaar,
+  // en die nummers federeren toch al los als eigen Audio-objecten met hun
+  // `context` naar deze post. Hield deze tak het bandje tegen, dan hield hij
+  // niets geheim -- alleen de VOLGORDE en het feit dat het een cassette is. In
+  // de hub viel het bandje daardoor uiteen in vier losse nummers onder een kale
+  // teaserkaart. Een cassette die terugwijst naar "lees verder (supporters)"
+  // dient de betaalde post beter dan vier weesnummers.
   if (post.paid) {
     const esc = (x) => String(x || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
@@ -571,4 +636,9 @@
     const rawTeaser = String(post.excerpt || '').trim()
       || _firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().slice(0, 280);
+    const openBijlagen = openAudioAttachments(base, site, post);
+    const band = mixtapeAttachment(base, site, post);
+    // Het bandje alleen als er ook echt iets open in zit: een cassette waarvan
+    // elk nummer gesloten is, is een lege doos met een titel erop.
+    if (band && openBijlagen.length) openBijlagen.push(band);
     return {
       '@context': AP_CONTEXT,
@@ -579,4 +649,5 @@
       url: human,
       published: toISO(post.published_at || post.created_at || Date.now()),
+      ...(openBijlagen.length ? { attachment: openBijlagen } : {}),
       to: [PUBLIC],
       cc: [`${aId}/followers`],
@@ -705,28 +776,5 @@
     }
   } catch { /* non-fatal */ }
-  // fedi_open tracks → real AS2 Audio attachments (the actual file URL, served ungated) so
-  // EVERY client incl. the Mastodon apps plays them inline natively. Gated tracks (default)
-  // stay link/card-only — the file is never exposed for them. Resolve from post.content so a
-  // later body mutation can't affect it.
-  const openAudio = [];
-  if (hadAudio) {
-    const seenA = new Set();
-    const addRow = (r) => {
-      const fn = r.filename || (r.storage_path || '').split('/').pop();
-      if (!fn || seenA.has(fn)) return; seenA.add(fn);
-      const a = { type: 'Audio', mediaType: r.mime_type || 'audio/mpeg', url: `${base}/audio/stream/${encodeURIComponent(fn)}`, name: r.title || 'Audio' };
-      // Cover art on the Audio attachment (AS2 `icon`): track cover, else the post cover.
-      // Mastodon renders it as the artwork thumbnail on its native audio player.
-      const art = abs(r.cover_url || post.cover_image_url || null);
-      if (art) a.icon = { type: 'Image', mediaType: guessMediaType(art), url: art };
-      openAudio.push(a);
-    };
-    const SEL = 'SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM audio_tracks t JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND ';
-    try {
-      for (const mm of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare(SEL + 't.id = ?').get(mm[1]); if (r) addRow(r); }
-      for (const mm of (post.content || '').matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare(SEL + 't.site_id = ? AND t.album = ? ORDER BY t.rowid').all(site.id, mm[1].trim())) addRow(r);
-      for (const mm of (post.content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND pt.playlist_id = ? ORDER BY pt.position').all(mm[1])) addRow(r);
-    } catch { /* non-fatal */ }
-  }
+  const openAudio = openAudioAttachments(base, site, post);
   // ONVERTAALD voor een verhuizing (FEP-1580). De doelinstantie IS een Klonkt:
   // die rendert [[track:]], [[album:]] en [[playlist:]] zelf en maakt er een
@@ -795,25 +843,7 @@
   for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players
 
-  // HET BANDJE OP DE DRAAD. Zonder dit stuk bestaat `Mixtape` alleen in onze
-  // eigen code: de playlist-collectie blijft namelijk een OrderedCollection
-  // (dat moet, anders verliest een lezer die `type` als tekst uitpakt het hele
-  // object), en dan zegt niets naar buiten toe ooit dat dit een cassette is.
-  // Gemeten op 21-8: in de Note van een mixtape-post kwam het woord Mixtape
-  // niet voor, en de hub gooide zo'n bandje daarom stil weg.
-  //
-  // Als bijlage en niet als het object zelf: de post blijft een Note, zodat
-  // Mastodon en alles wat `Mixtape` niet kent gewoon een bericht met audio
-  // ziet. Wie het type wel kent, vindt het bandje als geheel.
-  try {
-    const soort = postMusicType(post.content || '', site.id);
-    if (soort && soort.type === 'mixtape' && soort.collectie && soort.collectie.id) {
-      const pl = db.prepare('SELECT * FROM playlists WHERE id = ? AND site_id = ?')
-        .get(soort.collectie.id, site.id);
-      if (pl) {
-        const tape = buildMixtapeObject(base, site, { ...pl, _post: post }, playlistOpenTracks(pl.id));
-        if (tape) attachment.push(tape);
-      }
-    }
-  } catch { /* een bandje minder is geen kapotte post */ }
+  // Het bandje als bijlage — zie mixtapeAttachment() voor het waarom.
+  const tape = mixtapeAttachment(base, site, post);
+  if (tape) attachment.push(tape);
 
   // Inline @user@host mentions: the Mention tag objects + the mentioned actor URIs. Only
Index: src/views/pages/paid-gate.ejs
===================================================================
--- src/views/pages/paid-gate.ejs	(revision fbacddc02f08c6a7ed8e09f98fdd30b318feb63c)
+++ src/views/pages/paid-gate.ejs	(revision b4af6f3a028053e65a6bdb83c3202bd7efa3e641)
@@ -8,4 +8,12 @@
   <% if (typeof pgTeaser !== 'undefined' && pgTeaser) { %>
     <div class="pg-teaser"><p><%= pgTeaser %></p></div>
+  <% } %>
+
+  <%# De opengezette muziek staat VOOR de poort en niet erachter: wie hem in een
+      hub of in Mastodon al kan afspelen, hoort hem ook hier te kunnen afspelen.
+      Alleen de tekst zit achter de poort, en die staat hier niet -- de route
+      geeft alleen de audio-shortcodes mee (paidOpenAudioHtml). %>
+  <% if (typeof pgAudio !== 'undefined' && pgAudio) { %>
+    <div class="pg-audio post-content"><%- pgAudio %></div>
   <% } %>
 
@@ -39,4 +47,5 @@
   .pg-teaser { font-family: var(--font-body, serif); font-size: 1.1rem; line-height: 1.7; color: var(--ink); opacity: .95;
     -webkit-mask-image: linear-gradient(180deg, #000 55%, transparent); mask-image: linear-gradient(180deg, #000 55%, transparent); }
+  .pg-audio { margin: 1.5rem 0 0; }
   .pg-card { margin: 1.5rem 0 0; border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); border-radius: 18px; padding: 30px 26px; text-align: center; }
   .pg-lock { font-size: 38px; margin-bottom: 6px; }
