How-to

How to build a 24/7 music stream playlist like a radio station

A 24/7 music stream that loops one file still needs what a radio station has: a running order, rules that keep the same artist from playing twice in a row, and a station ID now and then. This guide borrows the radio tools for it, the format clock and the separation rules written into US webcasting law, then builds the whole rotation into one long file with ffmpeg, without gaps between tracks and at one even loudness.

Short answer

  • Plan an hour, then repeat it. Radio software calls the plan of an hour a clock: a station ID, a block of tracks, a short bumper, more tracks. Your file is several of these hours in a row.
  • Space the artists. A published benchmark is the US webcasting rule: in any 3 hours, no more than 4 tracks by one artist and 3 from one album, with limits on how many in a row. It does not license anything on YouTube, but it is a sound spacing rule.
  • Check the loop point. In a looped file the last track runs straight into the first, so the spacing has to hold across that join too. Our shuffle script below checks it.
  • Build one file, encode once. Decode every track to WAV at one loudness, join the WAVs, then encode to video in one pass. Joining MP3s directly left a gap of 26 to 51 ms at every join in our test.
  • Length decides repetition. A 3-hour file plays 8 times a day, by our arithmetic. With a still picture, an hour of our test file took about 95 MB.

From 17 U.S.C. 114 (Cornell LII), SoundExchange, the Rivendell and AzuraCast documentation, the ffmpeg documentation and loopcast.io, checked on October 29, 2026. Commands and script tested on our server the same day.

1. The format clock: plan one hour

Radio automation does not have to pick each song as it goes: the shape of an hour is planned once and filled from the music library. The open source radio automation system Rivendell describes its version: "An RDLogManager clock is a collection of RDLogManager events, arranged in terms of their start time (relative to the beginning of the hour) and length. It basically can be thought of as the "schedule" of an hour". Clocks then go into a grid, which "has slots for every hour of every day of the broadcast week", 168 in all. The same idea goes by format clock, hour clock or hot clock.

A looped stream needs a simpler version. Nothing outside the file is timed to it, and viewers join at random points, so the clock does not have to land on exactly 60 minutes. It is a pattern you repeat until the file is long enough. Here is an example for a music stream with 4-minute tracks:

An example hour clock for a looped music streamOur example. Track counts and times are our arithmetic for 4-minute tracks.
PositionWhat playsWhy
1Station ID, a few secondsTells someone who just joined what they are listening to
2 to 5Four tracks from the main rotationThe core sound of the channel
6Short bumper (a sound logo, no words)Marks the half without stopping the music
7 to 10Three main tracks and one from a smaller pool (newest or featured)New tracks get regular plays without taking over
11Station ID with a message: where to find the tracklist or the artistsYour own information, in your own voice
12 to 15Four tracks from the main rotationCloses the hour

That is 12 tracks, about 48 minutes of music, plus the IDs. Radio automation handles the pools the same way: AzuraCast's documentation says "you can have one or more playlists that are considered “General Rotation” and will play throughout the day, or playlists that only play every few songs, or every few minutes." For a looped file, you do the same once, when you build it.

Different times of day can have different clocks, for example a quieter one overnight. A looped stream can do it with two files, one for the day and one for the night (section 6).

2. Track spacing and artist separation

Hearing the same artist twice in ten minutes is what makes a stream feel like a short playlist on repeat. US law has a published set of separation rules. The statutory licence for webcasting, administered by SoundExchange, requires services to stay within the "sound recording performance complement", defined in 17 U.S.C. 114(j)(13) as the transmission "during any 3-hour period, on a particular channel used by a transmitting entity" of no more than:

  • "3 different selections of sound recordings from any one phonorecord lawfully distributed for public performance or sale in the United States, if no more than 2 such selections are transmitted consecutively"; or
  • "4 different selections of sound recordings" by "the same featured recording artist", or from a set or compilation released together as a unit, "if no more than three such selections are transmitted consecutively".

SoundExchange puts it more simply: "No more than 4 tracks by the same featured artist (or from a compilation album) may be transmitted to the same listener within a 3 hour period (and no more than 3 of those tracks may be transmitted consecutively)." The same section of the law also rules out, for these services, a "continuous program which is of less than 3 hours duration", and the law defines a continuous program as one "continuously performed in the same order and that is accessed at a point in the program that is beyond the control of the transmission recipient". That is a description of a looped file.

These rules do not apply to your YouTube or Kick stream, and following them does not license any music there. SoundExchange says that for services such as YouTube, "the statutory license does not apply, and a direct license must be obtained from the copyright holder". Our radio station guide explains that, and our guide to music licences for 24/7 streams quotes what the libraries actually allow. We use the numbers here only as a published benchmark for spacing.

Spacing rules for a looped rotationThe 3-hour limits are from 17 U.S.C. 114(j)(13). The rest is our advice.
RuleBenchmarkIn a looped file
Same artistNo more than 4 in any 3 hours, no more than 3 in a rowNever two in a row; keep at least two other tracks between them
Same albumNo more than 3 in any 3 hours, no more than 2 in a rowTreat an album like an artist
Same trackNot set by the ruleOnce per file; the file length then sets how often it plays (section 6)
File lengthA continuous program under 3 hours does not qualify3 hours or more if your library and file size allow it
The loop pointNot coveredThe last tracks run into the first ones, so check spacing across the join

The last row is the one playlist tools miss. A shuffled list can look fine from top to bottom and still put the same artist at the end and at the start, which back to back on a loop is two in a row.

3. Station IDs and breaks

An ID is how a radio station tells a listener who just tuned in where they are. On a 24/7 stream it does the same for someone who opened the stream halfway through an hour, and it is the part of the stream that is entirely yours. Some practical points:

  • Keep it short. A few seconds: the channel name and what it plays. Our test IDs were 4 seconds.
  • Use your own audio. Record the voice yourself, or check the licence of a bought jingle package covers online video, as our radio station guide notes.
  • Be honest about the loop. An ID that says "live now" or greets the chat suggests someone is there. Loopcast's YouTube page says: "Do not stage chat or imply someone is present and interacting. A loop that is honestly a loop is fine; one pretending to be a person is not."
  • Rotate several IDs. Two or three versions keep the same ID from coming back every 15 minutes in exactly the same words.
  • Sponsor reads only with a deal. A message slot is for your own links unless someone is paying for it under written terms.

Breaks between tracks are a choice. A lofi mix usually runs without them; our 24/7 lofi stream guide crossfades every track into the next. A radio-style stream plays tracks whole, with IDs between them, which is what the build below does.

4. Shuffle without repeats or clashes

A plain shuffle can put two tracks by the same artist next to each other. This short Python script orders a track list so every track plays once, the same artist never comes back within GAP tracks (across the loop point too), and a station ID follows every few tracks. It needs a tracks.csv with a header line file,artist,album and one row per track.

import csv, random, sys

GAP = 2        # at least 2 other tracks between two tracks by the same artist
ID_EVERY = 3   # a station ID after every 3 tracks
IDS = ["id1.wav", "id2.wav"]

def ok(order, gap):
    # check every pair of neighbours within the gap, across the loop point too
    n = len(order)
    for i in range(n):
        for j in range(1, gap + 1):
            if order[i]["artist"] == order[(i + j) % n]["artist"]:
                return False
    return True

tracks = list(csv.DictReader(open("tracks.csv")))
for attempt in range(10000):
    order = random.sample(tracks, len(tracks))  # every track once, no repeats
    if ok(order, GAP):
        break
else:
    sys.exit("No order found: add tracks or lower GAP")

with open("playlist.txt", "w") as out:
    for i, t in enumerate(order):
        out.write(f"file 'wav/{t['file'].rsplit('.', 1)[0]}.wav'\n")
        if (i + 1) % ID_EVERY == 0:
            out.write(f"file 'wav/{IDS[(i // ID_EVERY) % len(IDS)]}'\n")
        print(t["artist"], t["file"])

It writes playlist.txt in the list format ffmpeg's concat demuxer reads, pointing at the WAV copies made in the next section. Set ID_EVERY to match your clock (4 for the example in section 1). To space albums as well, add the same check with "album" in place of "artist".

What we tested, on October 29, 2026 with Python 3: 12 test tracks by 4 artists, 3 each. We ran the script 200 times: every run used each track exactly once, wrote 4 IDs alternating between the two files, and had no artist closer than two tracks apart, including across the loop point. With GAP = 4, which 12 tracks by 4 artists cannot satisfy, it stopped with the "No order found" message instead of writing a bad list. A random search like this is fine for a few hundred tracks; if it keeps failing, your library has too few artists for the gap you asked for.

5. Build one long file without gaps

The file is built in three steps: decode every track to WAV at one loudness, join the WAVs, then encode the video once. Each step has a reason.

Why not join the MP3s directly? The ffmpeg manual says the concat demuxer joins files "as if all their packets had been muxed together", and warns that "it is done globally and may cause gaps if all streams do not have exactly the same length". In our test, joining 12 MP3 tracks with -c copy left a near-silent gap of 26 to 51 ms at every one of the 11 joins. Decoded to WAV first, the same tracks joined with no gap. It also makes mixed sources possible: the manual says "All files must have the same streams (same codecs, same time base, etc.)", and after step 1 every file is the same WAV format, even if the IDs started as AAC at another sample rate. Our seamless loop guide shows the same effect with AAC.

Step 1: decode to WAV at one loudness. On a stream left playing for hours, a loud track after a quiet one is what makes people reach for the volume. ffmpeg's loudnorm filter does "EBU R128 loudness normalization". Run it on each track, not on the finished mix, so every track lands at the same level:

mkdir -p wav
for f in *.mp3 *.m4a *.flac; do
  [ -e "$f" ] || continue
  ffmpeg -i "$f" -af loudnorm=I=-16:TP=-1.5:LRA=11 -ar 48000 -ac 2 \
    -c:a pcm_s16le "wav/${f%.*}.wav"
done
  • I=-16 is the target loudness in LUFS, TP=-1.5 the true peak ceiling. -16 is our choice for this test, not a platform rule; what matters on a rotation is that every track gets the same target.
  • -ar 48000 is needed: the manual says that to detect true peaks "the audio stream will be upsampled to 192 kHz. Use the -ar option or aresample filter to explicitly set an output sample rate."
  • This is loudnorm's single pass mode. The manual says the filter supports "single pass (livestreams, files) and double pass (files) modes"; in double pass you measure the file first and pass the results back, and loudnorm can then "Normalize by linearly scaling the source audio", leaving the track's own dynamics alone. Single pass is the simple start.

Step 2: join the WAVs. Run the shuffle script from section 4 to write playlist.txt, then:

ffmpeg -f concat -safe 0 -i playlist.txt -c copy mix.wav

Step 3: encode once, with the picture. This puts the mix under a still station card (1280 by 720) and stops exactly when the sound ends:

LEN=$(ffprobe -v error -show_entries format=duration -of csv=p=0 mix.wav)
ffmpeg -loop 1 -framerate 30 -i card.png -i mix.wav \
  -map 0:v -map 1:a -t "$LEN" -c:v libx264 -preset veryfast -tune stillimage \
  -pix_fmt yuv420p -g 60 -c:a aac -b:a 192k -ar 48000 \
  -movflags +faststart rotation.mp4

We first used -shortest to stop at the end of the sound. In our test it let the picture run 0.46 seconds past the sound, which would be a silent beat at every loop. Setting -t to the WAV's own length avoided it. For a moving scene instead of a card, the command in our lofi stream guide loops a short clip under the mix; give it the same -t.

What we measuredTested on our server on October 29, 2026 with ffmpeg 7.0.2, on generated test tones: 12 MP3 tracks (21 to 32 seconds, at very different levels) and 2 AAC IDs at 48 kHz.
TestResult
12 MP3s joined with -c copyA gap of 26 to 51 ms at each of the 11 joins
Loudness after step 1Sources from -42.2 to -18.7 LUFS; all 14 WAVs at -16.0 or -15.9 LUFS
WAVs joined (step 2)334.043 seconds, exactly the sum of the parts; no gap at any track join
Short silences left5 to 11 ms after each ID, already present at the end of the AAC-encoded ID files; export IDs as WAV to avoid it
Final file (step 3)H.264 at 30 fps with a keyframe every 2 seconds, AAC at 192 kbps and 48 kHz, index at the start of the file, 8.8 MB
The file played twice end to end35 ms of silence at the loop point, part of it from the last ID

A few milliseconds between two items is the same as a quick segue on air. If your file ends on a track that fades out, nobody will hear it; our seamless loop guide covers how to measure a seam if you crossfade instead.

6. How long the file should be before it repeats

Each track plays once per pass, so the file length sets how often a regular listener hears it. A 1-hour file plays 24 times a day; a 3-hour file, 8 times (our arithmetic: 24 hours divided by the file length). Our lofi stream guide has the full table and the file size limits for moving pictures. Two numbers from this guide help decide:

  • 3 hours is the shortest continuous program the US webcasting rules accept (section 2). At 4 minutes a track it takes 45 tracks, by our arithmetic, plus the IDs.
  • About 95 MB an hour is what our still-card test file used: 192 kbps of audio and about 11 kbps of video. A 4 GB upload limit would then hold about 42 hours (our arithmetic: 4,000 MB divided by 95 MB an hour). With a still card, the 4 GB limit only matters if you have more than about 42 hours of music. A moving picture needs far more, which is the lofi guide's table.

If the library is small, a shorter file played at different times of day can feel less repetitive than one long file: a day rotation and a quieter night rotation, each built with the steps above. That needs a sender that switches files on a schedule, which is the next section.

7. Running the rotation 24/7

Once the file exists, something has to send it to YouTube or Kick around the clock: OBS on a computer that stays on, a server running ffmpeg, or a hosted loop. Our guide to looping a video in OBS compares a home PC with a hosted loop, and covers OBS's own playlist source if you would rather not build one file. If you run an audio feed from a radio automation system instead, the relay command is in our radio station guide.

Before you put the rotation on air

  1. Every track is licensed for YouTube (and Kick, if you use it) and for a 24/7 stream.
  2. You wrote down a clock: where IDs, bumpers and each pool of tracks go in an hour.
  3. No artist plays within two tracks of itself, including across the loop point.
  4. Every track and ID was decoded to WAV at the same loudness target before joining.
  5. The final file was encoded once, with -t set to the WAV's length.
  6. You listened to the join from the end of the file back to the start.
  7. IDs and messages are your own audio and do not pretend someone is live.
  8. You know how many times a day the file repeats, and the tracklist is in the description.

FAQ

What is a radio format clock?

A plan of one hour: where the station IDs and each category of music go. Radio automation software fills it from the library. Rivendell describes its clock as events "arranged in terms of their start time (relative to the beginning of the hour) and length". For a looped stream, the clock is the pattern you repeat when you build the file.

How do I stop the same artist playing back to back?

Use a shuffle that checks spacing, like the script in section 4, and check the loop point as well: in a looped file, the last track is followed by the first. A published benchmark is the US webcasting rule of no more than 4 tracks by one artist in any 3 hours, and no more than 3 in a row.

Does following the SoundExchange rules make my YouTube stream legal?

No. Those rules are conditions of the US statutory webcasting licence, and SoundExchange says that for YouTube "the statutory license does not apply, and a direct license must be obtained from the copyright holder". Use them only as a spacing guide.

Why are there gaps between songs when I join MP3s with ffmpeg?

Joining compressed files with -c copy keeps each file's padding. In our test it left 26 to 51 ms at every join. Decode each track to WAV first, join the WAVs, and encode once.

How long should a 24/7 music stream playlist be?

As long as your library allows. A 3-hour file plays 8 times a day and matches the shortest continuous program the US webcasting rules accept; with a still picture, file size is rarely the limit.

Sources

  1. 17 U.S.C. 114, sections (d)(2)(C) and (j) (Cornell LII). Checked on October 29, 2026.
  2. SoundExchange: Licensing 101. Checked on October 29, 2026.
  3. Rivendell Operations Guide: RDLogManager (GitHub). AzuraCast: Playlists. Checked on October 29, 2026.
  4. ffmpeg documentation: formats (concat demuxer) and filters (loudnorm). Commands tested on our server with ffmpeg 7.0.2 on October 29, 2026, on generated test tones; the shuffle script tested with Python 3 the same day. Nothing was uploaded.
  5. Loopcast: YouTube page and pricing. Checked on October 29, 2026.