[object Object]

← back to Rose City Rooftop Solstice

Make music LIVE AUDIO-REACTIVE: decode archive.org tracks through a Web Audio analyser and drive the whole scene (beat/bass/level) — the entire gathering now moves to the music

9c4c7bf475cc41eb16cbd3c3b06e9e89ec3bd557 · 2026-09-01 17:24:48 -0700 · Steve Abrams

Files touched

Diff

commit 9c4c7bf475cc41eb16cbd3c3b06e9e89ec3bd557
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 1 17:24:48 2026 -0700

    Make music LIVE AUDIO-REACTIVE: decode archive.org tracks through a Web Audio analyser and drive the whole scene (beat/bass/level) — the entire gathering now moves to the music
---
 README.md  |  37 ++++++++---
 index.html | 217 ++++++++++++++++++++++++++++++++++++++++++++-----------------
 2 files changed, 185 insertions(+), 69 deletions(-)

diff --git a/README.md b/README.md
index d20b500..859b1c1 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@ or you can pin one.
 Keyboard: `Space` pause · `1 2 3` cameras · `R` restart · `N` names ·
 `T` trails · `D` diagrams · `M` reduced motion · `C` hide panel.
 
-## Music (optional, from the archive)
+## Music — live and audio-reactive (from the archive)
 
 The control dock has a **Music · from the archive** section that streams live
 **Grateful Dead** recordings from the [Internet Archive](https://archive.org)'s
@@ -48,14 +48,33 @@ The control dock has a **Music · from the archive** section that streams live
 taping-friendly, and these recordings are shared with their blessing; each
 now-playing show links back to its archive.org item for credit.
 
-- **Play music** starts a curated, verified show (Cornell '77 by default).
-- **Shuffle show** queries the archive live and loads a random popular show.
-- **⏮ / ⏭** step tracks; the volume slider is independent of visual tempo.
-
-This is the *only* part of the piece that touches the network, and it's **audio
-only** — it starts on a click (respecting autoplay rules), and if you're offline
-it simply says so. The visuals stay fully self-contained and, per the honest
-note, **never** react to the audio.
+**When music plays, the whole scene reacts to it — live.** Each track is fetched
+and decoded, then played through a Web Audio graph
+(`BufferSource → Analyser → Gain`). Every frame the analyser is read for
+**bass / mid / treble / overall level** plus a **beat onset**, and those features
+drive *everything*:
+
+- the master clock speeds up with musical energy (the whole gathering moves faster when it's loud),
+- **dancers** grow their moves with loudness and **lift on every beat**,
+- the **rose sculpture** pulses and blooms with the bass and beat,
+- **lanterns** and the **city glow** breathe with the music,
+- **bears** sway harder on bass, **cats** leap higher, **pigeons** get extra lift on beats, **moths** flutter with the treble.
+
+Controls: **Play/Pause**, **Shuffle show** (queries the archive live for a random
+popular show), **⏮ / ⏭** track stepping, and a volume slider (independent of the
+analysis, so quiet music still drives the visuals).
+
+Why buffer-and-decode rather than a plain `<audio>` element? Reading a stream's
+spectrum through Web Audio needs the decoded samples in the graph; the
+buffer-source path guarantees that reactivity works on every browser (it's the
+same synthesis path as an oscillator), which a `MediaElementSource` does not.
+Tradeoff: each track is fetched in full first (the light 64 kbps derivative,
+~2–3 MB, ~2 s), and the next track is prefetched while the current one plays.
+
+This music is the **only** part of the piece that touches the network, and it's
+**audio only** — it starts on a click (respecting autoplay rules) and reports
+plainly if you're offline. With the music **off**, the visuals are fully
+self-contained and run on their authored rhythm.
 
 ## An honest note
 
diff --git a/index.html b/index.html
index 9d79b0a..502a127 100644
--- a/index.html
+++ b/index.html
@@ -231,7 +231,7 @@
 
     <div class="grp" role="group" aria-label="Music from the Internet Archive">
       <h2 style="margin-top:0">Music · from the archive</h2>
-      <p class="hint" id="npLine">Optional: live Grateful Dead recordings streamed from the Internet Archive. Even while it plays, the visuals never sync to the audio — their rhythm stays authored.</p>
+      <p class="hint" id="npLine">Press play: live Grateful Dead recordings stream from the Internet Archive, and the whole scene moves to them — dancers step on the beat, the rose pulses with the bass, lanterns and glow breathe with the music.</p>
       <div class="actions">
         <button class="btn" id="mPlay" aria-pressed="false">Play music</button>
         <button class="btn" id="mShuffle">Shuffle show</button>
@@ -263,7 +263,7 @@
   <button class="btn dock-toggle" id="dockToggle" aria-expanded="true" aria-controls="controls">Hide panel</button>
 
   <div class="toolbar">
-    <p class="note"><b>An honest note.</b> This piece keeps time on its own. Its rhythm, swells and blooms are <b>authored</b> — a composed visual tempo — <em>not</em> reactive to any music or microphone. Optional music streams live from the <b>Internet Archive</b>’s Grateful Dead collection; even with it playing, the visuals never sync to the audio. Turn on <b>Reduced motion</b> for a calm, still composition.</p>
+    <p class="note"><b>An honest note.</b> With the music <b>off</b>, this piece keeps its own time — an <b>authored</b> visual tempo. Press <b>Play music</b> and it becomes <b>live audio-reactive</b>: the scene analyzes the streaming Grateful Dead set (from the <b>Internet Archive</b>) in real time, so the beat, bass and brightness drive the whole gathering. Change the show and the whole scene changes with it. <b>Reduced motion</b> holds a calm, still composition.</p>
   </div>
 
 <script>
@@ -314,8 +314,10 @@ const S = {
   cam:'rooftop', targetScene:0, autoScene:true, sceneT:0,
   tempo:1, density:1, isolate:'all', paused:false, reduced:false,
   names:false, trails:true, diagrams:false,
-  t:0,                 // animation clock (scaled by tempo)
+  t:0,                 // animation clock (scaled by tempo AND by live audio energy)
   cfrac:SCENES[0].complexity,
+  // live audio-reactivity features (all 0 / inert when no music is playing)
+  audio:{ level:0, bass:0, mid:0, treble:0, beat:0, active:false, _prev:0, _avg:0, _refr:0 },
 };
 
 /* current, continuously-lerped palette + numeric fields */
@@ -602,8 +604,9 @@ function drawCityGlow(){
   // luminous band where the city meets the rooftops
   const [,y]=project(0, 320, 1);
   const g=ctx.createLinearGradient(0,y-60,0,y+120);
+  const gA=0.18*(S.audio.active?(1+0.8*S.audio.level):1);
   g.addColorStop(0, rgb(P.glow,0));
-  g.addColorStop(0.5, rgb(P.glow,0.18));
+  g.addColorStop(0.5, rgb(P.glow,gA));
   g.addColorStop(1, rgb(P.glow,0));
   ctx.fillStyle=g; ctx.fillRect(0,y-60,VW,180);
 }
@@ -740,7 +743,7 @@ const BODY_COLORS_RGB = BODY_COLORS.map(hx);
 function drawLantern(L,i){
   const [x,y]=project(L.x,L.y);
   const s=cam.scale;
-  const pulse=0.75+0.25*Math.sin(S.t*1.5+i);
+  const pulse=0.75+0.25*Math.sin(S.t*1.5+i) + (S.audio.active?(S.audio.bass*0.5+S.audio.beat*0.5):0);
   const rad=14*s*pulse*(0.7+0.6*P.star);
   const g=ctx.createRadialGradient(x,y,1,x,y,rad*2.6);
   g.addColorStop(0, rgb(P.lantern, 0.9));
@@ -761,9 +764,12 @@ function figure(x,y,sc,ph,col,style,lean,alpha){
     ctx.globalAlpha=(alpha==null?1:alpha); ctx.fillStyle=col;
     ctx.beginPath(); ctx.arc(x,y-6*s,2.4*s,0,TAU); ctx.fill(); ctx.globalAlpha=1; return;
   }
-  const bob = S.reduced?0:Math.sin(ph)*2*s;
-  const swing = S.reduced?0.25:Math.sin(ph);
-  lean = (lean||0);
+  const AA=S.audio;
+  const amp = AA.active ? (0.55 + 1.15*AA.level) : 1;      // bigger moves when louder
+  const pop = AA.active ? AA.beat*7*s : 0;                 // every dancer lifts on the beat
+  const bob = (S.reduced?0:Math.sin(ph)*2*s*amp) + pop;
+  const swing = (S.reduced?0.25:Math.sin(ph))*amp;
+  lean = (lean||0)*(AA.active?(0.7+0.8*amp*0.6):1);
   ctx.save();
   ctx.globalAlpha=(alpha==null?1:alpha);
   ctx.translate(x, y-bob);
@@ -800,10 +806,11 @@ function figure(x,y,sc,ph,col,style,lean,alpha){
 function drawRose(){
   const [x,y]=project(ROSE.x, ROSE.y);
   const s=cam.scale;
-  const bloom = clamp((S.cfrac-0.4)/0.6, 0, 1); // fuller in later scenes
+  const AA=S.audio;
+  const bloom = clamp((S.cfrac-0.4)/0.6, 0, 1) * (AA.active?(1+0.35*AA.level):1); // fuller in later scenes + with music
   const rot = S.reduced?0.4:S.t*0.35;
   const layers=5, petalsPer=8;
-  const baseR=ROSE.R*s*(0.42+0.28*bloom);
+  const baseR=ROSE.R*s*(0.42+0.28*bloom) * (AA.active?(1 + 0.28*AA.bass + 0.16*AA.beat):1);
   // ground glow
   const g=ctx.createRadialGradient(x,y,2,x,y,baseR*2.6);
   g.addColorStop(0, rgb(P.glow,0.85));
@@ -914,7 +921,7 @@ function drawBears(){
   BEARS.forEach(b=>{
     const [x,y]=project(b.x,b.y); const s=b.s*cam.scale;
     if(s<0.5) return;
-    const sway=S.reduced?0:Math.sin(S.t*1.1+b.ph)*0.10;
+    const sway=S.reduced?0:Math.sin(S.t*1.1+b.ph)*0.10*(S.audio.active?(1+1.3*S.audio.bass):1);
     ctx.save(); ctx.translate(x,y); ctx.rotate(sway);
     const col=mix(P.roof,[60,44,38],0.5);
     ctx.fillStyle=rgb(col);
@@ -957,7 +964,7 @@ function drawCats(){
   CATS.forEach(c=>{
     const t=(Math.sin(S.t*c.sp+c.ph)*0.5+0.5); // 0..1 leap progress
     const wx=lerp(c.p0[0],c.p1[0],t);
-    const arc=Math.sin(t*Math.PI)*34;
+    const arc=Math.sin(t*Math.PI)*34*(S.audio.active?(1+0.6*S.audio.level):1);
     const wy=lerp(c.p0[1],c.p1[1],t)-arc-4;
     const [x,y]=project(wx,wy); const s=cam.scale;
     if(s<0.5) return;
@@ -983,7 +990,8 @@ function drawCats(){
 function drawPigeons(){
   const t=S.t*0.5*S.tempo0 + PIGEONS.cycle;
   const phase=(t%8)/8;                // 0..1 launch/rise/settle
-  const rise = phase<0.7? (phase/0.7): (1-(phase-0.7)/0.3);
+  let rise = phase<0.7? (phase/0.7): (1-(phase-0.7)/0.3);
+  if(S.audio.active) rise = clamp(rise + S.audio.beat*0.35 + S.audio.treble*0.12, 0, 1.2);
   const [hx0,hy0]=PIGEONS.home;
   PIGEONS.birds.forEach(bd=>{
     const spiral = t*2 + bd.a;
@@ -1005,8 +1013,9 @@ function drawMoths(){
   MOTHS.forEach(m=>{
     const L=LANTERNS[m.L];
     const a=m.a + S.t*m.sp;
-    const wx=L.x + Math.cos(a)*m.r + Math.sin(S.t*3+m.ph)*3;
-    const wy=L.y + Math.sin(a*1.3)*m.r*0.7 + Math.cos(S.t*3.4+m.ph)*3;
+    const jit=3+(S.audio.active?S.audio.treble*9:0);
+    const wx=L.x + Math.cos(a)*m.r + Math.sin(S.t*3+m.ph)*jit;
+    const wy=L.y + Math.sin(a*1.3)*m.r*0.7 + Math.cos(S.t*3.4+m.ph)*jit;
     const [x,y]=project(wx,wy); const s=cam.scale;
     const flap=Math.sin(S.t*18+m.ph);
     ctx.save(); ctx.translate(x,y);
@@ -1153,9 +1162,12 @@ let last=performance.now();
 S.tempo0=1; // smoothed tempo used inside figure phase math
 function frame(now){
   const rawdt=Math.min(0.05,(now-last)/1000); last=now;
+  sampleAudio();                                   // read the live spectrum every frame
   if(!S.paused && !S.reduced){
     S.tempo0=lerp(S.tempo0,S.tempo,0.1);
-    S.t += rawdt*S.tempo;
+    // the master clock speeds up with musical energy → the whole scene moves to the music
+    const boost = S.audio.active ? (S.audio.level*1.3 + S.audio.beat*0.25) : 0;
+    S.t += rawdt*S.tempo*(1+boost);
     // scene drifting
     if(S.autoScene){
       S.sceneT += rawdt;
@@ -1274,11 +1286,14 @@ window.addEventListener('keydown',e=>{
 });
 
 /* ===================================================================
-   Optional live music — Grateful Dead recordings streamed from the
-   Internet Archive (archive.org). Public, keyless API. The band was
-   famously taping-friendly; these are shared with their blessing.
-   NOTE: this adds a runtime network dependency for AUDIO ONLY. The
-   visuals stay fully self-contained/offline and never react to sound.
+   Optional LIVE-REACTIVE music — Grateful Dead sets streamed from the
+   Internet Archive (archive.org; public keyless API — the band was
+   taping-friendly, shared with their blessing). Each track is fetched
+   and decoded into an AudioBuffer, then played through a Web Audio
+   graph (BufferSource → Analyser → Gain → speakers), so the live
+   spectrum drives every element on screen. This is the ONLY part that
+   touches the network, and it's audio-only; the visuals stay
+   self-contained and react ONLY while a set is playing.
    =================================================================== */
 const MUSIC = {
   // curated, verified-real item ids — filenames are always fetched at runtime, never hardcoded
@@ -1290,67 +1305,149 @@ const MUSIC = {
     {id:'gd73-02-15.sbd.hall.1580.sbeok.shnf',         label:'Dane County Coliseum · Feb 15, 1973'},
     {id:'gd71-08-06.aud.bertrando.yerys.129.sbeok.shnf',label:'Hollywood Palladium · Aug 6, 1971'},
   ],
-  audio:new Audio(), playlist:[], idx:0, showId:'', showLabel:'', loading:false, wantPlay:false,
+  playlist:[], idx:0, showId:'', showLabel:'', loading:false, wantPlay:false,
+  ctx:null, analyser:null, gain:null, freq:null,
+  srcNode:null, buffers:new Map(), playing:false, _stopping:false, _loadToken:0,
 };
-MUSIC.audio.preload='none';
-MUSIC.audio.volume=0.55;
 
 const iaItem = id => 'https://archive.org/details/'+id;
 const iaFile = (id,name) => 'https://archive.org/download/'+id+'/'+encodeURIComponent(name);
-const npEl=()=>document.getElementById('npLine');
-function setNP(t){ const e=npEl(); if(e) e.textContent=t; }
+function setNP(t){ const e=document.getElementById('npLine'); if(e) e.textContent=t; }
 function setCredit(id){
   const c=document.getElementById('mCredit'); if(!c) return;
-  if(!id){ c.textContent=''; return; }
-  c.innerHTML='Source: <a href="'+iaItem(id)+'" target="_blank" rel="noopener noreferrer">archive.org ↗</a> · taping-friendly recording, shared with the band’s blessing.';
+  c.innerHTML = id ? 'Source: <a href="'+iaItem(id)+'" target="_blank" rel="noopener noreferrer">archive.org ↗</a> · taping-friendly recording, shared with the band’s blessing.' : '';
 }
 function updateMusicBtn(playing){
   const b=document.getElementById('mPlay'); if(!b) return;
   b.textContent=playing?'Pause music':'Play music'; b.setAttribute('aria-pressed',String(playing));
 }
 
+// Build the Web Audio graph once (on a user gesture). Degrades to non-reactive on failure.
+function ensureCtx(){
+  if(MUSIC.ctx){ if(MUSIC.ctx.state==='suspended') MUSIC.ctx.resume(); return true; }
+  try{
+    const AC=window.AudioContext||window.webkitAudioContext;
+    MUSIC.ctx=new AC();
+    MUSIC.analyser=MUSIC.ctx.createAnalyser();
+    MUSIC.analyser.fftSize=1024; MUSIC.analyser.smoothingTimeConstant=0.82;
+    MUSIC.gain=MUSIC.ctx.createGain(); MUSIC.gain.gain.value=0.55;
+    MUSIC.analyser.connect(MUSIC.gain); MUSIC.gain.connect(MUSIC.ctx.destination); // analyser BEFORE gain → full spectrum at any volume
+    MUSIC.freq=new Uint8Array(MUSIC.analyser.frequencyBinCount);
+    MUSIC.ctx.resume();
+    return true;
+  }catch(e){ MUSIC.ctx=null; return false; }
+}
+
+// one mp3 per track; prefer the light 64Kbps derivative so loads are fast (~2–3MB / ~2s)
+function pickFiles(files){
+  const mp3=files.filter(f=>/\.mp3$/i.test(f.name||''));
+  const low=mp3.filter(f=>/64Kbps/i.test(String(f.format||'')));
+  const vbr=mp3.filter(f=>/VBR/i.test(String(f.format||'')));
+  const use=(low.length?low:(vbr.length?vbr:mp3));
+  use.sort((a,b)=>(a.name<b.name?-1:a.name>b.name?1:0));
+  return use;
+}
+
 async function loadShow(show, autoplay, silent){
   MUSIC.wantPlay = MUSIC.wantPlay || !!autoplay;
-  if(MUSIC.loading) return;
-  MUSIC.loading=true;
+  const token=++MUSIC._loadToken;
   if(!silent) setNP('Loading '+show.label+' …');
   try{
     const r=await fetch('https://archive.org/metadata/'+show.id);
     const d=await r.json();
-    let files=(d.files||[]).filter(f=>/\.mp3$/i.test(f.name||''));
-    const vbr=files.filter(f=>String(f.format||'').toUpperCase().indexOf('VBR')>=0);
-    if(vbr.length) files=vbr;                 // one derivative per track
-    files.sort((a,b)=> (a.name<b.name?-1:a.name>b.name?1:0));
-    if(!files.length) throw new Error('no mp3 files');
+    const files=pickFiles(d.files||[]);
+    if(!files.length) throw new Error('no mp3');
+    if(token!==MUSIC._loadToken) return;                 // superseded by a newer load
     MUSIC.showId=show.id; MUSIC.showLabel=show.label; MUSIC.idx=0;
     MUSIC.playlist=files.map(f=>({url:iaFile(show.id,f.name), title:(f.title||f.name.replace(/\.mp3$/i,'')).replace(/^gd[\d\-]+[a-z]*-?/i,'').trim()||f.name}));
-    MUSIC.loading=false;
     setCredit(show.id);
-    if(MUSIC.wantPlay) playTrack(0);
+    if(MUSIC.wantPlay) startTrack(0);
     else if(!silent) setNP('Ready — '+show.label);
   }catch(e){
-    MUSIC.loading=false;
     if(!silent) setNP('Couldn’t reach the Internet Archive — check your connection, then try again.');
   }
 }
-function playTrack(i){
-  if(!MUSIC.playlist.length){ MUSIC.wantPlay=true; loadShow(MUSIC.shows[0],true); return; }
+
+async function getBuffer(url){
+  if(MUSIC.buffers.has(url)) return MUSIC.buffers.get(url);
+  const r=await fetch(url);                               // CORS on archive.org fileserver is open (ACAO:*)
+  const ab=await r.arrayBuffer();
+  const buf=await MUSIC.ctx.decodeAudioData(ab);
+  MUSIC.buffers.set(url,buf);
+  if(MUSIC.buffers.size>4){ const k=MUSIC.buffers.keys().next().value; MUSIC.buffers.delete(k); } // bound memory
+  return buf;
+}
+
+function stopSource(){
+  if(MUSIC.srcNode){
+    MUSIC._stopping=true;
+    try{ MUSIC.srcNode.onended=null; MUSIC.srcNode.stop(); }catch(e){}
+    try{ MUSIC.srcNode.disconnect(); }catch(e){}
+    MUSIC.srcNode=null; MUSIC._stopping=false;
+  }
+}
+
+async function startTrack(i){
+  if(!ensureCtx() || !MUSIC.playlist.length) return;
   MUSIC.idx=((i%MUSIC.playlist.length)+MUSIC.playlist.length)%MUSIC.playlist.length;
-  const t=MUSIC.playlist[MUSIC.idx];
+  const track=MUSIC.playlist[MUSIC.idx];
+  const token=++MUSIC._loadToken;
   MUSIC.wantPlay=true;
-  MUSIC.audio.src=t.url;
-  setNP('♪ '+t.title+' — '+MUSIC.showLabel);
-  const p=MUSIC.audio.play();
-  if(p&&p.catch) p.catch(()=>{ updateMusicBtn(false); setNP('Tap “Play music” once more to allow audio.'); });
+  setNP('Buffering ♪ '+track.title+' …');
+  try{
+    const buf=await getBuffer(track.url);
+    if(token!==MUSIC._loadToken) return;                 // a newer start superseded us
+    stopSource();
+    const sn=MUSIC.ctx.createBufferSource();
+    sn.buffer=buf; sn.connect(MUSIC.analyser);
+    sn.onended=()=>{ if(!MUSIC._stopping && MUSIC.srcNode===sn) startTrack(MUSIC.idx+1); };
+    if(MUSIC.ctx.state==='suspended') await MUSIC.ctx.resume();
+    sn.start(0);
+    MUSIC.srcNode=sn; MUSIC.playing=true;
+    updateMusicBtn(true);
+    setNP('♪ '+track.title+' — '+MUSIC.showLabel);
+    prefetchNext();
+  }catch(e){
+    if(MUSIC.idx<MUSIC.playlist.length-1) startTrack(MUSIC.idx+1);   // skip a bad track
+    else setNP('Couldn’t play this show — try “Shuffle show”.');
+  }
+}
+function prefetchNext(){
+  const n=MUSIC.playlist[(MUSIC.idx+1)%MUSIC.playlist.length];
+  if(n) getBuffer(n.url).catch(()=>{});
+}
+function pauseMusic(){ if(MUSIC.ctx && MUSIC.ctx.state==='running'){ MUSIC.ctx.suspend(); MUSIC.playing=false; updateMusicBtn(false); } }
+function resumeMusic(){ if(MUSIC.ctx){ MUSIC.ctx.resume(); MUSIC.playing=true; updateMusicBtn(true); } }
+
+// Per-frame: read the live spectrum → bass/mid/treble/level + a beat onset (used by the whole scene).
+function sampleAudio(){
+  const a=S.audio;
+  if(MUSIC.analyser && MUSIC.ctx && MUSIC.ctx.state==='running' && MUSIC.playing){
+    MUSIC.analyser.getByteFrequencyData(MUSIC.freq);
+    const f=MUSIC.freq, n=f.length;
+    const bE=Math.max(2,Math.floor(n*0.06)), mE=Math.floor(n*0.30), tE=Math.floor(n*0.70);
+    let bass=0,mid=0,tre=0,all=0;
+    for(let i=0;i<bE;i++)  bass+=f[i];
+    for(let i=bE;i<mE;i++) mid +=f[i];
+    for(let i=mE;i<tE;i++) tre +=f[i];
+    for(let i=0;i<tE;i++)  all +=f[i];
+    bass/=(bE*255); mid/=((mE-bE)*255); tre/=((tE-mE)*255); all/=(tE*255);
+    a.bass=lerp(a.bass,bass,0.4); a.mid=lerp(a.mid,mid,0.4); a.treble=lerp(a.treble,tre,0.4);
+    a.level=clamp(lerp(a.level,all*1.7,0.3),0,1);
+    const flux=bass-a._prev; a._prev=bass;
+    a._avg=lerp(a._avg,bass,0.08);
+    if(bass>a._avg*1.32 && flux>0.02 && a._refr<=0){ a.beat=1; a._refr=7; }
+    a._refr--; a.beat*=0.80;
+    a.active=true;
+  } else {
+    a.active=false; a.beat*=0.8;
+    a.level=lerp(a.level,0,0.12); a.bass=lerp(a.bass,0,0.12); a.mid=lerp(a.mid,0,0.12); a.treble=lerp(a.treble,0,0.12);
+  }
 }
-MUSIC.audio.addEventListener('ended', ()=>playTrack(MUSIC.idx+1));
-MUSIC.audio.addEventListener('error', ()=>{ if(MUSIC.playlist.length && MUSIC.idx<MUSIC.playlist.length-1) playTrack(MUSIC.idx+1); });
-MUSIC.audio.addEventListener('play',  ()=>updateMusicBtn(true));
-MUSIC.audio.addEventListener('pause', ()=>updateMusicBtn(false));
 
 async function liveShuffle(){
-  setNP('Finding a show in the archive …');
-  MUSIC.wantPlay=true;
+  ensureCtx();
+  setNP('Finding a show in the archive …'); MUSIC.wantPlay=true;
   try{
     const q=encodeURIComponent('collection:GratefulDead AND format:(VBR MP3)');
     const r=await fetch('https://archive.org/advancedsearch.php?q='+q+'&fl[]=identifier&fl[]=title&sort[]=downloads+desc&rows=60&output=json');
@@ -1359,25 +1456,25 @@ async function liveShuffle(){
     if(!docs.length) throw new Error('empty');
     const pick=docs[Math.floor(rnd()*docs.length)];
     const label=String(pick.title||'Grateful Dead').replace(/^Grateful Dead Live at\s*/i,'').replace(/\s+on\s+/,' · ');
-    MUSIC.loading=false; loadShow({id:pick.identifier,label}, true);
+    loadShow({id:pick.identifier,label}, true);
   }catch(e){
-    MUSIC.loading=false;
     loadShow(MUSIC.shows[Math.floor(rnd()*MUSIC.shows.length)], true);   // fallback: curated
   }
 }
 
 // wiring
 document.getElementById('mPlay').addEventListener('click',()=>{
-  if(MUSIC.audio.src && !MUSIC.audio.paused){ MUSIC.audio.pause(); return; }
-  if(MUSIC.audio.src){ const p=MUSIC.audio.play(); if(p&&p.catch)p.catch(()=>{}); return; }
-  if(MUSIC.playlist.length) playTrack(MUSIC.idx);
+  ensureCtx();
+  if(MUSIC.playing){ pauseMusic(); return; }
+  if(MUSIC.srcNode && MUSIC.ctx && MUSIC.ctx.state==='suspended'){ resumeMusic(); return; }
+  if(MUSIC.playlist.length) startTrack(MUSIC.idx);
   else { MUSIC.wantPlay=true; loadShow(MUSIC.shows[0], true); }
 });
-document.getElementById('mNext').addEventListener('click',()=>playTrack(MUSIC.idx+1));
-document.getElementById('mPrev').addEventListener('click',()=>playTrack(MUSIC.idx-1));
+document.getElementById('mNext').addEventListener('click',()=>{ if(MUSIC.playlist.length) startTrack(MUSIC.idx+1); });
+document.getElementById('mPrev').addEventListener('click',()=>{ if(MUSIC.playlist.length) startTrack(MUSIC.idx-1); });
 document.getElementById('mShuffle').addEventListener('click',liveShuffle);
-document.getElementById('mVol').addEventListener('input',e=>{ MUSIC.audio.volume=+e.target.value; });
-// silent metadata prefetch so the first Play is instant (no audio, no autoplay, leaves the note text intact)
+document.getElementById('mVol').addEventListener('input',e=>{ if(MUSIC.gain) MUSIC.gain.gain.value=+e.target.value; });
+// silent metadata prefetch so the first Play only needs to fetch the audio
 setTimeout(()=>{ if(!MUSIC.playlist.length && !MUSIC.loading) loadShow(MUSIC.shows[0], false, true); }, 1800);
 
 /* ============================ BOOT ============================ */

← 632cd63 Add optional live Grateful Dead music streamed from the Inte  ·  back to Rose City Rooftop Solstice  ·  (newest)