forked from Crown-Studio/roblox-ts-template
feat(audio): separate SFX and music with reactive volume settings
This commit is contained in:
parent
6464adde57
commit
cf6ff999fa
|
|
@ -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.
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
|
|
@ -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: {} };
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue