# 3. DJ Chatter Ducking and Receivers
Part of [[In-Game Radio Stations]]. Previous: [[2. Continuous Playback and Tuning]]. Next: [[4. Radio Station Pitfalls]].
> [!note] Verification status
> Duck Volume behaviour is from the 6000.5 Audio Mixer inspectors page (already used in [[1. Audio Mixer Groups and Routing]]). Snapshot fades use `AudioMixerSnapshot.TransitionTo`. Input hooks follow [[Input System]] consumption patterns conceptually; the receiver below also has public methods you can bind to Input System actions. **None of the code has been compiled or play-tested in Unity.**
## 3.1 Diegetic vs non-diegetic
| Kind | What the player is hearing | How you route it |
|---|---|---|
| Non-diegetic score | Orchestra that the character cannot hear | `Music/Bed` etc. from [[Music Mood Mixer]], `spatialBlend = 0` |
| Diegetic radio | A device *in the world* is playing | Same `RadioPlayer` mix, but the *receiver* is an object: car dash, boombox, wrist radio |
| Semi-diegetic | GTA while on foot vs in car: the music is "from the radio" even if you cheat spatialization | Keep playback 2D for intelligibility inside a vehicle; spatialize only when the camera is outside the car looking at it |
A kitchen boombox the player can walk around wants `spatialBlend = 1` and the settings in [[Spatial Audio and Reverb Zones]]. A car interior radio almost always stays 2D (or very slightly 3D) so panning does not fight the camera as you steer. [[Car Controller]] already owns the vehicle; the receiver should turn on with "player entered car" and off (or fade) with "player exited."
Do not run the adaptive combat score *and* a maxed-out radio without a snapshot to decide who wins. A `RadioOn` snapshot that pulls `Music/Bed`, `Music/Tension` and `Music/Percussion` down 8-12 dB, leaving `Music/Radio` untouched, is the clean hand-off.
## 3.2 DJ chatter
Two overlay styles:
1. **Talk-over.** A DJ clip plays on its own source routed to `Dialogue/DJ` (or `Dialogue`) while the song keeps going. Duck Volume on `Music` (or on `Music/Radio` specifically), side-chained from that DJ group, automatically dips the track - the same send you built for spoken dialogue in Audio 1.
2. **Segue.** A stinger or canned line is a playlist entry *between* songs (`RadioEntryKind.DjBed` / `Stinger`). The virtual clock already walks over it. Use `audibleOnTuneIn = false` so a player who tunes in during a two-second ident skips to the next song.
Talk-over is what people mean by "DJ chatter." Implement it as an observer of `RadioPlayer.TrackChanged`: when a new `Song` starts at time 0 (a natural boundary, not a mid-song seek), maybe play an intro.
```csharp
using UnityEngine;
using UnityEngine.Audio;
/// Plays optional DJ lines when a song starts from its beginning (not on mid-song tune-in).
[RequireComponent(typeof(RadioPlayer))]
public class RadioDjAnnouncer : MonoBehaviour
{
[SerializeField] AudioMixerGroup djGroup;
[SerializeField] AudioMixerSnapshot radioOnSnapshot;
[SerializeField] AudioMixerSnapshot radioOffSnapshot;
[SerializeField] float snapshotFade = 0.35f;
AudioSource _dj;
RadioPlayer _radio;
void Awake()
{
_radio = GetComponent<RadioPlayer>();
var go = new GameObject("RadioDJ");
go.transform.SetParent(transform, false);
_dj = go.AddComponent<AudioSource>();
_dj.playOnAwake = false;
_dj.loop = false;
_dj.spatialBlend = 0f;
_dj.priority = 32;
_dj.outputAudioMixerGroup = djGroup;
}
void OnEnable()
{
_radio.TrackChanged += OnTrackChanged;
_radio.StationChanged += OnStationChanged;
}
void OnDisable()
{
_radio.TrackChanged -= OnTrackChanged;
_radio.StationChanged -= OnStationChanged;
}
void OnStationChanged(RadioStationDefinition station)
{
_dj.Stop();
if (_radio.IsOn) radioOnSnapshot?.TransitionTo(snapshotFade);
else radioOffSnapshot?.TransitionTo(snapshotFade);
}
void OnTrackChanged(RadioStationDefinition station, RadioPlaylistEntry entry)
{
if (station == null || entry == null) return;
if (entry.kind != RadioEntryKind.Song) return;
// Mid-song tune-in has time > a small epsilon on the front source;
// we only chatter when a song starts (PlayScheduled from 0, or a seek of ~0).
// RadioPlayer invokes TrackChanged for both cases; inspect timeSamples.
// If the just-scheduled/played source is past the first ~0.25 s, this was a seek.
// (Approximate: DJ does not talk over a chorus the player jumped into.)
if (WasMidSongSeek()) return;
if (station.djIntros == null || station.djIntros.Length == 0) return;
if (Random.value > station.djIntroChance) return;
var line = station.djIntros[Random.Range(0, station.djIntros.Length)];
if (line == null) return;
_dj.volume = station.djVolume;
_dj.PlayOneShot(line);
}
bool WasMidSongSeek()
{
// If either radio child source is already well into a clip, we treated this
// as a tune-in. A scheduled natural transition starts at timeSamples == 0.
foreach (var src in GetComponentsInChildren<AudioSource>())
{
if (src == _dj || src.clip == null || !src.isPlaying) continue;
if (src.timeSamples > src.clip.frequency / 4) // > ~0.25 s
return true;
}
return false;
}
public void NotifyPower(bool on)
{
if (on) radioOnSnapshot?.TransitionTo(snapshotFade);
else
{
_dj.Stop();
radioOffSnapshot?.TransitionTo(snapshotFade);
}
}
}
```
> [!warning] Code not compiled in Unity
> Not compiled. Prefer a dedicated Duck Volume on `Music/Radio` side-chained from `Dialogue/DJ` so a DJ line ducks the song without ducking the whole mix (UI, footsteps, the adaptive percussion stem you may still want during a chase). If you already have Dialogue ducking all of `Music`, a DJ routed into Dialogue will reuse it - convenient, slightly less precise.
`WasMidSongSeek` is deliberately conservative. It is better to skip a DJ line on a natural transition than to talk over a chorus the player just tuned into. If you want stricter logic, extend `RadioPlayer.TrackChanged` to pass a `bool startedFromBeginning` rather than inferring from `timeSamples`.
## 3.3 The receiver (knob in the world)
The player does not talk to `RadioPlayer` directly from thirty scripts. A receiver is the Component pattern ([[Game Programming Patterns (Review)]]): the car dash, the boombox, and a debug keyboard each *use* the same service.
```csharp
using UnityEngine;
using UnityEngine.InputSystem;
/// Diegetic radio face: power and tuning. Put this on a car dash or a world prop.
public class RadioReceiver : MonoBehaviour
{
[SerializeField] RadioPlayer player;
[SerializeField] RadioDjAnnouncer announcer;
[SerializeField] bool turnOnWithEnable = false;
[SerializeField] bool spatializeWhenWorldBound = false;
[SerializeField] AudioSource worldSpeaker; // optional 3D analogue; leave null for 2D dash
[Header("Optional Input System actions")]
[SerializeField] InputActionReference toggleAction;
[SerializeField] InputActionReference nextAction;
[SerializeField] InputActionReference prevAction;
void OnEnable()
{
Bind(toggleAction, _ => Toggle());
Bind(nextAction, _ => Next());
Bind(prevAction, _ => Prev());
if (turnOnWithEnable) SetPower(true);
}
void OnDisable()
{
Unbind(toggleAction);
Unbind(nextAction);
Unbind(prevAction);
if (turnOnWithEnable) SetPower(false);
}
static void Bind(InputActionReference r, System.Action<InputAction.CallbackContext> cb)
{
if (r == null || r.action == null) return;
r.action.performed += cb;
if (!r.action.enabled) r.action.Enable();
}
static void Unbind(InputActionReference r)
{
// Intentionally not removing anonymous handlers here - use the public methods
// from a dedicated input component if you need clean unsubscription.
// See [[Input System]] for consumption patterns.
if (r != null && r.action != null) { /* action lifetime owned by the asset */ }
}
public void Toggle() => SetPower(player == null || !player.IsOn);
public void SetPower(bool on)
{
if (player == null) return;
player.SetOn(on);
announcer?.NotifyPower(on);
if (worldSpeaker != null)
{
worldSpeaker.spatialBlend = spatializeWhenWorldBound ? 1f : 0f;
worldSpeaker.mute = !on;
}
}
public void Next() => player?.TuneNext();
public void Prev() => player?.TunePrev();
/// Keyboard fallback so the tutorial is playable before actions are authored.
void Update()
{
var kb = Keyboard.current;
if (kb == null) return;
if (kb.rKey.wasPressedThisFrame) Toggle();
if (kb.periodKey.wasPressedThisFrame) Next();
if (kb.commaKey.wasPressedThisFrame) Prev();
}
}
```
> [!warning] Code not compiled in Unity
> Not compiled. The `Update` keyboard fallback uses `UnityEngine.InputSystem.Keyboard` from the Input System package ([[Input System]]). If the project is still on the old input manager, replace that block with `Input.GetKeyDown(KeyCode.R)` etc., or (better) finish the Input System setup and bind `Toggle` / `Next` / `Prev` actions to the public methods. The anonymous `Bind` helper is convenient for a demo and sloppy about unsubscription - Part 5 of the Input System tutorial is the pattern you want in production.
For a vehicle: put `RadioReceiver` on the car, set `turnOnWithEnable` false, and call `SetPower(true)` from your "player entered vehicle" code (the same place [[Car Controller]] would start accepting input). On exit, `SetPower(false)` or leave it playing quietly and spatialize `worldSpeaker` so walking away from the car hears the song recede - that is a Spatial Audio exercise.
For a world boombox: treat it as an interactable ([[3. Interactable Components with Audio]]). `Use` toggles power; a second interaction (or a world-space UI list) calls `Next`.
## 3.4 Station readout
`RadioPlayer.StationChanged` and `TrackChanged` are enough to drive a diegetic label: call sign, song title, a needle graphic. In UI Toolkit, register those events and set a `Label`. In VR, put that label on a dash panel ([[8.UI Toolkit for VR]], [[6.Diegetic Terminal and Wrist Display]]). The radio then teaches Level 8 by *being* the HUD for music.
## 3.5 Wanted-level and mission takeover
GTA will interrupt a station for a news flash or duck it under mission speech. You already have the tools:
- Mission dialogue -> existing Dialogue duck send onto `Music`.
- A scripted takeover -> `radioOnSnapshot` vs a `RadioNews` snapshot, or `player.TuneTo(newsStation)`.
- Adaptive combat score that must win -> `player.SetOn(false)` plus the Combat snapshot from [[Music Mood Mixer]], or a weighted blend that pulls `Music/Radio` down while raising `Music/Percussion`.
Decide this in one place (the mood director, or a thin `MusicArbitration` component) rather than letting the wanted-level script and the radio receiver fight over snapshots. That is the same "one director owns the mix" rule as Audio 3.