diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 902638d..6b4fe68 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -2886,6 +2886,56 @@ function startMeter(uuid, stream){
}
function stopMeter(uuid){ meterCtl.delete(uuid); }
+/* ==================================================================
+ * notification tones — synthesised via Web Audio so no asset needed.
+ *
+ * playToneJoin() — ascending major-third chime (523→659 Hz):
+ * friendly 'welcome' signature
+ * playToneRaise() — single bell-like 880 Hz tone: gentle attention
+ *
+ * A single shared AudioContext is lazily created on first use; the
+ * user already gestured to enter the room (clicked 'enter'), so the
+ * autoplay policy is satisfied. Each tone uses a short exponential
+ * fade-out to avoid click artifacts.
+ *
+ * Tones are skipped if the page is hidden (background tab) — the
+ * idea is to notify the LIVE user, not interrupt them when they
+ * stepped away.
+ * ================================================================== */
+let _toneCtx = null;
+function _toneCtxGet(){
+ if (_toneCtx) return _toneCtx;
+ try { _toneCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch(_){ return null; }
+ return _toneCtx;
+}
+function playToneBlip(freq, durationMs, type){
+ if (typeof document !== 'undefined' && document.hidden) return;
+ const ctx = _toneCtxGet(); if (!ctx) return;
+ try {
+ const t0 = ctx.currentTime;
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.type = type || 'sine';
+ osc.frequency.value = freq;
+ osc.connect(gain); gain.connect(ctx.destination);
+ /* short attack, exponential decay → no click on start or end */
+ gain.gain.setValueAtTime(0.0001, t0);
+ gain.gain.exponentialRampToValueAtTime(0.12, t0 + 0.012);
+ gain.gain.exponentialRampToValueAtTime(0.0001, t0 + durationMs / 1000);
+ osc.start(t0);
+ osc.stop(t0 + durationMs / 1000 + 0.02);
+ } catch(_){}
+}
+function playToneJoin(){
+ /* C5 → E5 ascending major third — a classic 'someone arrived' lift */
+ playToneBlip(523.25, 140, 'sine');
+ setTimeout(() => playToneBlip(659.25, 200, 'sine'), 120);
+}
+function playToneRaise(){
+ /* single A5 bell-ish tone — light, attention-getting */
+ playToneBlip(880.0, 260, 'triangle');
+}
+
/* ==================================================================
* room state mirror (server is source of truth, we mirror locally for
* rendering). updated by welcome / peer-joined / peer-left / state /
@@ -3075,6 +3125,10 @@ async function handleSignal(raw){
case 'peer-joined':
members.set(m.uuid, { uuid:m.uuid, pubkey:m.pubkey, handle:m.handle, role:m.role, joined_at: Date.now()/1000 });
{ const pub = pubHexFromMsg(m, 'pubkey'); logLine('', idTag(m.uuid, pub)+' joined as '+m.role); }
+ /* classic 'guest joined' chime — server only sends peer-joined
+ * to OTHER members (broadcastExcept), so we're always notifying
+ * the existing room about a new arrival. */
+ playToneJoin();
/* re-announce our mic state so the new arrival's UI reflects
* reality. Server-side mic-state-req covers welcome timing; this
* covers the standard 'a new peer joined while we were already
@@ -3153,6 +3207,9 @@ async function handleSignal(raw){
case 'hand-raised':
handraise.add(m.uuid); renderRoom();
{ const pub = pubHexFromMsg(m, 'pubkey'); logLine('', idTag(m.uuid, pub)+' raised hand'); }
+ /* attention bell for mods — skip if it's our own hand going up
+ * (server broadcasts to everyone including the raiser). */
+ if (m.uuid !== myUUID) playToneRaise();
break;
case 'hand-lowered':
handraise.delete(m.uuid); renderRoom();
@@ -4029,8 +4086,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');