Move toju-app into own its folder

This commit is contained in:
2026-03-29 23:55:24 +02:00
parent 0467a7b612
commit 8162e0444a
287 changed files with 42 additions and 34 deletions
@@ -0,0 +1,262 @@
/**
* 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 './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);
})
);
}
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();
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 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());
}
}
@@ -0,0 +1,94 @@
import { Injectable, inject } from '@angular/core';
import { ChatEvent } from '../../../shared-kernel';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { LatencyProfile } from '../domain/voice-connection.models';
@Injectable({ providedIn: 'root' })
export class VoiceConnectionFacade {
readonly isVoiceConnected = inject(RealtimeSessionFacade).isVoiceConnected;
readonly isMuted = inject(RealtimeSessionFacade).isMuted;
readonly isDeafened = inject(RealtimeSessionFacade).isDeafened;
readonly isNoiseReductionEnabled = inject(RealtimeSessionFacade).isNoiseReductionEnabled;
readonly hasConnectionError = inject(RealtimeSessionFacade).hasConnectionError;
readonly connectionErrorMessage = inject(RealtimeSessionFacade).connectionErrorMessage;
readonly shouldShowConnectionError = inject(RealtimeSessionFacade).shouldShowConnectionError;
readonly peerLatencies = inject(RealtimeSessionFacade).peerLatencies;
readonly onRemoteStream = inject(RealtimeSessionFacade).onRemoteStream;
readonly onPeerConnected = inject(RealtimeSessionFacade).onPeerConnected;
readonly onPeerDisconnected = inject(RealtimeSessionFacade).onPeerDisconnected;
readonly onVoiceConnected = inject(RealtimeSessionFacade).onVoiceConnected;
private readonly realtime = inject(RealtimeSessionFacade);
async ensureSignalingConnected(timeoutMs?: number): Promise<boolean> {
return await this.realtime.ensureSignalingConnected(timeoutMs);
}
broadcastMessage(event: ChatEvent): void {
this.realtime.broadcastMessage(event);
}
getConnectedPeers(): string[] {
return this.realtime.getConnectedPeers();
}
getRemoteVoiceStream(peerId: string): MediaStream | null {
return this.realtime.getRemoteVoiceStream(peerId);
}
getLocalStream(): MediaStream | null {
return this.realtime.getLocalStream();
}
getRawMicStream(): MediaStream | null {
return this.realtime.getRawMicStream();
}
async enableVoice(): Promise<MediaStream> {
return await this.realtime.enableVoice();
}
disableVoice(): void {
this.realtime.disableVoice();
}
async setLocalStream(stream: MediaStream): Promise<void> {
await this.realtime.setLocalStream(stream);
}
toggleMute(muted?: boolean): void {
this.realtime.toggleMute(muted);
}
toggleDeafen(deafened?: boolean): void {
this.realtime.toggleDeafen(deafened);
}
async toggleNoiseReduction(enabled?: boolean): Promise<void> {
await this.realtime.toggleNoiseReduction(enabled);
}
setOutputVolume(volume: number): void {
this.realtime.setOutputVolume(volume);
}
setInputVolume(volume: number): void {
this.realtime.setInputVolume(volume);
}
async setAudioBitrate(kbps: number): Promise<void> {
await this.realtime.setAudioBitrate(kbps);
}
async setLatencyProfile(profile: LatencyProfile): Promise<void> {
await this.realtime.setLatencyProfile(profile);
}
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
this.realtime.startVoiceHeartbeat(roomId, serverId);
}
stopVoiceHeartbeat(): void {
this.realtime.stopVoiceHeartbeat();
}
}
@@ -0,0 +1,370 @@
import {
Injectable,
effect,
inject
} from '@angular/core';
import { STORAGE_KEY_USER_VOLUMES } from '../../../core/constants';
import { ScreenShareFacade } from '../../../domains/screen-share';
import { VoiceConnectionFacade } from './voice-connection.facade';
export interface PlaybackOptions {
isConnected: boolean;
outputVolume: number;
isDeafened: boolean;
}
/**
* Per-peer Web Audio pipeline that routes the remote MediaStream
* through a GainNode so volume can be amplified beyond 100% (up to 200%).
*
* Chrome/Electron workaround: a muted HTMLAudioElement is attached to
* the stream first so that `createMediaStreamSource` actually outputs
* audio. The priming element itself is silent; audible output is routed
* through a separate output element fed by
* `GainNode -> MediaStreamDestination` so output-device switching stays
* reliable during Linux screen sharing.
*/
interface PeerAudioPipeline {
audioElement: HTMLAudioElement;
outputElement: HTMLAudioElement;
context: AudioContext;
sourceNodes: MediaStreamAudioSourceNode[];
gainNode: GainNode;
}
@Injectable({ providedIn: 'root' })
export class VoicePlaybackService {
private readonly voiceConnection = inject(VoiceConnectionFacade);
private readonly screenShare = inject(ScreenShareFacade);
private peerPipelines = new Map<string, PeerAudioPipeline>();
private pendingRemoteStreams = new Map<string, MediaStream>();
private rawRemoteStreams = new Map<string, MediaStream>();
private userVolumes = new Map<string, number>();
private userMuted = new Map<string, boolean>();
private preferredOutputDeviceId = 'default';
private temporaryOutputDeviceId: string | null = null;
private masterVolume = 1;
private deafened = false;
private captureEchoSuppressed = false;
constructor() {
this.loadPersistedVolumes();
effect(() => {
this.captureEchoSuppressed = this.screenShare.isScreenShareRemotePlaybackSuppressed();
this.recalcAllGains();
});
effect(() => {
this.temporaryOutputDeviceId = this.screenShare.forceDefaultRemotePlaybackOutput()
? 'default'
: null;
void this.applyEffectiveOutputDeviceToAllPipelines();
});
this.voiceConnection.onRemoteStream.subscribe(({ peerId }) => {
const voiceStream = this.voiceConnection.getRemoteVoiceStream(peerId);
if (!voiceStream) {
this.removeRemoteAudio(peerId);
return;
}
this.handleRemoteStream(peerId, voiceStream, this.buildPlaybackOptions());
});
this.voiceConnection.onVoiceConnected.subscribe(() => {
const options = this.buildPlaybackOptions(true);
this.playPendingStreams(options);
this.ensureAllRemoteStreamsPlaying(options);
});
this.voiceConnection.onPeerDisconnected.subscribe((peerId) => {
this.removeRemoteAudio(peerId);
});
}
handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void {
if (!options.isConnected) {
this.pendingRemoteStreams.set(peerId, stream);
return;
}
if (!this.hasAudio(stream)) {
this.rawRemoteStreams.delete(peerId);
this.removePipeline(peerId);
return;
}
this.removePipeline(peerId);
this.rawRemoteStreams.set(peerId, stream);
this.masterVolume = options.outputVolume;
this.deafened = options.isDeafened;
this.createPipeline(peerId, stream);
}
removeRemoteAudio(peerId: string): void {
this.pendingRemoteStreams.delete(peerId);
this.rawRemoteStreams.delete(peerId);
this.removePipeline(peerId);
}
playPendingStreams(options: PlaybackOptions): void {
if (!options.isConnected)
return;
this.pendingRemoteStreams.forEach((stream, peerId) => this.handleRemoteStream(peerId, stream, options));
this.pendingRemoteStreams.clear();
}
ensureAllRemoteStreamsPlaying(options: PlaybackOptions): void {
if (!options.isConnected)
return;
const peers = this.voiceConnection.getConnectedPeers();
for (const peerId of peers) {
const stream = this.voiceConnection.getRemoteVoiceStream(peerId);
if (stream && this.hasAudio(stream)) {
const trackedRaw = this.rawRemoteStreams.get(peerId);
if (!trackedRaw || trackedRaw !== stream) {
this.handleRemoteStream(peerId, stream, options);
}
}
}
}
updateOutputVolume(volume: number): void {
this.masterVolume = volume;
this.recalcAllGains();
}
updateDeafened(isDeafened: boolean): void {
this.deafened = isDeafened;
this.recalcAllGains();
}
getUserVolume(peerId: string): number {
return this.userVolumes.get(peerId) ?? 100;
}
setUserVolume(peerId: string, volume: number): void {
const clamped = Math.max(0, Math.min(200, volume));
this.userVolumes.set(peerId, clamped);
this.applyGain(peerId);
this.persistVolumes();
}
isUserMuted(peerId: string): boolean {
return this.userMuted.get(peerId) ?? false;
}
setUserMuted(peerId: string, muted: boolean): void {
this.userMuted.set(peerId, muted);
this.applyGain(peerId);
this.persistVolumes();
}
applyOutputDevice(deviceId: string): void {
this.preferredOutputDeviceId = deviceId || 'default';
void this.applyEffectiveOutputDeviceToAllPipelines();
}
teardownAll(): void {
this.peerPipelines.forEach((_pipeline, peerId) => this.removePipeline(peerId));
this.peerPipelines.clear();
this.rawRemoteStreams.clear();
this.pendingRemoteStreams.clear();
}
private buildPlaybackOptions(forceConnected = this.voiceConnection.isVoiceConnected()): PlaybackOptions {
return {
isConnected: forceConnected,
outputVolume: this.masterVolume,
isDeafened: this.deafened
};
}
/**
* Build the Web Audio graph for a remote peer:
*
* remoteStream
* ↓
* muted <audio> element (Chrome workaround - primes the stream)
* ↓
* MediaStreamSource → GainNode → MediaStreamDestination → output <audio>
*/
private createPipeline(peerId: string, stream: MediaStream): void {
// Chromium/Electron needs a muted <audio> element before Web Audio can read the stream.
const audioEl = new Audio();
const outputEl = new Audio();
const audioTracks = stream.getAudioTracks().filter((track) => track.readyState === 'live');
audioEl.srcObject = stream;
audioEl.muted = true;
audioEl.play().catch(() => {});
const ctx = new AudioContext();
const gainNode = ctx.createGain();
const mediaDestination = ctx.createMediaStreamDestination();
const sourceNodes = audioTracks.map((track) => ctx.createMediaStreamSource(new MediaStream([track])));
sourceNodes.forEach((sourceNode) => sourceNode.connect(gainNode));
gainNode.connect(mediaDestination);
outputEl.srcObject = mediaDestination.stream;
outputEl.muted = false;
outputEl.volume = 1;
outputEl.play().catch(() => {});
const pipeline: PeerAudioPipeline = {
audioElement: audioEl,
outputElement: outputEl,
context: ctx,
sourceNodes,
gainNode
};
this.peerPipelines.set(peerId, pipeline);
this.applyGain(peerId);
void this.applyEffectiveOutputDeviceToPipeline(pipeline);
}
private async applyEffectiveOutputDeviceToAllPipelines(): Promise<void> {
await Promise.all(Array.from(this.peerPipelines.values(), (pipeline) =>
this.applyEffectiveOutputDeviceToPipeline(pipeline)
));
}
private async applyEffectiveOutputDeviceToPipeline(pipeline: PeerAudioPipeline): Promise<void> {
const deviceId = this.getEffectiveOutputDeviceId();
if (!deviceId) {
return;
}
// eslint-disable-next-line
const anyAudio = pipeline.outputElement as any;
const tasks: Promise<unknown>[] = [];
if (typeof anyAudio.setSinkId === 'function') {
tasks.push(anyAudio.setSinkId(deviceId).catch(() => undefined));
}
if (tasks.length > 0) {
await Promise.all(tasks);
}
}
private getEffectiveOutputDeviceId(): string {
return this.temporaryOutputDeviceId ?? this.preferredOutputDeviceId;
}
private removePipeline(peerId: string): void {
const pipeline = this.peerPipelines.get(peerId);
if (!pipeline)
return;
try {
pipeline.gainNode.disconnect();
} catch {
// nodes may already be disconnected
}
pipeline.sourceNodes.forEach((sourceNode) => {
try {
sourceNode.disconnect();
} catch {
// nodes may already be disconnected
}
});
pipeline.audioElement.srcObject = null;
pipeline.audioElement.remove();
pipeline.outputElement.srcObject = null;
pipeline.outputElement.remove();
if (pipeline.context.state !== 'closed') {
pipeline.context.close().catch(() => {});
}
this.peerPipelines.delete(peerId);
}
private applyGain(peerId: string): void {
const pipeline = this.peerPipelines.get(peerId);
if (!pipeline)
return;
if (this.deafened || this.captureEchoSuppressed || this.isUserMuted(peerId)) {
pipeline.gainNode.gain.value = 0;
return;
}
const userVol = this.getUserVolume(peerId) / 100; // 0.0-2.0
const effective = this.masterVolume * userVol;
pipeline.gainNode.gain.value = effective;
}
private recalcAllGains(): void {
this.peerPipelines.forEach((_pipeline, peerId) => this.applyGain(peerId));
}
private persistVolumes(): void {
try {
const data: Record<string, { volume: number; muted: boolean }> = {};
this.userVolumes.forEach((vol, id) => {
data[id] = { volume: vol, muted: this.userMuted.get(id) ?? false };
});
// Also persist any muted-only entries
this.userMuted.forEach((muted, id) => {
if (!data[id]) {
data[id] = { volume: 100, muted };
}
});
localStorage.setItem(STORAGE_KEY_USER_VOLUMES, JSON.stringify(data));
} catch {
// localStorage not available
}
}
private loadPersistedVolumes(): void {
try {
const raw = localStorage.getItem(STORAGE_KEY_USER_VOLUMES);
if (!raw)
return;
const data = JSON.parse(raw) as Record<string, { volume: number; muted: boolean }>;
Object.entries(data).forEach(([id, entry]) => {
if (typeof entry.volume === 'number') {
this.userVolumes.set(id, entry.volume);
}
if (entry.muted) {
this.userMuted.set(id, true);
}
});
} catch {
// corrupted data - ignore
}
}
private hasAudio(stream: MediaStream): boolean {
return stream.getAudioTracks().length > 0;
}
}