From cf6ff999fada151a4f22366f0a458704fee6c649 Mon Sep 17 00:00:00 2001 From: Infuseting Date: Wed, 14 Oct 2026 12:11:10 +0200 Subject: [PATCH] feat(audio): separate SFX and music with reactive volume settings --- docs/audio.md | 36 +++++++ flamework.build | 4 +- src/client/audio/catalog.ts | 12 +++ src/client/audio/mixer.ts | 120 +++++++++++++++++++++ src/client/audio/settings.ts | 30 ++++++ src/client/controllers/audio-controller.ts | 15 +++ 6 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 docs/audio.md create mode 100644 src/client/audio/catalog.ts create mode 100644 src/client/audio/mixer.ts create mode 100644 src/client/audio/settings.ts create mode 100644 src/client/controllers/audio-controller.ts diff --git a/docs/audio.md b/docs/audio.md new file mode 100644 index 0000000..14f4cc0 --- /dev/null +++ b/docs/audio.md @@ -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. diff --git a/flamework.build b/flamework.build index f7724ca..b308eda 100644 --- a/flamework.build +++ b/flamework.build @@ -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": {} } \ No newline at end of file diff --git a/src/client/audio/catalog.ts b/src/client/audio/catalog.ts new file mode 100644 index 0000000..54a3302 --- /dev/null +++ b/src/client/audio/catalog.ts @@ -0,0 +1,12 @@ +import { AudioChannel } from "./settings"; + +export interface AudioClip { + readonly assetId: string; + readonly volume?: number; + readonly playbackSpeed?: number; +} + +export type AudioCatalog = Readonly>>>; + +// Add only audio assets that the experience is authorized to use. +export const AUDIO_CATALOG: AudioCatalog = { SFX: {}, Music: {} }; diff --git a/src/client/audio/mixer.ts b/src/client/audio/mixer.ts new file mode 100644 index 0000000..e257509 --- /dev/null +++ b/src/client/audio/mixer.ts @@ -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(); + private readonly master = new Instance("AudioFader"); + private readonly active = new Set(); + 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; + } +} diff --git a/src/client/audio/settings.ts b/src/client/audio/settings.ts new file mode 100644 index 0000000..16512b8 --- /dev/null +++ b/src/client/audio/settings.ts @@ -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>; + readonly muted: Readonly>; +} + +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]; +} diff --git a/src/client/controllers/audio-controller.ts b/src/client/controllers/audio-controller.ts new file mode 100644 index 0000000..76453ee --- /dev/null +++ b/src/client/controllers/audio-controller.ts @@ -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); + } +}