/blog / lofi-radio-40-lines

Building a 24/7 lofi radio with 40 lines of code

The 24/7 lofi stream is a genre unto itself — the anime girl studying, the rain loop, the endless mellow beats. Under the hood it’s just a queue of tracks feeding an audio stream. Which means you can build one where the queue never repeats, because every track is generated on demand. Here’s the whole thing in about 40 lines of Node.

The architecture

Three moving parts:

  1. A generator loop that keeps a buffer of upcoming tracks topped up.
  2. A player queue that streams the current track and advances.
  3. Auto top-up on the billing side, so the loop doesn’t die at 3 a.m. when credits run out.

The generator loop

The trick to variety without weirdness is a constrained random prompt. Fix the genre, vary the texture:

const moods = ["rainy night", "sunday morning", "late-night study", "autumn walk"];
const textures = ["vinyl crackle", "soft piano", "muted guitar", "tape hiss", "warm rhodes"];

const prompt = () =>
  `lofi hip hop instrumental, ${pick(moods)}, ${pick(textures)}, ` +
  `mellow, no vocals, steady beat`;

async function generate() {
  const res = await fetch("https://api.songapi.dev/v1/generate", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SONGAPI_KEY}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ prompt: prompt(), instrumental: true })
  });
  return (await res.json()).id;
}

Note instrumental: true — lofi radio doesn’t want a singer. Tracks come back around two to three minutes each (the exact length is in duration_secs on the completed generation), and every generation includes two variations in clips, so each request buys you roughly five minutes of airtime.

The queue

Keep three tracks buffered ahead of playback. Poll each generation until it’s ready, then push the audio URL onto the queue:

const queue = [];

async function waitFor(id) {
  while (true) {
    const res = await fetch(`https://api.songapi.dev/v1/generations/${id}`, {
      headers: { Authorization: `Bearer ${process.env.SONGAPI_KEY}` }
    });
    const gen = await res.json();
    if (gen.status === "complete") return gen.clips.map((c) => c.audio_url);
    if (gen.status === "failed") throw new Error(gen.error); // credits auto-refunded
    await new Promise((r) => setTimeout(r, 5000));
  }
}

async function keepBufferFull() {
  while (true) {
    if (queue.length < 3) queue.push(...await waitFor(await generate()));
    else await new Promise((r) => setTimeout(r, 10_000));
  }
}

Both variations go on air — they came with the generation anyway, and they double the airtime per credit.

In production you’d use a webhook_url instead of polling, but for a weekend project the loop is easier to reason about — and it’s most of our 40-line budget.

Playing it out

What consumes the queue depends on where you’re streaming. For a YouTube or Twitch stream, ffmpeg reading from a playlist file works; for a web player, serve the queue as a JSON endpoint and let the client advance through the URLs. The piece that matters for this post: shift a URL off the queue when the current track ends, and the generator loop backfills automatically.

function next() {
  return queue.shift(); // generator loop refills in the background
}

The 3 a.m. problem

A continuously generating radio consumes credits continuously: with both variations in rotation at roughly five minutes of audio per generation, a full day is about 290 generations — call it 3,200 credits. The failure mode is obvious — the stream dies when the balance hits zero, always at the worst hour.

This is exactly what auto top-up is for. Set a threshold in the dashboard and your balance refills automatically when it drops below it (minimum $50 per top-up). The radio becomes genuinely hands-off: the only ongoing decision is whether to keep it running.

TIP Credits never expire, so pre-buying a larger pack at the volume rate is cheaper than topping up at the starter rate — see pricing for the per-credit breakdown.

Where to take it

The fun of a generated radio is that the queue is programmable. Some directions to push:

  • Time-aware prompts — “sunday morning” textures on weekends, “late-night study” after 10 p.m.
  • Listener requests — accept a mood word in chat and splice it into the next prompt.
  • Seasonal drift — rotate the mood pool monthly so the station evolves.

Total cost to run: about $0.055 per generation — two tracks, roughly five minutes of audio — against the standard pack. Start with the $5 starter pack to prove the loop out (90 generations, about seven hours of never-repeating radio), then move to a bigger pack for the always-on station.

Ready to try it yourself?

$5 gets you 1,000 credits — and they never expire.

[ get your API key ][ see pricing ]