Files
ChatApp/apps/desktop/src/lib/audioSettings.ts
T
byGalax 1303c8e26f feat: backup/restore, user profile popover, image compress, video blur, wake lock
- backup/restore dialog + user profile popover components
- image compression, video blur, wake lock utilities
- message cache + conversation messages hook refinements
- call context, active speakers, screen share dialog tweaks
- audio + screen share settings persistence
- refreshed app icons (smaller sizes) across all platforms
2026-04-21 12:11:09 +02:00

153 lines
4.8 KiB
TypeScript

// Audio quality preferences for outgoing voice/music. `voice` is the default
// — Opus 48 kbps stereo with full DSP (echo cancellation + noise suppression
// + AGC). `hifi` bumps to 96 kbps stereo Opus and disables all DSP so music,
// instruments, or broadcast-style voice streams stay uncoloured.
const STORAGE_KEY = 'chatapp.audio';
export type AudioQuality = 'voice' | 'hifi';
export interface AudioSettings {
quality: AudioQuality;
// Preferred input deviceId from enumerateDevices. null = use browser default
// (whatever the OS points at). Persisted across sessions, so "grandma's
// mic is default" stays even after browser picks the wrong device.
inputDeviceId: string | null;
// Preferred output (speaker/headphone) deviceId. null = system default.
// Applied to every <audio> element we attach via HTMLMediaElement.setSinkId.
outputDeviceId: string | null;
// RMS threshold (0..1) for the "is this participant talking" ring. Lower =
// more sensitive. Default 0.03 catches soft speech without lighting up on
// keyboard noise. Users on quiet mics can lower; users in noisy rooms bump up.
voiceThreshold: number;
// Per-publish DSP toggle. Off lets hifi-style music go through unmodified;
// on cleans up voice when the quality preset doesn't already imply it.
// Defaults to "follow the quality preset".
noiseSuppression: boolean;
// Background blur on the local camera track. Lazy — requires
// @livekit/track-processors + its MediaPipe selfie-segmentation model
// (~1.5MB) which downloads on first activation.
videoBackgroundBlur: boolean;
}
const DEFAULTS: AudioSettings = {
quality: 'voice',
inputDeviceId: null,
outputDeviceId: null,
voiceThreshold: 0.03,
noiseSuppression: true,
videoBackgroundBlur: false,
};
export interface AudioQualityParams {
label: string;
bitrateKbps: number;
stereo: boolean;
sampleRateHz: number;
// DSP toggles — off for hifi so music isn't coloured by noise-suppression.
echoCancellation: boolean;
noiseSuppression: boolean;
autoGainControl: boolean;
}
const PARAMS: Record<AudioQuality, AudioQualityParams> = {
voice: {
label: 'Voice · 48 kbps',
bitrateKbps: 48,
stereo: false,
sampleRateHz: 48_000,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
hifi: {
label: 'HiFi · 510 kbps Stereo',
bitrateKbps: 510, // Opus max, ~CD-quality stereo
stereo: true,
sampleRateHz: 48_000,
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
};
export const AUDIO_QUALITY_ORDER: ReadonlyArray<AudioQuality> = ['voice', 'hifi'];
export function getAudioQualityParams(q: AudioQuality): AudioQualityParams {
return PARAMS[q];
}
type Listener = (s: AudioSettings) => void;
const listeners = new Set<Listener>();
let cached: AudioSettings | null = null;
function isQuality(v: unknown): v is AudioQuality {
return v === 'voice' || v === 'hifi';
}
function read(): AudioSettings {
if (cached) return cached;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
cached = DEFAULTS;
return cached;
}
const parsed = JSON.parse(raw) as Partial<AudioSettings>;
const rawThreshold = typeof parsed.voiceThreshold === 'number' ? parsed.voiceThreshold : NaN;
cached = {
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality,
inputDeviceId:
typeof parsed.inputDeviceId === 'string' && parsed.inputDeviceId.length > 0
? parsed.inputDeviceId
: DEFAULTS.inputDeviceId,
outputDeviceId:
typeof parsed.outputDeviceId === 'string' && parsed.outputDeviceId.length > 0
? parsed.outputDeviceId
: DEFAULTS.outputDeviceId,
voiceThreshold:
Number.isFinite(rawThreshold) && rawThreshold >= 0.005 && rawThreshold <= 0.2
? rawThreshold
: DEFAULTS.voiceThreshold,
noiseSuppression:
typeof parsed.noiseSuppression === 'boolean'
? parsed.noiseSuppression
: DEFAULTS.noiseSuppression,
videoBackgroundBlur:
typeof parsed.videoBackgroundBlur === 'boolean'
? parsed.videoBackgroundBlur
: DEFAULTS.videoBackgroundBlur,
};
return cached;
} catch {
cached = DEFAULTS;
return cached;
}
}
function write(s: AudioSettings): void {
cached = s;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} catch {
/* quota / private mode */
}
for (const l of listeners) l(s);
}
export function getAudioSettings(): AudioSettings {
return read();
}
export function updateAudioSettings(patch: Partial<AudioSettings>): AudioSettings {
const next = { ...read(), ...patch };
write(next);
return next;
}
export function subscribeAudioSettings(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}