feat(audio): separate SFX and music with reactive volume settings

This commit is contained in:
Infuseting 2026-10-14 12:11:10 +02:00
parent 6464adde57
commit cf6ff999fa
6 changed files with 216 additions and 1 deletions

36
docs/audio.md Normal file
View File

@ -0,0 +1,36 @@
# Audio
Le système est local à chaque joueur : `AudioController` expose `mixer` par injection Flamework. Le catalogue `src/client/audio/catalog.ts` sépare les assets `SFX` et `Music`. Il est volontairement vide ; ajouter des assets audio autorisés pour l'expérience avant de les utiliser.
```ts
// Inside a Flamework controller with AudioController injected:
this.audioController.mixer.playSfx("Confirm");
this.audioController.mixer.playMusic("Lobby");
this.audioController.mixer.stopMusic();
```
Chaque SFX crée une lecture indépendante, ce qui permet leur superposition. La fin du son ou le handle `stop()` libère le lecteur, son Wire et sa connexion. Une seule musique joue à la fois, en boucle ; sélectionner à nouveau la même piste ne la redémarre pas. Une clé inconnue retourne `undefined` sans interrompre la musique courante. Le contrôleur libère tous les lecteurs et abonnements à sa destruction.
## Raccordement de Settings
Le producer Reflex `audioSettings` est la source des réglages pour l'interface et le mixer. Les abonnements sont appliqués à la frame suivante. Utiliser `getState()` pour lire, `subscribe()` pour observer (et appeler sa fonction de désabonnement au démontage), et ces actions pour modifier :
```ts
import { audioSettings } from "client/audio/settings";
audioSettings.setVolume("SFX", 0.8);
audioSettings.setVolume("Music", 0.4);
audioSettings.setVolume("Master", 1);
audioSettings.setMuted("Music", true);
audioSettings.reset();
```
Les sliders utilisent des valeurs entre 0 et 1. Les valeurs finies sont bornées ; NaN et les infinis sont ignorés. Le mute conserve la valeur du slider et laisse la musique avancer. Le volume audible vaut `clip.volume × channel.volume × Master.volume`, ou zéro si le canal ou Master est muet. Les sons démarrés après un changement utilisent également ces réglages. Les réglages durent la session ; une future persistance peut charger et sauvegarder les valeurs via ces mêmes actions. Aucune page Settings ni sauvegarde serveur n'est ajoutée ici.
## Choix technique
- [Objets audio Roblox](https://create.roblox.com/docs/audio/objects) : `AudioPlayer → AudioFader (SFX/Music) → AudioFader (Master) → AudioDeviceOutput`. Ce graphe préserve le mixage propre à chaque asset et suit la recommandation actuelle du moteur.
- `@rbxts/reflex` **4.3.1** ([documentation](https://github.com/littensy/reflex)) gère l'état réactif ; `@rbxts/trove` **1.3.0** ([documentation](https://github.com/Sleitnick/rbxts-trove)) gère le nettoyage. Les déclarations installées ont été contrôlées.
- `@rbxts/sound-manager` **2.3.4** ([mainteneur](https://github.com/xelnae-r/Sound-Manager)) a été comparé au moteur et son code installé inspecté. Ses registres non spatiaux utilisent `Sound`, désormais déconseillé par Roblox, et `setCategoryVolume` remplace les volumes des assets. Ses méthodes globales parcourent également les registres voisins. Il reste installé pour compatibilité avec le template mais n'est pas utilisé pour ce nouveau système.
Le graphe actuel couvre la musique et les effets 2D (interface, notifications). Les sons spatiaux nécessiteront un routage avec `AudioEmitter` et `AudioListener` conservant ces mêmes réglages de canaux.

View File

@ -9,7 +9,9 @@
"crownstudio-roblox-ts-template:out/server/services/admin-message-service@AdminMessageService": "server/services/admin-message-service@AdminMessageService",
"crownstudio-roblox-ts-template:out/shared/admin-message@AdminMessageEvents": "shared/admin-message@AdminMessageEvents",
"crownstudio-roblox-ts-template:out/client/controllers/admin-message-controller@AdminMessageController": "client/controllers/admin-message-controller@AdminMessageController",
"crownstudio-roblox-ts-template:out/client/input/button-action@ButtonAction": "client/input/button-action@ButtonAction"
"crownstudio-roblox-ts-template:out/client/input/button-action@ButtonAction": "client/input/button-action@ButtonAction",
"crownstudio-roblox-ts-template:out/client/audio/mixer@AudioMixer": "client/audio/mixer@AudioMixer",
"crownstudio-roblox-ts-template:out/client/controllers/audio-controller@AudioController": "client/controllers/audio-controller@AudioController"
},
"metadata": {}
}

View File

@ -0,0 +1,12 @@
import { AudioChannel } from "./settings";
export interface AudioClip {
readonly assetId: string;
readonly volume?: number;
readonly playbackSpeed?: number;
}
export type AudioCatalog = Readonly<Record<AudioChannel, Readonly<Record<string, AudioClip>>>>;
// Add only audio assets that the experience is authorized to use.
export const AUDIO_CATALOG: AudioCatalog = { SFX: {}, Music: {} };

120
src/client/audio/mixer.ts Normal file
View File

@ -0,0 +1,120 @@
import { Players, SoundService } from "@rbxts/services";
import { Trove } from "@rbxts/trove";
import { AudioCatalog, AudioClip } from "./catalog";
import { AudioChannel, AudioSettings, audioSettings, selectAudioVolume } from "./settings";
export interface AudioPlayback {
readonly stop: () => void;
}
export class AudioMixer {
private readonly trove = new Trove();
private readonly root = new Instance("Folder");
private readonly channels = new Map<AudioChannel, AudioFader>();
private readonly master = new Instance("AudioFader");
private readonly active = new Set<AudioPlayback>();
private music?: AudioPlayback;
private musicName?: string;
private destroyed = false;
public constructor(private readonly catalog: AudioCatalog) {
this.root.Name = "LocalAudio";
this.root.Parent = SoundService;
this.trove.add(this.root);
this.master.Name = "Master";
this.master.Parent = this.root;
const output = new Instance("AudioDeviceOutput");
output.Name = "Output";
output.Player = Players.LocalPlayer;
output.Parent = this.root;
this.connect(this.master, output, this.root);
for (const channel of ["SFX", "Music"] as const) {
const fader = new Instance("AudioFader");
fader.Name = channel;
fader.Parent = this.root;
this.channels.set(channel, fader);
this.connect(fader, this.master, fader);
}
this.applySettings(audioSettings.getState());
this.trove.add(audioSettings.subscribe((settings) => this.applySettings(settings)));
}
public playSfx(name: string): AudioPlayback | undefined {
const clip = this.catalog.SFX[name];
return clip && !this.destroyed ? this.playClip("SFX", name, clip, false) : undefined;
}
public playMusic(name: string): AudioPlayback | undefined {
const clip = this.catalog.Music[name];
if (!clip || this.destroyed) return undefined;
if (this.musicName === name && this.music) return this.music;
this.stopMusic();
this.musicName = name;
this.music = this.playClip("Music", name, clip, true);
return this.music;
}
public stopMusic(): void {
this.music?.stop();
this.music = undefined;
this.musicName = undefined;
}
public destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
this.stopMusic();
for (const playback of this.active) playback.stop();
this.trove.destroy();
}
private playClip(
channel: AudioChannel,
name: string,
clip: AudioClip,
looping: boolean,
): AudioPlayback {
const cleanup = new Trove();
const player = new Instance("AudioPlayer");
player.Name = name;
player.Asset = clip.assetId;
player.Volume = clip.volume ?? 1;
player.PlaybackSpeed = clip.playbackSpeed ?? 1;
player.Looping = looping;
player.Parent = this.channels.get(channel)!;
cleanup.add(player);
this.connect(player, this.channels.get(channel)!, player);
let stopped = false;
const playback: AudioPlayback = {
stop: () => {
if (stopped) return;
stopped = true;
this.active.delete(playback);
if (this.music === playback) {
this.music = undefined;
this.musicName = undefined;
}
player.Stop();
cleanup.destroy();
},
};
cleanup.add(player.Ended.Connect(() => playback.stop()));
this.active.add(playback);
player.Play();
return playback;
}
private applySettings(settings: AudioSettings): void {
this.master.Volume = selectAudioVolume(settings, "Master");
for (const [channel, fader] of this.channels) {
fader.Volume = selectAudioVolume(settings, channel);
}
}
private connect(source: Instance, target: Instance, parent: Instance): void {
const wire = new Instance("Wire");
wire.SourceInstance = source;
wire.TargetInstance = target;
wire.Parent = parent;
}
}

View File

@ -0,0 +1,30 @@
import { createProducer } from "@rbxts/reflex";
export type AudioChannel = "SFX" | "Music";
export type AudioVolume = AudioChannel | "Master";
export interface AudioSettings {
readonly volumes: Readonly<Record<AudioVolume, number>>;
readonly muted: Readonly<Record<AudioVolume, boolean>>;
}
export const DEFAULT_AUDIO_SETTINGS: AudioSettings = {
volumes: { Master: 1, SFX: 1, Music: 1 },
muted: { Master: false, SFX: false, Music: false },
};
export const audioSettings = createProducer(DEFAULT_AUDIO_SETTINGS, {
setVolume: (state: AudioSettings, channel: AudioVolume, volume: number): AudioSettings => {
if (volume !== volume || math.abs(volume) === math.huge) return state;
return { ...state, volumes: { ...state.volumes, [channel]: math.clamp(volume, 0, 1) } };
},
setMuted: (state: AudioSettings, channel: AudioVolume, muted: boolean): AudioSettings => ({
...state,
muted: { ...state.muted, [channel]: muted },
}),
reset: (): AudioSettings => DEFAULT_AUDIO_SETTINGS,
});
export function selectAudioVolume(state: AudioSettings, channel: AudioVolume): number {
return state.muted[channel] ? 0 : state.volumes[channel];
}

View File

@ -0,0 +1,15 @@
import { Controller, OnStart } from "@flamework/core";
import { Trove } from "@rbxts/trove";
import { AUDIO_CATALOG } from "client/audio/catalog";
import { AudioMixer } from "client/audio/mixer";
@Controller()
export class AudioController implements OnStart {
public readonly mixer = new AudioMixer(AUDIO_CATALOG);
private readonly trove = new Trove();
public onStart(): void {
this.trove.add(() => this.mixer.destroy());
this.trove.attachToInstance(script);
}
}