Index: src/routes/admin-audio.js
===================================================================
--- src/routes/admin-audio.js	(revision 4e5fe295413a76ce5327e908eaa1d702d538ee59)
+++ src/routes/admin-audio.js	(revision 8ed36de2a9b6b822f55c28663bec1275f43fb70d)
@@ -23,4 +23,5 @@
 import { audioUrl } from '../services/AudioStreamService.js';
 import { mediaDir } from '../config/paths.js';
+import * as ActivityPubService from '../services/ActivityPubService.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -301,4 +302,10 @@
   if (!track) return res.redirect('/admin/audio?error=Not+found');
 
+  // Zeg de fediverse dat de track weg is, VOOR de rij verdwijnt -- zelfde
+  // volgorde en zelfde reden als bij een post (posts.js). Zonder dit blijft
+  // elke server die hem indexeerde ernaar wijzen terwijl het object 404 geeft;
+  // op de hub stond daardoor op 21-8 een track met een dode link.
+  ActivityPubService.deliverTrackDelete(site, track.track_id).catch(() => { /* best-effort */ });
+
   db.prepare('DELETE FROM audio_tracks WHERE id = ?').run(track.track_id);
   if (track.media_id) {
@@ -371,5 +378,11 @@
     if (o.media_id) db.prepare('DELETE FROM media WHERE id = ?').run(o.media_id);
   });
-  for (const o of orphans) deleteOne(o);
+  for (const o of orphans) {
+    // Ook hier aankondigen. Een wees is voor ONS een track zonder bestand, maar
+    // voor de buitenwereld was het een gewoon Audio-object dat zij hebben
+    // opgeslagen; stil weggooien laat hun kopie staan.
+    ActivityPubService.deliverTrackDelete(site, o.track_id).catch(() => { /* best-effort */ });
+    deleteOne(o);
+  }
 
   res.json({ ok: true, deleted: orphans.length });
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 4e5fe295413a76ce5327e908eaa1d702d538ee59)
+++ src/services/ActivityPubService.js	(revision 8ed36de2a9b6b822f55c28663bec1275f43fb70d)
@@ -37,5 +37,5 @@
 import { TRACK_KOLOMMEN,
   playlistOpenTracks, siteOpenTracks, openTrack, trackHostPosts,
-  buildTrackAudio, buildTrackCollection, buildTrackCreate,
+  buildTrackAudio, buildTrackCollection, buildTrackCreate, trackUri,
   buildPlaylistCollection, listPlaylistsAP, playlistLinkTags,
   buildPostTrackCollection, uitgavePost,
@@ -3107,7 +3107,20 @@
 
 // Tell followers a post is gone (Delete + Tombstone) so it's removed from their feeds.
-export async function deliverDelete(site, post) {
+/**
+ * Delete(Tombstone) voor een van onze EIGEN objecten, naar alle volgers.
+ *
+ * De romp staat apart omdat een post niet het enige is dat wij de draad op
+ * sturen. Een track is een eersterangs Audio-object met een eigen id
+ * (shaer-0nh), en die werd bij verwijderen nergens aangekondigd: de rij ging
+ * weg, het object ging 404 en elke server die hem had geindexeerd hield hem
+ * voor altijd. Op de hub kwam dat op 21-8 aan het licht als een track die naar
+ * een dode URL wees.
+ *
+ * Het object-id komt van de aanroeper. Dat moet ook wel: bij verwijderen is de
+ * rij vaak al weg, dus er valt niets meer op te zoeken.
+ */
+export async function deliverObjectDelete(site, objectId) {
   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug || !post || !post.id) return;
+  if (!base || !site || !site.slug || !objectId) return;
   const followers = fStmts().list.all(site.slug);
   if (!followers.length) return;
@@ -3115,14 +3128,29 @@
   const keys = getOrCreateKeys(site.slug);
   const me = actorId(base, site.slug);
-  const nid = noteId(base, post.id);
   const del = {
     '@context': AP_CONTEXT,
-    id: `${nid}#delete-${Date.now()}-${rid()}`,
+    id: `${objectId}#delete-${Date.now()}-${rid()}`,
     type: 'Delete',
     actor: me,
     to: [PUBLIC],
-    object: { id: nid, type: 'Tombstone' },
+    object: { id: objectId, type: 'Tombstone' },
   };
   for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, del, `${me}#main-key`, keys.private_pem);
+}
+
+export async function deliverDelete(site, post) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  if (!base || !post || !post.id) return;
+  return deliverObjectDelete(site, noteId(base, post.id));
+}
+
+/**
+ * Zelfde voor een track. Roep dit aan VOOR het verwijderen van de rij, net als
+ * bij een post: daarna is `id` er nog wel maar de context niet meer.
+ */
+export async function deliverTrackDelete(site, trackId) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  if (!base || !site || !site.slug || !trackId) return;
+  return deliverObjectDelete(site, trackUri(base, site, trackId));
 }
 
@@ -6966,5 +6994,5 @@
   buildPostTrackCollection, uitgavePost,
   buildLibrary, libraryId,
-  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
+  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverObjectDelete, deliverTrackDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
   feedCursor, feedChangesSince, waitForFeedChange,
   getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
Index: src/services/music/index.js
===================================================================
--- src/services/music/index.js	(revision 4e5fe295413a76ce5327e908eaa1d702d538ee59)
+++ src/services/music/index.js	(revision 8ed36de2a9b6b822f55c28663bec1275f43fb70d)
@@ -165,4 +165,17 @@
 }
 
+/**
+ * De identiteit van een track op de draad.
+ *
+ * Staat apart omdat hij op TWEE momenten nodig is die ver uit elkaar liggen:
+ * bij het bouwen van het Audio-object, en bij het verwijderen ervan, wanneer de
+ * rij al weg is en er dus niets meer te bouwen valt. Toen dit nog inline stond,
+ * kende alleen de bouwkant de vorm en ging er bij verwijderen geen Delete uit --
+ * elke server die de track had geindexeerd hield hem voor altijd (21-8).
+ */
+export function trackUri(base, site, id) {
+  return `${actorId(base, site.slug)}/tracks/${encodeURIComponent(id)}`;
+}
+
 export function buildTrackAudio(base, site, r, opts = {}) {
   const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
@@ -187,5 +200,5 @@
   const a = {
     ...(opts.standalone ? { '@context': AP_CONTEXT } : {}),
-    id: `${actorId(base, site.slug)}/tracks/${encodeURIComponent(r.id)}`,
+    id: trackUri(base, site, r.id),
     type: 'Audio',
     name: r.title || 'Audio',
Index: test/track-delete-federates.test.js
===================================================================
--- test/track-delete-federates.test.js	(revision 8ed36de2a9b6b822f55c28663bec1275f43fb70d)
+++ test/track-delete-federates.test.js	(revision 8ed36de2a9b6b822f55c28663bec1275f43fb70d)
@@ -0,0 +1,87 @@
+// Een track verwijderen hoort de fediverse te bereiken.
+//
+// Tot 21-8 gebeurde dat niet. Een post kondigde zijn verwijdering aan
+// (posts.js roept deliverDelete), maar een track niet: de rij ging weg, het
+// Audio-object gaf 404, en elke server die hem had geindexeerd bleef ernaar
+// wijzen. Op de hub kwam dat boven als "ongbakbeat (1)", een kaartje met een
+// dode link, en het viel alleen op omdat robo het zag staan.
+//
+// De test gaat door de ECHTE route en kijkt in de bezorgwachtrij, want een
+// eigen testhaak op deliver() bestaat hier niet. De volger krijgt een inbox op
+// een dichte poort: de directe poging faalt meteen, en dan hoort de activiteit
+// in ap_delivery te belanden. Dat is precies het bewijs dat we zoeken, want die
+// tabel was in het echte geval leeg.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'baas', 'b@t.nl', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
+  .run('s1', 'robo', 'Soundfabrics', 'u1');
+// Een dichte poort: faalt onmiddellijk, geen DNS, geen wachttijd.
+db.prepare('INSERT INTO ap_followers (slug, actor_uri, inbox) VALUES (?,?,?)')
+  .run('robo', 'https://elders.test/ap/users/x', 'http://127.0.0.1:1/inbox');
+
+const TRACK = 'trk-481fef66';
+db.prepare('INSERT INTO audio_tracks (id, site_id, title) VALUES (?,?,?)')
+  .run(TRACK, 's1', 'ongbakbeat (1)');
+
+const express = (await import('express')).default;
+const router = (await import('../src/routes/admin-audio.js')).default;
+const app = express();
+app.use(express.urlencoded({ extended: true }));
+app.use((req, res, next) => {
+  req.session = { user: { id: 'u1', role: 'god' } };
+  res.locals.site = db.prepare('SELECT * FROM sites WHERE slug = ?').get('robo');
+  res.locals.siteUrlBase = '';
+  next();
+});
+app.use('/admin/audio', router);
+
+const server = app.listen(0);
+server.unref();
+const poort = server.address().port;
+
+const wacht = (ms) => new Promise((r) => setTimeout(r, ms));
+
+test('een verwijderde track kondigt zichzelf af bij de volgers', async () => {
+  const r = await fetch(`http://127.0.0.1:${poort}/admin/audio/${TRACK}/delete`, {
+    method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(5000),
+  }).catch((e) => assert.fail(`de route antwoordde niet (${e.name})`));
+  assert.ok(r.status === 302 || r.status === 200, 'verwijderen mag niet stranden, kreeg ' + r.status);
+
+  assert.equal(
+    db.prepare('SELECT count(*) c FROM audio_tracks WHERE id = ?').get(TRACK).c, 0,
+    'de rij hoort weg te zijn',
+  );
+
+  // De bezorging is best-effort en async: even de mislukte poging laten landen.
+  for (let i = 0; i < 20 && !db.prepare('SELECT count(*) c FROM ap_delivery').get().c; i++) await wacht(100);
+
+  const rij = db.prepare('SELECT * FROM ap_delivery').get();
+  assert.ok(rij, 'er hoort een bezorging in de wachtrij te staan; leeg betekent dat er niets is verstuurd');
+
+  const act = JSON.parse(rij.body);
+  assert.equal(act.type, 'Delete');
+  assert.equal(act.object.type, 'Tombstone');
+  // De identiteit moet dezelfde zijn als die het Audio-object droeg, anders
+  // ruimt de ontvanger niets op: hij kent dat id niet.
+  assert.equal(act.object.id, `https://test.example/ap/users/robo/tracks/${TRACK}`);
+  assert.equal(act.actor, 'https://test.example/ap/users/robo');
+});
+
+test('het id in de Delete is exact het id waaronder de track de deur uit ging', async () => {
+  // De echte valkuil bij dit soort fixes: een tweede plek die het id opnieuw
+  // samenstelt en er net naast zit. Daarom bouwt trackUri() hem nu op een plek,
+  // en vergelijken we hier met wat de bouwkant zelf produceert.
+  const muziek = await import('../src/services/music/index.js');
+  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get('robo');
+  const audio = muziek.buildTrackAudio('https://test.example', site, { id: 'abc-123', title: 'x' });
+  assert.equal(audio.id, muziek.trackUri('https://test.example', site, 'abc-123'));
+});
