37becba7e2
Audio device selection: - audioSettings: persisted inputDeviceId + outputDeviceId - CallContext: uses stored input deviceId on mic enable, new setAudioInputDevice / setAudioOutputDevice actions that hot-swap without reconnect. Output swap applies HTMLMediaElement.setSinkId to every attached remote-audio element (LiveKit's switchActiveDevice only tracks elements it attached itself) - SettingsPage: new "Mikrofon" + "Ausgabegerät" selects with enumerateDevices, devicechange listener, permission-probe button. setSinkId-unsupported fallback is messaged but non-blocking Fullscreen: - FullscreenCall was absolute inset-0 z-40 which trapped it inside the <main> pane — sidebar + chat-list stayed visible. Switched to fixed inset-0 z-[60] so the call overlays the whole window Discord-style - ScreenShareViewer fullscreen: CSS-only toggle (native Fullscreen API unreliable under Tauri WKWebView), portalled to document.body when active so no ancestor stacking context can clip it. Esc exits ActiveCallBanner: - cleanup effect returned early when presence was entirely empty, leaving the "1 im Raum" fallback stuck after both peers left. Now schedules dismissLastCall as soon as othersIn.length === 0, with a 3s grace window to absorb presence re-sync flicker Bump tauri version 0.6.0 -> 0.7.0
125 lines
3.5 KiB
TypeScript
125 lines
3.5 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;
|
|
}
|
|
|
|
const DEFAULTS: AudioSettings = {
|
|
quality: 'voice',
|
|
inputDeviceId: null,
|
|
outputDeviceId: null,
|
|
};
|
|
|
|
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>;
|
|
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,
|
|
};
|
|
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);
|
|
}
|