# 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.