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