Move toju-app into own its folder
This commit is contained in:
97
toju-app/src/app/domains/voice-connection/README.md
Normal file
97
toju-app/src/app/domains/voice-connection/README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Voice Connection Domain
|
||||
|
||||
Bridges the application layer to the low-level realtime infrastructure for voice calls. Provides speaking detection via Web Audio analysis and per-peer volume control for playback. The actual WebRTC plumbing lives in `infrastructure/realtime`; this domain wraps it with a clean facade.
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
voice-connection/
|
||||
├── application/
|
||||
│ ├── voice-connection.facade.ts Proxy to RealtimeSessionFacade for voice signals and methods
|
||||
│ ├── voice-activity.service.ts RMS-based speaking detection via AnalyserNode (per-user signals)
|
||||
│ └── voice-playback.service.ts Per-peer GainNode chain, 0-200% volume, deafen support
|
||||
│
|
||||
├── domain/
|
||||
│ └── voice-connection.models.ts Re-exports LatencyProfile, VoiceStateSnapshot from shared-kernel / realtime
|
||||
│
|
||||
└── index.ts Barrel exports
|
||||
```
|
||||
|
||||
## Service relationships
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
VCF[VoiceConnectionFacade]
|
||||
VAS[VoiceActivityService]
|
||||
VPS[VoicePlaybackService]
|
||||
RSF[RealtimeSessionFacade]
|
||||
Models[voice-connection.models]
|
||||
|
||||
VCF --> RSF
|
||||
VAS --> VCF
|
||||
VPS --> VCF
|
||||
|
||||
click VCF "application/voice-connection.facade.ts" "Proxy to RealtimeSessionFacade" _blank
|
||||
click VAS "application/voice-activity.service.ts" "RMS-based speaking detection" _blank
|
||||
click VPS "application/voice-playback.service.ts" "Per-peer GainNode volume chain" _blank
|
||||
click RSF "../../infrastructure/realtime/realtime-session.service.ts" "Low-level WebRTC composition root" _blank
|
||||
click Models "domain/voice-connection.models.ts" "Re-exported types" _blank
|
||||
```
|
||||
|
||||
## Voice connection facade
|
||||
|
||||
`VoiceConnectionFacade` exposes signals and methods from `RealtimeSessionFacade` without leaking infrastructure details into feature components. It covers:
|
||||
|
||||
- Connection state: `isVoiceConnected`, `isMuted`, `isDeafened`, `hasConnectionError`
|
||||
- Stream access: `getRemoteVoiceStream`, `getLocalStream`, `getRawMicStream`
|
||||
- Controls: `enableVoice`, `disableVoice`, `toggleMute`, `toggleDeafen`, `toggleNoiseReduction`
|
||||
- Audio tuning: `setOutputVolume`, `setInputVolume`, `setAudioBitrate`, `setLatencyProfile`
|
||||
- Peer events: `onRemoteStream`, `onPeerConnected`, `onPeerDisconnected`
|
||||
- Heartbeat: `startVoiceHeartbeat`, `stopVoiceHeartbeat`
|
||||
|
||||
## Speaking detection
|
||||
|
||||
`VoiceActivityService` monitors audio levels for local and remote streams using the Web Audio API. Each tracked stream gets its own `AudioContext` with an `AnalyserNode`. A single `requestAnimationFrame` loop polls all analysers.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Stream[MediaStream] --> Ctx[AudioContext]
|
||||
Ctx --> Src[MediaStreamAudioSourceNode]
|
||||
Src --> Analyser[AnalyserNode<br/>fftSize = 256]
|
||||
Analyser --> Poll[rAF poll loop]
|
||||
Poll --> RMS{RMS >= 0.015?}
|
||||
RMS -- yes --> Speaking[speakingSignal = true]
|
||||
RMS -- no, 8 frames --> Silent[speakingSignal = false]
|
||||
|
||||
click Stream "application/voice-activity.service.ts" "VoiceActivityService.trackStream()" _blank
|
||||
click Poll "application/voice-activity.service.ts" "VoiceActivityService.poll()" _blank
|
||||
```
|
||||
|
||||
| Parameter | Value |
|
||||
|---|---|
|
||||
| FFT size | 256 samples |
|
||||
| Speaking threshold | RMS >= 0.015 |
|
||||
| Silent grace period | 8 consecutive frames below threshold |
|
||||
|
||||
The service exposes `isSpeaking(userId)` and `volume(userId)` as Angular signals. It automatically tracks remote peers via the `onRemoteStream` and `onPeerDisconnected` observables. Local mic tracking is started explicitly by calling `trackLocalMic(userId, stream)`.
|
||||
|
||||
A reactive `speakingMap` signal (a `Map<string, boolean>`) is published whenever any user's speaking state changes, so components can bind directly.
|
||||
|
||||
## Voice playback
|
||||
|
||||
`VoicePlaybackService` handles audio output for remote peers. Each peer gets an independent Web Audio pipeline:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Remote[Remote stream] --> Src[MediaStreamAudioSourceNode]
|
||||
Src --> Gain[GainNode<br/>0 - 200%]
|
||||
Gain --> Dest[MediaStreamAudioDestinationNode]
|
||||
Dest --> Audio[HTMLAudioElement<br/>.play]
|
||||
|
||||
click Remote "application/voice-playback.service.ts" "VoicePlaybackService.setupPeer()" _blank
|
||||
click Gain "application/voice-playback.service.ts" "VoicePlaybackService.setUserVolume()" _blank
|
||||
```
|
||||
|
||||
Volume per peer is stored in localStorage and restored on reconnect. The range is 0% to 200% (gain values 0.0 to 2.0). When the user deafens, all gain nodes are set to zero; undeafening restores the previous values.
|
||||
|
||||
A Chrome workaround attaches a muted `<audio>` element to keep the `AudioContext` from suspending when no audible output is detected.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { LATENCY_PROFILE_BITRATES } from '../../../infrastructure/realtime/realtime.constants';
|
||||
export type { LatencyProfile } from '../../../shared-kernel';
|
||||
export type { VoiceStateSnapshot } from '../../../infrastructure/realtime/realtime.types';
|
||||
4
toju-app/src/app/domains/voice-connection/index.ts
Normal file
4
toju-app/src/app/domains/voice-connection/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './application/voice-connection.facade';
|
||||
export * from './application/voice-activity.service';
|
||||
export * from './application/voice-playback.service';
|
||||
export * from './domain/voice-connection.models';
|
||||
Reference in New Issue
Block a user