audio: add master volume on -/= and move effect value to shift -/=; route connect flow notices to chat buffer

This commit is contained in:
Jage9
2026-02-22 18:33:55 -05:00
parent 12d3c62916
commit f2734659d2
9 changed files with 93 additions and 13 deletions

View File

@@ -128,6 +128,10 @@
},
{
"keys": "Dash or Equals",
"description": "Lower/raise master volume"
},
{
"keys": "Underscore or Plus",
"description": "Lower/raise active effect value"
},
{

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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,

View File

@@ -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';

View File

@@ -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);

View File

@@ -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) || '';
}

View File

@@ -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`)