diff --git a/apps/singularity-client/src/app/app.component.ts b/apps/singularity-client/src/app/app.component.ts index 3cbf6c9..7d6757a 100644 --- a/apps/singularity-client/src/app/app.component.ts +++ b/apps/singularity-client/src/app/app.component.ts @@ -1,4 +1,5 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { MicrophoneService } from './sing/services/microphone.service'; @Component({ selector: 'singularity-root', @@ -7,4 +8,13 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; standalone: false, changeDetection: ChangeDetectionStrategy.Eager }) -export class AppComponent {} +export class AppComponent implements OnInit { + constructor(private readonly microphoneService: MicrophoneService) { + } + + public ngOnInit(): void { + this.microphoneService.requestPermission$().subscribe({ + error: (error: unknown) => console.error('Unable to access the microphone', error) + }); + } +} diff --git a/apps/singularity-client/src/app/sing/services/microphone.service.spec.ts b/apps/singularity-client/src/app/sing/services/microphone.service.spec.ts index 1897d37..3763b30 100644 --- a/apps/singularity-client/src/app/sing/services/microphone.service.spec.ts +++ b/apps/singularity-client/src/app/sing/services/microphone.service.spec.ts @@ -1,4 +1,5 @@ import { TestBed } from '@angular/core/testing'; +import { firstValueFrom } from 'rxjs'; import { MicrophoneService } from './microphone.service'; @@ -13,4 +14,38 @@ describe('MicrophoneService', () => { it('should be created', () => { expect(service).toBeTruthy(); }); + + it('requests microphone access and stops its tracks after permission is granted', async () => { + let requestedConstraints: MediaStreamConstraints | undefined; + let trackStopped = false; + const mediaDevicesDescriptor = Object.getOwnPropertyDescriptor(navigator, 'mediaDevices'); + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { + getUserMedia: (constraints: MediaStreamConstraints) => { + requestedConstraints = constraints; + return Promise.resolve({ + getTracks: () => [{ + stop: () => { + trackStopped = true; + } + }] + } as unknown as MediaStream); + } + } + }); + + try { + await firstValueFrom(service.requestPermission$()); + + expect(requestedConstraints).toEqual({ audio: true }); + expect(trackStopped).toBe(true); + } finally { + if (mediaDevicesDescriptor) { + Object.defineProperty(navigator, 'mediaDevices', mediaDevicesDescriptor); + } else { + Reflect.deleteProperty(navigator, 'mediaDevices'); + } + } + }); }); diff --git a/apps/singularity-client/src/app/sing/services/microphone.service.ts b/apps/singularity-client/src/app/sing/services/microphone.service.ts index 5bff1d8..dca21d9 100644 --- a/apps/singularity-client/src/app/sing/services/microphone.service.ts +++ b/apps/singularity-client/src/app/sing/services/microphone.service.ts @@ -1,11 +1,11 @@ import { Injectable } from '@angular/core'; -import { asyncScheduler, finalize, from, map, Observable, scheduled, switchMap, take, timer } from 'rxjs'; +import { asyncScheduler, defer, finalize, from, map, Observable, scheduled, switchMap, take, tap, timer } from 'rxjs'; @Injectable({providedIn: 'root'}) export class MicrophoneService { private selectedMicrophones: string[] = []; - private audioContext = new AudioContext({ latencyHint: 0 }); + private audioContext?: AudioContext; public isAnyMicrophoneSelected(): boolean { return this.selectedMicrophones.length > 0; @@ -27,6 +27,14 @@ export class MicrophoneService { ) } + public requestPermission$(): Observable { + return defer(() => from(navigator.mediaDevices.getUserMedia({ audio: true }))) + .pipe( + tap((stream: MediaStream) => stream.getTracks().forEach(track => track.stop())), + map(() => undefined) + ); + } + public getDeviceStream$(deviceId: string): Observable { // We are using swicthMap and scheduled here, so that this observable does not become a hot observable. return scheduled([1], asyncScheduler) @@ -47,10 +55,10 @@ export class MicrophoneService { return this.getDeviceStream$(deviceId) .pipe( switchMap((stream: MediaStream) => { - const analyser = this.audioContext.createAnalyser(); + const analyser = this.getAudioContext().createAnalyser(); analyser.fftSize = 512; analyser.smoothingTimeConstant = 0.1; - const sourceNode = this.audioContext.createMediaStreamSource(stream); + const sourceNode = this.getAudioContext().createMediaStreamSource(stream); sourceNode.connect(analyser); return timer(0, 100).pipe( @@ -72,4 +80,9 @@ export class MicrophoneService { }) ); } + + private getAudioContext(): AudioContext { + this.audioContext ??= new AudioContext({ latencyHint: 0 }); + return this.audioContext; + } }