Index: src/assets/css/style.css
===================================================================
--- src/assets/css/style.css	(revision 67f71503172e1c48fc93ee0e7ea1fbc6f67e587e)
+++ src/assets/css/style.css	(revision 6dd26e55b97a257fb828412f04951bb77f39914a)
@@ -2421,4 +2421,14 @@
 .grid-tile-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; display: block; transition: opacity .4s ease; }
 .grid-tile-img.is-loading { opacity: 0; }
+/* Audio tile (Shaer, Robins vraag 30-7): the waveform PNG is white on
+   transparent and floats OVER the tile's own hue gradient, so every audio
+   post keeps its color and gets its shape. Contain + padding leaves the
+   centered title readable. */
+.grid-tile-img.grid-tile-wave {
+    background-color: transparent;
+    object-fit: contain;
+    padding: 26% 10%;
+    opacity: .85;
+}
 /* Cover videos are decorative, not players: let clicks + right-click pass through to the
    tile link so you get the normal link menu (not the browser's <video> menu/controls).
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 67f71503172e1c48fc93ee0e7ea1fbc6f67e587e)
+++ src/routes/activitypub.js	(revision 6dd26e55b97a257fb828412f04951bb77f39914a)
@@ -354,4 +354,17 @@
       }).catch(() => { /* never blocks the upload */ });
     }
+    // Audio gets the same courtesy (Robins vraag, 30-7: vrolijk de kale
+    // audio-tegel op): ffmpeg draws the waveform into <name>.poster.png.
+    // White on transparent, so the tile's own gradient stays the backdrop
+    // and every audio post keeps its own hue.
+    if (mime.startsWith('audio/')) {
+      Promise.all([import('child_process'), import('ffmpeg-static')]).then(([{ execFile }, ff]) => {
+        const bin = process.env.FFMPEG_PATH || ff.default;
+        if (!bin) return;
+        const poster = req.file.path + '.poster.png';
+        execFile(bin, ['-hide_banner', '-loglevel', 'error', '-y', '-i', req.file.path, '-filter_complex', 'showwavespic=s=800x256:colors=white', '-frames:v', '1', poster],
+          { timeout: 30000 }, (e) => { if (e && e.code !== 'ENOENT') console.warn('[media] waveform failed:', e.message); });
+      }).catch(() => { /* never blocks the upload */ });
+    }
     res.status(201).json({
       url: '/media/reply-media/' + req.file.filename,
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 67f71503172e1c48fc93ee0e7ea1fbc6f67e587e)
+++ src/services/ActivityPubService.js	(revision 6dd26e55b97a257fb828412f04951bb77f39914a)
@@ -2441,12 +2441,15 @@
     .map((a) => {
       const entry = { url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) };
-      // A video's poster frame, when the upload leg made one (shaer-zowq):
-      // rides along so the tag, the federated attachment and the apps all
-      // show a still instead of a black box.
-      if (entry.mediaType.startsWith('video/')) {
+      // The poster the upload leg made, when it did: a video's still frame
+      // (shaer-zowq, .poster.jpg) or an audio's waveform (Robins vraag 30-7,
+      // .poster.png). Rides along so the tag, the federated attachment and
+      // the apps all have something to show instead of a bare box.
+      const posterExt = entry.mediaType.startsWith('video/') ? '.poster.jpg'
+        : entry.mediaType.startsWith('audio/') ? '.poster.png' : null;
+      if (posterExt) {
         try {
           const mediaRoot = path.resolve(process.env.MEDIA_PATH || './storage/media');
           const rel = entry.url.replace(/^\/media\//, '');
-          if (fs.existsSync(path.join(mediaRoot, rel + '.poster.jpg'))) entry.poster = entry.url + '.poster.jpg';
+          if (fs.existsSync(path.join(mediaRoot, rel + posterExt))) entry.poster = entry.url + posterExt;
         } catch { /* no poster is fine */ }
       }
@@ -2461,5 +2464,7 @@
   const mediaHtml = media.map((a) => {
     if (a.mediaType.startsWith('image/')) return `<p><img src="${a.url}" alt="${esc(a.name)}"></p>`;
-    if (a.mediaType.startsWith('audio/')) return `<p><audio controls preload="metadata" src="${a.url}"></audio></p>`;
+    // data-poster: <audio> has no poster attribute, but the tile derivation
+    // reads this one to show the waveform (post-tile/post-card).
+    if (a.mediaType.startsWith('audio/')) return `<p><audio controls preload="metadata"${a.poster ? ` data-poster="${a.poster}"` : ''} src="${a.url}"></audio></p>`;
     const poster = a.poster ? ` poster="${a.poster}"` : '';
     return `<p><video controls playsinline preload="metadata"${poster} src="${a.url}"></video></p>`;
Index: src/views/partials/post-card.ejs
===================================================================
--- src/views/partials/post-card.ejs	(revision 67f71503172e1c48fc93ee0e7ea1fbc6f67e587e)
+++ src/views/partials/post-card.ejs	(revision 6dd26e55b97a257fb828412f04951bb77f39914a)
@@ -19,11 +19,28 @@
 // Content-derived fallback (see post-tile.ejs): a C2S post's media lives in
 // its content, and the card fronts it from there.
-let _cVideo = null, _cPoster = null, _cImg = null;
+let _cVideo = null, _cPoster = null, _cImg = null, _cAudio = false, _cWave = null;
 if (!_realCover && post.content) {
   const vm = String(post.content).match(/<video[^>]*\ssrc="([^"]+)"[^>]*>/i);
   if (vm) { _cVideo = vm[1]; const pm = vm[0].match(/poster="([^"]+)"/i); if (pm) _cPoster = pm[1]; }
-  else { const im = String(post.content).match(/<img[^>]*\ssrc="([^"]+)"/i); if (im) _cImg = im[1]; }
-}
-const _hasCover  = _realCover || !!_cVideo || !!_cImg;
+  else {
+    const im = String(post.content).match(/<img[^>]*\ssrc="([^"]+)"/i);
+    if (im) _cImg = im[1];
+    else {
+      // An audio post (Robins vraag, 30-7): the upload leg drew a waveform
+      // (data-poster on the tag); the card fronts it like a cover.
+      const am = String(post.content).match(/<audio[^>]*>/i);
+      if (am) { _cAudio = true; const wm = am[0].match(/data-poster="([^"]+)"/i); if (wm) _cWave = wm[1]; }
+    }
+  }
+}
+const _hasCover  = _realCover || !!_cVideo || !!_cImg || !!_cWave;
+// A C2S post has no title: fall back to the post's own words, then to a
+// friendly type label instead of "(zonder titel)".
+let _title = post.title || '';
+if (!_title && post.content) {
+  const _txt = String(post.content).replace(/<[^>]*>/g, ' ').replace(/&[a-zA-Z#0-9]+;/g, ' ').replace(/\s+/g, ' ').trim();
+  if (_txt) _title = _txt.length > 64 ? _txt.slice(0, 63).replace(/\s+\S*$/, '') + '…' : _txt;
+}
+if (!_title) _title = _cAudio ? '♪ audio' : _cVideo ? '▶ video' : '(zonder titel)';
 const _typeLabel = (post.type && post.type !== 'overig' && post.type !== 'post') ? post.type : '';
 const _isPinned  = !!post.pinned;
@@ -57,10 +74,10 @@
     <a class="post-list-cover<%= post.nsfw ? ' nsfw-media' : '' %>" href="<%= _href %>"
        <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>
-       aria-label="<%= post.title || '(zonder titel)' %>" tabindex="-1">
+       aria-label="<%= _title %>" tabindex="-1">
       <% if (post.cover_image_url) { %><img src="<%= thumb(post.cover_image_url, 480) %>"
            srcset="<%= thumb(post.cover_image_url, 320) %> 320w, <%= thumb(post.cover_image_url, 640) %> 640w, <%= thumb(post.cover_image_url, 1280) %> 1280w"
            sizes="(min-width: 768px) 120px, 100vw"
            alt="" loading="lazy" decoding="async"<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>>
-      <% } else if (post.cover_video_url) { %><video src="<%= post.cover_video_url %>" poster="<%= thumb(post.cover_video_url, 480) %>" autoplay loop muted playsinline></video><% } else if (_cVideo) { %><video src="<%= _cVideo %>"<% if (_cPoster) { %> poster="<%= _cPoster %>"<% } %> autoplay loop muted playsinline preload="metadata"></video><% } else if (_cImg) { %><img src="<%= _cImg %>" alt="" loading="lazy" decoding="async"><% } %>
+      <% } else if (post.cover_video_url) { %><video src="<%= post.cover_video_url %>" poster="<%= thumb(post.cover_video_url, 480) %>" autoplay loop muted playsinline></video><% } else if (_cVideo) { %><video src="<%= _cVideo %>"<% if (_cPoster) { %> poster="<%= _cPoster %>"<% } %> autoplay loop muted playsinline preload="metadata"></video><% } else if (_cImg) { %><img src="<%= _cImg %>" alt="" loading="lazy" decoding="async"><% } else if (_cWave) { %><img class="post-cover-wave" src="<%= _cWave %>" alt="" loading="lazy" decoding="async"><% } %>
       <% if (post.nsfw) { %><span class="nsfw-veil"><%- include('nsfw-veil', { cw: post.content_warning }) %></span><% } %>
     </a>
@@ -68,5 +85,5 @@
     <a class="post-list-cover nsfw-media" href="<%= _href %>"
        <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>
-       aria-label="<%= post.title || '(zonder titel)' %>" tabindex="-1">
+       aria-label="<%= _title %>" tabindex="-1">
       <span class="nsfw-veil"><%- include('nsfw-veil', { cw: post.content_warning }) %></span>
     </a>
@@ -89,5 +106,5 @@
       <a href="<%= _href %>"
          <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>>
-        <%= post.title || '(zonder titel)' %>
+        <%= _title %>
       </a>
     </h3>
@@ -189,4 +206,7 @@
   }
 
+  /* Audio cover (Shaer): white-on-transparent waveform on its own accent
+     gradient, contained so the shape reads at 120px and full-bleed alike. */
+  /* (see also .grid-tile-wave in style.css for the grid tiles) */
   /* Pinned indicator */
   .post-list-item.is-pinned .post-list-title a {
@@ -233,4 +253,18 @@
 
 /* ============================================================
+   AUDIO COVER (shared) — Shaer audio post (Robins vraag, 30-7):
+   the waveform PNG is white on transparent; give it its own accent
+   gradient and contain it so the shape reads at 120px and full-bleed.
+   ============================================================ */
+.post-list-cover .post-cover-wave {
+  width: 100%; height: 100%;
+  object-fit: contain !important;
+  padding: 14% 8%;
+  background-image: linear-gradient(135deg,
+      color-mix(in srgb, var(--accent, #888) 26%, var(--paper-2, var(--paper))) 0%,
+      color-mix(in srgb, var(--accent, #888) 8%, var(--paper-2, var(--paper))) 100%);
+}
+
+/* ============================================================
    PIN + DRAFT INDICATORS (shared)
    ============================================================ */
Index: src/views/partials/post-tile.ejs
===================================================================
--- src/views/partials/post-tile.ejs	(revision 67f71503172e1c48fc93ee0e7ea1fbc6f67e587e)
+++ src/views/partials/post-tile.ejs	(revision 6dd26e55b97a257fb828412f04951bb77f39914a)
@@ -11,11 +11,29 @@
 // content instead, like the Cirkel card: first <video> (with its poster) or
 // first <img>.
-let _cVideo = null, _cPoster = null, _cImg = null;
+let _cVideo = null, _cPoster = null, _cImg = null, _cAudio = false, _cWave = null;
 if (!_hasCover && post.content) {
   const vm = String(post.content).match(/<video[^>]*\ssrc="([^"]+)"[^>]*>/i);
   if (vm) { _cVideo = vm[1]; const pm = vm[0].match(/poster="([^"]+)"/i); if (pm) _cPoster = pm[1]; }
-  else { const im = String(post.content).match(/<img[^>]*\ssrc="([^"]+)"/i); if (im) _cImg = im[1]; }
+  else {
+    const im = String(post.content).match(/<img[^>]*\ssrc="([^"]+)"/i);
+    if (im) _cImg = im[1];
+    else {
+      // An audio post (Robins vraag, 30-7): the waveform the upload leg drew
+      // (data-poster) goes OVER the gradient, so the tile keeps its own hue.
+      const am = String(post.content).match(/<audio[^>]*>/i);
+      if (am) { _cAudio = true; const wm = am[0].match(/data-poster="([^"]+)"/i); if (wm) _cWave = wm[1]; }
+    }
+  }
 }
-const _showCover = _hasCover || !!_cVideo || !!_cImg;
+const _showCover = _hasCover || !!_cVideo || !!_cImg;   // the wave keeps the gradient
+// A C2S post has no title: fall back to the post's own words, and for a
+// media-only post to a friendly type label instead of "(untitled)". A tile
+// that already shows its picture needs no forced label at all.
+let _title = post.title || '';
+if (!_title && post.content) {
+  const _txt = String(post.content).replace(/<[^>]*>/g, ' ').replace(/&[a-zA-Z#0-9]+;/g, ' ').replace(/\s+/g, ' ').trim();
+  if (_txt) _title = _txt.length > 64 ? _txt.slice(0, 63).replace(/\s+\S*$/, '') + '…' : _txt;
+}
+if (!_title) _title = _cAudio ? '♪ audio' : (_cVideo || _cImg) ? '' : '(untitled)';
 const _typeLabel = (post.type && post.type !== 'post') ? post.type : '';
 const _src = post.source_name || '';   // bron-site (cirkel-feed: van welke site komt deze post)
@@ -30,5 +48,5 @@
    <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>>
 
-  <% if (post.cover_image_url) { %><img class="grid-tile-img" src="<%= thumb(post.cover_image_url, 480) %>" alt="" loading="lazy" decoding="async"<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>><% } else if (post.cover_video_url) { %><video class="grid-tile-img" src="<%= post.cover_video_url %>" poster="<%= thumb(post.cover_video_url, 480) %>" autoplay loop muted playsinline></video><% } else if (_cVideo) { %><video class="grid-tile-img" src="<%= _cVideo %>"<% if (_cPoster) { %> poster="<%= _cPoster %>"<% } %> autoplay loop muted playsinline preload="metadata"></video><% } else if (_cImg) { %><img class="grid-tile-img" src="<%= _cImg %>" alt="" loading="lazy" decoding="async"><% } %>
+  <% if (post.cover_image_url) { %><img class="grid-tile-img" src="<%= thumb(post.cover_image_url, 480) %>" alt="" loading="lazy" decoding="async"<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>><% } else if (post.cover_video_url) { %><video class="grid-tile-img" src="<%= post.cover_video_url %>" poster="<%= thumb(post.cover_video_url, 480) %>" autoplay loop muted playsinline></video><% } else if (_cVideo) { %><video class="grid-tile-img" src="<%= _cVideo %>"<% if (_cPoster) { %> poster="<%= _cPoster %>"<% } %> autoplay loop muted playsinline preload="metadata"></video><% } else if (_cImg) { %><img class="grid-tile-img" src="<%= _cImg %>" alt="" loading="lazy" decoding="async"><% } else if (_cWave) { %><img class="grid-tile-img grid-tile-wave" src="<%= _cWave %>" alt="" loading="lazy" decoding="async"><% } %>
 
   <% if (_typeLabel) { %>
@@ -43,9 +61,9 @@
   <% if (_hasCover) { %>
     <div class="grid-tile-overlay">
-      <strong><%= post.title || '(untitled)' %></strong>
+      <strong><%= _title %></strong>
       <% if (_src) { %><span class="grid-tile-overlay-src">van <%= _src %></span><% } %>
     </div>
   <% } else { %>
-    <span class="grid-tile-title"><%= post.title || '(untitled)' %></span>
+    <span class="grid-tile-title"><%= _title %></span>
     <% if (_src) { %><span class="grid-tile-gradient-src">van <%= _src %></span><% } %>
   <% } %>
