refactor: stricter domain: voice-connection
This commit is contained in:
+284
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* VoiceActivityService - monitors audio levels for local microphone
|
||||
* and remote peer streams, exposing per-user "speaking" state as
|
||||
* reactive Angular signals.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const speaking = voiceActivity.isSpeaking(userId);
|
||||
* // speaking() => true when the user's audio level exceeds the threshold
|
||||
*
|
||||
* const volume = voiceActivity.volume(userId);
|
||||
* // volume() => normalised 0-1 audio level
|
||||
* ```
|
||||
*
|
||||
* Internally uses the Web Audio API ({@link AudioContext} +
|
||||
* {@link AnalyserNode}) per tracked stream, with a single
|
||||
* `requestAnimationFrame` poll loop.
|
||||
*/
|
||||
import {
|
||||
Injectable,
|
||||
signal,
|
||||
computed,
|
||||
inject,
|
||||
OnDestroy,
|
||||
Signal
|
||||
} from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { VoiceConnectionFacade } from '../facades/voice-connection.facade';
|
||||
import { DebuggingService } from '../../../../core/services/debugging.service';
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, max-statements-per-line */
|
||||
|
||||
const SPEAKING_THRESHOLD = 0.015;
|
||||
const SILENT_FRAME_GRACE = 8;
|
||||
const FFT_SIZE = 256;
|
||||
|
||||
interface TrackedStream {
|
||||
ctx: AudioContext;
|
||||
sources: MediaStreamAudioSourceNode[];
|
||||
analyser: AnalyserNode;
|
||||
dataArray: Uint8Array<ArrayBuffer>;
|
||||
volumeSignal: ReturnType<typeof signal<number>>;
|
||||
speakingSignal: ReturnType<typeof signal<boolean>>;
|
||||
silentFrames: number;
|
||||
stream: MediaStream;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoiceActivityService implements OnDestroy {
|
||||
private readonly voiceConnection = inject(VoiceConnectionFacade);
|
||||
private readonly debugging = inject(DebuggingService);
|
||||
|
||||
private readonly tracked = new Map<string, TrackedStream>();
|
||||
private animFrameId: number | null = null;
|
||||
private readonly subs: Subscription[] = [];
|
||||
private readonly _speakingMap = signal<ReadonlyMap<string, boolean>>(new Map());
|
||||
|
||||
readonly speakingMap: Signal<ReadonlyMap<string, boolean>> = this._speakingMap;
|
||||
|
||||
constructor() {
|
||||
this.subs.push(
|
||||
this.voiceConnection.onRemoteStream.subscribe(({ peerId }) => {
|
||||
const voiceStream = this.voiceConnection.getRemoteVoiceStream(peerId);
|
||||
|
||||
if (!voiceStream) {
|
||||
this.untrackStream(peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.trackStream(peerId, voiceStream);
|
||||
})
|
||||
);
|
||||
|
||||
this.subs.push(
|
||||
this.voiceConnection.onPeerDisconnected.subscribe((peerId) => {
|
||||
this.untrackStream(peerId);
|
||||
})
|
||||
);
|
||||
|
||||
this.subs.push(
|
||||
this.voiceConnection.onVoiceConnected.subscribe(() => {
|
||||
this.ensureAllRemoteStreamsTracked();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
trackLocalMic(userId: string, stream: MediaStream): void {
|
||||
this.trackStream(userId, stream);
|
||||
}
|
||||
|
||||
untrackLocalMic(userId: string): void {
|
||||
this.untrackStream(userId);
|
||||
}
|
||||
|
||||
isSpeaking(userId: string): Signal<boolean> {
|
||||
const entry = this.tracked.get(userId);
|
||||
|
||||
if (entry)
|
||||
return entry.speakingSignal.asReadonly();
|
||||
|
||||
return computed(() => this._speakingMap().get(userId) ?? false);
|
||||
}
|
||||
|
||||
volume(userId: string): Signal<number> {
|
||||
const entry = this.tracked.get(userId);
|
||||
|
||||
if (entry)
|
||||
return entry.volumeSignal.asReadonly();
|
||||
|
||||
return computed(() => 0);
|
||||
}
|
||||
|
||||
trackStream(id: string, stream: MediaStream): void {
|
||||
const existing = this.tracked.get(id);
|
||||
const audioTracks = stream.getAudioTracks().filter((track) => track.readyState === 'live');
|
||||
|
||||
if (existing && existing.stream === stream)
|
||||
return;
|
||||
|
||||
if (existing)
|
||||
this.disposeEntry(existing);
|
||||
|
||||
if (audioTracks.length === 0) {
|
||||
this.tracked.delete(id);
|
||||
this.publishSpeakingMap();
|
||||
|
||||
if (this.tracked.size === 0)
|
||||
this.stopPolling();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = new AudioContext();
|
||||
|
||||
if (ctx.state === 'suspended')
|
||||
void ctx.resume();
|
||||
|
||||
const analyser = ctx.createAnalyser();
|
||||
const sources = audioTracks.map((track) => ctx.createMediaStreamSource(new MediaStream([track])));
|
||||
|
||||
analyser.fftSize = FFT_SIZE;
|
||||
sources.forEach((source) => source.connect(analyser));
|
||||
|
||||
const dataArray = new Uint8Array(analyser.fftSize) as Uint8Array<ArrayBuffer>;
|
||||
const volumeSignal = signal(0);
|
||||
const speakingSignal = signal(false);
|
||||
|
||||
this.tracked.set(id, {
|
||||
ctx,
|
||||
sources,
|
||||
analyser,
|
||||
dataArray,
|
||||
volumeSignal,
|
||||
speakingSignal,
|
||||
silentFrames: 0,
|
||||
stream
|
||||
});
|
||||
|
||||
this.ensurePolling();
|
||||
}
|
||||
|
||||
untrackStream(id: string): void {
|
||||
const entry = this.tracked.get(id);
|
||||
|
||||
if (!entry)
|
||||
return;
|
||||
|
||||
if (entry.speakingSignal()) {
|
||||
this.reportSpeakingState(id, false, 0);
|
||||
}
|
||||
|
||||
this.disposeEntry(entry);
|
||||
this.tracked.delete(id);
|
||||
this.publishSpeakingMap();
|
||||
|
||||
if (this.tracked.size === 0)
|
||||
this.stopPolling();
|
||||
}
|
||||
|
||||
private ensureAllRemoteStreamsTracked(): void {
|
||||
const peers = this.voiceConnection.getConnectedPeers();
|
||||
|
||||
for (const peerId of peers) {
|
||||
const stream = this.voiceConnection.getRemoteVoiceStream(peerId);
|
||||
|
||||
if (stream) {
|
||||
this.trackStream(peerId, stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ensurePolling(): void {
|
||||
if (this.animFrameId !== null)
|
||||
return;
|
||||
|
||||
this.poll();
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.animFrameId !== null) {
|
||||
cancelAnimationFrame(this.animFrameId);
|
||||
this.animFrameId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private poll = (): void => {
|
||||
let mapDirty = false;
|
||||
|
||||
this.tracked.forEach((entry, id) => {
|
||||
const { analyser, dataArray, volumeSignal, speakingSignal } = entry;
|
||||
|
||||
analyser.getByteTimeDomainData(dataArray);
|
||||
|
||||
let sumSquares = 0;
|
||||
|
||||
for (let sampleIndex = 0; sampleIndex < dataArray.length; sampleIndex++) {
|
||||
const normalised = (dataArray[sampleIndex] - 128) / 128;
|
||||
|
||||
sumSquares += normalised * normalised;
|
||||
}
|
||||
|
||||
const rms = Math.sqrt(sumSquares / dataArray.length);
|
||||
|
||||
volumeSignal.set(rms);
|
||||
|
||||
const wasSpeaking = speakingSignal();
|
||||
|
||||
if (rms >= SPEAKING_THRESHOLD) {
|
||||
entry.silentFrames = 0;
|
||||
|
||||
if (!wasSpeaking) {
|
||||
speakingSignal.set(true);
|
||||
this.reportSpeakingState(id, true, rms);
|
||||
mapDirty = true;
|
||||
}
|
||||
} else {
|
||||
entry.silentFrames++;
|
||||
|
||||
if (wasSpeaking && entry.silentFrames >= SILENT_FRAME_GRACE) {
|
||||
speakingSignal.set(false);
|
||||
this.reportSpeakingState(id, false, rms);
|
||||
mapDirty = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (mapDirty)
|
||||
this.publishSpeakingMap();
|
||||
|
||||
this.animFrameId = requestAnimationFrame(this.poll);
|
||||
};
|
||||
|
||||
private publishSpeakingMap(): void {
|
||||
const map = new Map<string, boolean>();
|
||||
|
||||
this.tracked.forEach((entry, id) => {
|
||||
map.set(id, entry.speakingSignal());
|
||||
});
|
||||
|
||||
this._speakingMap.set(map);
|
||||
}
|
||||
|
||||
private reportSpeakingState(peerId: string, isSpeaking: boolean, volume: number): void {
|
||||
this.debugging.recordEvent('webrtc:voice-activity', 'Speaking state changed', {
|
||||
peerId,
|
||||
isSpeaking,
|
||||
volume: Number(volume.toFixed(3))
|
||||
});
|
||||
}
|
||||
|
||||
private disposeEntry(entry: TrackedStream): void {
|
||||
entry.sources.forEach((source) => {
|
||||
try { source.disconnect(); } catch { /* already disconnected */ }
|
||||
});
|
||||
|
||||
try { entry.ctx.close(); } catch { /* already closed */ }
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopPolling();
|
||||
this.tracked.forEach((entry) => this.disposeEntry(entry));
|
||||
this.tracked.clear();
|
||||
this.subs.forEach((subscription) => subscription.unsubscribe());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user