Audio EN - antonprv/LoneBrawler GitHub Wiki
๐ฌ๐ง English | ๐ท๐บ ะ ัััะบะธะน
- Overview
- MusicPlayer
- Track Sequencer & Shuffle
- Track Loader & Caching
- Fader
- Sound Effects
- SoundService - volume binding
- MenuButtonSound
The audio system has two independent subsystems:
- Music - a fully async multi-track player with crossfade, shuffle, and per-level playlist support.
- SFX - a component-level sound list mapped to randomised audio clip groups.
Both read their volume from ISoundService reactive properties, so a single settings slider updates all active audio in real time.
MusicPlayer is a ZenjexBehaviour that manages background music. It works with two AudioSource slots - activeSource and stagingSource - that swap roles after every crossfade.
The player picks one of three paths based on the loaded playlist:
| Playlist state | Path | Behaviour |
|---|---|---|
| Empty / null | No-op | All public methods return immediately; nothing loads |
| Single track | Native loop |
AudioSource.loop = true; crossfade and auto-advance are bypassed |
| Multiple tracks | Crossfade loop | Next clip pre-loaded via Addressables; auto-advance with crossfade |
The single-track path matters for level music that uses one long ambient track - it hands looping to the engine with zero per-frame overhead.
public async UniTask Play()
public async UniTask Stop()
public async UniTask CrossfadeTo(MusicPlaylist playlist)All three are async UniTask methods. Each call starts a new CancellationTokenSource session, cancelling any in-flight fade or crossfade before the new one begins - no overlapping async operations.
For multi-track playlists, after Play() finishes its fade-in, AutoAdvanceLoop runs as a background UniTask. It watches activeSource.time and - when remaining playback equals crossfadeDuration - starts a crossfade to the next track. During the crossfade the next track loads into stagingSource, its volume fades up while activeSource fades down. When complete, sources swap and the following track pre-loads immediately.
MusicPlayerConfig ScriptableObject drives all timings:
| Field | Default | Purpose |
|---|---|---|
fadeInDuration |
1.5 s | Volume rise from 0 to target on Play()
|
fadeOutDuration |
1.5 s | Volume fall to 0 on Stop()
|
crossfadeDuration |
2 s | Overlap between outgoing and incoming tracks |
TrackSequencer (implementing ITrackSequencer) manages track ordering and looping.
-
Sequential mode - tracks play in the order listed in
MusicPlaylist.tracks. -
Shuffle mode - when
MusicPlaylist.shuffle = true, the track list is shuffled with Fisher-Yates viaIRandomServiceat the start of every loop cycle. The same sequence never repeats across two consecutive loops. -
Loop control - when
MusicPlaylist.loop = false,AutoAdvanceLoopexits after the last track without scheduling a new crossfade, and the music stops after the final fade-out.
IsSingleTrack is exposed by TrackSequencer and used by MusicPlayer to pick the native-loop fast path.
TrackPreLoader (implementing ITrackLoader) handles async loading of AudioClip assets from Addressables.
public async UniTask<AudioClip> LoadAsync(AssetReferenceT<AudioClip> reference, CancellationToken ct)Loaded clips are cached by asset GUID. When AutoAdvanceLoop calls PreloadNext(), the next clip fetches while the current track still has crossfadeDuration seconds left - so it's already in memory when the crossfade begins. ReleaseAll() releases every loaded handle in one call, used on MusicPlayer.OnDestroy() and before each CrossfadeTo().
Fader (implementing IFader) interpolates volume:
public async UniTask Fade(AudioSource source, float from, float to, float duration, CancellationToken ct)Volume lerps frame-by-frame using ITimeService.UnscaledDeltaTime, so fades are unaffected by Time.timeScale - pausing the game doesn't freeze a running fade. The CancellationToken lets any in-progress fade abort immediately when a new operation starts.
SoundList is a ZenjexBehaviour that implements ISoundProvider. It holds a DictionaryData<SoundType, AudioClipGroup> mapping sound event types to clip groups.
public AudioClip GetSound(SoundType soundType)
{
if (soundClips.TryGetValue(soundType, out AudioClipGroup group))
return group.TryGetRandom(_random);
return null;
}AudioClipGroup is a simple array of AudioClip references. TryGetRandom picks one at random through IRandomService, giving variance to repeated sounds - footsteps, sword swings, impact hits. The same SoundType can have 3-5 variants in its group to avoid mechanical repetition.
SoundPlayer is a ZenjexBehaviour placed alongside a SoundComponent on an actor. It resolves ISoundProvider from the sibling component and plays clips through typed AudioSource slots:
public async UniTask PlaySound(SoundType type, Action onSoundFinished = null)
{
var sound = _soundProvider.GetSound(type);
if (sound == null) return;
if (soundComponent.SoundSources.TryGetValue(type, out AudioSource source))
{
source.clip = sound;
soundComponent.PlaySound(type);
await UniTask.WaitWhile(() => source.isPlaying, ..., _cancellationToken);
onSoundFinished?.Invoke();
}
}PlaySound is awaitable - callers can await it to know exactly when the clip finishes. _cancellationToken is bound to the component's OnDestroy, so a pending await cancels automatically if the actor is destroyed mid-play.
SoundService (implementing ISoundService) is the single source of truth for audio volumes:
public ReactiveProperty<float> SoundVolumeRP { get; set; } = new(1f);
public ReactiveProperty<float> MusicVolumeRP { get; set; } = new(1f);MusicPlayer subscribes to MusicVolumeRP on Awake and updates _targetVolume reactively - no polling, no Update calls. SFX AudioSources subscribe to SoundVolumeRP through SoundComponent. The settings UI just writes to these properties and everything propagates automatically.
Volume values persist in SystemSettings, a save object separate from GameProgress:
public void ReadSettings(SystemSettings s) { SoundVolumeRP.Value = s.SoundVolume; MusicVolumeRP.Value = s.MusicVolume; }
public void WriteToSettings(SystemSettings s) { s.SoundVolume = SoundVolumeRP.CurrentValue; s.MusicVolume = MusicVolumeRP.CurrentValue; }SaveLoadService always writes SystemSettings on save regardless of isInitial - volume preferences survive new game starts.
MenuButtonSound adds hover and click audio to main menu buttons without requiring a subclass or extra setup. It's a standalone MonoBehaviour placed alongside a Button component.
public void OnPointerEnter(PointerEventData eventData)
{
if (!button.interactable) return;
PlayHoverSound().Forget();
}_wasHovered and _wasPressed bools stop sound stacking: if the hover or click sound is already playing, a second call returns immediately. This handles rapid mouse movement and double-clicks without per-frame tracking.
OnClickSoundFinished (Observable<Unit>) is exposed for cases where the caller needs to delay a scene transition until the click sound finishes - for example, not loading the next scene before the button click plays out.