Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/main.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 18 additions & 8 deletions src/music.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)},
Expand Down
57 changes: 57 additions & 0 deletions test/music.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<artists.length;i++)assert.notEqual(artists[i],artists[i-1],`songs ${i} and ${i+1} are by ${artists[i]}`)
}finally{globalThis.window=realWindow;globalThis.fetch=realFetch;globalThis.Audio=realAudio}
})

test('the volume slider is only offered when the device honours it, and always on the synth',async()=>{
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}
})
Loading