audio: add master volume on -/= and move effect value to shift -/=; route connect flow notices to chat buffer
This commit is contained in:
@@ -31,6 +31,7 @@ type OutputMode = 'stereo' | 'mono';
|
||||
|
||||
export class AudioEngine {
|
||||
private audioCtx: AudioContext | null = null;
|
||||
private masterGainNode: GainNode | null = null;
|
||||
private sfxGainNode: GainNode | null = null;
|
||||
private readonly sampleCache = new Map<string, AudioBuffer>();
|
||||
private readonly sampleLoaders = new Map<string, Promise<AudioBuffer>>();
|
||||
@@ -43,6 +44,7 @@ export class AudioEngine {
|
||||
private loopbackEnabled = false;
|
||||
private loopbackRuntime: EffectRuntime | null = null;
|
||||
private outputMode: OutputMode = 'stereo';
|
||||
private masterVolume = 50;
|
||||
private voiceLayerEnabled = true;
|
||||
private effectIndex = EFFECT_SEQUENCE.findIndex((effect) => effect.id === 'off');
|
||||
private readonly effectValues: Record<EffectId, number> = {
|
||||
@@ -61,8 +63,11 @@ export class AudioEngine {
|
||||
(window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!Ctor) return;
|
||||
this.audioCtx = new Ctor();
|
||||
this.masterGainNode = this.audioCtx.createGain();
|
||||
this.masterGainNode.gain.value = this.masterVolume / 100;
|
||||
this.masterGainNode.connect(this.audioCtx.destination);
|
||||
this.sfxGainNode = this.audioCtx.createGain();
|
||||
this.sfxGainNode.connect(this.audioCtx.destination);
|
||||
this.sfxGainNode.connect(this.masterGainNode);
|
||||
}
|
||||
if (this.audioCtx.state === 'suspended') {
|
||||
await this.audioCtx.resume();
|
||||
@@ -73,6 +78,10 @@ export class AudioEngine {
|
||||
return this.audioCtx;
|
||||
}
|
||||
|
||||
getOutputDestinationNode(): AudioNode | null {
|
||||
return this.masterGainNode ?? this.audioCtx?.destination ?? null;
|
||||
}
|
||||
|
||||
supportsStereoPanner(): boolean {
|
||||
return !!this.audioCtx && typeof this.audioCtx.createStereoPanner === 'function';
|
||||
}
|
||||
@@ -168,6 +177,23 @@ export class AudioEngine {
|
||||
this.outputMode = mode;
|
||||
}
|
||||
|
||||
setMasterVolume(value: number): number {
|
||||
const next = Math.max(0, Math.min(100, Number.isFinite(value) ? Math.round(value) : 50));
|
||||
this.masterVolume = next;
|
||||
if (this.masterGainNode && this.audioCtx) {
|
||||
this.masterGainNode.gain.setValueAtTime(next / 100, this.audioCtx.currentTime);
|
||||
}
|
||||
return this.masterVolume;
|
||||
}
|
||||
|
||||
adjustMasterVolume(step: number): number {
|
||||
return this.setMasterVolume(this.masterVolume + step);
|
||||
}
|
||||
|
||||
getMasterVolume(): number {
|
||||
return this.masterVolume;
|
||||
}
|
||||
|
||||
toggleOutputMode(): OutputMode {
|
||||
this.outputMode = this.outputMode === 'stereo' ? 'mono' : 'stereo';
|
||||
return this.outputMode;
|
||||
@@ -245,11 +271,11 @@ export class AudioEngine {
|
||||
if (this.supportsStereoPanner()) {
|
||||
pannerNode = this.audioCtx.createStereoPanner();
|
||||
if (this.voiceLayerEnabled) {
|
||||
gainNode.connect(pannerNode).connect(this.audioCtx.destination);
|
||||
gainNode.connect(pannerNode).connect(this.masterGainNode ?? this.audioCtx.destination);
|
||||
}
|
||||
} else {
|
||||
if (this.voiceLayerEnabled) {
|
||||
gainNode.connect(this.audioCtx.destination);
|
||||
gainNode.connect(this.masterGainNode ?? this.audioCtx.destination);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,7 +428,13 @@ export class AudioEngine {
|
||||
if (!this.loopbackEnabled) {
|
||||
return;
|
||||
}
|
||||
this.loopbackRuntime = connectEffectChain(this.audioCtx, this.outboundInputGain, this.audioCtx.destination, effect, effectValue);
|
||||
this.loopbackRuntime = connectEffectChain(
|
||||
this.audioCtx,
|
||||
this.outboundInputGain,
|
||||
this.masterGainNode ?? this.audioCtx.destination,
|
||||
effect,
|
||||
effectValue,
|
||||
);
|
||||
}
|
||||
|
||||
private clampLevel(value: number): number {
|
||||
|
||||
@@ -137,11 +137,12 @@ export class ItemEmitRuntime {
|
||||
const initialRates = resolveEmitRates(item);
|
||||
setElementPreservesPitch(element, initialRates.preservePitch);
|
||||
element.playbackRate = initialRates.playbackRate;
|
||||
const destination = this.audio.getOutputDestinationNode() ?? audioCtx.destination;
|
||||
if (this.audio.supportsStereoPanner()) {
|
||||
panner = audioCtx.createStereoPanner();
|
||||
gain.connect(panner).connect(audioCtx.destination);
|
||||
gain.connect(panner).connect(destination);
|
||||
} else {
|
||||
gain.connect(audioCtx.destination);
|
||||
gain.connect(destination);
|
||||
}
|
||||
this.outputs.set(item.id, { soundUrl, element, source, effectInput, effectRuntime, effect, effectValue, gain, panner });
|
||||
void element.play().catch(() => undefined);
|
||||
|
||||
@@ -361,12 +361,13 @@ export class RadioStationRuntime {
|
||||
const effect = normalizeRadioEffect(item.params.mediaEffect);
|
||||
const effectValue = normalizeRadioEffectValue(item.params.mediaEffectValue);
|
||||
const effectRuntime = connectEffectChain(audioCtx, effectInput, gain, effect, effectValue);
|
||||
const destination = this.audio.getOutputDestinationNode() ?? audioCtx.destination;
|
||||
let panner: StereoPannerNode | null = null;
|
||||
if (this.audio.supportsStereoPanner()) {
|
||||
panner = audioCtx.createStereoPanner();
|
||||
gain.connect(panner).connect(audioCtx.destination);
|
||||
gain.connect(panner).connect(destination);
|
||||
} else {
|
||||
gain.connect(audioCtx.destination);
|
||||
gain.connect(destination);
|
||||
}
|
||||
this.itemRadioOutputs.set(item.id, {
|
||||
streamUrl,
|
||||
|
||||
@@ -10,6 +10,8 @@ export type MainModeCommand =
|
||||
| 'toggleItemLayer'
|
||||
| 'toggleMediaLayer'
|
||||
| 'toggleWorldLayer'
|
||||
| 'masterVolumeUp'
|
||||
| 'masterVolumeDown'
|
||||
| 'openEffectSelect'
|
||||
| 'effectValueUp'
|
||||
| 'effectValueDown'
|
||||
@@ -43,8 +45,10 @@ export function resolveMainModeCommand(code: string, shiftKey: boolean): MainMod
|
||||
if (code === 'Digit3') return 'toggleMediaLayer';
|
||||
if (code === 'Digit4') return 'toggleWorldLayer';
|
||||
if (code === 'KeyE') return shiftKey ? null : 'openEffectSelect';
|
||||
if (code === 'Equal' || code === 'NumpadAdd') return 'effectValueUp';
|
||||
if (code === 'Minus' || code === 'NumpadSubtract') return 'effectValueDown';
|
||||
if (code === 'Equal') return shiftKey ? 'effectValueUp' : 'masterVolumeUp';
|
||||
if (code === 'Minus') return shiftKey ? 'effectValueDown' : 'masterVolumeDown';
|
||||
if (code === 'NumpadAdd') return 'masterVolumeUp';
|
||||
if (code === 'NumpadSubtract') return 'masterVolumeDown';
|
||||
if (code === 'KeyC') return shiftKey ? null : 'speakCoordinates';
|
||||
if (code === 'KeyV') return shiftKey ? 'calibrateMicrophone' : 'openMicGainEdit';
|
||||
if (code === 'Enter') return 'useItem';
|
||||
|
||||
@@ -241,6 +241,7 @@ audio.setOutputMode(outputMode);
|
||||
loadEffectLevels();
|
||||
loadAudioLayerState();
|
||||
loadMicInputGain();
|
||||
loadMasterVolume();
|
||||
void loadHelp();
|
||||
void loadChangelog();
|
||||
|
||||
@@ -469,6 +470,21 @@ function persistMicInputGain(value: number): void {
|
||||
settings.saveMicInputGain(value);
|
||||
}
|
||||
|
||||
/** Loads persisted master output volume and applies default when missing. */
|
||||
function loadMasterVolume(): void {
|
||||
const parsed = settings.loadMasterVolume();
|
||||
if (parsed === null) {
|
||||
audio.setMasterVolume(50);
|
||||
return;
|
||||
}
|
||||
audio.setMasterVolume(parsed);
|
||||
}
|
||||
|
||||
/** Persists master output volume to local storage. */
|
||||
function persistMasterVolume(value: number): void {
|
||||
settings.saveMasterVolume(value);
|
||||
}
|
||||
|
||||
/** Applies current layer toggles to peer voice, media streams, and item emitters. */
|
||||
async function applyAudioLayerState(): Promise<void> {
|
||||
audio.setVoiceLayerEnabled(audioLayers.voice);
|
||||
@@ -1101,7 +1117,7 @@ function getConnectionFlowDeps(): ConnectFlowDeps {
|
||||
state,
|
||||
dom,
|
||||
sanitizeName,
|
||||
updateStatus,
|
||||
updateStatus: (message) => pushChatMessage(message),
|
||||
updateConnectAvailability,
|
||||
settingsSaveNickname: (value) => settings.saveNickname(value),
|
||||
mediaIsConnecting: () => mediaSession.isConnecting(),
|
||||
@@ -1266,6 +1282,15 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
||||
case 'toggleWorldLayer':
|
||||
toggleAudioLayer('world');
|
||||
return;
|
||||
case 'masterVolumeUp':
|
||||
case 'masterVolumeDown': {
|
||||
const step = command === 'masterVolumeUp' ? 5 : -5;
|
||||
const next = audio.adjustMasterVolume(step);
|
||||
persistMasterVolume(next);
|
||||
updateStatus(`Master volume ${next}`);
|
||||
audio.sfxUiBlip();
|
||||
return;
|
||||
}
|
||||
case 'openEffectSelect': {
|
||||
const currentEffect = audio.getCurrentEffect();
|
||||
const currentIndex = EFFECT_SEQUENCE.findIndex((effect) => effect.id === currentEffect.id);
|
||||
|
||||
@@ -8,6 +8,7 @@ const AUDIO_OUTPUT_NAME_STORAGE_KEY = 'chatGridAudioOutputDeviceName';
|
||||
const AUDIO_OUTPUT_MODE_STORAGE_KEY = 'chatGridAudioOutputMode';
|
||||
const AUDIO_LAYER_STATE_STORAGE_KEY = 'chatGridAudioLayers';
|
||||
const MIC_INPUT_GAIN_STORAGE_KEY = 'chatGridMicInputGain';
|
||||
const MASTER_VOLUME_STORAGE_KEY = 'chatGridMasterVolume';
|
||||
const NICKNAME_STORAGE_KEY = 'spatialChatNickname';
|
||||
|
||||
type DevicePreference = {
|
||||
@@ -71,6 +72,17 @@ export class SettingsStore {
|
||||
localStorage.setItem(MIC_INPUT_GAIN_STORAGE_KEY, String(value));
|
||||
}
|
||||
|
||||
loadMasterVolume(): number | null {
|
||||
const raw = localStorage.getItem(MASTER_VOLUME_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
saveMasterVolume(value: number): void {
|
||||
localStorage.setItem(MASTER_VOLUME_STORAGE_KEY, String(value));
|
||||
}
|
||||
|
||||
loadNickname(): string {
|
||||
return localStorage.getItem(NICKNAME_STORAGE_KEY) || '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user