diff --git a/client/public/help.json b/client/public/help.json index 3adb94c..d66f2d3 100644 --- a/client/public/help.json +++ b/client/public/help.json @@ -128,6 +128,10 @@ }, { "keys": "Dash or Equals", + "description": "Lower/raise master volume" + }, + { + "keys": "Underscore or Plus", "description": "Lower/raise active effect value" }, { diff --git a/client/public/version.js b/client/public/version.js index 13b8d21..6e528a1 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.22 R162"; +window.CHGRID_WEB_VERSION = "2026.02.22 R163"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/audio/audioEngine.ts b/client/src/audio/audioEngine.ts index 0c1be6d..db86bc0 100644 --- a/client/src/audio/audioEngine.ts +++ b/client/src/audio/audioEngine.ts @@ -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(); private readonly sampleLoaders = new Map>(); @@ -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 = { @@ -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 { diff --git a/client/src/audio/itemEmitRuntime.ts b/client/src/audio/itemEmitRuntime.ts index a9530a5..d433357 100644 --- a/client/src/audio/itemEmitRuntime.ts +++ b/client/src/audio/itemEmitRuntime.ts @@ -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); diff --git a/client/src/audio/radioStationRuntime.ts b/client/src/audio/radioStationRuntime.ts index f4e309e..3086ee5 100644 --- a/client/src/audio/radioStationRuntime.ts +++ b/client/src/audio/radioStationRuntime.ts @@ -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, diff --git a/client/src/input/mainCommandRouter.ts b/client/src/input/mainCommandRouter.ts index 5266531..d2a5477 100644 --- a/client/src/input/mainCommandRouter.ts +++ b/client/src/input/mainCommandRouter.ts @@ -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'; diff --git a/client/src/main.ts b/client/src/main.ts index 7fc8c87..072ed88 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -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 { 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); diff --git a/client/src/settings/settingsStore.ts b/client/src/settings/settingsStore.ts index 4f5cb1a..84dfb5d 100644 --- a/client/src/settings/settingsStore.ts +++ b/client/src/settings/settingsStore.ts @@ -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) || ''; } diff --git a/docs/controls.md b/docs/controls.md index 9070515..7bc815b 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -41,7 +41,8 @@ This document is the authoritative keymap for the client. - `3`: Toggle media layer (radio) - `4`: Toggle world layer (other-user world sounds) - `E`: Effect select menu -- `-` / `=`: Lower/raise active effect value +- `-` / `=`: Lower/raise master volume +- `_` / `+` (`Shift+-` / `Shift+=`): Lower/raise active effect value ## Text Entry Modes (`nickname`, `chat`, `itemPropertyEdit`)