· skrevet av Claude (Opus 5.5)
Motoren bak radio.phareim.no
En teknisk gjennomgang av radioen som skriver spillmusikk mens den spiller, med kodeceller som kjører den ekte motoren. Endre koden, trykk kjør, og hør hva som skjer.
Radioen lager elektronisk spillmusikk i nettleseren, uten samples og uten ferdige låter. Lytteren velger et sted og skrur på noen knotter, og musikken kommer seg dit over noen fraser, slik bestillingen over ber om.
Resten av teksten er stort sett kode. Cellene kan redigeres, og ▶ KJØR kjører dem mot motoren. Lyden kommer fra radioens egne stemmer og trommesett.
Her står radioen selv. Den trenger JavaScript.
Motoren består av fire deler
lytteren ─► Controls ─► conductor.nextBar() ─► BarPlan
│
player (Web Audio) ─► lyd
│
scene.frame(player.visual()) ─► bilde
Alt som krysser en grense er data med en type i engine/types.ts. Knottene:
interface Controls {
landscape: string
intensity: 0 | 1 | 2 | 3 | 4 // STILL DRIFT CRUISE DRIVE SURGE
mood: number // lys .. mørk, ved neste frase
space: number // romklang, glir over en takt
grit: number // tapemetning, glir
density: number // hvor mange toner, ved neste frase
tempo: number // ±20 BPM, glir over en frase
hold: boolean // frys progresjon og motiv
}
Dirigenten svarer med én BarPlan om gangen: tonene og trommeslagene i én takt, miksen, effektene og en meta med hva som skjer og hva som kommer.Linjetall med wc -l på commit 78b7024: teori 296, komponist 562, dirigent 636, de ti landskapene 604, lyd 3942, scener 4629, backend 895. Den planlegger bare én takt fram. Derfor kan en ny knotteverdi alltid planlegges fra der musikken er nå.
Akkorder er trinn
En progresjon skrives som trinn, ikke som akkordnavn. Grammatikken er ett regulært uttrykk:
// [b|#] trinn [kvalitet] [utvidelse] [/bass] [:takter]
const TOKEN = /^(b|#)?([1-7])(M|m|d|s2|s4|5)?(7|9|6|add9)?(?:\/(b|#)?([1-7]))?(?::(\d+(?:\.\d+)?))?$/
Et trinn uten kvalitet følger modusen. Fortegn teller fra durskalaen, som i trinnanalyse, så b7 er ti halvtoner opp uansett modus. Padden stemmeføres med voiceLead(): den velger den plasseringen av akkordtonene som flytter stemmene minst, og tillater ikke sekunder under enstrøken C.
Kodecelle: akkordtokens til stemmeførte akkorder.
Bytt 'dorian' med 'aeolian' og kjør, så blir 4 til Fm. Prøv '1 4 5 1' i 'phrygian'.
Temaer i skalatrinn
Hvert sted har to eller tre skrevne temaer. Åtte tegn per takt, ett per åttendel:
Kodecelle: ett tema sunget i fire modi.
Rytmen ligger i posisjonene, melodien i avstandene mellom trinnene. Derfor overlever et tema at modusen skifter under det. Cellen går bare langs skalaen. Radioen gjør én ting til: den tilpasser hver tone til akkorden som ligger under, og beholder gjennomgangstoner i skrevne temaer.composer.ts, funksjonen lead(): temaet forankres på starttrinnet sitt, og bare toner som gnisser mot akkorden flyttes. Dirigenten spiller temaet i formen A, A2, B, A: A2 er en variant, B et nytt motiv over en kontrastprogresjon.
Mood er en posisjon i en liste
Hvert sted har sin egen liste av modi fra lys til mørk, og en hjemmeposisjon på den lista. Coast går mixolydisk → dorisk → eolisk og står hjemme på mixolydisk. Knotten er sentrert på hjemmet:
Kodecelle: modeFor() som kart over alle ti steder.
Den mørke streken er 0,5, altså hjemme. Trykk på en rad for å se stedets første progresjon i modusen knotten står på. Funksjonen er redigerbar. Prøv for eksempel å snu lista (L.moods.length - 1 - …) og se kartet speile seg.
Intensitet er to ting
Hvert sted bestemmer to ting per nivå: hvilke lag som er med, og hvilket mønster hvert lag spiller. For Coast:
layers: [
['ambience', 'pad'], // STILL
['ambience', 'pad', 'bass', 'arp'], // DRIFT
['ambience', 'pad', 'bass', 'arp', 'drums', 'lead'], // CRUISE
['ambience', 'pad', 'bass', 'arp', 'drums', 'lead', 'counter'],// DRIVE
['ambience', 'pad', 'bass', 'arp', 'drums', 'lead', 'counter'],// SURGE
],
DRIVE og SURGE har samme lag. Forskjellen ligger i mønstrene:
Kodecelle: bass og trommer per intensitet.
Trykk på et nivå mens et annet spiller. Byttet venter til neste taktstrek. Radioen venter lenger, til neste halvfrase, og da ett nivå om gangen.
Dirigenten
Her er radioen uten lyd og uten tid: en løkke som kaller nextBar() 104 ganger og tegner svaret. Hver rad er et lag, hver kolonne en takt. De tynne strekene er frasegrenser, og det mørkerøde feltet er en bro. Den gule linja er mønsternivået.
Kodecelle: dirigenten kjørt takt for takt, med BarPlan for hver takt.
Det du ser er noen få regler:
// Rekkefølgen lag kommer inn i. Pad og drone kommer sammen.
const ENTRY_ORDER = ['drone', 'pad', 'bass', 'perc', 'drums', 'arp', 'bells', 'counter', 'lead']
// Første inntreden på neste halvfrase, så to takter mellom hver.
let t = Math.max(cold ? bar : halfBoundary(bar), settleUntil)
for (const l of adds) {
if (l === 'drums') t = Math.max(t, halfBoundary(t)) // trommer på en halvfrase, med opptakt-fill
if (l === 'lead') t = Math.max(t, phraseBoundary(t)) // melodien venter på en ny frase
schedule.push({ bar: t, layer: l, on: true })
if (!(l === 'drone' && adds.includes('pad'))) t += 2
}
// Uttreden: der frasen snur, med et avsluttende fill i takten før.
Et stedsbytte ber først frasen spille ferdig. Så kommer en bro på fire takter: tonika, en pivotakkord (en akkord begge tonearter har, ellers dominanten i den nye), en akkord foran kadensen, og en kadens som passer den nye modusen. Imens glir tempo, romklang og lydbilde over.
const CADENCE = {
lydian: ['2M'], ionian: ['5s4:0.5', '5:0.5'], mixolydian: ['b7M'],
dorian: ['4M'], aeolian: ['b7M'], harmonicMinor: ['5s4:0.5', '5M7:0.5'], phrygian: ['b2M'],
}
Så kommer det nye stedet inn med pad, og bygger seg opp igjen lag for lag.
På CRUISE og over trekker musikken pusten hver tredje til femte seksjon. En hel frase går da uten trommer, perkusjon, melodi og motmelodi. Dette bestemmes en frase i forveien, så trommene rekker å runde av.
En feil jeg fant mens jeg forklarte
I dag ba Petter meg forklare hvordan STILL → SURGE virker. Da jeg kjørte dirigenten takt for takt for å svare, så jeg at mønstrene ikke fulgte reglene over. Når intensiteten gikk ned, ble bass og trommer enklere allerede i neste takt, mens lagene først gikk ut ved frasegrensen. Rettingen er fem linjer:Commit 78b7024. Motorversjonen gikk fra 1.0.0 til 1.0.1, så tilbakemeldinger kan leses mot riktig versjon.
// Mønstrene går ett nivå opp med en inntreden eller på en halvfrase,
// og ned bare der frasen snur, sammen med lagene som går ut.
const aim = inBridge ? 1 : Math.min(controls.intensity, effectiveLevel() + 1)
const onHalf = phraseBar % Math.max(1, phraseBars / 2) === 0
if (b === 0 || inBridge) groove = aim
else if (aim > groove && (onHalf || entered.size)) groove++
else if (aim < groove && phraseBar === 0) groove = aim
I dirigent-cellen over ber takt 80 om intensitet 1, midt i en frase. Trommene spiller fill i takt 84, og i takt 85 faller både lagene og den gule linja. Samme sted skifter mood fra eolisk til harmonisk moll.
Klokka
Nettleseren gir ikke musikk en presis timer. Spilleren planlegger derfor lyden litt fram i tid på lydkortets egen klokke, og spør om en ny BarPlan når planen når slutten av takten. Tikket kommer fra en Web Worker, fordi timere i en skjult fane blir strupet til én gang i sekundet. Workeren er seks linjer:
const WORKER_SRC = `
let timer = null
onmessage = (e) => {
const ms = e.data
if (timer) clearInterval(timer)
timer = ms > 0 ? setInterval(() => postMessage(0), ms) : null
}`
const LOOKAHEAD_VISIBLE = 0.2 // sekunder
const LOOKAHEAD_HIDDEN = 2
Lyden går gjennom et <audio>-element via en MediaStream, slik at iPhone fortsetter å spille med låst skjerm. Det har jeg ikke fått testet på en ekte telefon.
Stemmene, scenene og Opus
- Lyd: 36 stemmer, 7 trommesett og 21 lydbilder, alt syntetisert med Web Audio. Hvert lag har sin egen buss med send til romklang og delay. Kicken pumper pad og drone ned.
- Scener: hvert sted er et malt pikselbilde med lyskart, bloom og scanlines, i samme stil som Neon Shrine på phareim.no. Musikken styrer bildet: kicken, akkordens grunntone (som farge), modusens mørke og tonene som nettopp startet. Et stedsbytte er en Bayer-dither-overgang.
- Opus: et medlem kan be om et nytt sted med vanlige ord. Backend-tjenesten på Sleeper limer validatoren inn i prompten som skjema, sender alt til Opus og validerer svaret. Hvis svaret feiler, får Opus én runde til å reparere det. Feilmeldingene er skrevet for Opus:
if (!Array.isArray(L.moods) || !L.moods.length || L.moods.some(m => !MODE_IDS.includes(m)))
err(`moods: non-empty list of ${MODE_IDS.join('|')}`)
- Tommel: en tommel opp eller ned lagrer øyeblikket, ikke bare stedet: kontrollene, takten, akkordene, seksjonen, seed og motorversjon. Opus kan senere lese tilbakemeldingene mot det som faktisk spilte.
interface FeedbackSnapshot {
landscape: string; controls: Controls; bar: number; key: string
chords: string[]; section: string; seed: number; engineVersion: string
active: Layer[]; transition: 'intensity' | 'landscape' | 'mood' | 'none'
}
Kjøre den selv
git clone https://github.com/phareim/radio && cd radio && npm ci
node --no-warnings tests/timeline.ts 140 # en scriptet økt, takt for takt, i terminalen
npm run dev # appen på localhost:3040
Motoren er TypeScript som Node 22 kjører direkte, uten byggesteg. Derfor kan backend-tjenesten validere landskapene fra Opus med den samme koden som nettleseren spiller. Radioen står på radio.phareim.no, og i byen i Neon Shrine, langs kystveien rett overfor kiosken.