From 8128f752a3480af326c64268559b44dcbff33464 Mon Sep 17 00:00:00 2001 From: jay23606 <4602056+jay23606@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:05:06 -0400 Subject: [PATCH] Radio: one song per fresh genre, no back-to-back artist; hide the volume slider where the device ignores it Co-Authored-By: Claude Sonnet 5 --- README.md | 6 +++-- src/main.js | 4 ++-- src/music.js | 26 ++++++++++++++------- test/music.test.js | 57 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 58e8001..de21dae 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,10 @@ room sidebar and give the table more space. Pool Masters Radio streams real, actually-licensed tracks (CC BY / BY-SA, fetched from Jamendo via the Openverse API) rather than a fixed playlist. It is on by default at 20% volume (turn it off or change the volume and that choice is kept), -picks from 36 genres and a random page of each, so the mix is thousands of tracks, -and never repeats a song until 80 others have played. A failed request is retried on +asks for a fresh genre (of 59) and a random page for every song and plays one track from it (never the rest of that page, which +would be twenty songs of one genre, and never the artist who has just played), so the mix is thousands of tracks, +and never repeats a song until 80 others have played. Some phones (iPhones) ignore an audio element's volume, so +the volume slider is hidden while a real song plays there, and the button's tooltip says to use the device's own buttons. A failed request is retried on other genres; a browser that blocks autoplay gets the song on your first tap. Only if no real track can be had does it fall back to the procedurally-generated station catalogue (`src/music.js`'s 48 `MUSIC_PRESETS`, the pool-themed loops), and it tries diff --git a/src/main.js b/src/main.js index 107ef7b..7e04a7c 100644 --- a/src/main.js +++ b/src/main.js @@ -151,9 +151,9 @@ function bind(){ const paintSfx=()=>{$('#mute-sfx').textContent=sfx.enabled?'♪':'✕';$('#mute-sfx').classList.toggle('on',sfx.enabled)} $('#mute-sfx').onclick=()=>{sfx.setEnabled(!sfx.enabled);localStorage.setItem('pool-masters:muted',sfx.enabled?'0':'1');paintSfx()} paintSfx() - const paintMusic=()=>{$('#music-toggle').textContent=music.enabled?'♫':'♩';$('#music-toggle').classList.toggle('on',music.enabled);$('#music-toggle').title=`Background music: ${music.title}`;$('#music-volume').hidden=!music.enabled;$('#music-volume').value=Math.round(music.volume*100)} + const paintMusic=()=>{$('#music-toggle').textContent=music.enabled?'♫':'♩';$('#music-toggle').classList.toggle('on',music.enabled);$('#music-toggle').title=`Background music: ${music.title}${music.enabled&&!music.volumeAdjustable?' (this device sets the volume with its own buttons)':''}`;$('#music-volume').hidden=!music.enabled||!music.volumeAdjustable;$('#music-volume').value=Math.round(music.volume*100)} state.paintMusic=paintMusic - music.onTrack(track=>{const credit=$('#music-credit');credit.hidden=false;credit.href=track.url;credit.textContent=`♫ ${track.title} — ${track.creator}`;credit.title=`${track.license} · Open the track source`}) + music.onTrack(track=>{paintMusic();const credit=$('#music-credit');credit.hidden=false;credit.href=track.url;credit.textContent=`♫ ${track.title} — ${track.creator}`;credit.title=`${track.license} · Open the track source`}) $('#music-toggle').onclick=()=>{music.setEnabled(!music.enabled);localStorage.setItem('pool-masters:music',music.enabled?'1':'0');paintMusic()} $('#music-volume').oninput=e=>{music.setVolume(Number(e.target.value)/100);localStorage.setItem('pool-masters:music-volume',music.volume)} $('#music-shuffle').onclick=()=>{const title=music.shuffle();toast(`Now playing: ${title}`);paintMusic()} diff --git a/src/music.js b/src/music.js index 4d776e5..3395989 100644 --- a/src/music.js +++ b/src/music.js @@ -33,16 +33,24 @@ export const MUSIC_PRESETS=moods.flatMap(([name,scale,bpm,wave,lead])=>[0,1,2,3] export function createMusic(){ let ctx,master,limiter,enabled=false,volume=1,index=Math.floor(Math.random()*MUSIC_PRESETS.length),timer,step=0 - let stream=null,loading=false,remoteTitle='',remoteCredit='',remoteCreator='',remoteLicense='',queue=[],duckTimer + let stream=null,loading=false,remoteTitle='',remoteCredit='',remoteCreator='',remoteLicense='',lastCreator='',duckTimer const played=[],playedSet=new Set();let searchBag=[] const listeners=new Set() // Jamendo's music collection provides actual tracks; general Openverse audio // results also include pets, ambience, and sound effects. - const searches=['indie rock','folk','hip hop','electronic','soul','jazz','latin','pop','vocal','ambient','funk','piano','blues','reggae','rock','acoustic','lofi','house','synthwave','classical','country','r&b','disco','world','chillout','trip hop','swing','bossa nova','punk','metal','techno','gospel','ska','dub','singer songwriter','instrumental'] + const searches=['indie rock','folk','hip hop','electronic','soul','jazz','latin','pop','vocal','ambient','funk','piano','blues','reggae','rock','acoustic','lofi','house','synthwave','classical','country','r&b','disco','world','chillout','trip hop','swing','bossa nova','punk','metal','techno','gospel','ska','dub','singer songwriter','instrumental','chill','dance','new age','celtic','flamenco','indie pop','alternative','post rock','downtempo','soundtrack','cinematic','folk rock','electro swing','breakbeat','hip-hop instrumental','jazz fusion','world music','experimental','bluegrass','trance','drum and bass','soft rock','psychedelic'] const shuffled=items=>{const out=[...items];for(let i=out.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[out[i],out[j]]=[out[j],out[i]]}return out} const nextSearch=()=>{if(!searchBag.length)searchBag=shuffled(searches);return searchBag.pop()} const remember=track=>{const id=track.foreign_landing_url||track.url;if(playedSet.has(id))return;played.push(id);playedSet.add(id);if(played.length>80)playedSet.delete(played.shift())} const current=()=>MUSIC_PRESETS[index] + // Some phones (iPhones) ignore an audio element's volume: it stays at the device's level whatever is set. + // The slider is only honest when the setting sticks, so it is tested once and reported. + let elementVolume=null + const elementVolumeWorks=()=>{ + if(elementVolume!=null)return elementVolume + try{if(typeof Audio!=='function')return true;const a=new Audio();a.volume=.5;elementVolume=a.volume===.5}catch{elementVolume=true} + return elementVolume + } const context=()=>{ if(ctx)return ctx const C=window.AudioContext||window.webkitAudioContext @@ -135,7 +143,6 @@ export function createMusic(){ } async function startRadio(){ if(!enabled||stream||loading)return - const next=queue.pop();if(next)return playTrack(next) loading=true try{ // A different genre each time, and a random page of it, so the catalogue is a few @@ -151,14 +158,17 @@ export function createMusic(){ candidates=(data.results||[]).filter(track=>track.source==='jamendo'&&track.url?.startsWith('https://')&&track.duration>=90000&&['by','by-sa'].includes(track.license)) }catch{/* try the next genre */} } - // Do not replay a song until 80 other selections have been remembered. - queue=shuffled(candidates.filter(track=>!playedSet.has(track.foreign_landing_url||track.url))) - if(!queue.length)queue=shuffled(candidates) - if(queue.length)playTrack(queue.pop());else startSynth() + // One song from each fresh search, never the rest of that page: playing a whole page in a row is + // twenty songs of one genre, which sounds like a small mix. Not a song heard in the last 80, and not + // the artist who has just played, unless that leaves nothing. + const unheard=candidates.filter(track=>!playedSet.has(track.foreign_landing_url||track.url)) + const fresh=unheard.filter(track=>track.creator!==lastCreator) + const pick=shuffled(fresh.length?fresh:unheard.length?unheard:candidates)[0] + if(pick){lastCreator=pick.creator;playTrack(pick)}else startSynth() }catch{startSynth()}finally{loading=false} } return { - get enabled(){return enabled},get title(){return remoteTitle||current().name},get credit(){return remoteCredit},get lyric(){return current().lyric},get volume(){return volume}, + get enabled(){return enabled},get title(){return remoteTitle||current().name},get credit(){return remoteCredit},get lyric(){return current().lyric},get volume(){return volume},get volumeAdjustable(){return !stream||elementVolumeWorks()}, onTrack(listener){listeners.add(listener);return()=>listeners.delete(listener)}, setEnabled(value){enabled=!!value;if(enabled)startRadio();else stop()}, setVolume(value){volume=Math.max(.1,Math.min(1,Number(value)||1));if(stream)stream.volume=volume;if(enabled&&ctx)master.gain.linearRampToValueAtTime(volume,ctx.currentTime+.08)}, diff --git a/test/music.test.js b/test/music.test.js index 859671e..452a055 100644 --- a/test/music.test.js +++ b/test/music.test.js @@ -85,3 +85,60 @@ test('a browser that blocks autoplay gets the real song on the first touch, not m.setEnabled(false) }finally{globalThis.window=realWindow;globalThis.fetch=realFetch;globalThis.Audio=realAudio;globalThis.addEventListener=realAdd} }) + +// twenty different songs, four artists, as one page of a search would return +const page=n=>Array.from({length:20},(_,i)=>({source:'jamendo',url:`https://example.test/p${n}-${i}.mp3`,duration:120000,license:'by',title:`Song ${n}-${i}`,creator:`Artist ${i%4}`,foreign_landing_url:`https://example.test/p${n}-${i}`})) + +test('each song comes from a fresh genre: the radio never plays out the rest of one page in a row',async()=>{ + const realWindow=globalThis.window,realFetch=globalThis.fetch,realAudio=globalThis.Audio + globalThis.window={AudioContext:fakeAudioContextClass()} + const queries=[];let calls=0 + globalThis.fetch=url=>{queries.push(new URL(url).searchParams.get('q'));return Promise.resolve({ok:true,json:async()=>({results:page(++calls)})})} + const audios=[] + globalThis.Audio=class{constructor(src){this.src=src;this.volume=1;audios.push(this)}play(){return Promise.resolve()}pause(){}removeAttribute(){}load(){}} + try{ + const m=createMusic();m.setEnabled(true);await tick(30) + for(let i=0;i<7;i++){audios.at(-1).onended();await tick(30)} + m.setEnabled(false) + assert.equal(audios.length,8,'eight songs played') + assert.equal(queries.length,8,'and each one asked for its own search, not the leftovers of the last') + assert.ok(new Set(queries).size>=6,`different genres (${new Set(queries).size} of 8)`) + const pages=audios.map(a=>a.src.match(/p(\d+)-/)[1]);assert.equal(new Set(pages).size,8,'no two songs from the same page') + assert.equal(new Set(audios.map(a=>a.src)).size,8,'no repeats') + }finally{globalThis.window=realWindow;globalThis.fetch=realFetch;globalThis.Audio=realAudio} +}) + +test('the same artist is not played twice in a row when the page offers anyone else',async()=>{ + const realWindow=globalThis.window,realFetch=globalThis.fetch,realAudio=globalThis.Audio + globalThis.window={AudioContext:fakeAudioContextClass()} + const creators={};let calls=0 + globalThis.fetch=()=>{const p=page(++calls);p.forEach(t=>{creators[t.url]=t.creator});return Promise.resolve({ok:true,json:async()=>({results:p})})} + const audios=[] + globalThis.Audio=class{constructor(src){this.src=src;this.volume=1;audios.push(this)}play(){return Promise.resolve()}pause(){}removeAttribute(){}load(){}} + try{ + const m=createMusic();m.setEnabled(true);await tick(30) + for(let i=0;i<12;i++){audios.at(-1).onended();await tick(30)} + m.setEnabled(false) + const artists=audios.map(a=>creators[a.src]) + for(let i=1;i{ + const realWindow=globalThis.window,realFetch=globalThis.fetch,realAudio=globalThis.Audio + globalThis.window={AudioContext:fakeAudioContextClass()} + globalThis.fetch=()=>Promise.resolve({ok:true,json:async()=>({results:page(1)})}) + // an iPhone: the element takes any value and reports the device level + globalThis.Audio=class{constructor(){this._v=1}get volume(){return this._v}set volume(_v){/* ignored */}play(){return Promise.resolve()}pause(){}removeAttribute(){}load(){}} + try{ + const m=createMusic();assert.equal(m.volumeAdjustable,true,'nothing playing: nothing to say') + m.setEnabled(true);await tick(30) + assert.equal(m.volumeAdjustable,false,'a real song on a phone that ignores element volume') + m.setEnabled(false) + globalThis.Audio=class{constructor(){this.volume=1}play(){return Promise.resolve()}pause(){}removeAttribute(){}load(){}} + const n=createMusic();n.setEnabled(true);await tick(30);assert.equal(n.volumeAdjustable,true,'a browser that honours it');n.setVolume(.4);n.setEnabled(false) + globalThis.fetch=()=>Promise.reject(new Error('offline')) + globalThis.Audio=class{constructor(){this._v=1}get volume(){return this._v}set volume(_v){}} + const s=createMusic();s.setEnabled(true);await tick(30);assert.equal(s.volumeAdjustable,true,'the synth is adjustable through Web Audio, whatever the element does');s.setEnabled(false) + }finally{globalThis.window=realWindow;globalThis.fetch=realFetch;globalThis.Audio=realAudio} +})