# 2. Continuous Playback and Tuning
Part of [[In-Game Radio Stations]]. Previous: [[1. Radio Station Data and Playlists]]. Next: [[3. DJ Chatter Ducking and Receivers]].
> [!note] Verification status
> Checked 2026-11-08 against `AudioSource.PlayScheduled` (absolute `AudioSettings.dspTime`; schedule ~100-200 ms ahead; times in the past start as soon as possible without reconstructing missed audio), `AudioSource.SetScheduledEndTime` (absolute end time on the same timeline), `AudioSource.time` / `timeSamples` (sample-accurate seek), `AudioSource.clip`, `isPlaying`. **None of the code has been compiled or play-tested in Unity.**
## 2.1 The continuity trick
Players believe a station "kept playing" if, when they return, the song is further along by roughly the wall-clock time they were away. You do not need the clip to have been rendering.
Per station, store:
- A timeline (Part 1).
- A `double originDsp` - the `AudioSettings.dspTime` at which station-time 0 occurred.
- The current playlist is then `stationTime = dspTime - originDsp` (modulo total duration if looping).
Tune away: stop the audible sources, leave the origin alone. Tune back: compute `stationTime`, find the entry and offset, assign the clip, set `time` or `timeSamples`, `Play()`. The chorus is waiting.
Using `dspTime` rather than `Time.time` keeps the clock aligned with the audio thread, which is the same reason Unity's music-stitching example uses it. `dspTime` is documented as based on the actual number of samples the audio system processes, and more precise than `Time.time`. If you pause the audio listener (`AudioListener.pause`), `dspTime` also pauses - which is what you want for a paused game, and not what you want if you paused only gameplay. For a car radio that should keep playing in a pause menu, do not pause the listener; use a snapshot instead ([[2. Audio Mixer Snapshots and Exposed Parameters]]).
## 2.2 Why two sources
Unity's `PlayScheduled` example uses two `AudioSource`s and flips between them so loading/preparing clip B does not hitch clip A. You want the same thing when a song ends and the next song (or a DJ stinger) begins *while the player is listening*. Mid-song seeks on tune-in only need one of those sources.
`PlayDelayed` is the relative-time cousin and is fine for "the ad starts in 0.5 s." It is not how you stitch two tracks onto a shared downbeat.
## 2.3 The player
```csharp
using System;
using UnityEngine;
using UnityEngine.Audio;
/// One radio: many stations, one audible pair of sources, virtual clocks for the rest.
public class RadioPlayer : MonoBehaviour
{
[SerializeField] RadioStationDefinition[] stations;
[SerializeField] AudioMixerGroup radioGroup;
[SerializeField] int seed = 12345;
[SerializeField] double scheduleAhead = 0.2;
[SerializeField] bool playOnStart = true;
public event Action<RadioStationDefinition, RadioPlaylistEntry> TrackChanged;
public event Action<RadioStationDefinition> StationChanged;
RadioStationTimeline[] _timelines;
double[] _originDsp;
int _index;
bool _on;
AudioSource _a;
AudioSource _b;
AudioSource _front;
AudioSource _back;
double _nextBoundaryDsp;
int _playingEntry = -1;
public bool IsOn => _on;
public int StationCount => stations != null ? stations.Length : 0;
public RadioStationDefinition CurrentStation =>
StationCount == 0 ? null : stations[Mathf.Clamp(_index, 0, StationCount - 1)];
void Awake()
{
_a = CreateSource("RadioA");
_b = CreateSource("RadioB");
_front = _a;
_back = _b;
var rng = new System.Random(seed);
int n = StationCount;
_timelines = new RadioStationTimeline[n];
_originDsp = new double[n];
double now = AudioSettings.dspTime;
for (int i = 0; i < n; i++)
{
_timelines[i] = new RadioStationTimeline(stations[i], rng);
_originDsp[i] = now; // every station "started" when the radio booted
}
}
AudioSource CreateSource(string name)
{
var go = new GameObject(name);
go.transform.SetParent(transform, false);
var src = go.AddComponent<AudioSource>();
src.playOnAwake = false;
src.loop = false;
src.spatialBlend = 0f;
src.priority = 0;
src.outputAudioMixerGroup = radioGroup;
return src;
}
void Start()
{
if (playOnStart) SetOn(true);
}
void Update()
{
if (!_on || CurrentStation == null) return;
// Schedule the upcoming boundary slightly ahead of the audio thread.
if (AudioSettings.dspTime + scheduleAhead >= _nextBoundaryDsp)
ScheduleFollowingEntry();
}
public void SetOn(bool on)
{
if (_on == on) return;
_on = on;
if (_on) TuneTo(_index, force: true);
else StopAudible();
}
public void ToggleOn() => SetOn(!_on);
public void TuneNext() => TuneTo(_index + 1);
public void TunePrev() => TuneTo(_index - 1);
public void TuneTo(int index, bool force = false)
{
if (StationCount == 0) return;
index %= StationCount;
if (index < 0) index += StationCount;
if (!force && index == _index && _on && _front.isPlaying)
return;
_index = index;
_on = true;
StationChanged?.Invoke(CurrentStation);
SeekAudibleToClock();
}
public void TuneTo(RadioStationDefinition station)
{
if (station == null) return;
for (int i = 0; i < StationCount; i++)
if (stations[i] == station) { TuneTo(i, force: true); return; }
}
void SeekAudibleToClock()
{
var tl = _timelines[_index];
if (tl == null || tl.Order.Length == 0)
{
StopAudible();
return;
}
float stationTime = (float)(AudioSettings.dspTime - _originDsp[_index]);
if (!tl.FindAt(stationTime, out int entry, out float timeInClip))
{
StopAudible();
return;
}
// Skip inaudible-on-tune-in entries (stingers, typically).
int guard = 0;
while (!tl.Order[entry].audibleOnTuneIn && guard++ < tl.Order.Length)
{
entry = (entry + 1) % tl.Order.Length;
timeInClip = 0f;
if (!tl.Station.loopPlaylist && entry == 0)
break;
}
PlayEntryNow(entry, timeInClip);
}
void PlayEntryNow(int entry, float timeInClip)
{
var tl = _timelines[_index];
var item = tl.Order[entry];
StopAudible();
_front.clip = item.clip;
_front.volume = tl.Station.trackVolume;
// timeSamples is the precise seek; fall back to time if the clip has no samples reported.
if (item.clip.samples > 0)
{
int sample = Mathf.Clamp(
Mathf.FloorToInt(timeInClip * item.clip.frequency),
0, Mathf.Max(0, item.clip.samples - 1));
_front.timeSamples = sample;
}
else
{
_front.time = Mathf.Max(0f, timeInClip);
}
_front.Play();
_playingEntry = entry;
float remaining = Mathf.Max(0.01f, item.DurationSeconds - timeInClip);
_nextBoundaryDsp = AudioSettings.dspTime + remaining;
TrackChanged?.Invoke(tl.Station, item);
}
void ScheduleFollowingEntry()
{
var tl = _timelines[_index];
if (tl == null || tl.Order.Length == 0) return;
int next = _playingEntry + 1;
if (next >= tl.Order.Length)
{
if (!tl.Station.loopPlaylist)
{
_nextBoundaryDsp = double.PositiveInfinity;
return;
}
next = 0;
}
var item = tl.Order[next];
_back.clip = item.clip;
_back.volume = tl.Station.trackVolume;
_back.timeSamples = 0;
_back.PlayScheduled(_nextBoundaryDsp);
_front.SetScheduledEndTime(_nextBoundaryDsp);
// Flip so the scheduled source is now "front" once it takes over.
var tmp = _front;
_front = _back;
_back = tmp;
_playingEntry = next;
_nextBoundaryDsp += item.DurationSeconds;
TrackChanged?.Invoke(tl.Station, item);
}
void StopAudible()
{
_a.Stop();
_b.Stop();
_a.clip = null;
_b.clip = null;
_playingEntry = -1;
_nextBoundaryDsp = double.PositiveInfinity;
}
/// Seconds the current station thinks have elapsed since the radio booted.
public float DebugStationTime()
{
if (CurrentStation == null) return 0f;
return (float)(AudioSettings.dspTime - _originDsp[_index]);
}
}
```
> [!warning] Code not compiled in Unity
> Not compiled. Put `RadioPlayer` on a bootstrap or gameplay object, assign two or more station assets, and route `radioGroup` to `Music/Radio`. The player's `MusicVol` still lives on parent `Music`, so station loudness follows the settings menu from [[2. Audio Mixer Snapshots and Exposed Parameters]].
### Why seek with `timeSamples`
The 6000.5 docs describe `timeSamples` as the playback position in PCM samples, and recommend it when you want more precise timing than `time`. Radio continuity is exactly that: a 0.02 s error is a click; a 1-sample error is not. `clip.frequency` is the clip's sample rate, which may differ from `AudioSettings.outputSampleRate` - that is why the seek uses the *clip* frequency, not the mixer rate.
### Why every station shares the radio's boot time as origin
If each station's origin were "when I first tuned to it," flipping away for 40 seconds and back would restart the first song. Boot-time origins make the whole dial feel like a city of broadcasts that were already on the air. For a New Game, rebuild timelines and reset origins in the same place you reset [[High Scores, Saves and Player Stats]].
If you want stations to *desynchronize* (so they do not all start track 1 at t = 0), add a per-station `startOffsetSeconds` on the definition and subtract it from `originDsp`.
## 2.4 Hearing the continuity
A quick class demo:
1. Tune to station A, note the lyric or a visual countdown (`DebugStationTime` plus the current title via `TrackChanged`).
2. Flip to station B for ten seconds.
3. Flip back. You should be ~10 seconds further into A's song, not at 0:00 and not at the exact sample you left (you should have moved forward).
If you land at 0:00, the seek did not run (`timeSamples` ignored because `Play()` was called before assigning the clip, or the clip is a stream that does not support arbitrary seeks well). If you land exactly where you left off, the origin was rewritten on tune-out - do not do that.
## 2.5 Load and streams
`PlayScheduled`'s documentation calls it out as the right tool when opening and buffering a stream would otherwise spike the CPU. Radio playlists of long songs are the textbook case: prepare the *next* track ~200 ms (or more, for streamed files) before the boundary, on the inactive source. Do not `Resources.Load` the next clip on the boundary frame itself; keep references on the ScriptableObject so they are already loaded (or at least already referenced) by the time you schedule.
Part 3 layers DJ chatter on top of this clock, ducks the song, and hangs a receiver on a car dash.