Fix bugs and clean noise reduction

This commit is contained in:
2026-03-06 02:22:43 +01:00
parent 0ed9ca93d3
commit 2d84fbd91a
39 changed files with 3443 additions and 1544 deletions

View File

@@ -55,6 +55,16 @@ export class MediaManager {
/** Remote audio output volume (0-1). */
private remoteAudioVolume = VOLUME_MAX;
// -- Input gain pipeline (mic volume) --
/** The stream BEFORE gain is applied (for identity checks). */
private preGainStream: MediaStream | null = null;
private inputGainCtx: AudioContext | null = null;
private inputGainSourceNode: MediaStreamAudioSourceNode | null = null;
private inputGainNode: GainNode | null = null;
private inputGainDest: MediaStreamAudioDestinationNode | null = null;
/** Normalised 0-1 input gain (1 = 100%). */
private inputGainVolume = 1.0;
/** Voice-presence heartbeat timer. */
private voicePresenceTimer: ReturnType<typeof setInterval> | null = null;
@@ -69,7 +79,7 @@ export class MediaManager {
* whether the worklet is actually running. This lets us honour the
* preference even when it is set before the mic stream is acquired.
*/
private _noiseReductionDesired = false;
private _noiseReductionDesired = true;
// State tracked locally (the service exposes these via signals)
private isVoiceActive = false;
@@ -102,6 +112,10 @@ export class MediaManager {
getLocalStream(): MediaStream | null {
return this.localMediaStream;
}
/** Returns the raw microphone stream before processing, if available. */
getRawMicStream(): MediaStream | null {
return this.rawMicStream;
}
/** Whether voice is currently active (mic captured). */
getIsVoiceActive(): boolean {
return this.isVoiceActive;
@@ -152,7 +166,7 @@ export class MediaManager {
const mediaConstraints: MediaStreamConstraints = {
audio: {
echoCancellation: true,
noiseSuppression: true,
noiseSuppression: !this._noiseReductionDesired,
autoGainControl: true
},
video: false
@@ -177,6 +191,9 @@ export class MediaManager {
? await this.noiseReduction.enable(stream)
: stream;
// Apply input gain (mic volume) before sending to peers
this.applyInputGainToCurrentStream();
this.logger.logStream('localVoice', this.localMediaStream);
this.bindLocalTracksToAllPeers();
@@ -196,6 +213,7 @@ export class MediaManager {
*/
disableVoice(): void {
this.noiseReduction.disable();
this.teardownInputGain();
// Stop the raw mic tracks (the denoised stream's tracks are
// derived nodes and will stop once their source is gone).
@@ -241,6 +259,9 @@ export class MediaManager {
this.localMediaStream = stream;
}
// Apply input gain (mic volume) before sending to peers
this.applyInputGainToCurrentStream();
this.bindLocalTracksToAllPeers();
this.isVoiceActive = true;
this.voiceConnected$.next();
@@ -252,16 +273,10 @@ export class MediaManager {
* @param muted - Explicit state; if omitted, the current state is toggled.
*/
toggleMute(muted?: boolean): void {
if (this.localMediaStream) {
const audioTracks = this.localMediaStream.getAudioTracks();
const newMutedState = muted !== undefined ? muted : !this.isMicMuted;
const newMutedState = muted !== undefined ? muted : !this.isMicMuted;
audioTracks.forEach((track) => {
track.enabled = !newMutedState;
});
this.isMicMuted = newMutedState;
}
this.isMicMuted = newMutedState;
this.applyCurrentMuteState();
}
/**
@@ -294,6 +309,11 @@ export class MediaManager {
this.noiseReduction.isEnabled
);
// Do not update the browser's built-in noiseSuppression constraint on the
// live mic track here. Chromium may share the underlying capture source,
// which can leak the constraint change into other active streams. We only
// apply the browser constraint when the microphone stream is acquired.
if (shouldEnable === this.noiseReduction.isEnabled)
return;
@@ -318,6 +338,9 @@ export class MediaManager {
}
}
// Re-apply input gain to the (possibly new) stream
this.applyInputGainToCurrentStream();
// Propagate the new audio track to every peer connection
this.bindLocalTracksToAllPeers();
}
@@ -331,6 +354,32 @@ export class MediaManager {
this.remoteAudioVolume = Math.max(VOLUME_MIN, Math.min(VOLUME_MAX, volume));
}
/**
* Set the input (microphone) volume.
*
* If a local stream is active the gain node is updated in real time.
* If no stream exists yet the value is stored and applied on connect.
*
* @param volume - Normalised 0-1 (0 = silent, 1 = 100%).
*/
setInputVolume(volume: number): void {
this.inputGainVolume = Math.max(0, Math.min(1, volume));
if (this.inputGainNode) {
// Pipeline already exists - just update the gain value
this.inputGainNode.gain.value = this.inputGainVolume;
} else if (this.localMediaStream) {
// Stream is active but gain pipeline hasn't been created yet
this.applyInputGainToCurrentStream();
this.bindLocalTracksToAllPeers();
}
}
/** Get current input gain value (0-1). */
getInputVolume(): number {
return this.inputGainVolume;
}
/**
* Set the maximum audio bitrate on every active peer's audio sender.
*
@@ -525,8 +574,79 @@ export class MediaManager {
});
}
// -- Input gain helpers --
/**
* Route the current `localMediaStream` through a Web Audio GainNode so
* the microphone level can be adjusted without renegotiating peers.
*
* If a gain pipeline already exists for the same source stream the gain
* value is simply updated. Otherwise a new pipeline is created.
*/
private applyInputGainToCurrentStream(): void {
const stream = this.localMediaStream;
if (!stream)
return;
// If the source stream hasn't changed, just update gain
if (this.preGainStream === stream && this.inputGainNode && this.inputGainCtx) {
this.inputGainNode.gain.value = this.inputGainVolume;
return;
}
// Tear down the old pipeline (if any)
this.teardownInputGain();
// Build new pipeline: source → gain → destination
this.preGainStream = stream;
this.inputGainCtx = new AudioContext();
this.inputGainSourceNode = this.inputGainCtx.createMediaStreamSource(stream);
this.inputGainNode = this.inputGainCtx.createGain();
this.inputGainNode.gain.value = this.inputGainVolume;
this.inputGainDest = this.inputGainCtx.createMediaStreamDestination();
this.inputGainSourceNode.connect(this.inputGainNode);
this.inputGainNode.connect(this.inputGainDest);
// Replace localMediaStream with the gained stream
this.localMediaStream = this.inputGainDest.stream;
this.applyCurrentMuteState();
}
/** Keep the active outbound track aligned with the stored mute state. */
private applyCurrentMuteState(): void {
if (!this.localMediaStream)
return;
const enabled = !this.isMicMuted;
this.localMediaStream.getAudioTracks().forEach((track) => {
track.enabled = enabled;
});
}
/** Disconnect and close the input-gain AudioContext. */
private teardownInputGain(): void {
try {
this.inputGainSourceNode?.disconnect();
this.inputGainNode?.disconnect();
} catch { /* already disconnected */ }
if (this.inputGainCtx && this.inputGainCtx.state !== 'closed') {
this.inputGainCtx.close().catch(() => {});
}
this.inputGainCtx = null;
this.inputGainSourceNode = null;
this.inputGainNode = null;
this.inputGainDest = null;
this.preGainStream = null;
}
/** Clean up all resources. */
destroy(): void {
this.teardownInputGain();
this.disableVoice();
this.stopVoiceHeartbeat();
this.noiseReduction.destroy();