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');