feat: camera quality settings, audio processing toggles, config-seeded defaults

- Add camera video quality controls (resolution/framerate/bitrate/codec)
  to Settings > Video, mirroring the screen share settings UI
- Add audio processing toggles (echo cancellation, noise suppression,
  auto gain control) to Settings > Audio, replacing URL-param-only controls
- Display raw values inline on all sliders (framerate, bitrate, volume)
- Add config-seeded defaults: config.json media_quality values now seed
  Setting defaults for users who haven't explicitly set preferences
- Camera settings are applied when joining a call via ConnectionFactory

Signed-off-by: Ryan Emmick <ryanemmick4@gmail.com>
This commit is contained in:
Ryan Emmick
2026-02-11 03:02:51 -06:00
parent bc38d15b6d
commit b043230680
9 changed files with 362 additions and 31 deletions

View File

@@ -26,6 +26,7 @@ import {
import { getUrlParams } from "./UrlParams"; import { getUrlParams } from "./UrlParams";
import { Config } from "./config/Config"; import { Config } from "./config/Config";
import { seedSettingsFromConfig } from "./settings/settings";
import { platform } from "./Platform"; import { platform } from "./Platform";
import { isFailure } from "./utils/fetch"; import { isFailure } from "./utils/fetch";
import { initializeWidget } from "./widget"; import { initializeWidget } from "./widget";
@@ -220,6 +221,7 @@ export class Initializer {
this.loadStates.config = LoadState.Loading; this.loadStates.config = LoadState.Loading;
Config.init().then( Config.init().then(
() => { () => {
seedSettingsFromConfig(Config.get().media_quality);
this.loadStates.config = LoadState.Loaded; this.loadStates.config = LoadState.Loaded;
this.initStep(resolve); this.initStep(resolve);
}, },

View File

@@ -52,3 +52,8 @@ Please see LICENSE in the repository root for full details.
outline: none; outline: none;
border-color: var(--cpd-color-text-link-external); border-color: var(--cpd-color-text-link-external);
} }
.settingValue {
font-weight: normal;
color: var(--cpd-color-text-secondary);
}

View File

@@ -29,6 +29,14 @@ import {
screenShareFramerate as screenShareFramerateSetting, screenShareFramerate as screenShareFramerateSetting,
screenShareBitrate as screenShareBitrateSetting, screenShareBitrate as screenShareBitrateSetting,
screenShareCodec as screenShareCodecSetting, screenShareCodec as screenShareCodecSetting,
advancedCamera as advancedCameraSetting,
cameraResolution as cameraResolutionSetting,
cameraFramerate as cameraFramerateSetting,
cameraBitrate as cameraBitrateSetting,
cameraCodec as cameraCodecSetting,
echoCancellationSetting,
noiseSuppressionSetting,
autoGainControlSetting,
type VideoCodec, type VideoCodec,
} from "./settings"; } from "./settings";
import { PreferencesSettingsTab } from "./PreferencesSettingsTab"; import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
@@ -157,6 +165,8 @@ export const SettingsModal: FC<Props> = ({
<div className={styles.volumeSlider}> <div className={styles.volumeSlider}>
<label> <label>
{t("settings.screen_share_framerate_label", "Framerate")} {t("settings.screen_share_framerate_label", "Framerate")}
{": "}
<span className={styles.settingValue}>{framerateRaw} fps</span>
</label> </label>
<Slider <Slider
label={t("settings.screen_share_framerate_label", "Framerate")} label={t("settings.screen_share_framerate_label", "Framerate")}
@@ -172,6 +182,10 @@ export const SettingsModal: FC<Props> = ({
<div className={styles.volumeSlider}> <div className={styles.volumeSlider}>
<label> <label>
{t("settings.screen_share_bitrate_label", "Bitrate")} {t("settings.screen_share_bitrate_label", "Bitrate")}
{": "}
<span className={styles.settingValue}>
{(bitrateRaw / 1_000_000).toFixed(1)} Mbps
</span>
</label> </label>
<Slider <Slider
label={t("settings.screen_share_bitrate_label", "Bitrate")} label={t("settings.screen_share_bitrate_label", "Bitrate")}
@@ -209,6 +223,177 @@ export const SettingsModal: FC<Props> = ({
); );
}; };
const CameraSettings: React.FC = (): ReactNode => {
const [advancedEnabled, setAdvancedEnabled] = useSetting(
advancedCameraSetting,
);
const [resolution, setResolution] = useSetting(cameraResolutionSetting);
const [framerate, setFramerate] = useSetting(cameraFramerateSetting);
const [framerateRaw, setFramerateRaw] = useState(framerate);
const [bitrate, setBitrate] = useSetting(cameraBitrateSetting);
const [bitrateRaw, setBitrateRaw] = useState(bitrate);
const [codec, setCodec] = useSetting(cameraCodecSetting);
return (
<>
<h4>{t("settings.camera_header", "Camera quality")}</h4>
<FieldRow>
<InputField
id="advancedCamera"
label={t(
"settings.advanced_camera_label",
"Advanced camera settings",
)}
description={t(
"settings.advanced_camera_description",
"Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.",
)}
type="checkbox"
checked={advancedEnabled}
onChange={(e): void => setAdvancedEnabled(e.target.checked)}
/>
</FieldRow>
{advancedEnabled && (
<>
<div className={styles.volumeSlider}>
<label htmlFor="cameraResolution">
{t("settings.camera_resolution_label", "Resolution")}
</label>
<select
id="cameraResolution"
value={resolution}
onChange={(e): void => setResolution(e.target.value)}
>
<option value="640x360">360p</option>
<option value="960x540">540p</option>
<option value="1280x720">720p</option>
<option value="1920x1080">1080p</option>
<option value="2560x1440">1440p</option>
</select>
</div>
<div className={styles.volumeSlider}>
<label>
{t("settings.camera_framerate_label", "Framerate")}
{": "}
<span className={styles.settingValue}>
{framerateRaw} fps
</span>
</label>
<Slider
label={t("settings.camera_framerate_label", "Framerate")}
value={framerateRaw}
onValueChange={setFramerateRaw}
onValueCommit={setFramerate}
min={5}
max={60}
step={5}
tooltipFormatter={(v): string => `${v} fps`}
/>
</div>
<div className={styles.volumeSlider}>
<label>
{t("settings.camera_bitrate_label", "Bitrate")}
{": "}
<span className={styles.settingValue}>
{(bitrateRaw / 1_000_000).toFixed(1)} Mbps
</span>
</label>
<Slider
label={t("settings.camera_bitrate_label", "Bitrate")}
value={bitrateRaw}
onValueChange={setBitrateRaw}
onValueCommit={setBitrate}
min={200_000}
max={8_000_000}
step={100_000}
tooltipFormatter={(v): string =>
`${(v / 1_000_000).toFixed(1)} Mbps`
}
/>
</div>
<div className={styles.volumeSlider}>
<label htmlFor="cameraCodec">
{t("settings.camera_codec_label", "Codec")}
</label>
<select
id="cameraCodec"
value={codec}
onChange={(e): void =>
setCodec(e.target.value as VideoCodec)
}
>
<option value="vp8">VP8</option>
<option value="vp9">VP9</option>
<option value="h264">H.264</option>
<option value="av1">AV1</option>
</select>
</div>
</>
)}
</>
);
};
const AudioProcessingSettings: React.FC = (): ReactNode => {
const [echoCancellation, setEchoCancellation] = useSetting(
echoCancellationSetting,
);
const [noiseSuppression, setNoiseSuppression] = useSetting(
noiseSuppressionSetting,
);
const [autoGainControl, setAutoGainControl] = useSetting(
autoGainControlSetting,
);
return (
<>
<h4>{t("settings.audio_processing_header", "Audio processing")}</h4>
<p>
{t(
"settings.audio_processing_description",
"Changes apply on next call join.",
)}
</p>
<FieldRow>
<InputField
id="echoCancellation"
label={t(
"settings.echo_cancellation_label",
"Echo cancellation",
)}
type="checkbox"
checked={echoCancellation}
onChange={(e): void => setEchoCancellation(e.target.checked)}
/>
</FieldRow>
<FieldRow>
<InputField
id="noiseSuppression"
label={t(
"settings.noise_suppression_label",
"Noise suppression",
)}
type="checkbox"
checked={noiseSuppression}
onChange={(e): void => setNoiseSuppression(e.target.checked)}
/>
</FieldRow>
<FieldRow>
<InputField
id="autoGainControl"
label={t(
"settings.auto_gain_control_label",
"Automatic gain control",
)}
type="checkbox"
checked={autoGainControl}
onChange={(e): void => setAutoGainControl(e.target.checked)}
/>
</FieldRow>
</>
);
};
const devices = useMediaDevices(); const devices = useMediaDevices();
useEffect(() => { useEffect(() => {
if (open) devices.requestDeviceNames(); if (open) devices.requestDeviceNames();
@@ -263,7 +448,13 @@ export const SettingsModal: FC<Props> = ({
/> />
<div className={styles.volumeSlider}> <div className={styles.volumeSlider}>
<label>{t("settings.audio_tab.effect_volume_label")}</label> <label>
{t("settings.audio_tab.effect_volume_label")}
{": "}
<span className={styles.settingValue}>
{Math.round(soundVolumeRaw * 100)}%
</span>
</label>
<p>{t("settings.audio_tab.effect_volume_description")}</p> <p>{t("settings.audio_tab.effect_volume_description")}</p>
<Slider <Slider
label={t("video_tile.volume")} label={t("video_tile.volume")}
@@ -276,6 +467,8 @@ export const SettingsModal: FC<Props> = ({
/> />
</div> </div>
</Form> </Form>
<Separator />
<AudioProcessingSettings />
</> </>
), ),
}; };
@@ -295,6 +488,8 @@ export const SettingsModal: FC<Props> = ({
<Separator /> <Separator />
<BlurCheckbox /> <BlurCheckbox />
<Separator /> <Separator />
<CameraSettings />
<Separator />
<ScreenShareSettings /> <ScreenShareSettings />
</> </>
), ),

View File

@@ -20,6 +20,7 @@ export class Setting<T> {
this.key = `matrix-setting-${key}`; this.key = `matrix-setting-${key}`;
const storedValue = localStorage.getItem(this.key); const storedValue = localStorage.getItem(this.key);
this.hasStoredValue = storedValue !== null;
let initialValue = defaultValue; let initialValue = defaultValue;
if (storedValue !== null) { if (storedValue !== null) {
try { try {
@@ -39,6 +40,7 @@ export class Setting<T> {
} }
private readonly key: string; private readonly key: string;
private readonly hasStoredValue: boolean;
private readonly _value$: BehaviorSubject<T>; private readonly _value$: BehaviorSubject<T>;
private readonly _lastUpdateReason$: BehaviorSubject<string | null>; private readonly _lastUpdateReason$: BehaviorSubject<string | null>;
@@ -53,6 +55,17 @@ export class Setting<T> {
public readonly getValue = (): T => { public readonly getValue = (): T => {
return this._value$.getValue(); return this._value$.getValue();
}; };
/**
* Update the setting's value from a config source, but only if the user
* hasn't explicitly set a value in localStorage. This lets admins set
* org-wide defaults in config.json that users can override.
*/
public seedFromConfig(value: T): void {
if (!this.hasStoredValue) {
this._value$.next(value);
}
}
} }
/** /**
@@ -177,3 +190,93 @@ export const screenShareCodec = new Setting<VideoCodec>(
"screen-share-codec", "screen-share-codec",
"vp9", "vp9",
); );
// Camera video quality settings
export const advancedCamera = new Setting<boolean>("advanced-camera", false);
export const cameraResolution = new Setting<string>(
"camera-resolution",
"1280x720",
);
export const cameraFramerate = new Setting<number>("camera-framerate", 30);
export const cameraBitrate = new Setting<number>(
"camera-bitrate",
1_700_000,
);
export const cameraCodec = new Setting<VideoCodec>("camera-codec", "vp8");
// Audio processing settings
export const echoCancellationSetting = new Setting<boolean>(
"echo-cancellation",
true,
);
export const noiseSuppressionSetting = new Setting<boolean>(
"noise-suppression",
true,
);
export const autoGainControlSetting = new Setting<boolean>(
"auto-gain-control",
true,
);
/**
* Seed setting defaults from config.json's media_quality section.
* Call this after Config.init() has resolved.
* Only updates settings that the user hasn't explicitly set in localStorage.
*/
export function seedSettingsFromConfig(
mediaQuality: {
video_codec?: VideoCodec;
video?: {
max_resolution?: number;
max_bitrate?: number;
max_framerate?: number;
};
screen_share?: {
max_resolution?: number;
max_bitrate?: number;
max_framerate?: number;
};
} | undefined,
): void {
if (!mediaQuality) return;
const codec = mediaQuality.video_codec;
if (codec) {
screenShareCodec.seedFromConfig(codec);
cameraCodec.seedFromConfig(codec);
}
const screen = mediaQuality.screen_share;
if (screen) {
if (screen.max_resolution) {
const width = Math.round((screen.max_resolution * 16) / 9);
screenShareResolution.seedFromConfig(`${width}x${screen.max_resolution}`);
}
if (screen.max_framerate) {
screenShareFramerate.seedFromConfig(screen.max_framerate);
}
if (screen.max_bitrate) {
screenShareBitrate.seedFromConfig(screen.max_bitrate);
}
}
const video = mediaQuality.video;
if (video) {
if (video.max_resolution) {
const width = Math.round((video.max_resolution * 16) / 9);
cameraResolution.seedFromConfig(`${width}x${video.max_resolution}`);
}
if (video.max_framerate) {
cameraFramerate.seedFromConfig(video.max_framerate);
}
if (video.max_bitrate) {
cameraBitrate.seedFromConfig(video.max_bitrate);
}
}
}

View File

@@ -488,8 +488,6 @@ export function createCallViewModel$(
livekitKeyProvider, livekitKeyProvider,
getUrlParams().controlledAudioDevices, getUrlParams().controlledAudioDevices,
options.livekitRoomFactory, options.livekitRoomFactory,
getUrlParams().echoCancellation,
getUrlParams().noiseSuppression,
); );
const connectionManager = createConnectionManager$({ const connectionManager = createConnectionManager$({

View File

@@ -27,10 +27,17 @@ import type {
import type { MediaDevices } from "../../MediaDevices.ts"; import type { MediaDevices } from "../../MediaDevices.ts";
import type { Behavior } from "../../Behavior.ts"; import type { Behavior } from "../../Behavior.ts";
import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx"; import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
import { getLiveKitOptions } from "../../../livekit/options.ts";
import { import {
defaultLiveKitOptions, advancedCamera,
getLiveKitOptions, cameraResolution,
} from "../../../livekit/options.ts"; cameraFramerate,
cameraBitrate,
cameraCodec,
echoCancellationSetting,
noiseSuppressionSetting,
autoGainControlSetting,
} from "../../../settings/settings.ts";
// TODO evaluate if this should be done like the Publisher Factory // TODO evaluate if this should be done like the Publisher Factory
export interface ConnectionFactory { export interface ConnectionFactory {
@@ -56,8 +63,6 @@ export class ECConnectionFactory implements ConnectionFactory {
* @param livekitKeyProvider - Optional key provider for end-to-end encryption. * @param livekitKeyProvider - Optional key provider for end-to-end encryption.
* @param controlledAudioDevices - Option to indicate whether audio output device is controlled externally (native mobile app). * @param controlledAudioDevices - Option to indicate whether audio output device is controlled externally (native mobile app).
* @param livekitRoomFactory - Optional factory function (for testing) to create LivekitRoom instances. If not provided, a default factory is used. * @param livekitRoomFactory - Optional factory function (for testing) to create LivekitRoom instances. If not provided, a default factory is used.
* @param echoCancellation - Whether to enable echo cancellation for audio capture.
* @param noiseSuppression - Whether to enable noise suppression for audio capture.
*/ */
public constructor( public constructor(
private client: OpenIDClientParts, private client: OpenIDClientParts,
@@ -67,8 +72,6 @@ export class ECConnectionFactory implements ConnectionFactory {
livekitKeyProvider: BaseKeyProvider | undefined, livekitKeyProvider: BaseKeyProvider | undefined,
private controlledAudioDevices: boolean, private controlledAudioDevices: boolean,
livekitRoomFactory?: () => LivekitRoom, livekitRoomFactory?: () => LivekitRoom,
echoCancellation: boolean = true,
noiseSuppression: boolean = true,
) { ) {
const defaultFactory = (): LivekitRoom => const defaultFactory = (): LivekitRoom =>
new LivekitRoom( new LivekitRoom(
@@ -82,8 +85,6 @@ export class ECConnectionFactory implements ConnectionFactory {
worker: new E2EEWorker(), worker: new E2EEWorker(),
}, },
controlledAudioDevices: this.controlledAudioDevices, controlledAudioDevices: this.controlledAudioDevices,
echoCancellation,
noiseSuppression,
}), }),
); );
this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory; this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory;
@@ -122,14 +123,13 @@ export class ECConnectionFactory implements ConnectionFactory {
/** /**
* Generate the initial LiveKit RoomOptions based on the current media devices and processor state. * Generate the initial LiveKit RoomOptions based on the current media devices and processor state.
* Reads audio processing and camera quality settings directly from Settings.
*/ */
function generateRoomOption({ function generateRoomOption({
devices, devices,
processorState, processorState,
e2eeLivekitOptions, e2eeLivekitOptions,
controlledAudioDevices, controlledAudioDevices,
echoCancellation,
noiseSuppression,
}: { }: {
devices: MediaDevices; devices: MediaDevices;
processorState: ProcessorState; processorState: ProcessorState;
@@ -138,22 +138,46 @@ function generateRoomOption({
| { e2eeManager: BaseE2EEManager } | { e2eeManager: BaseE2EEManager }
| undefined; | undefined;
controlledAudioDevices: boolean; controlledAudioDevices: boolean;
echoCancellation: boolean;
noiseSuppression: boolean;
}): RoomOptions { }): RoomOptions {
const liveKitOptions = getLiveKitOptions(); const liveKitOptions = getLiveKitOptions();
// Apply advanced camera settings if enabled
let videoCaptureDefaults = {
...liveKitOptions.videoCaptureDefaults,
deviceId: devices.videoInput.selected$.value?.id,
processor: processorState.processor,
};
let publishDefaults = liveKitOptions.publishDefaults;
if (advancedCamera.getValue()) {
const resParts = cameraResolution.getValue().split("x");
const width = Number(resParts[0]);
const height = Number(resParts[1]);
const fps = cameraFramerate.getValue();
const bps = cameraBitrate.getValue();
const codec = cameraCodec.getValue();
videoCaptureDefaults = {
...videoCaptureDefaults,
resolution: { width, height, frameRate: fps },
};
publishDefaults = {
...publishDefaults,
videoEncoding: { maxBitrate: bps, maxFramerate: fps },
videoCodec: codec,
};
}
return { return {
...liveKitOptions, ...liveKitOptions,
videoCaptureDefaults: { videoCaptureDefaults,
...liveKitOptions.videoCaptureDefaults, publishDefaults,
deviceId: devices.videoInput.selected$.value?.id,
processor: processorState.processor,
},
audioCaptureDefaults: { audioCaptureDefaults: {
...liveKitOptions.audioCaptureDefaults, ...liveKitOptions.audioCaptureDefaults,
deviceId: devices.audioInput.selected$.value?.id, deviceId: devices.audioInput.selected$.value?.id,
echoCancellation, echoCancellation: echoCancellationSetting.getValue(),
noiseSuppression, noiseSuppression: noiseSuppressionSetting.getValue(),
autoGainControl: autoGainControlSetting.getValue(),
}, },
audioOutput: { audioOutput: {
// When using controlled audio devices, we don't want to set the // When using controlled audio devices, we don't want to set the

View File

@@ -22,6 +22,10 @@ import {
} from "../../../utils/test.ts"; } from "../../../utils/test.ts";
import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx"; import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
import { constant } from "../../Behavior"; import { constant } from "../../Behavior";
import {
echoCancellationSetting,
noiseSuppressionSetting,
} from "../../../settings/settings.ts";
// At the top of your test file, after imports // At the top of your test file, after imports
vi.mock("livekit-client", async (importOriginal) => { vi.mock("livekit-client", async (importOriginal) => {
@@ -58,11 +62,14 @@ describe("ECConnectionFactory - Audio inputs options", () => {
{ echo: false, noise: true }, { echo: false, noise: true },
{ echo: false, noise: false }, { echo: false, noise: false },
])( ])(
"it sets echoCancellation=$echo and noiseSuppression=$noise based on constructor parameters", "it sets echoCancellation=$echo and noiseSuppression=$noise based on settings",
({ echo, noise }) => { ({ echo, noise }) => {
// test("it sets echoCancellation and noiseSuppression based on constructor parameters", () => {
const RoomConstructor = vi.mocked(LivekitRoom); const RoomConstructor = vi.mocked(LivekitRoom);
// Set audio processing settings
echoCancellationSetting.setValue(echo);
noiseSuppressionSetting.setValue(noise);
const ecConnectionFactory = new ECConnectionFactory( const ecConnectionFactory = new ECConnectionFactory(
mockClient, mockClient,
"!roomid:example.org", "!roomid:example.org",
@@ -73,9 +80,6 @@ describe("ECConnectionFactory - Audio inputs options", () => {
}), }),
undefined, undefined,
false, false,
undefined,
echo,
noise,
); );
ecConnectionFactory.createConnection( ecConnectionFactory.createConnection(
testScope, testScope,
@@ -120,9 +124,6 @@ describe("ECConnectionFactory - ControlledAudioDevice", () => {
}), }),
undefined, undefined,
controlled, controlled,
undefined,
false,
false,
); );
ecConnectionFactory.createConnection( ecConnectionFactory.createConnection(
testScope, testScope,

View File

@@ -19,6 +19,7 @@ const createRoomWidgetClientSpy = vi.mocked(createRoomWidgetClient);
vi.mock("./config/Config", () => ({ vi.mock("./config/Config", () => ({
Config: { Config: {
init: vi.fn().mockImplementation(async () => Promise.resolve()), init: vi.fn().mockImplementation(async () => Promise.resolve()),
get: vi.fn().mockReturnValue({}),
}, },
})); }));
const configInitSpy = vi.mocked(Config.init); const configInitSpy = vi.mocked(Config.init);

View File

@@ -21,6 +21,7 @@ import type { IWidgetApiRequest } from "matrix-widget-api";
import { LazyEventEmitter } from "./LazyEventEmitter"; import { LazyEventEmitter } from "./LazyEventEmitter";
import { getUrlParams } from "./UrlParams"; import { getUrlParams } from "./UrlParams";
import { Config } from "./config/Config"; import { Config } from "./config/Config";
import { seedSettingsFromConfig } from "./settings/settings";
import { ElementCallReactionEventType } from "./reactions"; import { ElementCallReactionEventType } from "./reactions";
// Subset of the actions in element-web // Subset of the actions in element-web
@@ -195,6 +196,7 @@ export const initializeWidget = (
// Wait for the config file to be ready (we load very early on so it might not // Wait for the config file to be ready (we load very early on so it might not
// be otherwise) // be otherwise)
await Config.init(); await Config.init();
seedSettingsFromConfig(Config.get().media_quality);
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 }); await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
return client; return client;
}; };