mirror of
https://github.com/element-hq/element-call.git
synced 2026-10-09 07:37:26 +00:00
* Give the microphone level its own observable * Add a microphone level meter * Show speakers and microphones in the quick audio menu * Wire speaker selection through the call footer * Add stories for the meter and the device menu * Add end-to-end specs for the quick audio menu * Keep the footer while a menu opened from it is open * Use the shared audio capture stub in the lobby test --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
144 lines
5.0 KiB
TypeScript
144 lines
5.0 KiB
TypeScript
/*
|
|
Copyright 2026 Element Creations Ltd.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
import { BehaviorSubject, Observable } from "rxjs";
|
|
import { logger } from "matrix-js-sdk/lib/logger";
|
|
|
|
import { type Behavior } from "./Behavior";
|
|
|
|
/** What the microphone picks up, or why it can't be read. */
|
|
export type MicrophoneState =
|
|
// A Behavior, so a changing level can be drawn without re-rendering.
|
|
| { type: "level"; level$: Behavior<number> }
|
|
| { type: "permission-denied" }
|
|
| { type: "no-device" };
|
|
|
|
/** Scale a level is reported on. Fixed rather than the bar count, so a level reads the same at any width. */
|
|
export const LEVEL_SCALE = 24;
|
|
|
|
/**
|
|
* The level of a microphone, captured while subscribed. Its own capture rather
|
|
* than the call's track, because pre-join freezes that track to the device
|
|
* selected at mount.
|
|
*/
|
|
export function observeMicrophoneState$(
|
|
deviceId: string | undefined,
|
|
): Observable<MicrophoneState> {
|
|
return new Observable<MicrophoneState>((subscriber) => {
|
|
let stream: MediaStream | undefined;
|
|
let context: AudioContext | undefined;
|
|
let frame: number | undefined;
|
|
let level: BehaviorSubject<number> | undefined;
|
|
|
|
// Idempotent: teardown and start can both call it.
|
|
const release = (): void => {
|
|
if (frame !== undefined) cancelAnimationFrame(frame);
|
|
stream?.getTracks().forEach((track) => track.stop());
|
|
void context?.close();
|
|
frame = undefined;
|
|
stream = undefined;
|
|
context = undefined;
|
|
level?.complete();
|
|
level = undefined;
|
|
};
|
|
|
|
const start = async (): Promise<void> => {
|
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
audio:
|
|
deviceId === undefined ? true : { deviceId: { exact: deviceId } },
|
|
});
|
|
// Unsubscribed while the permission prompt was open.
|
|
if (subscriber.closed) return release();
|
|
|
|
context = new AudioContext();
|
|
// Starts suspended outside a user gesture, which would read as silence.
|
|
if (context.state === "suspended") await context.resume();
|
|
if (subscriber.closed) return release();
|
|
|
|
const analyser = context.createAnalyser();
|
|
analyser.fftSize = 1024;
|
|
context.createMediaStreamSource(stream).connect(analyser);
|
|
const samples = new Uint8Array(analyser.fftSize);
|
|
let displayed = 0;
|
|
let previousFrame = performance.now();
|
|
const current = new BehaviorSubject(0);
|
|
level = current;
|
|
subscriber.next({ type: "level", level$: current });
|
|
|
|
const read = (): void => {
|
|
analyser.getByteTimeDomainData(samples);
|
|
// RMS: perceived loudness rather than the peak.
|
|
let sum = 0;
|
|
for (const sample of samples) {
|
|
const centred = (sample - 128) / 128;
|
|
sum += centred * centred;
|
|
}
|
|
const now = performance.now();
|
|
displayed = smoothVolume(
|
|
displayed,
|
|
Math.sqrt(sum / samples.length),
|
|
now - previousFrame,
|
|
);
|
|
previousFrame = now;
|
|
// Frames that don't move the quantised level say nothing.
|
|
const next = segmentsForVolume(displayed);
|
|
if (next !== current.value) current.next(next);
|
|
frame = requestAnimationFrame(read);
|
|
};
|
|
read();
|
|
};
|
|
|
|
start().catch((e: unknown) => {
|
|
// Building the graph can fail after the device was granted.
|
|
release();
|
|
subscriber.next(stateForFailure(e));
|
|
});
|
|
|
|
return release;
|
|
});
|
|
}
|
|
|
|
/** What a failure to open the microphone means for the person using it. */
|
|
function stateForFailure(e: unknown): MicrophoneState {
|
|
const name = e instanceof Error ? e.name : "";
|
|
if (name === "NotAllowedError" || name === "SecurityError")
|
|
return { type: "permission-denied" };
|
|
if (name === "NotFoundError" || name === "OverconstrainedError")
|
|
return { type: "no-device" };
|
|
logger.error("Could not read the microphone level", e);
|
|
return { type: "no-device" };
|
|
}
|
|
|
|
/** Below this counts as silence, so a quiet room's hiss doesn't light the first bars. */
|
|
const NOISE_FLOOR = 0.02;
|
|
|
|
/** Quantises a 0..1 volume onto {@link LEVEL_SCALE}. */
|
|
export function segmentsForVolume(volume: number): number {
|
|
if (!Number.isFinite(volume) || volume <= NOISE_FLOOR) return 0;
|
|
// Square root, so ordinary speech reaches the middle of the scale.
|
|
const aboveFloor = (Math.min(volume, 1) - NOISE_FLOOR) / (1 - NOISE_FLOOR);
|
|
return Math.min(LEVEL_SCALE, Math.ceil(Math.sqrt(aboveFloor) * LEVEL_SCALE));
|
|
}
|
|
|
|
/** Rise time constant: short, so a syllable registers as it starts. */
|
|
export const ATTACK_MS = 50;
|
|
|
|
/** Fall time constant: longer, so the gaps between words don't flicker. */
|
|
export const RELEASE_MS = 120;
|
|
|
|
/** Eases towards a reading, by elapsed time so the frame rate doesn't matter. */
|
|
export function smoothVolume(
|
|
displayed: number,
|
|
reading: number,
|
|
elapsedMs: number,
|
|
): number {
|
|
if (elapsedMs <= 0) return displayed;
|
|
const timeConstant = reading > displayed ? ATTACK_MS : RELEASE_MS;
|
|
const towards = 1 - Math.exp(-elapsedMs / timeConstant);
|
|
return displayed + (reading - displayed) * towards;
|
|
}
|