Min bygglogg · 2020 → 2026

Från en vanlig MP3-ström till Relay 7 Kontrollcenter.

Det här är den långa versionen. Jag började 2020 med en vanlig MP3-radio. Sedan har jag bytt installationer, byggt webb, brandvägg, API:er, app, egen musiklogik, showmotor, AI-flöde, Westin-AI, scheduler och en massa kod som egentligen inte fanns i planen från början.

Relay SevenRelay 7 KontrollcenterWestin-AIGemma 3 + Ollama2020–2026trial and error
Varför jag skriver det här

Jag vill ha kvar omvägarna, inte bara slutresultatet.

Det mesta som ser självklart ut idag började som en halvbra lösning på ett helt annat problem. Jag har installerat om, flyttat ansvar, byggt specialkod, tagit bort specialkod, köpt hårdvara, kört slut på videominne, jagat fel routes och hört radioprat som fått mig att direkt öppna koden igen.

Det här är därför inte en snygg changelog. Det är historien om hur jag faktiskt byggt stationen.

2020en vanlig MP3-ström
2026ett modulärt kontrollcenter
24/7grundkravet sedan starten
mångaomtag som blev bättre arkitektur
Tekniska namn är medvetet omgjorda. Jag beskriver systemen som mina egna Relay Seven-komponenter. Gemma 3 och Ollama är de enda externa produktnamnen som används här. Känsliga driftuppgifter, privata adresser och sådant som inte hör hemma offentligt är borttaget.

Det började inte med AI, appar eller ett kontrollcenter. Det började med en vanlig MP3-ström.

Relay Seven började redan 2020, långt innan stationen hade det namn, det utseende och den teknik den har idag. Första versionen var i grunden precis det en internetradio behöver vara för att överhuvudtaget räknas som radio: en vanlig MP3-ström som gick att ansluta till och lyssna på. Ingen avancerad programlogik, ingen egen showmotor och ingen röstkedja som försökte förstå sammanhang. Jag ville först få den mest grundläggande saken att fungera stabilt: musik in, MP3 ut, lyssning över nätet.

De första åren handlade därför mycket mer om uthållighet än om funktioner. En stream som fungerar i fem minuter är ett test. En stream som kan stå och rulla kväll efter kväll utan att jag behöver sitta bredvid den är början på en radiostation. Jag fick lära mig den skillnaden ganska tidigt. Det finns massor av små fel som aldrig syns när man provar en låt: anslutningar som blir hängande, en källa som inte reconnectar, metadata som stannar på föregående låt, en fil som har annorlunda samplingsfrekvens eller en spelare som tror att strömmen dött bara för att leveransen varit ojämn en kort stund.

På den tiden behövde jag inte ett avancerat system. Jag behövde en kedja som gick att förstå. Därför blev första generationen medvetet enkel. MP3 var distributionsformatet och allt annat fick växa fram efter hand. Den enkelheten har faktiskt påverkat många senare beslut. Varje gång projektet blivit för komplicerat har jag kunnat gå tillbaka till samma fråga: vad är den minsta kedjan som måste fungera för att musiken fortfarande ska höras?

Det är fortfarande ett viktigt designmål 2026. Jag kan idag starta om kontrollcentret, bygga om en språkmodul eller ändra en scheduler utan att själva musikströmmen måste dö. Den principen föddes inte i en arkitekturritning. Den föddes under de tidiga åren när hela projektets värde låg i att MP3-strömmen faktiskt fortsatte gå.

Förenklad första generation
musikfiler
   ↓
enkel spelkö
   ↓
MP3-kodning
   ↓
stream
   ↓
lyssnare

Det fanns inget glamoröst i den kedjan, men den gav mig något väldigt viktigt: en fungerande kärna att experimentera runt. Jag behövde inte lösa allt på en gång. Jag kunde låta radion vara radio först och bygga intelligensen senare.

Hur jag tänkte då

2020 fanns ingen anledning att bygga ett stort system. Jag behövde först bevisa att idén hade ett värde för mig. Det gjorde att jag höll arkitekturen nästan löjligt enkel. Ju färre delar som fanns, desto lättare var det att förstå varför strömmen dog när den dog.

Jag fick samtidigt den första smaken av ett problem som skulle följa projektet i sex år: en internetradio är en kedja av realtid och halv-realtid. Filen kan finnas på disk men ändå komma för sent. Nätet kan vara snabbt men ändå få ett kort avbrott. Klienten kan ha buffert men ändå tappa om upstream står still för länge. Redan här lärde jag mig att felsökning måste göras från källa till lyssnare, inte från den komponent jag råkar misstänka först.

Jag testade därför mycket genom att medvetet stoppa och starta delar. Vad händer när källan försvinner? Kommer strömmen tillbaka? Vad ser klienten? Måste någon manuellt trycka på play igen? Sådana tester blev grunden för det jag idag kallar recovery-behaviour.

De första nätterna med strömmen

Det första jag fick lära mig var att en MP3-ström kan fungera perfekt medan jag tittar på den och ändå bete sig helt annorlunda efter några timmar. Jag kunde starta en spelare, höra musik, byta låt och tro att allt var klart. Sedan kunde jag komma tillbaka senare och upptäcka att metadata hade frusit, att källan hade tappat kopplingen eller att någon del inte hade återanslutit efter ett kort nätproblem.

Jag började därför testa på ett helt annat sätt. I stället för att bara fråga “spelar det?” började jag medvetet skapa fel. Jag stoppade källan, startade om den, bröt anslutningen och såg vad som kom tillbaka av sig självt. Jag testade vad som händer om en låtfil saknas, om nästa fil har ett annat tekniskt format och om en klient ansluter mitt i ett byte. Det gav mig tidigt en respekt för recovery som jag fortfarande använder idag.

Det var också här jag började förstå värdet av att inte låta en enda process vara allt. Om samma process väljer låt, kodar ljud, håller streamen och serverar metadata blir ett litet fel ett stort fel. Jag hade inte orden “separation of concerns” i huvudet varje gång, men jag började intuitivt vilja bryta isär saker.

Tidigt återhämtningsmål
if source_disappears:
      keep_service_alive()
      retry_source()
  
  if metadata_breaks:
      audio_must_continue()
  
  if one_component_restarts:
      avoid_restarting_everything()

Det här är viktigt eftersom dagens Relay Seven ser väldigt avancerat ut jämfört med 2020, men själva grundkravet är fortfarande identiskt: musikströmmen är den sista saken som ska ge upp.

Första riktiga lärdomen: en radioström är ett långtidstest, inte ett demo.

När den första MP3-strömmen fungerade kom nästa period: att få den att fortsätta fungera. Det här låter mindre spännande än senare AI- och showproblem, men flera av de viktigaste driftvanorna kommer härifrån.

Jag började se skillnaden mellan ett fel som stoppar allt och ett fel som bara gör upplevelsen sämre. Ett streamavbrott är uppenbart. Fel metadata är mer förrädiskt eftersom ljudet fortsätter samtidigt som informationen runt radion börjar ljuga. Ett problem med återanslutning kan ligga dolt tills en komponent behöver startas om. En långsam fil kan fungera nittionio gånger och stanna den hundrade.

Det gjorde att jag började tänka i watchdogs, health checks och återhämtning långt innan de funktionerna fick fina namn. Om en del kan dö måste någon annan kunna upptäcka det. Om en tjänst måste startas om ska resten helst fortsätta. Om state måste sparas ska det göras innan processen försvinner.

Jag byggde inte allt detta 2020 på en gång. Det växte fram genom att samma typ av driftfel återkom i olika former. Varje liten fix blev en ledtråd till hur nästa generation borde se ut.

Det var här jag slutade tänka “starta radion” och började tänka “driva radion”. Det är två helt olika problem.

Det tråkiga jobbet som senare blev guld

Jag började föra enklare anteckningar om när något stannade och vad som hänt precis innan. Det var inte avancerad observability, men det lärde mig att tidsstämplar är värdefulla. “Det dog någon gång i natt” hjälper nästan inte alls. “Källan tappade kontakt 02:14:07 och försökte återansluta 02:14:12” går att arbeta med.

Jag började också skilja på process up och service healthy. En process kan leva men vara oanvändbar. Den kan ha en öppen port men inte leverera ljud. Den idén återkommer idag i Westin-AI och Kontrollcenter där health inte bara betyder att processen finns.

Jag byggde mina första mentala health checks innan jag hade riktiga health endpoints

I början bestod kontrollen ofta av ganska manuella frågor. Hör jag ljud? Byter låtnamnet? Går det att ansluta igen efter att spelaren stoppats? Fortsätter servern efter en omstart? Det var enkelt, men det lärde mig att “processen kör” är ett dåligt mått på om en radiotjänst är frisk.

En process kan leva men sitta fast. En port kan svara men skicka gammal data. En ljudkälla kan vara ansluten utan att faktiskt mata frames. Därför började jag tänka i flera nivåer av hälsa: process, anslutning, dataflöde och faktisk output.

Den tanken har senare blivit mycket konkret i tjänsterna. Westin-AI behöver inte bara säga att processen lever; Kontrollcenter behöver veta om modellen är redo att rendera. Now Playing behöver inte bara svara HTTP; svaret ska ha färsk metadata. Broadcast kan vara uppe, men om ljudkön inte rör sig är något ändå fel.

Jag började också inse att loggar måste berätta mer än “error”. Ett fel som bara säger att något gick snett tvingar mig att återskapa problemet. Ett fel som säger vilken del, när, med vilket state och vilken fallback som valdes blir dokumentation i realtid.

Ljudet fungerade. Då började jag störa mig på allt runt ljudet.

När strömmen i sig var stabil nog började jag lägga märke till det lyssnaren faktiskt ser: artist, låttitel, status och senare historik. Metadata låter som en liten detalj tills flera källor skriver den på olika sätt.

En källa kan skriva artist och titel i ett enda fält. En annan kan separera dem. En intern stationstext kan råka se ut som en låt. Ett stations-ID kan dyka upp som historik om jag bara sparar allt som förändras. Om samma titel rapporteras flera gånger kan en enkel historik tro att låten spelats flera gånger.

Jag började därför bygga ett eget lager som tolkar informationen innan den visas. Det var en tidig föregångare till dagens Relay 7 Now Playing API.

Principen som växte fram
rå metadata
   ↓
normalisera text
   ↓
filtrera interna poster
   ↓
deduplicera
   ↓
publicera "nu spelas"
   ↓
lägg riktig låt i historiken

Det här var också första gången jag på riktigt byggde något som inte behövdes för själva ljudet men som gjorde stationen mer begriplig. När den komponenten väl fanns blev det självklart att webben senare skulle kunna visa historik, omslag, showinfo och nästa låt utan att behöva förstå hela radiomotorn.

Metadata blev mitt första API-problem

Det var lockande att låta webben läsa rå streammetadata direkt. Jag gjorde sådana försök, men problemet blev snart att varje klient behövde förstå specialfallen. Jag ville inte ha samma regex i webben, mobilen och en framtida adminpanel.

Jag började därför tänka i ett canonical now-playing object. Det behöver inte spegla exakt hur upstream skriver metadata. Det ska spegla hur Relay Seven definierar “en aktuell låt”. När den tanken satt blev API-lagret ett naturligt hem för fler saker senare.

Historiken blev det första stället där “rådata” och “sanning för lyssnaren” skildes åt

När jag började visa historik märkte jag snabbt att rå metadata inte är samma sak som en publik händelselogg. Ett internt stations-ID kan vara relevant för driften men ointressant i historiken. Samma låt kan rapporteras flera gånger när metadata uppdateras. En tom artist eller titel ska inte skapa en post bara för att upstream skickade något.

Jag började lägga in små filter ett efter ett. Först ignorera tomma poster. Sedan ignorera kända interna etiketter. Sedan deduplicera samma artist/titel inom kort tid. Sedan hålla ett begränsat antal poster så sidan inte växer okontrollerat.

Det här blev så småningom en viktig designprincip för alla publika API:er: jag vill skicka domändata, inte implementationens rådata. Om Broadcast internt kallar något “voice_queue” behöver appen inte veta det. Om ett stations-ID spelas ska historiken kunna behandla det enligt publik policy utan att klienten känner till hur Broadcast klassificerar filer.

Publik historik är filtrerad domändata
event = normalize(rawMetadata)
  
  if event.is_internal:
      skip()
  
  if same_as_last_public_track(event):
      skip()
  
  history.prepend(event)
  history = history.slice(0, limit)

Det här gjorde senare flytten till flera klienter betydligt lättare.

Från “ström” till “station”: scheman, jinglar och tanken på olika tider på dygnet.

Under de första åren var det fortfarande en ganska traditionell MP3-radio, men jag började gradvis vilja styra mer än bara vilken fil som kom härnäst. Jag ville att vissa saker skulle hända vid vissa tider. Jag ville kunna lägga stationselement mellan låtar. Jag ville kunna ha ett annat sound på kvällen än mitt på dagen.

Det var här de första schematankarna dök upp. En enkel spellista är bara en lista. En station behöver regler: när får listan spelas, hur ofta får samma artist komma tillbaka, hur ofta ska ett stations-ID höras, vad händer om en låt är för lång och nästa block egentligen ska börja?

Jag testade flera sätt att få detta att fungera. Några var rena tidsregler. Några byggde på separata listor. Några byggde på att en spelare helt enkelt fick byta källa vid ett visst klockslag. Alla fungerade i någon form, men ingen löste hela problemet.

Det var ett återkommande mönster: ju mer jag försökte tvinga in “radio” i en vanlig spelkö, desto fler undantag behövdes. Det blev en tidig signal om att showlogik senare måste bli en egen sak.

Det första schemat såg enklare ut än problemet var

Jag började med typiska tidsregler: den här listan gäller här, den där listan gäller där. Men låtar känner inte till klockan. En sexminuterslåt som startar 17:58 kan lätt sabotera ett program som ska börja 18:00 om ingenting planerar framåt.

Först försökte jag lösa det med hårda bryt. Sedan med mer luft runt blockgränser. Sedan med att förbjuda vissa låtar nära gränsen. Varje variant lärde mig en del, och senare kunde jag kombinera idéerna i Show Engine i stället för att lägga fler specialfall i playlisten.

Schemaexperimenten skapade mina första “regler ovanpå regler”

När jag började tidsstyra innehåll dök problem upp som inte fanns i den enkla MP3-perioden. En lista kunde vara rätt för klockslaget men fel för övergången. En jingle kunde vara tillåten enligt sitt intervall men hamna precis före ett planerat programbyte. En låt kunde vara helt rätt musikaliskt men för lång för det tidsfönster som återstod.

Jag löste först sådant lokalt. Ett extra villkor här, en spärr där. Det fungerar väldigt snabbt men bygger snart ett system där ingen enskild regel förklarar slutresultatet. Det var första gången jag fick känna kostnaden av specialfall.

Den erfarenheten är en stor anledning till att dagens kod försöker centralisera vissa typer av beslut. Showgränser ägs av Show Engine. Talk-kollisioner ägs av Talk Scheduler. Musikrecency ägs av Picker. När en ny regel ska in försöker jag först avgöra vilket av de lagren som faktiskt borde äga den.

Det är en ganska stor utveckling från de tidiga åren: då var fokus att få nästa beteende att fungera. Nu är fokus ofta att få beteendet att fungera utan att nästa beteende måste känna till specialfallet.

Jag ville kunna köra live utan att kasta bort automatiken.

Nästa steg var live. En automatiserad station är bra tills jag själv vill gå in och styra. Då behöver livekällan kunna ta över utan att jag river hela streamkedjan.

Jag testade därför en separat livestudio med decks, cues och manuell mixning. Den var mycket bättre på mänsklig kontroll än min enkla automationskedja. Problemet var att den samtidigt var ganska dålig som 24/7-hjärna. Den förutsatte i grunden att någon sitter framför den.

Jag provade att låta liveverktyget spela automatiskt när jag inte var där. Jag provade externa triggers. Jag provade att låta en annan tjänst mata det med material. Varje försök löste ett problem men skapade ett nytt beroende. Om livestudion både är liveverktyg och automatisk motor blir en omstart plötsligt ett stationsavbrott.

Det här ledde till en modell som finns kvar idag: live är en källa, inte hela stationen. När live används ska den kunna äga ljudvägen. När live slutar ska automationen kunna ta tillbaka. Den principen gjorde senare byten av radiomotor mycket mindre dramatiska.

Live visade varför ägarskap måste vara explicit

När live och automation båda kan producera ljud måste någon veta vem som äger utgången. Jag hade perioder där det räckte att livekällan “fanns” för att automationen skulle bete sig annorlunda, men det är inte samma sak som att live faktiskt sänder.

Det ledde till tydligare state: connected, armed, active och fallback är olika saker. Den typen av tillståndsmaskin låter överdriven tills en livekälla dör mitt i sändning och automationen ska veta om den får ta över direkt.

Livevägen lärde mig skillnaden mellan närvaro och ägarskap

En livekälla kan vara ansluten utan att vara on air. Den kan vara redo men tyst. Den kan försvinna mitt i en låt. Tidiga försök där “ansluten” automatiskt betydde “live äger sändningen” skapade därför konstiga lägen.

Jag började tänka i tillstånd i stället: live kan vara frånkopplad, ansluten, armed eller aktiv. Automation behöver veta när den ska stå tillbaka och när den får ta över igen. Det är en annan typ av state än “vilken låt spelar”.

Det gav också en viktig säkerhetsprincip: live ska kunna fallera utan att göra hela streamen svart. Om den aktiva källan försvinner måste det finnas en definierad väg tillbaka till automation. Jag föredrar ett oväntat men spelande reservläge framför total tystnad.

Den här idén påverkar idag även AI-segment. Ett optional talk kan försvinna helt utan att musiken behöver påverkas. Den generativa kedjan får aldrig bli enda vägen till ljud.

När radion började få fler tjänster behövde nätet bli en del av arkitekturen.

En enkel stream behöver inte särskilt avancerad nätarkitektur. Ett kontrollcenter, webb, API:er, appstatus, spelare och interna tjänster gör det.

Jag började därför separera det som ska vara publikt från det som bara ska gå att nå internt. Brandväggen blev inte längre “bara internet in och ut”, utan en riktig del av radiosystemet. Externa anrop skulle landa i Relay 7 Gateway och därifrån gå till rätt intern tjänst. Interna API:er skulle inte behöva exponeras direkt.

Det gav ett tydligare flöde:

Internet→Relay 7 Firewall→Relay 7 Gateway→Webb/API/Player

Jag delade också upp vissa nät i separata zoner för att kunna styra trafik och bandbredd utan att påverka radiomaskinerna. Det var trial and error även här. En regel som ser korrekt ut på papper kan få oväntade effekter när en reverse proxy, en stream och en app använder olika typer av långlivade anslutningar.

Det här är också skälet till att jag idag undviker att hårdkoda intern nätinformation i frontend. Webben ska prata med publika API-vägar. Gatewayn ska veta var den interna tjänsten finns.

Brandväggen blev också felsökningsverktyg

När jag hade flera interna tjänster började jag använda brandvägg och gateway för att göra gränserna synliga. Om bara Gateway får exponeras publikt kan jag snabbt avgöra om ett fel ligger före eller efter den.

Jag testade också bandbreddsregler och separata nätprofiler. Det lärde mig att en radioanslutning inte beter sig som vanlig webbladdning. En långlivad stream kan påverkas på helt andra sätt av shaping och timeouts. Därför försöker jag numera separera regler för interaktiv webbtrafik från regler för långlivade ljudflöden.

Jag fick lära mig att långlivade anslutningar inte är vanlig webbtrafik

När jag började göra mer med brandvägg, shaping och separata nät såg jag att en stream beter sig annorlunda än en vanlig sida. En webbsida gör korta anrop och kan ofta bara försöka igen. En radioström vill hålla samma flöde levande länge.

Det betyder att aggressiva timeouts, konstiga NAT-beteenden eller felaktig bandbreddspolicy kan ge små avbrott som aldrig syns när jag bara testar en API-request. Jag fick därför börja testa nätförändringar med riktig lyssning över tid.

Jag började även hålla radiomaskinerna separerade från annan trafik där det var praktiskt. Poängen är inte att en MP3-ström kräver enorm bandbredd. Poängen är förutsägbarhet. Om en annan enhet råkar starta en stor överföring ska inte radiokedjan behöva märka det.

Brandväggen blev på så sätt både skydd, trafikpolis och felsökningsgräns.

Till slut fick projektet ett namn som kändes som en station: Relay Seven.

Efter flera års tekniskt experimenterande kom perioden där jag började behandla projektet mer som en riktig radiostation och mindre som en stream jag råkade äga. Då blev namnet viktigt på ett helt annat sätt.

Jag provade flera varianter. Relay. Relay One. Relay 7. Till slut fastnade Relay Seven. Det var kort, gick att bygga en grafisk identitet kring och lät som något som kunde stå både på en webbspelare, en app och ett programnamn.

När namnet satt började resten falla på plats: logotyp, färger, programspråk och hur stationen skulle presentera sig. Det var också då jag började bli mycket mer petig med hur stationsnamnet uttalas i automatiskt radioprat.

Namnvalet påverkade koden mer än väntat

När Relay Seven blev det fasta namnet började stationsidentiteten dyka upp överallt: metadata, titelrader, jinglar, apptexter, API-output och promptregler. Det gjorde mig uppmärksam på hur farligt det är att hårdkoda namn i tjugo filer.

Jag började därför flytta stationsnamn och liknande identitet till gemensamma settings. Samma princip används för location, showname och persona. Branding är data när det går, inte utspridd kod.

Rewind, Retro och första försöken att ge olika timmar olika identitet.

Jag började arbeta mer med programnamn och temablock. Rewind, Retro och olika varianter testades. Jag ville ha block där 90- och 00-tal, eurodance och den gamla dansmusiken kunde få en tydligare plats.

Det viktiga var inte exakt vilket namn som användes varje vecka. Det viktiga var att showen fick en egen kontext. Om samma programledare pratar i två olika block ska det ändå kunna kännas olika.

Den tanken ledde så småningom till showprofiler: playlistregler, energi, ämne, host, gäster, pratfrekvens och egna featuretyper. Men i april började det med något mycket enklare: “det här ska kännas som ett program, inte bara som tre timmar med en annan lista”.

Programnamn blev mer än branding

När jag började skapa namn som Rewind märkte jag att ett programnamn snabbt drar med sig tekniska krav. Om Rewind är ett riktigt program vill jag kunna säga när det går, vem som leder det, vilket musikområde det har och hur promotionen ska låta.

Det betyder att showen behöver en identitet som går att referera till i schema, prompt, webb och app. Jag ville inte att varje yta skulle bära en egen textsträng med “fredag 18–21”. Den typen av duplicering blir alltid fel förr eller senare.

Det här blev början på mer strukturerad showdata. Namn, tidsfönster, host, mood och policy kan läsas av flera lager. När en show byter tid ska jag i princip ändra sanningen på ett ställe.

Webbspelaren blev snabbt mycket mer än en play-knapp.

Jag byggde ut webbspelaren med “nu spelas”, historik, status och mer stationinformation. Historiken begränsades och fick egna filter så stationselement och tom metadata inte skulle förstöra listan.

Det som från början var några rader klientkod blev gradvis en hel frontend till radioplattformen. Jag la till bättre layout, mobilanpassning, omslag, progress, volym, requestflöde, likes och schema.

Färgpaletten som fortfarande används idag växte fram här: nästan svart bakgrund, varmvit text, orange accent och guldtoner. Jag ville att sidan skulle kännas mörk och modern utan att bli ännu en generisk neon-DJ-sida.

Det här drev också backend framåt. Om webben ska visa korrekt historik kan klienten inte behöva gissa vad en “riktig låt” är. Relay 7 Now Playing API fick därför mer ansvar.

Webben blev också en driftpanel

När jag ändå hade realtidsdata började jag använda webben för att upptäcka fel. Om now-playing inte uppdateras men ljudet hörs vet jag att metadata/API-lagret är misstänkt. Om appen ser nytt men webben gammalt är klientcache mer sannolik.

Det här är en anledning till att jag gillar små statusytor. En bra UI-indikator kan vara minst lika användbar som en loggrad när jag snabbt ska förstå vilket lager som lever.

Jag började bygga UI efter driftbehov och inte bara utseende

Webben var först till för lyssnaren, men jag märkte snabbt hur värdefull den var för mig själv. Om “nu spelas” stannar på en gammal låt medan ljudet fortsätter vet jag direkt att audio och metadata har skiljt sig åt. Om historiken uppdateras men omslaget inte gör det kan felet ligga ännu längre ut i kedjan.

Det gjorde att jag började uppskatta små visuella signaler. En statuspunkt, senaste uppdateringstid och ett tydligt fallbackläge kan spara mycket mer tid än en stor logg när jag först ska hitta rätt del.

Jag ville samtidigt inte förvandla publikspelaren till en teknisk dashboard. Därför växte tanken på en separat kontrollvy fram: publiken får det som är relevant för lyssning, jag får de diagnostiska detaljerna i Kontrollcenter.

Jag trodde att stations-ID skulle vara den lätta delen.

Stationsjinglar och korta identar blev ett återkommande testområde. Själva texten kan vara några få ord, men den måste uttalas rätt, ligga rätt i nivå och dyka upp lagom ofta.

Jag testade fonetisk stavning, olika pauser och olika sätt att skriva stationsnamnet. Jag lärde mig snabbt att en lösning som fungerar för en fras inte automatiskt fungerar för nästa.

Det ledde till att jag började separera tre problem som först såg ut som ett:

  • Texten: vad ska sägas?
  • Uttalet: hur ska det skrivas för röstmotorn?
  • Placeringen: när ska det höras i förhållande till musiken?

Den uppdelningen är idag självklar i Relay Seven, men den kom från att samma korta jingle kunde vara korrekt i ett lager och fel i nästa.

Uttalsproblemen blev en försmak av hela AI-arkitekturen

Jingleförsöken lärde mig att text och ljud inte är samma sak. En text kan vara perfekt skriven och ändå produceras på ett sätt som låter fel. Det är därför jag senare undvek att blanda voice style, uttalsknep och faktainnehåll i samma lager.

Jag vill att Kontrollcenter bestämmer vilket innehåll som är korrekt. Westin-AI bestämmer hur det ska produceras enligt en styleprofil. Om ett särskilt ord kräver en uttalsform ska den hanteras som uttalsdata, inte som en kreativ omskrivning av hela meningen.

Den separationen gör det också lättare att byta teknik under röstmotorn utan att ändra showlogiken.

Första stora allt-i-ett-installationen: snabb väg till fler funktioner, men också fler lager att slåss med.

Jag installerade under en period en stor färdig AutoDJ-miljö som tog ansvar för bibliotek, spellistor, schema, ljudmotor och stream i ett paket. Det var ett jättesteg framåt jämfört med den tidiga MP3-kedjan.

Plötsligt kunde jag lägga regler på spellistor, sätta tider, hantera jinglar och administrera mycket från webben. Jag fick snabbt funktioner som annars hade tagit lång tid att skriva själv.

Men priset var att mycket konfiguration genererades åt mig. När jag ville göra något som plattformen inte hade ett fält för hamnade jag i ett mellanläge: antingen modifiera genererad konfiguration och riskera att den skrivs över, eller bygga ett lager bredvid.

Det blev många sådana lager. Egna API:er utanför plattformen. Egen webb. Egen gateway. Egna filter för historik. Specialregler för live. Jag fick mer funktion men också fler saker som kunde peka åt fel håll.

Den installationen var viktig eftersom den lärde mig exakt vilka funktioner jag ville behålla när jag senare byggde mer själv: bibliotek, schema, tydliga listregler, hälsostatus och robust streamhantering.

Varför den första stora installationen ändå var värdefull

Det hade varit lätt att i efterhand kalla den här perioden en omväg. Det vore fel. Den färdiga plattformen gav mig en referens för vad bra radioadministration behöver innehålla. Jag kunde se hur bibliotek, schema och streamkontroll hänger ihop utan att först skriva allt själv.

Det jag senare byggde eget är därför inte byggt ur tomma luften. Jag tog med mig funktionerna jag faktiskt använde och slutade bära runt på funktionerna som inte passade Relay Seven.

Den första stora plattformen gav mig både fart och teknisk skuld

Jag fick snabbt ett administrationsgränssnitt, schema och bibliotek. Det var exakt vad jag behövde då. Samtidigt började egna behov byggas utanför systemet. För varje specialfunktion ökade avståndet mellan “standardinstallationen” och den station jag faktiskt körde.

Det blev små scripts, extra proxyregler, egna endpoints och manuella ändringar. Inget av det var katastrof i sig, men efter ett tag blev det svårt att svara på en enkel fråga: vilken del är egentligen källan till sanningen?

Det var en viktig anledning till nästa generations tänk. Jag ville inte längre ha en huvudprodukt och tjugo saker som korrigerar den. Jag ville ha tydliga egna tjänster med ansvar jag själv definierar.

Samtidigt försökte jag behålla de bra idéerna: tydliga spellistor, schema som går att administrera och robust streamdrift. Jag kastade alltså inte erfarenheten när jag bytte arkitektur.

Rätt domän, fungerande ljud, helt fel backend.

Ett av de mest frustrerande felen var när allt såg korrekt ut utifrån. Sidan laddade. Streamen gav ljud. Ändå kom innehållet från fel källa.

Problemet visade sig ligga i en gammal proxyväg som fortfarande pekade mot en tidigare intern tjänst. Det var ett typexempel på hur en modulär miljö kan lura mig: varje enskild komponent är frisk, men kopplingen mellan dem är fel.

Efter det började jag dokumentera routingen mycket striktare. Relay 7 Gateway fick tydliga vägar för webb, now-playing, appstatus, spelare och andra API:er. Jag började också testa tjänsterna både direkt internt och genom den publika vägen.

Felsökningsordning jag började använda
1. svarar interna tjänsten?
2. svarar gatewayn lokalt?
3. matchar host/path rätt backend?
4. fungerar TLS/DNS utifrån?
5. får klienten rätt payload?

Den ordningen har sparat mycket tid senare. Jag försöker numera bevisa varje länk i kedjan i stället för att ändra tre saker samtidigt.

Jag ändrade en sak i taget efter det

Routingfelet lärde mig en disciplin som jag försöker följa hårdare nu: ändra inte backend, gateway och DNS i samma felsökningssteg. Om jag gör tre förändringar och problemet försvinner vet jag inte vilken som löste det.

Numera försöker jag få ett bevis per lager. Curl-liknande probe mot intern tjänst. Samma payload via Gateway. Sedan extern väg. Först därefter klienten. Det är långsammare de första fem minuterna och mycket snabbare efter en timme.

Jag slutade felsöka med “ändra tills det funkar”

Fel routing var ett bra exempel på varför metod spelar roll. Hade jag samtidigt ändrat streamkonfiguration, gateway och DNS hade problemet kanske försvunnit men jag hade inte vetat varför.

Efter det försöker jag arbeta med bevis. Först intern tjänst direkt. Sedan samma tjänst via Gateway. Sedan publik adress. Sedan klienten. Om ett steg fungerar och nästa inte gör det ligger felet mellan dem.

Lager för lager
backend_ok = probe(internal_service)
  gateway_ok = probe(public_route_locally)
  dns_ok     = resolve(public_name)
  tls_ok     = handshake(public_name)
  client_ok  = load(real_client)

Den typen av disciplin har blivit ännu viktigare ju fler tjänster jag byggt.

Jag började skala bort allt-i-ett-lagret och köra ljudmotorn mer fristående.

När specialanpassningarna blev fler började jag testa en renare installation där själva broadcastmotorn fick stå mer på egna ben. Jag ville minska mängden magi mellan mina inställningar och det som faktiskt körde.

Det gav mer arbete i början. Jag behövde själv tänka på queue, mounts, streamoutput, konfigurationsfiler och tjänsteövervakning. Men jag fick också en helt annan insyn. Om en låt gjorde något konstigt kunde jag se vad ljudmotorn faktiskt fått för instruktion.

Jag började separera live, automation och extern stream tydligare. Det gjorde senare övergången till Relay 7 Broadcast naturlig. I stället för att en webpanel genererar allt ser jag broadcastdelen som en egen tjänst med ett enkelt kontrakt: här är musik, här är tal, här är jinglar, här är transitions.

Den fristående broadcastgenerationen gav mig kontroll men kostade mer eget ansvar

När jag började äga fler konfigurationsfiler själv försvann vissa bekvämligheter. Jag fick själv se till att tjänsten startar efter reboot, att kataloger finns, att rätt användare kommer åt filerna och att fel inte lämnar en halvskriven config.

Det var här atomiska filskrivningar och tydliga servicegränser började bli intressanta. Hellre skriv tempfil och rename än låt en läsare se en halv JSON-fil. Små driftmönster som det har senare blivit standard i state-modulerna.

Mer frihet betydde också fler saker jag själv måste ta ansvar för

När jag började köra mer fristående tjänster försvann den färdiga plattformens skyddsräcken. Nu behövde jag själv hålla koll på service-start, katalogrättigheter, loggning, konfigurationsbackup och hur en halvskriven statefil undviks.

Det var här jag började använda atomiska skrivningar för state. I stället för att skriva direkt till slutfilen skriver jag till en tillfällig fil och byter namn först när hela innehållet är klart. Om processen dör mitt under skrivning ska nästa start inte mötas av halv JSON.

Det låter långt från radio, men det är precis den typen av tråkig teknik som gör att en show inte glömmer sitt state bara för att en process startades om vid fel ögonblick.

Målet blev tydligt: 256 kbps, 44,1 kHz stereo och ljud som inte hoppar i nivå.

När kedjan var mer öppen började jag lägga mer tid på ljudet. Målet blev MP3 256 kbps, 44,1 kHz stereo, jämn upplevd nivå, ren signal och övergångar som inte låter som en slump.

Jag testade normalisering, kompression, gain, limiter och olika crossfadeidéer. För mycket processning gjorde stationen trött och platt. För lite gjorde skillnaden mellan två mastringar väldigt tydlig.

Crossfade var ett eget träsk. En fast femsekunderscrossfade kan låta bra på två danslåtar men riktigt illa om den ena låten redan har ett långt outro eller den nästa har ett känsligt intro. Det ledde till tanken att analysis-data ska kunna hjälpa transitionen i stället för att allt avgörs av en fast timer.

Den delen är fortfarande inte “klar”. Jag har hellre ett system som går att förbättra än en hårdkodad effekt som låtsas vara smart.

Ljudtesterna var mycket mer lyssning än mätning

Jag kunde mäta nivå och bitrate, men frågan “låter det bra mellan de här två låtarna?” behöver fortfarande öron. Jag gjorde därför många A/B-test med olika attack, release, gain och crossfade. Ibland såg en aggressiv inställning bättre ut på mätare men lät sämre efter tjugo minuter.

Det gjorde att jag blev försiktig med att optimera efter en enda siffra. Radio är en kedja av upplevd nivå, tonal balans och transitions. Ingen enskild mätare äger hela svaret.

Jag jagade inte bara högre nivå — jag jagade konsekvens

När jag jämförde olika låtar blev det tydligt att “så högt som möjligt” är ett dåligt mål. Jag ville att stationen skulle kännas jämn utan att slå sönder dynamiken i material som faktiskt mår bra av skillnader.

Jag testade därför attack och release i kompression, makeup gain och hur hårt limitering ska ingripa. För snabb attack kunde äta transienter. För lång release kunde få nivån att pumpa. För mycket makeup gjorde små fel i talfiler mycket tydligare.

Det var också därför voice-polish senare blev viktig. När tal går genom samma upplevda högljuddhetsmiljö förstärks kantproblem som knappt märks när filen spelas separat.

Jag har hellre en ljudkedja som är lite försiktig än en som låter imponerande i två minuter och tröttande efter en timme.

“Kan jag inte bara bygga min egen kontrollpanel?” blev början på Relay 7 Kontrollcenter.

Vid någon punkt blev det tydligt att jag redan byggde runt den färdiga radioplattformen på så många ställen att jag i praktiken hade börjat bygga en egen kontrollnivå.

Jag ville kunna se bibliotek, queue, show, aktuell värd, historik, analyssvar, nästa låt och talstatus på samma ställe. Jag ville kunna trigga ett station-ID manuellt, testa en feature och se exakt vilken regel som gjorde att ett segment stod över.

Där föddes idén till Relay 7 Kontrollcenter. Inte som ett skin och inte som en ny startsida, utan som den del som faktiskt äger reglerna.

Jag började också tänka annorlunda om konfiguration. En inställning ska helst ha en enda ägare. Om “prata bara mellan låtar” finns på tre ställen kommer de tre ställena förr eller senare tolka den olika.

Kontrollcenteridén ändrade hur jag skrev nya funktioner

Före Kontrollcenter tänkte jag ofta “var kan jag lägga in den här fixen?”. Efter Kontrollcenter började jag fråga “vilken modul borde äga detta?”. Det är en mycket bättre fråga.

Det är exempelvis därför weather state inte ligger i promptkoden och varför Picker inte ligger i röstkedjan. Jag försöker placera regler där deras data redan finns och exponera ett enkelt resultat till nästa lager.

Kontrollcenter blev platsen där jag kunde se varför, inte bara vad

Ett vanligt radiogränssnitt visar ofta vilken låt som spelar och vad som ligger i kö. Jag ville dessutom se varför nästa beslut togs. Vilken pool valdes? Vilket showfilter var aktivt? Varför stod en skill över? Vilken talk-slot blockerade en annan?

Det gör Kontrollcenter till mer än en fjärrkontroll. Det är också ett förklaringslager. När en automatisk station beter sig konstigt är “varför” den viktigaste informationen.

Jag försöker därför få nya funktioner att lämna diagnostiska spår. En policy som returnerar false bör helst kunna ge en anledning. En scheduler som väntar ska kunna säga vad den väntar på.

En show som börjar 18:00 kan inte börja 18:06 bara för att låten råkade vara lång.

Det här var ett av problemen som tvingade fram riktig showlogik. Om ett program börjar vid en viss tid måste musikplaneringen ta hänsyn till gränsen innan den väljer en lång låt.

Jag testade först hårda avbrott. Det gav rätt tid men kunde låta brutalt. Sedan testade jag att bara låta låten spela klart. Det lät fint men gjorde schemat ungefärligt.

Den bättre lösningen var att göra showgränsen synlig för flera moduler. Picker kan undvika val som är olämpliga nära gränsen. Analyzer kan ge information som hjälper en mjuk cue-out. Show Engine kan förbereda intro och handoff innan själva klockslaget.

Det här är ett bra exempel på varför en “scheduler” inte räcker som ensam lösning. Showbyte är både musikbeslut, ljudbeslut, statebeslut och pratbeslut.

Showgränsen blev en deadline som hela systemet kan se

Jag vill på sikt att Picker ska kunna väga in hur många sekunder som finns kvar till en gräns, Analyzer ska kunna ge lämpliga cue-punkter och Show Engine ska kunna börja förbereda nästa intro. Det betyder att showgränsen måste representeras som data, inte bara som ett cronjobb.

Det är ett exempel på hur en tidsregel blir mycket kraftfullare när den görs synlig för flera lager.

Jag började planera bakåt från showgränsen

En bättre lösning på exakta programstarter är inte att bli aggressiv exakt 18:00. Det är att börja fatta bättre beslut före 18:00.

Om det är sex minuter kvar kan Picker exempelvis undvika en låt som sannolikt är sju minuter lång om showen kräver en ren gräns. Om en utgående låt ändå spänner över gränsen kan Analyzer hjälpa till med en musikalisk cue-out. Samtidigt kan Show Engine förbereda handoff och intro.

Det här är fortfarande ett område jag vill utveckla mer, men tankesättet är viktigt: realtidsradio blir bättre när systemet känner till framtida deadlines.

När både webb och app ska visa samma sak måste sanningen flytta bakåt till API-lagret.

Webben blev mer avancerad och mobilappen växte fram. Då blev duplicerad logik direkt irriterande. Jag ville inte att webben skulle ha en egen tolkning av historik och appen en annan.

Jag började därför dela upp funktionerna i små egna tjänster. Relay 7 Now Playing API ansvarar för aktuell låt och ren historik. Relay 7 Community API hanterar interaktion. Relay 7 Request API tar emot låtönskningar och validerar dem. Relay 7 App Status API ger appen information om stationens tillstånd.

Det gör också att en klient kan bytas utan att radiohjärnan ändras. Om jag bygger om webben ska Kontrollcenter inte bry sig. Om appen får en ny vy ska requestreglerna fortfarande ligga på servern.

Exempel på det publika kontraktet
{
  "now": {
    "artist": "...",
    "title": "...",
    "startedAt": "..."
  },
  "show": {
    "name": "...",
    "host": "..."
  },
  "history": [ ... ]
}

API-designen fick också felhantering

När mobilen och webben började bero på samma endpoints behövde jag bestämma vad som händer när upstream saknas. Ska history bli tom lista? Ska now vara null? Ska hela API-anropet bli fel?

Jag föredrar strukturerade degraderingar där klienten kan skilja “ingen data just nu” från “servern dog”. Det gör UI:t bättre och felsökningen tydligare.

API:erna fick bli kontrakt i stället för genvägar

Det var lockande att bara returnera exakt det interna objektet från en tjänst. Det är snabbt när man bygger första klienten men dyrt när intern kod senare behöver ändras.

Jag började därför tänka mer på stabila publika former. Now Playing ska inte läcka interna queuefält. Request API ska returnera ett begripligt resultat och inte bara ett lågnivåfel. Appstatus ska kunna versioneras utan att mobilen måste följa varje intern refaktorering.

Det gör att jag kan skriva om Kontrollcenter betydligt friare. Klienterna har ett kontrakt med API:t, inte med implementationen bakom.

Jag fick till slut behandla brandväggen som en egen produktdel.

När antalet interna tjänster ökade blev det farligt att bara öppna portar tills något fungerade. Jag ville ha en enda publik ingång, tydliga värdnamn och så få direkt exponerade backendtjänster som möjligt.

Relay 7 Firewall blev därför ett eget driftlager. Relay 7 Gateway terminerar publika anslutningar och skickar dem vidare. Kontrollcenter, rösttjänst och interna analyskomponenter kan ligga privat.

Jag testade DNS-ändringar, underdomäner och certifikatflöden flera gånger. Ett konkret problem var en ny spelaradress som först gav olika svar beroende på resolver och därefter ett internt TLS-fel genom gatewayn. Jag kunde då prova kedjan lokalt med rätt host-header och lösa problemet utan att röra själva radion.

Det var ännu en gång samma princip: isolera lagret som är fel. Om HTTPS för spelaren är trasigt betyder det inte att streamen är trasig.

DNS och TLS gav en ny sorts väntetid

En kodändring syns direkt när processen startas om. DNS gör inte alltid det. Jag kunde få olika svar från olika resolvers under samma minut. Det gjorde att jag ibland trodde att en gatewayfix inte fungerat när jag egentligen tittade på gammal resolution.

Jag började därför skilja på konfigurationsfel och propagationsfördröjning och testa explicit mot den server jag faktiskt ville nå. Det sparar mycket frustration vid nya underdomäner.

Brandväggsreglerna blev en del av deployrutinen

När en ny tjänst skapas frågar jag numera direkt om den verkligen behöver vara nåbar utifrån. Ofta är svaret nej. Analyzer, Memory och Westin-AI behöver inte ligga publikt bara för att Web och Gateway använder dem.

Det minskar attackytan och gör arkitekturen renare. Om jag ser en extern anslutning direkt mot en intern tjänst vet jag att något kringgår tänkt väg.

Jag började även skilja tydligare på namnupplösning, certifikat och backendrouting. Alla tre kan ge symptom som ser ut som “sidan fungerar inte”, men de är helt olika felsökningsdomäner.

När AI-delarna växte räckte det inte längre att bara skriva kod. Jag fick bygga maskiner också.

Språkmodellen och röstproduktionen ställde helt andra krav än en vanlig MP3-stream. Jag byggde därför en separat kraftigare maskin för de tunga delarna: 12 kärnor/24 trådar, 32 GB RAM, snabb NVMe och ett grafikkort med 10 GB videominne. Nätaggregatet dimensionerades rejält för att slippa samma problem som jag tidigare sett när ett kraftigt grafikkort drog mer än en äldre maskin tålde.

Jag hade dessutom flera äldre grafikkort att testa med, inklusive ett med 11 GB videominne och två mindre kort. Det ledde till många funderingar kring om flera GPU:er går att slå ihop, om mer VRAM är viktigare än nyare arkitektur och vilken del som egentligen använder GPU.

Trial and error blev ganska konkret. En finjustering kunde gå direkt i minnesstopp trots att vanlig inferens fungerade. Ett kort med lite mer VRAM kunde på papperet hjälpa men vara mycket långsammare. Flera kort kan hjälpa om programmet är skrivet för parallellism, men videominne går inte automatiskt ihop till en enda stor pool.

Det var en viktig arkitekturell slutsats: jag ska inte designa radiosystemet runt antagandet att hårdvara löser dålig pipeline. Om text och röst kör seriellt och startas för sent kan ett snabbare kort fortfarande ge sen radio.

Hårdvarubygget blev också ett kyl- och strömprojekt

När ett kraftigt GPU-kort kör tung inferens räcker det inte att komponenten passar elektriskt. Luftflöde, fläktriktning, hubbar, headers och nätaggregat blir en del av stabiliteten. Jag fick lägga tid på hur chassifläktar matar och tömmer lådan och hur flera hubbar kan kopplas utan att överbelasta en header.

Jag har också fått se vad som händer när ett äldre nätaggregat träffar skyddet under en GPU-spik. Det är en nyttig påminnelse om att “programmet kraschade” ibland betyder “strömförsörjningen gav upp”.

Hårdvaruinköpen blev en serie praktiska kompromisser

Jag ville ha en maskin som kunde köra både språkmodell och tung röstproduktion lokalt. Det innebar att CPU, RAM, lagring, GPU och nätaggregat behövde balanseras. Jag kunde lägga alla pengar på GPU och ändå få en dålig helhet om lagringen eller kylningen blev flaskhals.

Jag valde därför en 12-kärnig processor, 32 GB arbetsminne och snabb lokal lagring som bas. Ett kraftigt 10 GB-kort fick göra den tunga GPU-delen. Jag testade samtidigt äldre kort med olika mängd videominne för att se om mer VRAM kunde väga upp lägre råprestanda.

Det gav flera praktiska lärdomar. Finjustering kan behöva mycket mer minne än inferens. Ett extra gigabyte VRAM är inte automatiskt bättre om kortet i övrigt är mycket långsammare. Flera kort kan bara hjälpa om programmet faktiskt kan dela arbetet. Och ett moderkort med många platser betyder inte att åtta GPU:er magiskt blir en enda stor accelerator.

Jag fick även bygga om luftflödet, lägga till fläkthubbar och se till att nätaggregatet klarar transienterna. Hårdvaran blev alltså en del av mjukvaruprojektet.

Den största ombyggnaden: jag delade systemet i tjänster som går att byta var för sig.

Det här var punkten där den moderna Relay Seven-arkitekturen tog form. Jag började köra separata containrar/tjänster för gateway, webb, kontrollcenter, analyzer och broadcast. Westin-AI kördes som egen tjänst.

Skillnaden mot tidigare installationer var att ingen webpanel längre behövde äga hela stationens livscykel. Kontrollcenter kan byggas om separat. Broadcast kan fortsätta spela. Analyzer kan arbeta med musikdata. Webben kan deployas utan att röra ljudet.

Relay 7 WebRelay 7 GatewayRelay 7 KontrollcenterRelay 7 AnalyzerWestin-AIRelay 7 Broadcast

Jag började även lägga persistent state i en egen state-volym. Det gav mig möjlighet att byta container utan att sessioner, queueinformation och vissa minnen försvinner.

Det här är också anledningen till att jag idag betraktar systemet som min egen klon och min egen produktlinje. Jag har gjort så många egna ändringar i controller, showlogik, scheduler, prompts, audio och installation att namnet Relay 7 Kontrollcenter beskriver verkligheten bättre.

Containrarna gav mig en praktisk rollbackmodell

När varje tjänst har en egen image kan jag tagga den gamla innan byte. Det gör rollback mycket mer konkret. Jag behöver inte försöka återskapa exakt vilka filer som låg på disk; jag kan återgå till föregående tjänstbild och föregående konfiguration.

Det är också därför jag hellre bygger ny image än redigerar en körande container för hand. Direktpatchar är lockande klockan sent, men de skapar en verklighet som inte finns i versionsarkivet.

Den modulära installationen förändrade hur jag vågar testa

Tidigare kunde en större ändring kännas riskabel eftersom samma miljö ägde nästan allt. När Kontrollcenter, Broadcast, Analyzer och Web är separata kan jag bygga en ny Kontrollcenter-image och endast byta den tjänsten.

Om den nya versionen inte blir healthy kan jag gå tillbaka. Broadcast fortsätter under tiden med sin egen process. Den skillnaden gör att jag kan iterera mycket snabbare utan att varje experiment känns som ett fullständigt stationsunderhåll.

Jag började också ge builds tydliga versionsetiketter som berättar vilka större lager som ingår. Namnen blev ibland löjligt långa, men de gjorde felsökning lätt: jag kunde se direkt om en aktiv build innehöll tidsfix, voice smoothing, skill variation och weather memory.

Jag lät Gemma 3 göra språkjobbet och började ta bort allt den inte behöver göra.

Gemma 3 körs via Ollama och blev språkkomponenten i Kontrollcenter. Första versionerna gav den alldeles för mycket ansvar. Den kunde få ett stort kandidatunderlag, stationskontext, showtext, tidigare repliker och flera uppgifter i samma prompt.

Det gav långa svarstider och märkligt låg variation. Jag kunde se anrop där tusentals tokens gick in för att få ut ett par dussin ord. Det är ett dåligt förhållande i radio, framför allt om nästa låtgräns närmar sig.

Jag började därför flytta beslut till kod. Gemma 3 behöver inte välja låt om Relay 7 Picker redan kan göra det deterministiskt. Den behöver inte räkna ut svensk klockfras om tidmodulen redan kan ge en färdig fras. Den behöver inte avgöra om weather är nytt om Weather Memory redan vet.

Gemma 3 ska vara bra på formulering. Ollama ska köra modellen. Relay 7 Kontrollcenter ska veta vad som faktiskt är sant och tillåtet.

Promptbantning gav både fart och bättre kvalitet

Jag såg flera gånger att Gemma 3 fick enorma mängder kontext för ett mycket litet output. Det kunde dessutom göra att de viktigaste reglerna drunknade bland mindre viktig historik.

Jag började därför vara mer aggressiv med vad varje call får se. En track link behöver inte hela stationens historia. En hourly behöver inte råa playlistkandidater. En weather-rad behöver inte alla tidigare showbeats.

Det här är lika mycket kvalitetsarbete som prestandaoptimering.

Jag började behandla tokenmängd som produktionskostnad i tid

När Gemma 3 fick stora prompts kunde jag se att mycket av väntetiden låg i input, trots att output bara var en kort radiolänk. Det var slöseri på två sätt: mer beräkning och större risk att de viktigaste instruktionerna försvinner i mängden.

Jag började därför bygga prompten per uppgift. Ett hourly behöver tid, aktuell show och relevant persona, inte hela biblioteket. Ett track link behöver aktuell och närliggande musik, inte hela schedulerhistoriken. En skill får bara de contextfält den faktiskt begärt.

Jag försöker även undvika att återberätta regler som redan kan implementeras i kod. Om weather redan filtrerats bort för att condition är oförändrad behöver prompten inte lägga hundra ord på att övertyga modellen om att inte prata om väder.

Det här gav en mycket renare relation mellan Kontrollcenter och Gemma 3: mindre kontext, tydligare uppgift, mer förutsägbart resultat.

Westin-AI växte från TTS-endpoint till en riktig produktionsmotor för radioprat.

Westin-AI är min separata tjänst för att producera röstklipp. Kontrollcenter skickar färdigt manus, önskad röstprofil och leveransstil. Westin-AI returnerar en ljudfil som kan gå vidare till Broadcast.

Det enkla API-kontraktet döljer flera funktioner:

  • Health: Kontrollcenter kan kontrollera att röstmotorn är redo.
  • Warmup: tung modell kan laddas innan ett tidskritiskt segment.
  • Style control: samma röstidentitet kan levereras neutral, varm, torr, upbeat, energisk, självsäker eller late-night.
  • Formatkontroll: ljudet måste komma tillbaka i ett format som Broadcast kan behandla.
  • Timeout: sent tal är i vissa fall lika oanvändbart som misslyckat tal.
  • Felisolering: om röstmotorn går ner ska musiken fortsätta.

Jag gjorde också många tester med pauser och prosodi. Om röstmotorn får en textmassa utan tydliga pauser kan den rabbla. Punkt, komma, styckeindelning och kortare segment påverkar mer än man först tror.

Jag separerade dessutom röstens leveransstil från textens innehåll. Det gör att jag kan förbättra style-planeringen utan att röra prompts för Gemma 3.

Förenklat anrop till Westin-AI
POST /speak
{
  "text": "Alice Deejay, Will I Ever.",
  "style": "warm",
  "format": "wav"
}

Westin-AI måste vara förutsägbart för nästa lager

Broadcast ska inte behöva specialfall för varje röstgeneration. Därför försöker jag få Westin-AI att lämna ett konsekvent outputformat och tydliga fel. Ett timeout-fel är något annat än “motorn är nere” och något annat än “filen blev genererad men kunde inte valideras”.

Ju bättre de felen är klassificerade, desto bättre fallback kan Kontrollcenter välja.

Jag började se Westin-AI som en realtidstjänst, inte ett renderverktyg

En vanlig renderuppgift får gärna bli klar “sen”. Radio har ofta en deadline. Om en stationsident som skulle ligga mellan två specifika låtar blir färdig efter att seam redan passerat måste systemet kunna avgöra om den fortfarande är relevant.

Det innebär att Westin-AI inte bara behöver vara snabbt. Kontrollcenter behöver veta ungefär vad det kan förvänta sig. Warmupstatus, queue och timeout blir därför en del av den redaktionella logiken.

Jag vill på sikt kunna modellera uppskattad produktionstid per typ. Ett kort ident kanske kan produceras sent. Ett långt showintro behöver startas tidigare. När den informationen finns kan Show Engine planera bakåt på riktigt.

UI:t visade rätt värd. Efter restart pratade ändå den gamla. Då lärde jag mig hur farligt bra återhämtning kan vara.

Jag ändrade värd i gränssnittet och allt såg rätt ut. Ändå dök den tidigare värden upp i sändning. Första misstanken var prompt. Sedan cache. Sedan att en tjänst inte laddat om rätt inställning.

Det riktiga problemet var en gammal sessionsfil. Kontrollcenter gjorde det jag byggt det för att göra: återställa sessionen efter restart. Problemet var att sessionen inte längre representerade vad jag ville köra.

Jag löste det akut genom att stoppa Kontrollcenter, rensa den gamla sessionsposten och starta igen. Men den viktiga lösningen var konceptuell: persistent state måste ha en giltighetsmodell.

Det här återkom senare med weather. Att något överlever restart är inte automatiskt bra. Det ska överleva restart bara om det fortfarande är sant.

Statebuggen gjorde mig mer misstänksam mot “resume”

Resume låter alltid positivt. Men varje resume borde egentligen svara på två frågor: vilket state återställer jag och matchar det fortfarande den konfiguration som gäller nu?

Jag vill därför att fler statefiler får versionsnummer eller identitetsnycklar. Då kan en ny build aktivt avvisa gammalt state som inte längre är kompatibelt i stället för att försöka vara hjälpsam.

Jag började versionsmärka själva state-tänket

Persona-felet visade att en statefil kan vara syntaktiskt korrekt och ändå semantiskt fel. Därför vill jag att state allt oftare bär information om vilken session, show eller versionsform det hör till.

Om aktiv show inte matchar filens identitet ska state inte blint återanvändas. Om datastrukturen förändrats mellan builds ska migrering vara medveten. Det är bättre att kasta en liten mängd gammalt state än att återställa ett gammalt beslut som lyssnaren sedan hör.

Weather Memory-migreringen är ett exempel på försiktig återanvändning: jag tar bara hjälp av en nylig faktisk weather-rad och fyller condition konservativt. Jag försöker inte rekonstruera hela världen från gammal text.

En av de smartaste optimeringarna: jag slutade låta språkmodellen välja musik.

Musikval såg först ut som något en AI kunde vara bra på. Jag kan ge den en stor lista, beskriva showen och be den välja något som passar. Problemet är att det är dyrt, långsamt och svårtestat.

Det mesta jag faktiskt ville ha går att uttrycka som regler: rätt playlist, rätt vikt, inte spelad nyligen, artisten ska ha spacing, låten ska inte vara blockerad, showens filter ska matcha.

Relay 7 Picker blev därför lokal och deterministisk.

Förenklad kandidatpipeline
const pool = library
  .filter(track => showAllows(track))
  .filter(track => !recentlyPlayed(track))
  .filter(track => artistGapOk(track.artist))
  .filter(track => !blocked(track));

const next = weightedPick(pool);

Gemma 3 får fortfarande information om aktuell och kommande låt när det behövs för ett radioprat, men den behöver inte bränna tid på att välja själva filen.

Det här förbättrade både latency och felsökning. Om en felaktig låt väljs kan jag logga poolen och se exakt vilken regel som släppte igenom den.

Picker gav också bättre kontroll på biblioteket

När musikvalet blev kod kunde jag logga hur stor kandidatpoolen är efter varje filter. Det avslöjar exempelvis om en no-repeat-regel är för hård eller om en showfilterkombination nästan svälter poolen.

Det gör också att jag kan bygga fallbacksteg medvetet: relaxera en mjuk regel före en hård regel, aldrig tvärtom.

Picker gjorde det möjligt att mäta varför biblioteket känns tunt

Innan Picker var det svårt att veta om en tråkig period berodde på playlisten, modellen eller recencyregler. När kandidatpoolen byggs stegvis kan jag logga hur många låtar som återstår efter varje filter.

Pooldiagnostik
start             1842
  show_filter        612
  recent_track       544
  artist_spacing     487
  blocked            481
  weighted_selection   1

Om poolen plötsligt är tio låtar efter artist spacing vet jag att regeln är för aggressiv för den aktuella showen. Det är mycket bättre än att bara konstatera att radion “känns repetitiv”.

Det ger också möjlighet till kontrollerade fallbacknivåer. Jag kan relaxera en mjuk recencyregel innan jag någonsin rör en hård blockregel.

Jag flyttade svenska klockan ur AI:n och in i kod.

Tidsprat avslöjar en språkmodell direkt. 22:07 ska inte bli “tjugotvå noll sju”. Det ska låta som radio: “strax efter tio på kvällen”.

Jag skrev därför en ren time-funktion med svenska timord, dagdelar och minutband. Den returnerar flera säkra formuleringar för samma avrundade tid. Kontrollcenter väljer en och ger Gemma 3 den färdig.

Dagdel
if (hour < 5)  return "på natten";
if (hour < 10) return "på morgonen";
if (hour < 12) return "på förmiddagen";
if (hour < 18) return "på eftermiddagen";
if (hour < 23) return "på kvällen";
return "på natten";

Jag byggde också minutband så :00, :10, :30 och :50 inte behöver beskrivas med exakta minuter. Det blev exempelvis “ungefär”, “strax efter”, “kvart över”, “halv”, “kvart i” och “snart”.

Sen kom en ny bugg: modellen kunde lägga en egen approximation ovanpå min och säga något i stil med “det börjar närma sig nästan…”. Då skärpte jag prompten så den valda frasen ska kopieras som en enhet.

Tidsfraserna fick testas som matematik

Jag började behandla time-funktionen som en ren tabell som går att testa för alla sextio minuter. En formulering får inte bli falsk precis när ett band öppnar. “Några minuter efter” är exempelvis dåligt exakt på hel timme.

Den typen av minut-för-minut-test gör att språk kan verifieras nästan som logik, åtminstone inom de regler jag själv definierat.

Jag testade tidsfraserna över hela dygnet

När tidmodulen blev kod kunde jag äntligen testa den systematiskt. Jag kunde gå igenom alla timmar och minuter och se vad funktionen producerar. Det avslöjar gränsproblem som är lätta att missa när jag bara lyssnar några gånger.

Jag behövde också tänka på att fraser efter :40 ofta refererar till nästa timme. 21:53 talar om “tio”, men dagdelen ska fortfarande låta naturlig. Vid dygnsgränsen måste nästa timme normaliseras korrekt.

Det här visar en kul sak med radio: ett problem som låter språkligt blir till slut ganska ren matematik och tabelltestning.

Jag jagade ett avancerat ljudfel. Lösningen var ordet inherit.

Plötsligt började låtar kapas för tidigt. Några spelade bara en del av filen. Eftersom stationen vid det laget hade analyzer, cues, showgränser, crossfade och flera nivåer av konfiguration fanns det många trovärdiga misstänkta.

Jag gick igenom loggar, längder och regler. Till slut visade det sig att ett överordnat värde stod på 0 där det borde stå inherit. Det värdet gjorde att underliggande inställning inte fick gälla.

När jag ändrade det slutade låtarna brytas.

Det här är ett perfekt exempel på trial and error som jag tycker ska finnas kvar i historiken. En avancerad arkitektur betyder inte att felet alltid är avancerat. Ibland är felet ett enda standardvärde.

Felet med inherit ändrade hur jag granskar arv i inställningar

Efter att låtarna slutade kapas började jag vara mer skeptisk till värden som ser neutrala ut. Ett tomt fält, noll och “inherit” kan betyda tre helt olika saker.

Jag försöker därför göra administrationsfält tydligare när jag själv kontrollerar dem. Om ett värde är ärvt ska det stå ärvt. Om noll betyder avstängt ska det framgå. Magiska värden sparar UI-yta men kostar mycket felsökning.

Jag började mäta radioprat i sekunder, inte bara i kvalitet.

Ett banter kunde ta över hundra sekunder från start till färdigt resultat. Då spelar det ingen roll om texten är fantastisk om rätt radiolucka redan passerat.

Jag började dela upp tiden i separata steg: promptbyggnad, Gemma 3 via Ollama, Westin-AI, ljudpolish, queue och väntan på nästa säkra boundary.

Det ledde till idén om produktionstid före airtime. Show Engine ska på sikt kunna veta att ett tungt segment som ska höras 18:00 kanske måste börja renderas flera minuter tidigare.

Jag testade också warmup. Om röstmotorn hålls laddad slipper den kalla starten. Det kostar resurser men kan vara rätt under ett aktivt showblock.

Det här är fortfarande ett område jag förbättrar. Jag vill till slut ha en trace där ett enda segment visar exakt hur många millisekunder varje steg tog.

Latency gjorde queue-policy viktigare

Om ett klipp blir färdigt sent måste systemet avgöra om det fortfarande är relevant. Ett hourly som renderas tio minuter för sent kan inte alltid använda samma text. Ett showintro som kommer efter två låtar kanske ska kastas.

Det betyder att queue behöver förstå både ålder och typ, inte bara FIFO.

Jag började skilja generation deadline från air deadline

Det finns två tider som först såg ut som en: när text/röst måste vara färdig och när den ska höras. Om Broadcast bara kan spela ett visst segment på nästa track boundary måste renderingen vara klar före den boundaryn.

Det betyder att Kontrollcenter behöver planera med marginal. Ett klipp som blir klart två sekunder efter seam får kanske vänta fyra minuter på nästa. De två sekunderna kan alltså skapa flera minuters faktisk försening.

Det här gör prediction mycket värdefull. Jag behöver inte exakt veta hur lång nästa generering blir; jag behöver tillräckligt bra marginal för att inte missa rätt lucka.

Jag la 220 ms tystnad runt rösten och hela stationen började kännas lugnare.

Röstklipp som börjar på första aktiva sample kan låta hårda när de blandas in över musik. Kompressorn förstärker dessutom små kantproblem.

Jag la därför in tyst marginal före och efter varje talfil och behöll en kort fade inne i ljudet. Samtidigt förlängde jag duckingen så musiken går ner och upp mjukare.

Voice edge polish
HEAD_PAD_MS = 220
TAIL_PAD_MS = 220
EDGE_FADE_MS = 40

music duck down ≈ 1.2 s
voice plays
music duck up   ≈ 1.2 s

Det är en av mina favoritfixar eftersom den är så enkel jämfört med allt annat. Ingen AI. Ingen ny databas. Bara bättre timing i ljudet.

Voice smoothing gjorde mig mer uppmärksam på hela övergången

Efter 220 ms-padding hörde jag tydligare hur mycket själva duckkurvan påverkar upplevelsen. Om musiken går ner för snabbt låter det som en av/på-knapp. Om den går ner för långsamt börjar första orden drunkna.

Jag testade därför kombinationen av padding, edge fade och längre duck i stället för att optimera varje del separat. Det är samma helkedjetänk som i nätet: en liten förbättring i tre lager kan låta mycket bättre än en extrem inställning i ett.

Showen slutade vara “en playlist mellan två tider” och blev ett riktigt avsnitt.

Jag byggde Relay 7 Show Engine för program som behöver mer struktur. Ett avsnitt kan ha intro, musik, en feature mitt i, mer musik och ett outro. Engine håller reda på vilka beats som gått och vilken vinkel avsnittet fått.

Den kan också läsa tidigare arkiverade avsnitt av samma show och försöka välja en annan vinkel nästa gång. Det gör att samma program inte måste börja med samma typ av intro vecka efter vecka.

Episode-state sitter på sessionen. Ett beat markeras innan genereringen börjar. Det är medvetet: om Kontrollcenter kraschar mitt i ett segment vill jag hellre missa det än spela samma intro två gånger efter restart.

Episode-state
{
  status: "ok",
  plan: {
    angle: "fredagsenergi utan standardintro"
  },
  beats: {
    intro: true,
    feature: false,
    outro: false
  }
}

Jag vill att Show Engine ska kunna misslyckas snyggt

Om en plan inte kan genereras ska showen fortfarande kunna spela musik. Därför finns fallback-status. Planering är en förbättring av stationen, inte en single point of failure.

Det här är en återkommande princip: generativt innehåll får göra radion bättre men ska inte få makten att göra radion tyst.

Showplanen fick en fallback för att generativ planering aldrig ska bli ett stoppvillkor

Om planeringen misslyckas ska showen ändå kunna vara en fungerande musikshow. Därför kan episode-state markeras fallback och Broadcast fortsätter.

Jag gillar det mönstret eftersom det gör generativa funktioner additiva. De får göra stationen bättre men inte göra grundfunktionen skörare.

Samma princip gäller många skills. Nyheter, weather och faktasegment är optional. Musik är baslinjen.

Jag fick bygga ett eget handoff-state för de där tio sekunderna mellan två program.

Ett showbyte ser enkelt ut för lyssnaren. En värd säger något avslutande, nästa tar över och musiken fortsätter. För Kontrollcenter är det betydligt fler tillstånd.

Handoff kan vara förberedd men inte sänd. Den kan ligga renderad och vänta på en låtgräns. Showen kan hinna rulla innan ljudet spelats. En restart kan ske mitt i allt.

Jag införde därför ett explicit state för överlämningen: vad som är outgoing, incoming, vilken show som är mål, om manus är renderat och om handoff faktiskt har hörts.

Det gjorde också andra delar smartare. Hourly kan stå över om handoff äger gränsen. Weather behöver inte försöka trycka sig in där. Station-ID kan vänta.

Handoff lärde mig att “rendered” och “aired” måste skiljas åt

Först var det lockande att markera handoff som klar när manus och ljudfil var färdiga. Men ett renderat klipp som väntar på nästa boundary har ännu inte hänt för lyssnaren.

Det är därför state behöver separera producerat från faktiskt sänt. Samma skillnad används i Weather Memory: jag vill helst registrera senaste weather när det verkligen har nått air, inte bara när texten skapades.

Det blir extra viktigt vid restart. Kontrollcenter ska inte anta att något hördes bara för att filen fanns.

Problemet var inte fem dåliga timers. Problemet var att de konkurrerade om samma öra.

Vid det här laget hade jag hourly, station-ID, banter, programfeatures, generativa skills och jinglar. Första generationens lösning var naturlig: varje sak har sin egen timer.

Det fungerar tills två saker vill samma minut. Flyttar jag station-ID från :00 till :15 har jag bara flyttat konflikten. Lägger jag banter på :20 kan en feature fortfarande råka gå :19:40 och blockera det.

Jag byggde därför Relay 7 Talk Scheduler med en central slottabell.

Förenklad slottabell
[
 { kind:"programme", priority:1, window:1,  gap:0, air:"immediate" },
 { kind:"hourly",    priority:2, window:10, gap:3, air:"immediate" },
 { kind:"banter",    priority:3, window:10, gap:5, air:"immediate" },
 { kind:"station-id",priority:4, window:10, gap:3, air:"next-track" },
 { kind:"segment",   priority:5, window:1,  gap:0, role:"fill" },
 { kind:"jingle",    priority:6, window:1,  gap:3, role:"fill" }
]

Skillnaden är att ett segment som förlorar en minut kan vänta inom sitt fönster. Schedulern kan logga varför: senaste fristående prat var för nyligen, en annan slot tog minuten eller ett redan renderat klipp väntar på boundary.

Det blev mycket mer robust än att varje modul gissar själv.

Den viktiga delen var inte vem som hade rätt först

Det intressanta med referensmodulen var att två kodbaser som utvecklats på olika håll nådde samma arkitekturella slutsats. Det är ett starkt tecken på att problemet verkligen ligger i domänen och inte bara i en specifik implementation.

För mig blev det en bekräftelse på att central arbitration var rätt investering även om den krävde mer kod än en snabb timerpatch.

Talk Scheduler fick bli ren beslutslogik

Jag ville kunna testa schedulern utan att köra Gemma 3, Westin-AI eller Broadcast. Därför byggdes själva planeringen som så ren logik som möjligt: klocka, öppna slots, senaste talk, pending state och policyresultat in; fire, wait eller miss ut.

Det gör att jag kan simulera en hel timme på millisekunder och fånga regressioner. Om jag ändrar ett fönster kan ett test visa att station-ID plötsligt kan kollidera med hourly.

Det här är ett återkommande mönster i den nyare kodbasen: separera beslut från effekt. Först bestäm vad som ska hända. Sedan kör nät, modell och queue.

En annan utvecklares showmodul fastnade i samma problem. Där blev fixen en hel utvecklingsplan. Jag hade redan flyttat problemet till en central scheduler.

Jag följde samtidigt en fristående showmodul som löste många liknande radioproblem. Där fanns separata timers för flera typer av prat. När kollisionsproblemen började synas behövde utvecklaren inte bara rätta en liten detalj.

Fixförslaget växte till flera tydliga utvecklingssteg. Först skulle de befintliga timerreglerna extraheras till en gemensam planerare utan att beteendet ändrades. Därefter skulle riktig arbitration slås på med tidsfönster, min-gap och prioritet. Som ett tredje steg kunde generativa segment läggas in som lågprioriterad fill.

Det intressanta för mig var att jag redan hade dragit samma slutsats i min egen kod: dispatch måste centraliseras, policy ska fortfarande ägas av respektive modul.

Gammal typ av upplägg
timer_hourly  → maybeTalk()
timer_ident   → maybeTalk()
timer_banter  → maybeTalk()
timer_feature → maybeTalk()

// Alla tror att de äger minuten.
Min riktning
oneMinuteTick()
   ↓
collectOpenSlots()
   ↓
evaluatePolicies()
   ↓
arbitrate()
   ↓
fire | wait | miss

Det här var en av de tydligaste punkterna där jag kände att min egen kodbas hade vuxit förbi “patcha en befintlig radio”. Jag hade redan byggt en generell lösning på den typ av problem som fortfarande behövde brytas ut i flera steg i referensmodulen.

Jag tog också med mig något viktigt från deras problem: den centrala schedulern får inte börja duplicera alla andra regler. Listener-gate, budget, showstatus och skill-cooldown ska fortfarande avgöras av sina egna policyfunktioner. Schedulern ska bara avgöra vem som får använda tiden.

Referensmodulens problem gav mig en extern kontroll på min egen arkitektur

Det är lätt att bli hemmablind i en egen kodbas. När en separat utvecklare beskriver samma problem och landar i en väldigt liknande central scheduleridé blir det ett slags oberoende validering.

Jag tog samtidigt med mig deras tydliga uppdelning mellan dispatch och policy. Det är lätt att en central scheduler växer och börjar bestämma allt. Då har jag bara flyttat monoliten. Därför vill jag behålla listenerpolicy, budget och skill-cooldowns i sina egna moduler och låta schedulern konsumera svaren.

Banter kunde försvinna i en hel show trots att varje modul gjorde “rätt”.

En station-ident kunde hamna nära ett banterfönster. Banter hade en min-gap och såg därför att det var för tätt inpå senaste prat. Då stod den över.

Problemet var att första versionen såg :20 som ett ögonblick. Missas :20 så är nästa chans mycket senare. Ett kort station-ID kunde därför svälta ett helt banterblock.

Jag gjorde om ögonblicket till ett fönster. :20 betyder exempelvis “börja försöka här och fortsätt några minuter om radion är upptagen”.

Vänta i stället för att kasta bort
if (!quietGapClear) {
  return {
    act: "wait",
    retryUntil: slotEnd,
    reason: "too close to previous standalone talk"
  };
}

Det här är en väldigt radiomässig lösning. En mänsklig producent tänker inte “klockan blev 20 över, nu måste pratet ske på sekunden”. Producenten tänker “hitta en bra lucka här omkring”.

Variation behöver också gränser

Jag vill inte ha maximal variation. Jag vill ha trovärdig variation. Om en lugn weather-rad plötsligt får samma energiska stil som ett fredagsintro har jag bara bytt monotoni mot slump.

Därför är viktningen per segmenttyp viktig. Variation sker inom en roll, inte över hela registret varje gång.

Fönster löste också ett observationsproblem

När ett segment bara får en chans är det svårt att veta om det aldrig var eligible eller bara råkade krocka. Med fönster kan loggen säga “stod över, försöker igen till :29”. Det gör beteendet begripligt medan stationen kör.

Jag försöker få samma typ av reason-codes på fler ställen. Ett automatiskt system blir mycket lättare att lita på när det kan förklara varför det inte gjorde något.

Jag tröttnade på att höra samma meningsmall och gjorde variation till ett eget system.

Gemma 3 kunde skriva helt korrekta länkar men ändå börja likadant: artist, titel, liten kommentar, stationsnamn. Att lägga till “variera mer” hjälpte bara lite.

Jag byggde därför Radio Variation som väljer presentationens struktur före genereringen: längd, öppning, ton, rytm, identitetsplacering och avslut. Varje dimension har vikter och minne.

Exempel på variation
length  = pickAvoidingRecent(LENGTHS, 2)
opener  = pickAvoidingRecent(OPENERS, 4)
tone    = pickAvoidingRecent(TONES, 3)
rhythm  = pickAvoidingRecent(RHYTHMS, 3)
landing = pickAvoidingRecent(LANDINGS, 3)

Det betyder att modellen får en liten regi för just det breaket. Nästa gång kan regin vara helt annorlunda utan att jag ändrar fakta.

Det här gav betydligt större skillnad än att bara höja kreativiteten i modellen.

Det långsamma banter-anropet ändrade min syn på förberedelse

Före de riktigt långa genereringarna tänkte jag mest att talk ska produceras när det behövs. Efteråt blev det tydligt att tungt innehåll måste behandlas mer som förproduktion.

Show Engine vet redan vilka beats som kan komma. Det betyder att den kan börja samla kontext och värma tjänster innan exakt slot öppnar. På sikt vill jag även använda faktisk nylig latency som input till när produktionen startas.

Jag fixade länkarna och upptäckte direkt att skills fortfarande kunde låta som copy-paste.

Rewind-puffar och andra skills gick genom en annan runtimeväg än vanliga track links. Därför fick de inte automatiskt samma variationsplan.

Jag byggde Skill Variation som fungerar för automatiska skills, manuella runs, schemalagda runs och co-hostade segments. Den har egna öppningar, rytmer och landningar.

En viktig detalj är att variationen är presentationslager. Faktainnehåll, datum och tider är fortfarande överordnade. Om en promo måste säga fredag 18–21 får variationen flytta var uppgiften ligger i meningen, inte ändra den.

Texture är medvetet sällsynt

Jag testade tanken mentalt åt andra hållet: om varje break har restart, skratt eller “äh” blir det snabbt uppenbart att det är en feature. Därför måste clean dominera.

Jag håller också promoformerna ännu mer sällsynta och blockerar dem över flera tidigare val. Det ska kännas som att programledaren råkade nämna sajten, inte att ett cronjobb tog över.

Jag ville ha skillnad i struktur, inte synonymroulette

En tidig idé om variation var att byta ord och formuleringar. Det gav ytliga skillnader men samma känsla. Radio Variation blev bättre först när jag började variera dramaturgin: ibland reaktion först, ibland titel först, ibland minimal backsell, ibland en liten personlig kommentar.

Jag håller dessutom minnen per dimension. Det betyder att samma ton kan återkomma även om öppningen blockeras, och tvärtom. Systemet behöver inte kasta bort en hel bra stil bara för att en del av den nyss användes.

Jag la in kontrollerade småfel för att sluta låta för perfekt.

När variationen blev bättre uppstod nästan motsatt problem: texten kunde bli för ren. Riktiga radiopratare gör små omstarter, lägger in en liten tvekan eller ett kort “heh”.

Jag byggde Radio Host Texture med hög vikt på clean och låg vikt på imperfektion.

Vikter
clean       68
hesitation   8
restart      7
chuckle      5
aside        7
website      3
rewind       2

Det finns också hårda regler: max en sådan detalj per break. Aldrig inne i artistnamn, låttitel, programnamn, tid, datum, fakta eller webbadress.

Det här är ett bra exempel på en funktion som lätt kan bli sämre än inget alls om den överanvänds. Ett “äh” ibland låter mänskligt. Ett “äh” i varje break låter som en bugg.

Skills visade varför runtimevägar måste kartläggas

Det räckte inte att hitta en funktion som skapar radioprat och lägga variation där. Manuellt körda skills, schemalagda skills och automatiskt valda skills kunde gå olika vägar.

Jag fick därför kartlägga alla vägar och injicera variation på ställen där den verkligen täcker dem. Den typen av arbete är mindre synligt än en ny funktion men avgör om beteendet är konsekvent.

“Tio på natten” klockan 22:07. Jag fixade min egen kod, inte modellen.

Efter att tidsfraserna flyttats till kod hörde jag fortfarande “strax efter tio på natten” strax efter 22. Då fanns ingen anledning att skylla på Gemma 3. Dagpartsregeln var min.

Jag ändrade helt enkelt kvällsgränsen så 22:00–22:59 är “på kvällen” och natt börjar 23:00.

Det är en liten fix, men den visar varför jag gillar deterministiska regler. Jag kan peka på exakt raden som är fel, ändra den och veta att samma fel inte återkommer slumpmässigt.

Weather-fixen blev ett mönster för framtida durable memory

Jag vill använda samma idé på fler saker: spara minsta nödvändiga state, skriv atomiskt och läs tillbaka vid start. Men varje minne behöver sin egen expiry. Weather från nyss kan vara relevant. Ett banterskämt från gårdagen kanske inte ska vara det.

Durable betyder därför inte permanent. Det betyder överlevande med tydlig giltighet.

Texture fick ett eget anti-repeat eftersom slump inte är minne

Även en låg sannolikhet kan ge samma val två gånger i rad. Därför räcker det inte att säga att restart bara har sju procents vikt. Jag blockerar samma icke-clean-läge direkt efter sig självt.

Promoformer har ännu bredare minne. Om sajten nyss nämnts ska den inte nämnas igen bara för att slumpen råkar vilja det.

Det här är samma grundidé som i musikvalet: sannolikhet behöver kompletteras med recencyregler.

Vädret hade minne tills Kontrollcenter startade om. Det räknas inte som minne.

Weather-funktionen kunde jämföra aktuell condition med det senaste den pratat om. Problemet var att state bara fanns i RAM. Efter restart var senaste condition null och samma väder kunde väljas igen.

Jag skapade en liten durable state-fil som sparar condition, plats, senaste weather-text och timestamp. Vid start läses den tillbaka. Även weather-cooldown kan rekonstrueras från tiden.

Durable weather state
{
  "condition": "cloudy",
  "location": "Vara",
  "text": "senaste faktiskt sända väderraden",
  "at": "2026-09-18T22:13:36+02:00"
}

Jag gjorde också ett migrationsfall: om den nya state-filen inte finns men en nylig weather-rad finns i den redan persistenta sessionen kan Kontrollcenter använda den vid första uppgraderingsstarten.

Viktigast är ändå filtret före modellen. Om condition är oförändrad erbjuds inte weather som möjlig automatisk skill. Det sparar både tokens och lyssnarens tålamod.

Den lilla tidsfixen bekräftade att determinism är värd arbetet

Hade dagdelen fortfarande legat fritt i prompten hade “tio på natten” kunnat bero på modellvariation. Nu kunde jag se att det var en enda gräns i min kod. Jag ändrade den och testade funktionen.

Det är precis den typen av problem jag vill flytta från språkmodell till kod när det går.

När ett sökresultat handlade om fel sak blev det tydligt att “du måste säga något” är en farlig regel.

En faktaskill kan ibland få ett svar där namnet matchar men personen eller ämnet är fel. Om systemet samtidigt säger att modellen måste producera en rad tvingas Gemma 3 välja mellan att vara tyst och att hitta på.

Jag började därför göra grounding till en riktig gate. Skills som bygger på extern data får stå över om underlaget är tomt eller oanvändbart.

Stand-down före prat
const reason = unusableDataReason(data);

if (requiresGrounding(skill) && reason) {
  return {
    aired: false,
    reason
  };
}

Det är fortfarande ett utvecklingsområde. Nästa steg är ännu bättre identitetskontroll så en källa om någon med samma namn inte räcker.

Installern är också dokumentation

Ett bra installscript visar exakt vilka filer som förväntas finnas, vilket buildnamn som skapas och vilken service som ska startas om. På så sätt fungerar scriptet nästan som ett exekverbart förändringsdokument.

Jag försöker därför skriva ut aktiv image, backupplats och healthresultat i slutet. Det gör nästa felsökning mycket enklare.

Weather Memory blev också ett exempel på serialized writes

Om två completion-events kommer nära varandra vill jag inte att en äldre skrivning ska råka avslutas sist och skriva över nyare state. Därför serialiseras skrivningarna i en liten promise-kedja.

Princip
persistChain =
    persistChain
      .catch(ignore_previous_error)
      .then(() => atomicWrite(latestMemory))

Det är ett litet skydd men passar bra för en tjänst där flera async-flöden kan avslutas i oväntad ordning.

Westin-AI får en separat leveransstil så textvariation faktiskt hörs.

Jag byggde en style-planer före Westin-AI. Olika segment har olika viktning. Weather och nyheter lutar mer neutral/varm. Banter kan vara torrare eller mer energiskt. Handoff får oftare varm och trygg leverans.

Exempel
weather:
  neutral    40
  warm       28
  confident  18
  soft       14

banter:
  upbeat     24
  dry        24
  warm       22
  energetic  16
  neutral    14

Även här finns recent-memory så samma style inte väljs tre gånger bara för att den har högst vikt.

Grounding behöver både relevans och rätt identitet

En källa kan vara trovärdig och ändå vara fel källa för den aktuella artisten. Jag behöver därför skilja två frågor: “är den här källan generellt trovärdig?” och “handlar just den här träffen om rätt person eller verk?”.

Det gör trust-gate svårare än en domänallowlist. Jag vill kunna använda bra källor utan att ett vanligt namn råkar koppla fel person till låten.

Till slut behövde jag installers för mina installers.

När Controller fick fler egna lager blev de manuella kommandona farliga. Jag ville inte längre kopiera tio rader, glömma en build-flagga och sedan undra vilken version som faktiskt kör.

Jag började därför skriva ett installscript per större förändring. Scriptet gör backup av compose/config, sparar aktuell image, kör typkontroll, bygger ny Controlcenter-image, ändrar endast den tjänsten, väntar på health och kan återställa om den inte blir frisk.

Installmönster
backup_state
verify_expected_source
typecheck
build_new_controlcenter
switch_controlcenter_only
wait_for_health

if unhealthy:
    rollback_previous_config

Jag byggde även en separat mapp i versionsarkivet där aktuell Relay Seven-stack dokumenteras. Där finns vad som är aktivt och wrappers till installers.

Det är inte bara ordning. Det gör att nästa experiment kan vara aggressivt utan att vara dumdristigt.

Style-planen är medvetet enklare än textplanen

Jag vill inte att Westin-AI-style ska bli ännu en stor generativ modell. En liten viktad planner räcker långt och är lätt att förutse.

Det betyder också att jag kan ändra karaktären på en show genom vikter utan att röra själva röstmotorn. Ett kvällsblock kan exempelvis luta lite mer soft eller late-night medan ett energiskt block får fler upbeat-val.

Relay Seven idag: många små tydliga delar i stället för en enda mystisk box.

Relay 7 KontrollcenterSchema, state, prompts, policies, API, showorkestrering och dispatch.
Relay 7 PickerPlaylistregler, kandidatpool, vikter, no-repeat och artist spacing.
Relay 7 Show EngineEpisode-plan, intro, feature, outro och handoff.
Relay 7 Talk SchedulerPrioritet, windows, min-gap, wait/fire/miss och talk arbitration.
Relay 7 AnalyzerMusikstruktur, cues och information som kan hjälpa transitions.
Westin-AIRöstproduktion, warmup, health, style, outputformat och timing.
Relay 7 BroadcastMusik, röstköer, ducking, jinglar, fades och streamoutput.
Relay 7 MemorySessioner, recency, weather state, recent plays och durable återhämtning.
Relay 7 GatewayPublika adresser, TLS, vägning mot webb, player och API:er.
Relay 7 FirewallZoner, exponering, begränsning och separation mellan publikt och internt.
Relay 7 WebNu spelas, historik, schema, requests, likes och stationinformation.
Relay 7 MobileLyssning och samma serverdata som webben, utan duplicerad radiologik.

Gemma 3 via Ollama är en viktig komponent, men den är inte “hjärnan som äger allt”. Det är medvetet. Ju fler fakta och regler som går att avgöra deterministiskt desto mer kan modellen fokusera på det den ska vara bra på: språk.

Installerna gjorde att jag slutade vara rädd för små lokala ändringar

När backup, typkontroll, build, servicebyte och health finns i samma script blir ändringen reproducerbar. Jag behöver inte lita på att terminalhistoriken råkar finnas kvar.

Jag lägger också stor vikt vid att installern endast startar om den tjänst som verkligen behöver bytas. Om jag förändrar Kontrollcenter finns ingen anledning att samtidigt störa Broadcast eller Westin-AI.

Det minskar både risk och onödiga avbrott.

Det smartaste jag byggt är egentligen gränserna mellan systemen.

När jag tittar på hela resan från 2020 är det lätt att fastna i enskilda funktioner. Men den största utvecklingen är hur ansvaret delats upp.

  • Jag låter inte språkmodellen välja musik när kod kan göra det bättre.
  • Jag låter inte röstmotorn bestämma showlogik.
  • Jag låter inte webben tolka intern queue.
  • Jag låter inte varje timer avgöra själv om den får prata.
  • Jag låter inte ett restartbart RAM-minne vara enda sanningen för viktigt state.
  • Jag låter inte en publik klient känna till interna tjänsteadresser.

Det är den typen av gränser som gjort att projektet klarat alla ombyggnader. Flera installationsgenerationer har kommit och gått, men idéerna som visat sig bra har kunnat flytta med.

Jag började med frågan “hur får jag en MP3-stream att gå?”. Nu ställer jag oftare frågan “vilken modul ska äga den här sanningen?”. Det säger ganska mycket om hur långt projektet har gått.

Arkitekturen idag är resultatet av många borttagna idéer

Det är lätt att se moduldiagrammet som något jag ritade först och implementerade sedan. I verkligheten är det nästan tvärtom. Gränserna har vuxit fram där jag fått problem.

Picker finns separat för att modellbaserat musikval var dyrt och otydligt. Memory finns för att processminne inte överlever restart. Talk Scheduler finns för att timers började kollidera. Gateway finns för att publik routing blev för komplex för att ligga utspridd.

Modulnamnen är därför nästan en karta över gamla problem.

Det är fortfarande långt ifrån klart — och det är en del av grejen.

Det finns flera utvecklingsspår jag fortfarande vill fortsätta med.

  • Prediktiv produktion: börja generera tunga segment utifrån beräknad airtime och aktuell latency.
  • Semantiskt långtidsminne: komma ihåg ämnen utan att running gags blir odödliga.
  • Bättre trust-gate: externa fakta ska passera identitet och relevans innan Gemma 3 ens ser dem.
  • Mer showprofil: varje show ska få egen rytm och struktur, inte bara en playlist.
  • Smartare transitions: Analyzer-data ska användas mer direkt i cue-in/cue-out.
  • Gemensam latency-trace: varje talmoment ska kunna följas från beslut till faktisk air.
  • Mindre gammal kod: när en ny väg bevisats stabil ska gamla kompatibilitetslager bort.

Det jag absolut inte vill tillbaka till är en situation där stationen bara fungerar så länge jag inte rör något. Hela poängen med Relay 7 Kontrollcenter är att jag ska kunna fortsätta experimentera utan att göra själva radion skör.

Jag försöker numera optimera för nästa ombyggnad också

Jag vet efter sex år att dagens lösning inte är slutversionen. Därför försöker jag undvika att göra nya funktioner omöjliga att flytta senare.

Ett tydligt API, ett litet stateformat och en pure planner är lättare att ersätta än ett djupt specialfall inne i en stor process. Jag kommer säkert göra om delar igen. Skillnaden är att jag vill att nästa omtag ska vara ett kontrollerat byte, inte en total rivning.

Det är kanske den största mognaden i projektet: jag bygger inte längre som om varje beslut måste leva för alltid.

Radio Link V2, TTS-stilar och bättre DJ-prat

Arbetet med Saras radioprat fortsatte med fokus på större variation och bättre kontroll över hur rösten används.

Radio Link V2

Vi byggde vidare på systemet som bestämmer vilken typ av prat Sara ska använda mellan låtarna.

I stället för att nästan samma presentation genereras varje gång kan systemet nu välja mellan flera olika typer av radiolänkar, exempelvis backsell, frontsell, kortare länkar och friare prat.

Målet är att få mindre upprepningar och ett mer naturligt radioflöde.

Systemet får också bättre information om föregående och kommande låt så att AI:n kan använda rätt sammanhang när texten skapas.

TTS-stilar

Vi byggde även ut kedjan mellan radiosystemet och Westin-AI så att information om önskad talstil kan följa med hela vägen till röstgenereringen.

Flödet är ungefär:

Radio Controller → remoteTts → Westin-AI → VoxCPM

Det gör det möjligt att låta olika typer av radioprat använda olika uttryck, exempelvis neutralare, varmare eller mjukare röststil.

Under arbetet rättades även problem med remoteTts och dess anrop mot Westin-AI.

Efter ändringarna kunde riktiga TTS-anrop genomföras och WAV-filer genereras korrekt.

Bättre övergångar

Vi har också fortsatt justera hur pratet placeras mellan låtarna.

Extra marginal runt TTS-ljudet och längre ducking används för att ge rösten mer plats och göra övergångarna mellan musik och prat mjukare.

Det här är ännu ett steg mot att Sara ska låta mer som en riktig radiopratare och mindre som ett automatiskt TTS-system.

Radio Link V3 och sammanslagning av vår egen kod

Idag fortsatte arbetet med nästa version av systemet för Saras radioprat: Radio Link V3.

Den nya koden hämtades från Git och skulle kombineras med de anpassningar som redan finns i Relay Seven.

Git och våra egna ändringar

Eftersom vi har gjort flera egna ändringar i radiosystemet går det inte längre alltid att bara uppdatera originalkoden rakt av.

Vid dagens uppdatering uppstod därför en merge-konflikt i bland annat:

controller/src/llm/internal/prompts/scripts.ts

Konflikten beror på att både den nya versionen och våra egna Relay Seven-anpassningar har ändrat samma delar av koden.

Arbetet består därför av att gå igenom ändringarna och behålla både de nya funktionerna och det som redan fungerar i vår egen version.

Egna anpassningar måste bevaras

Det är särskilt viktigt att inte förlora sådant vi redan byggt för Relay Seven, exempelvis:

  • Saras svenska radiostil
  • regler för artist och låttitel
  • variation i radiopratet
  • svensk tidsinformation
  • våra TTS-anpassningar
  • kopplingen till Westin-AI
  • våra ändringar för Radio Link

Installationsproblem

Vi hittade även ett problem där ett installationsscript som kördes med sudo letade efter projektet under root-användarens hemkatalog i stället för den riktiga projektmappen.

Det gjorde att scriptet letade efter:

/root/relayseven-ai-src

trots att projektet egentligen ligger under användaren Westin.

Detta identifierades innan installationen fick fortsätta.

Nästa steg

Radio Link V3 är därför ännu inte färdiginstallerad.

Nästa steg är att lösa kodkonflikterna korrekt, kontrollera att våra tidigare funktioner finns kvar och därefter bygga och testa systemet innan den nya versionen får gå live.

Det börjar samtidigt bli tydligt att Relay Seven nu har en ganska kraftigt egenutvecklad variant av det ursprungliga radiosystemet.

Inga tidslinjeinlägg matchade sökningen.
Problemjournalen

Felvägarna som faktiskt formade plattformen.

Det här är sådant som lätt försvinner i en vanlig projektsammanfattning. För mig är det nästan viktigare än featurelistan, eftersom många av dagens lösningar finns just för att en tidigare idé inte höll hela vägen.

22
När en fix bara flyttar problemet

Jag lärde mig att fråga om regeln verkligen är löst eller bara flyttad.

En tidig schedulerlösning kunde vara att flytta ett station-ID från en minut till en annan så att det inte krockade med hourly. Det fungerar direkt och kan vara helt rimligt som akut fix. Problemet är att samma station-ID fortfarande kan krocka med banter, en showfeature eller ett sent handoff på den nya minuten.

Jag började därför skilja på lokala konflikter och strukturella konflikter. Om två saker bara råkar använda samma felaktiga minut kan en flytt vara rätt. Om flera typer av prat konkurrerar om samma resurs är minutplacering däremot bara symptomhantering.

Den distinktionen har blivit användbar långt utanför schedulern. Om två API:er båda behöver samma normalisering kanske normaliseringen hör hemma i ett gemensamt lager. Om flera prompts behöver samma tidsregel ska tidsregeln inte kopieras i flera prompts. Om flera services behöver veta aktuell show kanske showstate behöver ett tydligare kontrakt.

Jag försöker numera ställa frågan “kommer nästa liknande feature behöva samma specialfall?” Om svaret är ja är det ofta bättre att bygga en generell mekanism direkt.

23
När restart gjorde felet värre

Jag slutade betrakta omstart som neutral handling.

Omstart är ett av de vanligaste felsökningsverktygen eftersom den rensar temporärt state. Men i Relay Seven finns både temporärt och persistent state. Det betyder att restart kan lösa ett problem, lämna det exakt som det var eller i vissa fall återaktivera ett gammalt beslut.

Persona-problemet visade det tydligt. Jag hade en korrekt inställning i UI men en gammal session som överlevde och återställdes. Om jag bara hade fortsatt starta om hade jag inte kommit närmare orsaken.

Efter det började jag vilja veta vad varje service laddar vid boot. Vilka filer läses? Vilken stateversion accepteras? Vilka defaults används om filen saknas? Finns det migration? Det gör en restart mycket mer förutsägbar.

Jag vill på sikt att Kontrollcenter ska kunna visa en liten boot-rapport: vilka statekällor återställdes, vilka avvisades och varför. Det skulle göra flera typer av “den gamla grejen kom tillbaka”-fel betydligt lättare att förstå.

24
När ett API såg friskt ut

Ett 200-svar kan fortfarande vara fel.

Jag har haft flera situationer där en endpoint tekniskt svarar men datan inte längre är färsk. Det är särskilt farligt för now-playing och status eftersom klienten då visar en fullt rimlig men gammal verklighet.

Därför försöker jag inkludera tidsstämplar och färskhetskontroller i dataflödet. En health check kan dessutom behöva verifiera mer än att routern svarar. Om datakällan inte uppdaterats på länge kan tjänsten vara degraded trots att själva processen är uppe.

Det här påverkar även cache. Cache är bra för stabilitet och prestanda men måste ha en tydlig stale-policy. Jag vill hellre visa “tillfälligt okänd” än låta en sex timmar gammal låt se aktuell ut.

Det är samma princip som med AI-fakta: gammal eller osäker data ska märkas som osäker, inte presenteras självsäkert bara för att ett tekniskt anrop lyckades.

25
När brandväggen såg oskyldig ut

Jag fick lära mig att nätfel kan vara tidsberoende.

En brandväggsregel kan fungera för ett kort test och ändå skapa problem efter längre tid om den påverkar connection tracking, idle timeout eller shaping. Därför är nätprov för radio annorlunda än “curl gav svar”.

Jag har börjat testa tre saker separat: korta API-anrop, långlivad stream och återanslutning. De belastar nätvägen på olika sätt. En regeländring som är perfekt för vanlig webb kan behöva justeras för en anslutning som ska leva i timmar.

Jag håller också publika och interna tjänster separerade så långt det går. Om en intern tjänst inte ska vara publik finns ingen anledning att låta brandväggen acceptera externa anslutningar till den bara för att det är bekvämt under test. Jag använder hellre Gateway som den kontrollerade ingången.

Det gör dessutom loggarna mer värdefulla. Om all publik trafik går genom samma lager kan jag se vad världen faktiskt försöker nå.

26
När hårdvara gav ett mjukvarufel

Jag har sett symptom som såg ut som modellproblem men egentligen var resurser.

En tung röstkörning kan falla på videominne. Ett system kan börja byta aggressivt mot disk när arbetsminnet tar slut. Ett kraftigt grafikkort kan trigga skydd i ett för svagt nätaggregat. Alla tre kan på ytan se ut som “programmet kraschade”.

Det gjorde att jag började kontrollera resurser parallellt med applikationsloggar. Hur mycket videominne var faktiskt ledigt? Hur såg systemminnet ut? Fanns swaptryck? Hade GPU:n slagit i effekt eller temperatur?

Jag har också blivit mer försiktig med att dra slutsatsen att en större modell är bättre bara för att den är större. Om den kräver så mycket resurser att varje kort radiolänk får hög latency är den kanske sämre för radio även om den är bättre i ett benchmark.

För Relay Seven är användbar intelligens alltid intelligens inom en tidsbudget.

27
När genereringen blev klar men ändå var oanvändbar

Jag behövde införa begreppet stale content.

Det räcker inte att ett segment genereras korrekt. Det måste fortfarande vara sant och relevant när det ska spelas. En tidsfras kan bli gammal. Ett “nästa låt”-prat kan bli fel om kön ändrats. Ett showintro kan förlora sitt syfte om två låtar redan hunnit gå.

Därför har jag börjat tänka mer i validity windows. Vissa klipp kan vänta länge. Andra ska kasseras snabbt. Queue behöver alltså känna till typ och ålder, inte bara ordningen de kom in i.

Det här är en av anledningarna till att jag inte vill förgenerera allt för långt i förväg heller. Förproduktion minskar latency men ökar risken att kontexten ändras. Jag behöver hitta rätt balans per segmenttyp.

Det är ytterligare ett område där Show Engine så småningom kan bli smartare genom att använda deadline, förväntad latency och hur stabil kontexten är.

28
När loggen blev för mycket

Mer logg är inte automatiskt bättre logg.

När jag började lägga till debugrader överallt blev nästa problem att det viktiga drunknade. Ett radiokontrollsystem producerar många händelser: picks, queueförändringar, health, metadata, talkbeslut och scheduler-ticks.

Jag började därför vilja ha mer strukturerade events och tydliga kategorier. Ett normalt “stod över på grund av cooldown” behöver inte se ut som ett fel. Ett faktiskt generation timeout ska däremot sticka ut.

Jag vill även kunna filtrera på ett enskilt talk-id och följa det genom hela kedjan. Det är mycket mer användbart än att läsa tusen rader kronologiskt och försöka gissa vilka som hör ihop.

Det här ligger bakom tanken på gemensam trace för text, röst och air.

29
När en bra feature lät som reklam

Jag fick separera stationsidentitet från stationsspam.

Det är lätt för en automatisk värd att nämna stationens namn för ofta. Det är säkert språk och det passar nästan överallt. Problemet är att lyssnaren snabbt hör mallen.

Jag började därför minska stationnamnet i vanliga länkar och ge riktiga station-ID:n ett eget ansvar. Radio Host Texture får ibland nämna sajten eller ett program, men det är lågt viktat och spärrat av recent-memory.

Samma tänk gäller showpromo. En puff ska kännas som något värden kom på i rätt sammanhang, inte som en separat reklamrobot som bryter in i varje låt.

Det är en liten redaktionell detalj som i praktiken kräver state och policy för att inte bli mekanisk.

30
När variation blev för mycket

Jag vill inte att stationen ska vara oförutsägbar, bara levande.

Det finns en frestelse att öka slumpen varje gång något känns repetitivt. Jag märkte snabbt att det bara flyttar problemet. Om allt kan hända när som helst förlorar stationen karaktär.

Jag vill i stället ha begränsad variation inom en tydlig identitet. En weather-rad ska fortfarande låta som weather. Ett handoff ska fortfarande kännas varmt och tydligt. Rewind får ha mer energi än ett lugnt kvällsblock.

Det är därför variationen är viktad och segmentmedveten. Random används som verktyg inom definierade ramar, inte som ersättning för redaktion.

Jag ser det nästan som en mänsklig programledare: personen varierar sina meningar men byter inte personlighet slumpmässigt var tredje minut.

31
När något måste få vara tråkigt

Vissa moduler ska vara nästan helt deterministiska.

Jag har gradvis blivit mer bekväm med att vissa delar inte behöver någon kreativitet alls. Brandväggspolicy, health, cooldown, showtid och state migration blir inte bättre av variation.

Det kan kännas mindre “AI” men ger ett mycket bättre system. Gemma 3 kan lägga sin kapacitet på språk där variation har värde. Resten får vara kod.

Det här är också ett kostnadsbeslut. Varje fråga jag kan avgöra utan en modell sparar latency och resurser. När stationen går hela dygnet blir små onödiga anrop snabbt många.

Jag ser numera AI som en specialiserad komponent bland andra, inte som standardlösningen på varje beslut.

32
När en testfix riskerar att bli permanent

Jag märker tillfälliga lösningar tydligare nu.

Under snabb felsökning är det helt rimligt att lägga en workaround. Problemet kommer när workarounds överlever så länge att ingen längre minns varför de finns.

Jag försöker därför ge tillfälliga vägar tydliga namn, kommentarer eller dokumentation. Om en workaround senare kan tas bort ska det gå att hitta den.

Det är en stor anledning till att jag skapade en separat Relay Seven-dokumentationsmapp i versionsarkivet. Jag vill kunna läsa vad som faktiskt är aktivt och vilka lager som bara finns för kompatibilitet.

Teknisk skuld är inte alltid dålig. Oidentifierad teknisk skuld är mycket värre.

33
När en app tvingade fram bättre backend

Mobilklienten gjorde specialfall dyra direkt.

När bara webben fanns kunde jag ibland lösa något direkt i frontend. Så fort mobilen också behövde samma funktion blev den lösningen uppenbart felplacerad.

Det drev normalisering och policy bakåt till API-lagret. Nu spelas, historik, showstatus och requestresultat ska komma färdiga från servern. Klienterna får fokusera på presentation.

Det gör dessutom framtida klienter enklare. Om jag någon gång bygger ytterligare en spelaryta finns radiologiken redan bakom ett kontrakt.

Det är ett bra exempel på hur en ny frontend faktiskt förbättrade serverarkitekturen.

34
När ett program behöver en producent

Show Engine är på väg från scheduler till producentlogik.

Första steget var att veta när intro, feature och outro ska gå. Nästa steg är att förstå hur de ska förberedas och hur de påverkar resten av timmen.

Jag vill exempelvis att en lång feature ska kunna påverka hur mycket annat prat som planeras runt den. Om ett handoff ligger nära showgränsen kanske ett station-ID ska ge plats. Om en tung generering tar lång tid ska den startas tidigare.

Det betyder att Show Engine långsamt blir mer av en producent än en timer. Den behöver inte skriva allt innehåll själv, men den kan skapa utrymme för rätt innehåll vid rätt tid.

Det är en riktning jag tycker är mycket mer intressant än att bara öka antalet automatiska prat.

35
När jag jämför första och senaste generationen

Grundidén är fortfarande förvånansvärt lik.

2020 ville jag ha en stream som spelar musik stabilt och som jag själv kan styra. 2026 vill jag fortfarande exakt samma sak. Skillnaden är att “styra” nu betyder mycket mer.

Jag kan styra musikregler, showstruktur, programledarpersonlighet, hur ofta tal får komma, vilka API:er som exponeras, hur state överlever restart, hur röst klipps in och hur olika tjänster får konkurrera om airtime.

Det har alltså inte varit sex år av att byta mål. Det har varit sex år av att göra samma mål mer exakt.

Det är också därför jag tror projektet kommer fortsätta. Varje gång en ny detalj blir möjlig att kontrollera börjar jag direkt höra nästa detalj som kan bli bättre.

Fördjupad ingenjörsdagbok

Sådant som inte syns i funktionslistan.

De här anteckningarna är mer om själva arbetssättet: hur jag har installerat om, mätt, backat, köpt delar, provat idéer och lärt mig när en snabb lösning faktiskt kostar mer senare.

13
Installationer

Jag har nästan aldrig bytt allt på en gång.

När jag gått från en generations lösning till nästa har jag försökt behålla en fungerande väg bredvid den nya så länge som möjligt. Det gör tester tråkigare men säkrare. En ny broadcastväg får bevisa att den kan spela innan den gamla tas bort. Ett nytt API får jämföras mot den gamla datakällan innan webben pekas om.

Det här parallella arbetssättet har ibland gjort kataloger och tjänster lite rörigare under en övergång, men det har räddat mig från att stå med en enda halvfärdig väg. När jag väl vet att den nya delen fungerar kan den gamla tas bort mer kontrollerat.

Jag har också lärt mig att dokumentera vilket steg som faktiskt är aktivt. Annars är det lätt att en testservice blir permanent bara för att den “fortfarande fungerar”.

14
Backuper

Backup före experiment är billigare än försiktighet under experiment.

Jag vill kunna prova en idé ordentligt. Det blir svårt om varje förändring görs med handbromsen i för att jag är rädd att inte kunna återgå. Därför har backup och image-taggar blivit en del av själva utvecklingsflödet.

Jag sparar hellre en liten återställningspunkt för ofta än försöker rekonstruera gårdagens fungerande läge från minnet. Särskilt konfiguration och persistent state behöver behandlas med respekt eftersom kod kan hämtas igen men rätt kombination av runtimeinställningar kan vara svårare.

15
Mätning

Jag försöker mäta precis den del jag misstänker.

Om radioprat känns sent vill jag inte bara mäta total tid. Jag vill veta om fördröjningen låg i Gemma 3, Ollama, Westin-AI, filpolish, queue eller boundaryväntan. Samma sak med nät: intern tjänst, Gateway, TLS och klient är olika mätpunkter.

Det här är en metod som vuxit fram efter många onödiga omstarter. Ju bättre mätpunkter jag har, desto mindre behöver jag “prova något” på måfå.

16
Hårdvara

Jag köper inte längre bara efter den största siffran.

GPU-minne, rå prestanda, effekt, kylning och stöd i mjukvaran måste passa ihop. Ett äldre kort med mer minne kan vara användbart för vissa jobb men sämre för andra. Ett extremt kort hjälper inte om det får systemet att slå i strömgränsen.

Samma sak gäller lagring och RAM. Snabb lokal disk gör modell- och cachearbete mindre segt. Tillräckligt systemminne gör att en tung process inte direkt börjar slåss med swap. Jag försöker se hela arbetsflödet i stället för en benchmark.

17
Servicegränser

Jag vill kunna starta om en del utan att lyssnaren märker det.

Det är kanske den mest praktiska definitionen av bra modularitet jag har för Relay Seven. Om jag behöver ändra en prompt ska inte musikströmmen dö. Om Westin-AI behöver laddas om ska Broadcast fortsätta spela. Om Web byggs om ska Kontrollcenter inte bry sig.

Varje gång jag hittar ett fall där en liten förändring kräver full restart ser jag det som en signal att gränsen mellan tjänsterna kanske kan bli bättre.

18
Koddisciplin

Jag försöker göra nästa felsökning lättare medan jag skriver fixen.

När jag ändå ändrar en policy lägger jag gärna till en tydlig loggrad, ett reason-fält eller ett test. Det kostar lite mer i stunden men gör att nästa relaterade problem inte börjar från noll.

Talk Scheduler är ett bra exempel. Att logga “wait” och orsaken är mycket bättre än att ett segment bara uteblir. Weather Memory loggar laddnings- och persistfel. Installer skriver ut aktiv image och backupplats. Jag vill att systemet ska lämna spår efter sina beslut.

19
Redaktionell logik

Jag försöker skilja på tekniskt möjligt och redaktionellt vettigt.

Bara för att weather kan genereras var femte minut betyder det inte att det bör göras. Bara för att station-ID får plats betyder det inte att det ska ligga direkt efter ett showintro. Bara för att en källa hittats betyder det inte att faktan är värd att säga.

Det här är ett område där radiotänk och mjukvarutänk måste mötas. Systemet ska inte bara kunna utföra kommandon. Det ska kunna välja bort sådant som tekniskt går men låter dåligt.

20
Små förbättringar

Flera av de bästa ändringarna är nästan löjligt små.

220 ms padding. En korrigerad dagpartsgräns. En dedupregel. Ett “inherit” i stället för noll. En extra healthkontroll. Var för sig ser de ut som småsaker. Tillsammans avgör de om stationen känns genomarbetad.

Jag har därför slutat värdera ändringar efter hur mycket kod de kräver. Ett femradersfel som hörs varje timme kan vara viktigare än en stor ny feature som bara syns i adminvyn.

21
Det som återstår

Jag vill att mer av systemet ska kunna förklara sig självt.

Nästa stora steg är inte nödvändigtvis fler features. Jag vill kunna öppna ett segment i Kontrollcenter och se hela kedjan: varför det skapades, vilka regler som var aktiva, vilken variant som valdes, vilka källor som användes, hur lång tid texten tog, hur lång tid Westin-AI tog och exakt när det nådde air.

När den kedjan är synlig blir nya funktioner mycket lättare att bygga eftersom jag får feedback på verklig drift i stället för magkänsla.

Djupare än tidslinjen

Tekniska labbanteckningar.

Det här är principerna som vuxit fram bakom alla installationer och buggar. De är minst lika viktiga som funktionerna, för det är de som gör att nästa ändring inte behöver börja från noll.

01
Labbanteckning 01

Fail open eller fail closed?

Jag har flera typer av gates i systemet. En del ska hellre tillåta än blockera vid osäkerhet, andra ska göra tvärtom. Det är inte en detalj; det avgör hur stationen beter sig när något runt omkring den går sönder.

För ren musik är fail-open ofta rätt: om en optional metadatafunktion inte svarar ska musiken fortfarande spela. För en faktaskill är fail-closed bättre: om källan är oklar ska inget faktaprat gå ut. För listener-gating kan valet påverka om onödiga AI-jobb startas när statuskällan tillfälligt inte svarar.

Jag försöker därför undvika generella hjälpfunktioner som bara heter `isAllowed()` utan att det är tydligt vilken felsida de väljer. En policyfunktion ska helst uttrycka varför och hur den degraderar.

02
Labbanteckning 02

En enda writer per state

När två moduler får skriva samma state uppstår race och märkliga halvfel. Därför försöker jag ha en tydlig writer för varje viktig sak.

Show Engine äger showbeat-status. Talk Scheduler äger slot claims. Picker äger musikvalet. Broadcast äger vad som faktiskt är i ljudkö. Weather Memory äger senaste väderomnämnande. Gateway äger publik routing.

Andra moduler får läsa eller be ägaren göra en ändring, men de ska inte skriva samma fil eller flagga bakom ryggen på varandra.

03
Labbanteckning 03

Claim före await

En liten men viktig schedulerregel är att en slot ska markeras som tagen innan en lång async-operation startar.

Om jag väntar med claim tills Gemma 3 eller Westin-AI är klar kan nästa scheduler-tick komma under tiden och starta samma sak en gång till. Då har jag dubbla genereringar som båda tror att de vann.

Mönstret är därför ungefär:

`claim(slot) → await generate() → queue()`

och inte:

`await generate() → claim(slot) → queue()`.

Det är en liten ordningsdetalj som blir enorm när generering kan ta längre än en minut.

04
Labbanteckning 04

Varför jag gillar rena planners

Flera av de stabilaste delarna är rena funktioner. De får klocka, state och policyflaggor som input och returnerar ett beslut utan att läsa nät, disk eller queue själva.

Det gör att jag kan simulera en hel timme minut för minut. Jag kan testa vad som händer om hourly öppnar samtidigt som en handoff väntar. Jag kan prova ett gap på 179 sekunder och 181 sekunder utan att faktiskt vänta.

Den här typen av kod är mindre spektakulär än ett AI-demo men mycket mer värdefull när stationen går 24/7.

05
Labbanteckning 05

Silence is a feature

Jag har fått jobba aktivt mot impulsen att varje tillgänglig slot måste fyllas. Radio behöver inte en röst för att bevisa att systemet fungerar.

En skill får därför kunna svara `air:false`. Weather kan stå över. En sökskill kan stå över om källan är dålig. Segment Director kan välja inget.

Det här gör också att en modell inte behöver hitta på en anledning att prata bara för att ett cronjobb råkade starta.

06
Labbanteckning 06

Varför jag inte sparar allt i promptminnet

Persistent session är bra för kontinuitet men dåligt om allt matas tillbaka. Loggar innehåller sådant som modellen inte behöver: pick-resonemang, interna statusrader och ibland gamla ämnen som borde få dö.

Jag skiljer därför på driftminne och promptminne. Något kan sparas för felsökning utan att bli del av nästa generations kontext.

Det är särskilt viktigt när en modell har börjat upprepa något. Om varje upprepning sedan sparas och återförs som “det här har sagts nyligen” kan systemet paradoxalt nog förstärka ämnet i stället för att döda det.

07
Labbanteckning 07

Audio är också state

Ett genererat röstklipp är inte bara en fil. Det kan vara queued, pending boundary, playing eller completed. Schedulern måste veta skillnaden.

Om ett klipp är renderat men väntar på nästa track seam syns det inte som “senaste sända prat”. Om Talk Scheduler bara tittar på airtimehistorik kan den därför tro att örat är ledigt och rendera ytterligare ett segment.

Jag behandlar därför pending talk som ett eget state som kan blockera vissa slots under en begränsad tid.

08
Labbanteckning 08

Hårdvara löser inte fel pipeline

Jag har lagt tid på CPU, GPU, RAM, NVMe, kylning, fläkthubbar och nätaggregat. Det behövs när lokala modeller ska köras stabilt.

Men den viktigaste prestandafixen har flera gånger varit programmässig: ta bort onödiga tokens, flytta musikval till Picker, värm upp röstmotorn tidigare, undvik generering när weather ändå är oförändrat.

Det är bättre att spara ett helt modelljobb än att göra samma onödiga jobb tio procent snabbare.

09
Labbanteckning 09

Brandväggen ska skydda arkitekturen, inte bli arkitekturen

Jag vill inte att interna tjänster ska behöva känna till publika DNS-namn eller brandväggsregler. De ska lyssna där de ska lyssna. Gatewayn publicerar det som behöver publiceras. Brandväggen styr vad som får passera.

När de lagren blandas blir felsökning snabbt kaos. En app ska exempelvis inte behöva veta vilken intern port Now Playing kör på. Den ska anropa `/api/nowplaying` på stationens publika adress.

10
Labbanteckning 10

Varför installationsscript blev nödvändiga

När ändringarna var små kunde jag skriva några kommandon i terminalen. När en ändring påverkar source, build, compose, health och rollback blir manuell körning en risk.

Installerna fungerar därför nästan som små transaktioner. De sparar nuläget, verifierar förutsättningarna, bygger nytt, byter en begränsad komponent och kontrollerar health innan det räknas som klart.

Det gör det möjligt att experimentera mycket hårdare utan att bli beroende av mitt eget minne av exakt vilka femton kommandon jag körde kvällen innan.

11
Labbanteckning 11

“Fungerar” och “låter radio” är två olika nivåer

Många funktioner har haft en första version som tekniskt fungerar. Ett handoff hörs. Ett hourly säger tiden. Ett station-ID spelar. Ett TTS-klipp kommer ut.

Nästa nivå har nästan alltid varit svårare: ligger det i rätt lucka, låter frasen naturlig, duckar musiken snyggt, upprepar det senaste ordvalet, känns showen levande?

Det är där största delen av arbetet 2026 har legat. Inte att få funktionerna att existera, utan att få dem att försvinna in i radioflödet så lyssnaren inte tänker på systemet bakom.

12
Labbanteckning 12

Mina buggar har blivit arkitektur

Flera centrala delar finns idag på grund av ett specifikt fel.

Fel persona efter restart gav bättre sessioninvalidiering. Banter som försvann gav tidsfönster. Talk som staplades gav arbitration. Fel dagpart gav deterministisk svensk time policy. Repetitiv promo gav Skill Variation. Väder som upprepades efter restart gav durable Weather Memory. Hårda TTS-kanter gav head/tail-padding.

Jag försöker därför inte bara fixa symptom. När ett fel visar att två ansvarsområden ligger fel försöker jag flytta gränsen, även om det blir mer jobb just då.

Driftboken

Hur jag försöker göra 24/7-systemet säkert att förändra.

Det finns en skillnad mellan att skriva en funktion och att våga lägga den i en station som redan spelar. De här mönstren har vuxit fram för att jag ska kunna fortsätta ändra utan att göra varje deploy till ett lotteri.

36
Deploy

Jag byter minsta möjliga tjänst.

Om ändringen bara gäller Kontrollcenter bygger jag en ny Kontrollcenter-version, validerar konfigurationen och återskapar endast den tjänsten. Broadcast och Westin-AI lämnas i fred. Om ändringen bara gäller webben finns ingen anledning att röra radiologiken.

Förenklad deploy
save_current_state()
build(target_service)
validate()
replace(target_service)
wait_until_healthy()

if not healthy:
    restore_previous_version()

Det här minskar blast radius och gör också orsaken tydligare om något blir fel direkt efter deploy.

37
State

Jag sparar bara det som behöver överleva.

Allt behöver inte vara persistent. En tillfällig promptplan kan dö med processen. Ett showbeat som redan sänts får däremot inte glömmas. Weather-minne behöver överleva restart, men recent random-val kan ofta börja om utan katastrof.

Ju mindre state jag gör durable desto färre migrationsproblem får jag. Persistens är ett verktyg, inte ett mål.

38
API

Jag försöker alltid ha ett begripligt felsvar.

Om en request inte kan genomföras ska API:t inte bara säga false. Det ska kunna säga om låten saknas, är blockerad eller inte är tillåten just nu. Samma sak för interna policies: reason är nästan alltid värdefullt.

Det gör både UI och logg bättre. Jag slipper översätta ett generiskt fel i klienten.

39
Fallback

Musik är den sista säkra vägen.

Om Gemma 3 inte svarar kan musik spela. Om Westin-AI inte svarar kan musik spela. Om en skill saknar data kan musik spela. Det är en medveten hierarki.

Jag vill att avancerade funktioner ska vara lager ovanpå en frisk radiokärna. Ett optional lager får inte bli en ny single point of failure bara för att det är roligt eller smart.

40
Observability

Jag vill veta skillnaden mellan genererat, köat och sänt.

De tre händelserna kan ligga långt ifrån varandra i tid. Ett klipp kan genereras, sedan vänta på en boundary och först därefter bli faktiskt sänt. Om jag bara loggar generation får jag en falsk bild av airtime.

Därför vill jag att den framtida trace-vyn ska markera varje övergång separat.

41
Test

Jag testar gärna hela tidslinjer som data.

Pure planners gör det möjligt att mata in minuter från 00 till 59 och se exakt vilka slots som öppnar. Jag kan simulera showgräns, pending talk och min-gap utan riktig klocka.

Tidslinjetest
for minute in 0..59:
    state = planner.tick(
        clock=minute,
        pending=pendingTalk,
        lastTalk=lastAired
    )
    assertExpected(state)

Det är mycket säkrare än att vänta till rätt minut på en levande station och hoppas att allt annat råkar vara identiskt.

42
Dokumentation

Jag vill kunna förstå dagens system även efter nästa stora omtag.

Dokumentationen behöver inte beskriva varje rad kod. Den måste beskriva ansvar, aktiva versioner och viktiga beslut. Det är därför jag samlar den egna Relay Seven-stacken i en separat dokumentationsyta.

Om en ny build ersätter tre gamla fixlager ska dokumentationen uppdateras samtidigt. Annars börjar gamla namn leva kvar i instruktioner långt efter att koden ändrats.

Mer från verklig drift

Sådant som bara dyker upp när stationen faktiskt får rulla länge.

Det finns problem som aldrig syns i ett tio minuters test. De kommer först när tjänster startar i fel ordning, nätet blinkar till, en kö innehåller gammalt material eller en show precis råkar byta samtidigt som något annat väntar.

43
Startordning

Jag lärde mig att “alla services uppe” inte betyder att systemet är redo.

När flera tjänster startar samtidigt kan de vara processmässigt uppe i olika ordning. Gateway kan starta före API:t den ska skicka till. Kontrollcenter kan starta innan Westin-AI hunnit ladda. Webben kan vara tillgänglig innan now-playing har fått sin första riktiga datapunkt.

I början skapade det onödiga fel som såg dramatiska ut men egentligen bara betydde “dependency är inte redo ännu”. Jag började därför skilja mellan start och readiness. En service får gärna starta snabbt men måste kunna rapportera att den fortfarande väntar på något.

För beroenden som är optional försöker jag dessutom undvika hårda startkedjor. Kontrollcenter ska kunna starta även om Westin-AI är nere. Den ska bara förstå att röstgenerering inte är tillgänglig ännu. På samma sätt ska Web kunna visa en fallback om Now Playing inte hunnit få data.

Det här gör reboot mycket lugnare. I stället för att hela systemet måste komma upp i perfekt ordning kan delarna konvergera mot ready-läge.

Readinessprincip
process_started = true
dependencies = probe()

if required_dependency_missing:
    ready = false
else:
    ready = true

optional_dependency_missing:
    degrade_feature()
    keep_core_running()
44
Queue

Jag fick sluta tänka på kö som en enkel lista.

En vanlig queue är först in, först ut. Radioprat fungerar inte alltid så. Ett hourly som skapats för en viss tid kan bli stale. En showintro kan vara viktigare än ett opportunistiskt segment. Ett station-ID kan vara renderat men måste vänta till rätt seam. Ett jingle-trigger kan vara giltigt länge utan att behöva exakt nästa sekund.

Jag började därför behandla köposter som objekt med mer metadata: typ, skapad tid, eventuellt boundarykrav och hur länge innehållet fortfarande är relevant. Det gör att Broadcast och Scheduler kan resonera om en pending post i stället för att bara se “något ligger i listan”.

Den här skillnaden blev viktig när Talk Scheduler skulle avgöra om en ny slot får gå. Ett renderat men osänt klipp har ännu inte skapat ett faktiskt talk break, men det kan ändå vara på väg att ta nästa seam. Om schedulern inte vet det kan den generera ytterligare prat som sedan staplas.

Jag vill fortsätta utveckla den här modellen så queue kan kasta stale material tydligare och rapportera varför. Då kan Kontrollcenter visa “kasserad: showen hann byta” i stället för att ett gammalt klipp bara försvinner.

45
API-fel

Jag vill kunna skilja “ingen data” från “något är trasigt”.

Det här låter självklart men är lätt att missa. En tom historik kan betyda att stationen nyss startat. Den kan också betyda att metadatahämtningen havererat. Ett tomt requestresultat kan betyda att sökningen inte hittade något eller att bibliotekstjänsten inte svarade.

Jag började därför göra svaren mer uttryckliga. Datafältet kan vara tomt samtidigt som status beskriver varför. Klienten kan då välja rätt UI: “inga tidigare låtar ännu” är inte samma sak som “historiken är tillfälligt otillgänglig”.

Det här är särskilt viktigt för en app där jag inte sitter med serverloggar samtidigt. Klienten måste kunna bete sig rimligt utan att ha intern kunskap.

Tydligare API-svar
{
  "ok": true,
  "data": [],
  "state": "empty"
}

{
  "ok": false,
  "data": null,
  "state": "upstream_unavailable"
}

Jag föredrar den typen av tydlighet framför att allt reduceras till HTTP-status och klienten får gissa resten.

46
Testmetodik

Jag försöker alltid testa både normalfall och det jag hoppas aldrig händer.

När jag bygger en ny funktion är det lätt att bara testa lyckad väg. Showintro genereras, spelas och markeras klart. Men de intressanta buggarna finns ofta i mellanlägena: processen dör efter att beat markeras men före ljudfilen är klar; Gateway tappar backend i fem sekunder; Westin-AI svarar efter timeout; queue innehåller en post från föregående show.

Jag har därför börjat skriva testscenarier mer som små historier. “Kontrollcenter startar om 30 sekunder före showgräns med en handoff redan renderad.” “Weather har samma condition som före restart.” “Banter öppnar medan station-ID väntar på seam.”

Den typen av scenario ger ofta bättre tester än att bara kontrollera en funktion isolerat. Jag kan fortfarande hålla planeringsfunktionerna pure, men testdatan representerar verkliga händelsekedjor.

Det gör också buggrapporter värdefulla. En bra tidslinje med exakta händelser kan ofta översättas direkt till ett regressionstest.

47
Hårdvaruförsök

Jag har fått lära mig vilka prestandaproblem som är värda pengar och vilka som är värda kod.

När en röstmodell går långsamt är första impulsen lätt att köpa snabbare hårdvara. Ibland är det rätt. Men jag har flera gånger sett större vinst av att minska arbetet än att accelerera samma arbete.

Om Gemma 3 får en mycket kortare prompt kan texten bli klar snabbare utan någon ny komponent. Om weather filtreras bort innan modellkall sparas hela jobbet. Om Westin-AI värms innan showens tyngsta segment försvinner en kallstart. Om ett segment kan återanvända redan strukturerad context slipper Kontrollcenter bygga om samma data.

Hårdvara behövs fortfarande. Lokala modeller är tunga. Men jag försöker numera mäta först. Är flaskhalsen videominne, rå GPU, CPU, disk, modellstart eller bara för stor prompt? Först när jag vet det är ett inköp rationellt.

Det har också gjort gamla komponenter användbara på rätt sätt. Ett äldre kort kanske inte ska köra huvudrösten, men kan passa för en separat lättare uppgift om mjukvaran stödjer det. Jag försöker hellre fördela roller än anta att alla kort måste göra samma sak.

48
Sex års perspektiv

Det mest återkommande misstaget har varit att låta en komponent veta för mycket.

När en enda komponent får musikval, showtid, text, röst, queue och publicering blir första versionen ofta enkel. Sedan blir varje förändring riskabel. Jag har gjort den resan flera gånger i mindre skala.

Det är därför dagens Relay Seven går åt motsatt håll. Picker behöver inte veta hur Westin-AI låter. Westin-AI behöver inte veta vilken låt som kommer. Broadcast behöver inte veta hur Gemma 3 formulerade texten. Webben behöver inte veta hur Show Engine planerade sitt featurebeat.

Det gör att jag kan byta implementation bakom en gräns. Om jag skriver om Analyzer ska resten få ungefär samma sorts analyserad data. Om Westin-AI förändras ska Kontrollcenter fortfarande kunna skicka text och style. Om Picker blir smartare ska Broadcast fortfarande bara få nästa musikobjekt.

Det är egentligen hela resan i en teknisk mening. Jag började med en sammanhängande MP3-kedja. Sedan la jag lager ovanpå. Därefter började jag aktivt skära isär lagren igen, men nu med mycket mer kunskap om vilka gränser som faktiskt är användbara.

Det gör också att jag ser framtida ombyggnader som något normalt. Jag räknar med att flera delar kommer ändras igen. Målet är bara att nästa ändring ska kräva mindre rivning än den förra.

Hårdvaruresan

Från T3600-servrar och ständiga ominstallationer till 128 GB RAM och dubbla RTX 3080.

En stor del av Relay Seven-resan har inte handlat om kod överhuvudtaget. Den har handlat om hårdvara. Jag har byggt om samma idé på flera olika maskiner, bytt processorer, flyttat roller mellan servrar, köpt minne i flera omgångar, installerat om operativsystem och tjänster fler gånger än jag kan räkna och testat mig fram till vad som faktiskt fungerar när en radioplattform samtidigt ska köra lagring, databearbetning, webb, automation och lokal AI.

T3600-servrarna blev under en lång period riktiga arbetshästar. De var inte nya, men de hade en viktig fördel: jag kunde experimentera ganska brutalt utan att hela projektet stod och föll med en dyr ny maskin. Jag kunde byta processor, stoppa i mer RAM, flytta diskar, prova nya installationer och använda dem som både produktionsmaskiner och rena testbänkar.

T3600-perioden var i princip ett långt trial-and-error-labb.

Jag har bytt processorer i maskinerna för att få ut mer beräkningskraft och för att se hur mycket verklig skillnad olika CPU-konfigurationer gjorde i sådant som biblioteksscanning, ljudbearbetning, webb, databaser och senare olika AI-relaterade hjälpuppgifter. En uppgradering kunde se fantastisk ut på papperet och ändå ge ganska liten skillnad i just den arbetslast jag körde. Andra gånger kunde ett relativt enkelt processorbyte göra en gammal workstation användbar flera år till.

Jag har också installerat om dem otaliga gånger. Det har varit rena installationer, testinstallationer, nya filsystem, nya serviceupplägg och perioder där jag byggt upp en hel miljö för att några dagar senare inse att strukturen inte var den jag ville ha. I början var en ominstallation nästan ett misslyckande. Efter ett tag började jag se det som ett verktyg. Om en testmiljö blivit full av gamla beroenden, halvglömda konfigurationer och manuella ändringar kan en ren installation ibland vara snabbare och säkrare än att försöka förstå flera månaders teknisk arkeologi.

Det här gjorde att jag blev mycket bättre på att skilja mellan sådant som måste ligga i versionshantering och sådant som bara råkar finnas på en maskin. Om något viktigt bara existerar därför att jag skrev ett kommando för tre månader sedan är installationen inte reproducerbar. T3600-maskinerna tvingade mig att lära mig det genom upprepning.

96 GB RAM kom inte på en gång.

Minne köptes och byggdes ut i flera omgångar. Jag började mindre, såg vad maskinerna faktiskt använde och fyllde sedan på. Till slut körde jag T3600-konfigurationer med omkring 96 GB RAM. Det låter enormt jämfört med en vanlig radiodator, men när jag började använda maskinerna till fler roller samtidigt blev minnet väldigt praktiskt.

Jag kunde låta filcache jobba aggressivt, hålla större datamängder i minnet och framför allt experimentera med ramdiskar. Ramdisk blev ett naturligt test när jag ville se hur mycket av vissa operationer som faktiskt begränsades av lagring. Temporära filer, cache och andra saker som inte behöver överleva reboot kan vara extremt snabba när de ligger i RAM.

Principen med ramdisk
RAM
 ├─ systemminne
 ├─ cache
 └─ ramdisk
      ├─ temporära analysfiler
      ├─ kortlivad cache
      └─ data som får försvinna vid reboot

persistent data
      ↓
NVMe / disk

Ramdisk lärde mig också motsatsen: bara för att något är snabbt betyder det inte att det ska ligga där. Allt som måste överleva en omstart måste ha en persistent väg. Det är exakt samma tanke som senare dyker upp i Relay 7 Memory. Temporärt state och durable state är två olika saker, oavsett om jag pratar om RAM eller programkod.

Jag har dessutom fått lära mig strömförsörjning den dyra vägen.

När jag började stoppa kraftigare grafikkort och fler komponenter i äldre system blev strömförbrukningen ett riktigt problem. Ett nätaggregat kan se tillräckligt ut om jag bara summerar ungefärlig medeleffekt, men GPU:er kan dra kraftiga transienter. En maskin kan fungera fint i desktopläge och sedan falla ihop exakt när belastningen blir tung.

Jag har bokstavligen eldat upp eller kört slut på nätaggregat när konfigurationen dragit mer än vad de klarat av på ett stabilt sätt. Det var inte någon teoretisk diskussion om effektbudget längre. När en maskin slocknar under belastning, börjar om eller ett aggregat ger upp lär jag mig snabbt att marginal är viktigt.

Det förändrade hur jag dimensionerar nya maskiner. Jag räknar inte längre bara på “GPU:n drar X watt och processorn Y”. Jag vill ha marginal för transienter, övriga komponenter, framtida uppgraderingar och att nätaggregatet inte ska ligga nära sin gräns hela tiden.

Hur jag tänker kring effekt idag
normal CPU-load
+ normal GPU-load
+ GPU-transienter
+ diskar / fläktar / USB
+ extra kort
+ marginal
-----------------------------
= nätaggregat som inte behöver leva på gränsen

Det är också därför dagens huvudmaskin har ett nätaggregat på 1200 watt. Jag vill hellre ha ordentlig marginal än att återuppleva perioden där ett tungt AI-jobb kunde bli ett test av nätaggregatets skyddskretsar.

Nuvarande maskin är en helt annan klass än T3600-generationen.

Den maskin jag kör idag är byggd runt en Ryzen 9 5950X. Det är 16 kärnor och 32 trådar, vilket ger betydligt mer utrymme för att köra flera tunga roller parallellt än de gamla workstationplattformarna gav.

Arbetsminnet har vuxit vidare till 128 GB RAM. Det ger mig gott om marginal för modeller, filcache, analys, tjänster, utvecklingsverktyg och ramdisk utan att systemet direkt börjar slå mot swap så fort flera saker arbetar samtidigt.

Den stora skillnaden sitter ändå på GPU-sidan. I samma dator kör jag nu två RTX 3080 med 10 GB videominne vardera. Det betyder inte att jag automatiskt får en enda GPU med 20 GB VRAM. Varje kort har fortfarande sitt eget minne. Men två kraftfulla kort i samma dator gör att jag kan fördela olika arbetsuppgifter mellan dem när programvaran tillåter det.

Nuvarande huvudmaskin
CPU
  Ryzen 9 5950X
  16 kärnor / 32 trådar

RAM
  128 GB

GPU 0
  RTX 3080
  10 GB VRAM

GPU 1
  RTX 3080
  10 GB VRAM

PSU
  1200 W

Användning
  Relay Seven-utveckling
  lokala modeller
  Westin-AI
  analys
  testjobb
  parallella AI-arbetslaster

Den stora vinsten med två GPU:er är alltså inte att VRAM magiskt slås ihop. Vinsten är att jag kan undvika att allt konkurrerar om samma kort. Ett tungt röstjobb kan ligga på ett kort medan en annan GPU används för en annan modell eller ett separat experiment. Det ger mycket större frihet än när en enda 10 GB-GPU måste bära varje tung arbetslast.

Det här passar dessutom väldigt bra med den mjukvaruarkitektur jag byggt. Eftersom Westin-AI, Kontrollcenter och andra delar redan är separerade kan olika tjänster i framtiden bindas till olika GPU:er utan att resten av radiosystemet behöver förstå detaljerna. Hårdvaruarkitekturen och servicearkitekturen börjar alltså passa ihop.

Trial and error har varit minst lika viktigt här som i koden.

Jag har provat äldre grafikkort, nyare grafikkort, mer VRAM, mindre VRAM, olika CPU:er, flera mängder RAM och flera nätaggregat. Jag har testat sådant som sett smart ut men inte gett den förbättring jag väntat mig. Jag har också gjort uppgraderingar som först känts överdrivna och senare visat sig ge exakt den marginal som behövdes.

Det viktiga har varit att faktiskt mäta arbetslasten. Om ett jobb kraschar på videominne hjälper inte fler CPU-kärnor. Om modellen redan får plats men väntar på promptbearbetning kanske ett GPU-byte inte är den bästa första lösningen. Om hela maskinen stänger av under tung GPU-last är det direkt fel att börja optimera modellkod innan strömförsörjningen är kontrollerad.

Jag har därför blivit betydligt mer metodisk även med hårdvara. Temperatur, minnesanvändning, GPU-minne, effekt och faktisk exekveringstid får avgöra vilken uppgradering som är relevant.

T3600-maskinerna lärde mig hur långt gammal serverhårdvara går att pressa. Dagens 5950X-maskin med 128 GB RAM, två RTX 3080 och 1200 watt ger mig i stället utrymme att bygga utan att varje nytt experiment först måste slåss om de sista resurserna.

Det är en ganska bra fysisk spegling av hela Relay Seven-projektet. Jag började med det jag hade, bytte en del i taget, gjorde misstag, installerade om, byggde vidare och lärde mig var flaskhalsarna faktiskt fanns. Maskinerna har utvecklats på nästan exakt samma sätt som koden.

Relay Seven idag

Vad varje del faktiskt ansvarar för.

Jag försöker hålla ansvar tydligt. Om ett problem uppstår ska det gå att säga vilken modul som äger det.

Kontrollcenter

State, schema, promptregler, policies och orkestrering.

Picker

Musikval, vikter, no-repeat och artist spacing.

Show Engine

Programplan, intro, feature, outro och handoff.

Talk Scheduler

Vem som får prata och när en slot får vänta.

Analyzer

Musikstruktur och data för smartare transitions.

Westin-AI

Röstproduktion, warmup, style och ljudoutput.

Broadcast

Musik, talk queues, ducking, fades och stream.

Memory

Sessionsstate, weather, recency och restartåterhämtning.

Gateway

Publika adresser, TLS och routing mot interna tjänster.

Firewall

Separation mellan publik och intern trafik.

Webb

Player, historik, requests, likes och showinfo.

Mobil

Lyssning och samma serverdata som webben.

Tjänstekartan

De små tjänsterna som gör att helheten går att bygga om utan att allt rasar.

Jag har gradvis flyttat funktioner från stora monolitiska lösningar till tydligare tjänster. De här namnen är mina egna funktionella namn; varje tjänst ska helst kunna beskrivas med en mening.

A
Relay 7 Now Playing API

En sann version av vad som faktiskt spelas.

Den normaliserar metadata, filtrerar stationselement, håller historik ren och ger webben/mobilen ett stabilt kontrakt. Jag vill aldrig att en klient ska behöva tolka rå streammetadata.

När historikregler ändras gör jag det på servern och båda klienterna får beteendet samtidigt.

B
Relay 7 Request API

Önskningar är mer än ett formulär.

En request behöver valideras mot bibliotek, spärrar och aktuell stationpolicy. API:t ska kunna säga varför en låt inte kan begäras i stället för att frontenden låtsas att allt lyckades.

C
Relay 7 Community API

Interaktion utan att radiomotorn behöver bli webbserver.

Likes, enklare communitydata och publik information ligger utanför Broadcast. Det minskar blast radius: ett fel i en communityfunktion ska inte påverka ljudet.

D
Relay 7 App Status API

Mobilen behöver små tydliga svar.

Appen ska kunna fråga om stationstatus och funktionstillgänglighet utan att känna till intern servicearkitektur. Det gör också versionering enklare när backend förändras.

E
Relay 7 Gateway

Publik routing är ett eget ansvar.

Gatewayn översätter publika host/path-kombinationer till interna tjänster. När en ny spelaradress eller API-väg läggs till behöver backend inte flytta. Det är också här TLS och vissa headers hanteras.

F
Relay 7 Health

Up är inte samma sak som ready.

Jag försöker ge varje kritisk tjänst ett enkelt sätt att svara om den faktiskt kan göra jobbet. Westin-AI kan vara processmässigt uppe men fortfarande ladda. Kontrollcenter kan leva men sakna en dependency. Health ska beskriva tjänstens verkliga användbarhet.

Efter sex år

Det som förändrats mest är hur jag tänker om problemen.

2020 var nästan varje fråga konkret: spelar streamen, kommer nästa låt, syns titeln? Idag är frågorna oftare systemfrågor: vem äger den här regeln, vilket state ska överleva, vilken modul får fatta beslutet och hur ska nästa lager kunna förstå resultatet?

Jag tycker det är en bra utveckling. Den betyder inte att systemet blivit färdigt eller perfekt. Tvärtom hittar jag fortfarande små fel nästan varje gång jag lyssnar aktivt. Skillnaden är att jag oftare kan placera felet i rätt domän direkt.

När en tidsfras är fel tittar jag på time policy. När en låt är fel tittar jag på Picker. När ett prat är för sent tittar jag på trace mellan Kontrollcenter, Gemma 3, Ollama, Westin-AI och queue. När samma ämne kommer tillbaka efter restart tittar jag på durable state och promptrecap. När två saker vill prata tittar jag på Talk Scheduler.

Den kartan i huvudet är kanske det viktigaste jag byggt. Koden kan bytas. Hårdvara kan bytas. En tjänst kan skrivas om. Men om ansvaret är tydligt går nästa generation mycket snabbare att bygga.

Jag har också slutat se omtag som misslyckanden. Den första MP3-kedjan var rätt lösning när behovet var litet. Den stora färdiga AutoDJ-generationen var rätt sätt att snabbt lära mig vad en komplett radioadministration behöver. Den mer fristående broadcastgenerationen gav mig insyn. Kontrollcenter-generationen gav mig ägarskap över reglerna. Varje steg var rimligt i sin tid.

Det betyder att dagens arkitektur inte heller behöver vara slutpunkten. Om en bättre uppdelning visar sig senare vill jag kunna flytta dit utan att förlora det som redan fungerar. Det är därför API-kontrakt, stategränser, installers och tester är så viktiga för mig nu.

Det finns fortfarande något ganska enkelt i botten av allt: jag vill slå på Relay Seven och höra en station som känns genomtänkt. Om en teknisk lösning inte gör den upplevelsen bättre behöver jag fråga om lösningen verkligen behövs.

Sista anteckningen för den här versionen

Jag bygger fortfarande medan radion spelar.

Det kanske låter självklart, men en stor del av hela projektets karaktär kommer från att utvecklingen sker mot ett system som faktiskt används. Jag kan skriva en idé på dagen, lägga in den på kvällen och sedan höra nästa problem i riktig kontext. Det är annorlunda än att bygga en demo där varje testscenario är kontrollerat.

Jag har lärt mig att lyssna efter tekniska symptom. Om pratet kommer precis för sent flera gånger är det sannolikt inte bara otur. Om samma formulering dyker upp igen är det inte säkert att modellen är “dålig”; anti-repeat kan ligga på fel runtimeväg. Om en låt kapas kan det vara analys, showgräns eller en enda ärvd inställning. Om en persona återkommer efter restart ska jag titta på state innan jag skriver om prompten.

Den erfarenheten gör att själva lyssningen blivit en del av testningen. Jag hör stationen som en användare men försöker samtidigt tänka som drifttekniker: vilken modul kan skapa just det här beteendet?

Det är också därför små fel fortfarande får mycket uppmärksamhet. “Tio på natten” i stället för “tio på kvällen” stoppar inte streamen. Men om jag accepterar sådana detaljer börjar stationen långsamt kännas automatiserad på fel sätt. Jag vill att tekniken ska försvinna bakom resultatet.

Jag tror därför att Relay Seven kommer fortsätta utvecklas på samma sätt: lyssna, hitta något som skaver, spåra det till rätt lager, bygga en lösning som helst löser mer än just symptomen och sedan dokumentera vad som ändrades.

Det är en ganska lång väg från 2020 års MP3-ström. Samtidigt är känslan fortfarande densamma när en ändring sitter rätt: jag hör det direkt.

Det sista lagret

Från experiment till något jag faktiskt kan förvalta.

Det finns en punkt i ett hobbyprojekt där det slutar räcka att komma ihåg hur allt hänger ihop. Relay Seven passerade den punkten för länge sedan. När jag började 2020 kunde jag i princip hålla hela kedjan i huvudet. En källa, en MP3-ström och några enkla regler gick att förstå utan särskilt mycket dokumentation. När systemet senare fick flera tjänster, egna API:er, showstate, röstproduktion, analyssystem och olika typer av automatiskt prat blev min egen minnesbild en dålig single point of failure.

Det är därför jag har blivit mycket mer intresserad av förvaltning än jag trodde att jag skulle bli. En funktion är inte riktigt klar bara för att den fungerar ikväll. Jag vill veta hur den installeras igen, hur jag ser vilken version som är aktiv, hur jag går tillbaka och vilken annan modul som förväntar sig dess output.

Det här syns tydligt i hur jag behandlar versionsbyten. Jag vill ha en backup innan ändringen, en verifiering av källkoden, ett reproducerbart bygge, ett begränsat servicebyte och en healthkontroll efteråt. Om någonting går fel ska rollback vara en planerad väg, inte panik.

Så jag vill tänka kring förändring
before:
    record_active_version()
    save_config()
    save_relevant_state()

change:
    verify_source()
    build()
    replace_only_target_service()

after:
    health_check()
    inspect_logs()
    listen_to_real_output()

if regression:
    rollback()
    keep_evidence()

“Keep evidence” är viktigare än det ser ut. Om jag direkt skriver över allt som fungerade dåligt förlorar jag ibland det bästa underlaget för att förstå felet. En loggrad, en gammal statefil eller exakt buildnamn kan vara det som gör att jag nästa dag kan se vad som faktiskt hände.

Jag har också blivit bättre på att skilja mellan akut fix och permanent fix. Om stationen gör något dumt on air vill jag stoppa symptomet snabbt. Men därefter går jag gärna tillbaka och frågar varför arkitekturen tillät felet. Weather kunde stoppas med en längre cooldown, men den permanenta lösningen var durable state och filter före modellen. Banter kunde räddas med extra manuella triggers, men den bättre lösningen var talk windows och arbitration. Repetitivt prat kunde få fler promptord, men den bättre lösningen var strukturell variation med recent-memory.

Det är just de exemplen som gjort att jag försöker undvika “prompt som plåster” när problemet egentligen är state eller kontrollflöde. En modell kan instrueras väldigt mycket, men om den får fel kontext eller kallas vid fel tid är prompten inte rätt lager att fixa.

Jag har också lärt mig när jag ska låta bli att automatisera.

Det är lätt att bli lockad av att allt ska vara dynamiskt. Men varje dynamiskt beslut är ytterligare ett läge som kan bli fel. Därför har jag med tiden gjort flera saker mer statiska: tydliga showtider, fasta policygränser, kodade tidsfraser och explicit serviceansvar.

Jag vill att automationen ska ta hand om det repetitiva och det tidskritiska. Jag vill inte att den ska skapa osäkerhet i sådant jag redan vet exakt. Om Rewind går fredag 18–21 behöver ingen modell resonera om det. Om 22:30 ska beskrivas som kväll behöver ingen modell göra en kulturell bedömning. Om en artist nyligen spelats kan Picker läsa state.

Den typen av begränsning gör faktiskt de generativa delarna bättre. Gemma 3 får färre saker att hålla reda på och kan lägga mer av sin kapacitet på språk, ton och variation. Ollama får färre onödiga anrop. Westin-AI får kortare och tydligare manus att producera.

Nästa stora förbättring blir förmodligen inte en enda stor feature.

Jag tror mer på många små förbättringar i kedjan: bättre trace, bättre deadlineberäkning, tydligare reason-codes, bättre showprofiler, lite smartare cueinformation och ännu renare stategränser. Det är sådant som gör att stationen känns mindre automatisk utan att jag behöver lägga till dubbelt så mycket funktion.

Det är ganska talande för hur projektet utvecklats. I början var varje framsteg synligt: nu finns en stream, nu finns metadata, nu finns en webbspelare. Idag kan en av de viktigaste ändringarna vara att ett segment som inte borde ha pratats aldrig genereras. Det syns inte som en ny knapp. Det hörs som bättre radio.

Och det är fortfarande slutmåttet jag använder. Inte hur många moduler jag byggt, hur många rader kod som finns eller hur avancerad diagrammet ser ut. Om jag lyssnar en hel kväll och systemet gör färre saker som får mig att vilja öppna terminalen direkt, då har jag faktiskt förbättrat Relay Seven.

Jag började med en MP3-ström.
Nu bygger jag en radioplattform.

Det mesta blev inte som första planen. Det är precis därför systemet ser ut som det gör idag. Varje omtag har flyttat en regel till ett bättre ställe, tagit bort ett onödigt beroende eller gjort stationen lättare att förstå nästa gång något går fel.

och det här är fortfarande inte slutversionen.