diff --git a/client/index.html b/client/index.html index b6f6e30..3cfd7fb 100644 --- a/client/index.html +++ b/client/index.html @@ -61,6 +61,7 @@

P: Ping server

M: Mute/unmute

Shift+M: Toggle stereo/mono output

+

! (Shift+1): Toggle loopback monitor

E: Cycle voice effect

Dash or Equals: Lower/raise active effect value

diff --git a/client/public/version.js b/client/public/version.js index d3311c6..8113647 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,3 +1,3 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.20 R59"; +window.CHGRID_WEB_VERSION = "2026.02.20 R60"; diff --git a/client/src/audio/audioEngine.ts b/client/src/audio/audioEngine.ts index 32a59be..07ded1d 100644 --- a/client/src/audio/audioEngine.ts +++ b/client/src/audio/audioEngine.ts @@ -38,6 +38,8 @@ export class AudioEngine { private outboundInputGain: GainNode | null = null; private outboundDestination: MediaStreamAudioDestinationNode | null = null; private outboundEffectRuntime: EffectRuntime | null = null; + private loopbackEnabled = false; + private loopbackRuntime: EffectRuntime | null = null; private outputMode: OutputMode = 'stereo'; private effectIndex = EFFECT_SEQUENCE.findIndex((effect) => effect.id === 'off'); private readonly effectValues: Record = { @@ -160,6 +162,12 @@ export class AudioEngine { return this.outputMode; } + toggleLoopback(): boolean { + this.loopbackEnabled = !this.loopbackEnabled; + this.rebuildOutboundEffectGraph(); + return this.loopbackEnabled; + } + async attachRemoteStream( peer: SpatialPeerRuntime, stream: MediaStream, @@ -321,6 +329,19 @@ export class AudioEngine { effect, this.effectValues[effect], ); + this.rebuildLoopbackGraph(effect, this.effectValues[effect]); + } + + private rebuildLoopbackGraph(effect: EffectId, effectValue: number): void { + if (!this.audioCtx || !this.outboundInputGain) { + return; + } + disconnectEffectRuntime(this.loopbackRuntime); + this.loopbackRuntime = null; + if (!this.loopbackEnabled) { + return; + } + this.loopbackRuntime = connectEffectChain(this.audioCtx, this.outboundInputGain, this.audioCtx.destination, effect, effectValue); } private clampLevel(value: number): number { diff --git a/client/src/main.ts b/client/src/main.ts index b0b3c97..9f16d37 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -988,6 +988,13 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void { return; } + if (code === 'Digit1' && shiftKey) { + const enabled = audio.toggleLoopback(); + updateStatus(enabled ? 'Loopback on.' : 'Loopback off.'); + audio.sfxUiBlip(); + return; + } + if (code === 'KeyE') { const effect = audio.cycleOutboundEffect(); updateStatus(effect.label);