825160ee46
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.
Highlights:
- All 17 Tauri release commits ported (audio fixes, custom notification
sound, Discord-style chat UX, profile banner, changelog page,
Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
fix).
- Native napi-rs audio-loopback addon with WASAPI process-loopback:
* EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
never hear themselves echoed back through the capture.
* INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
picked window's audio is captured, not the whole OS mixer
(Discord parity).
* HWND -> PID resolution via Win32 GetWindowThreadProcessId.
- Discord-style screen-source picker (thumbnail grid, screens vs
apps tabs, live-refreshing thumbnails).
- Hash routing fix for packaged builds (file:// can't resolve
BrowserRouter paths).
- Tauri sources removed (apps/desktop/src-tauri).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
171 lines
6.2 KiB
TypeScript
171 lines
6.2 KiB
TypeScript
// Central registry of Web-Audio pipelines for every remote audio track we're
|
|
// playing. Exists because `HTMLMediaElement.volume` caps at 1.0 — to let a
|
|
// user boost a quiet peer past 100% we need an explicit GainNode in the
|
|
// output chain. Once a track is on the WebAudio path, `audio.muted` / volume
|
|
// no longer drive output (the MediaElementSource diverts samples through the
|
|
// graph), so all gating — deafen, watch-state, manual mute, per-user volume —
|
|
// is collapsed into a single effective-gain value per pipeline.
|
|
//
|
|
// The registry is a plain module-level Map. Attach/detach lifecycle is owned
|
|
// by CallContext's attachTrack/detachTrack helpers; gain recomputation is
|
|
// also triggered from CallContext when any state the formula depends on
|
|
// changes.
|
|
|
|
export type RemoteTrackSource = 'microphone' | 'screenshare';
|
|
|
|
export interface RemoteAudioPipeline {
|
|
/** LiveKit track sid — stable identifier for this track's lifetime. */
|
|
trackSid: string;
|
|
participantId: string;
|
|
trackSource: RemoteTrackSource;
|
|
audio: HTMLAudioElement;
|
|
ctx: AudioContext;
|
|
source: MediaElementAudioSourceNode;
|
|
gain: GainNode;
|
|
}
|
|
|
|
const pipelines = new Map<string, RemoteAudioPipeline>();
|
|
|
|
// Build the WebAudio chain for an already-attached HTMLAudioElement. Returns
|
|
// null when WebAudio isn't available (older browsers) — callers should fall
|
|
// back to `audio.volume` in that case.
|
|
export function createPipeline(
|
|
audio: HTMLAudioElement,
|
|
info: { trackSid: string; participantId: string; trackSource: RemoteTrackSource },
|
|
): RemoteAudioPipeline | null {
|
|
const AudioCtx =
|
|
window.AudioContext ??
|
|
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
|
if (!AudioCtx) return null;
|
|
try {
|
|
const ctx = new AudioCtx();
|
|
// A fresh AudioContext under Chromium's autoplay policy starts in
|
|
// `suspended` state when no recent user gesture is in scope —
|
|
// attachTrack fires from a LiveKit event, not the call-start click,
|
|
// so we can't rely on the gesture crossing the async boundary. Kick
|
|
// resume() immediately and retry on any statechange so a later
|
|
// suspension (window backgrounding, device change) doesn't leave
|
|
// the remote peer silent permanently.
|
|
const tryResume = () => {
|
|
if (ctx.state === 'suspended') {
|
|
void ctx.resume().catch(() => {
|
|
/* ignore — will retry on next statechange */
|
|
});
|
|
}
|
|
};
|
|
tryResume();
|
|
ctx.addEventListener('statechange', tryResume);
|
|
const source = ctx.createMediaElementSource(audio);
|
|
const gain = ctx.createGain();
|
|
// Start silent; the caller (CallContext) applies the correct effective
|
|
// gain immediately after registering via `setPipelineGain`.
|
|
gain.gain.value = 0;
|
|
source.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
// Do NOT set `audio.muted = true` here. Chromium gates the
|
|
// media-element's internal sample production behind the muted flag,
|
|
// and that gate sits *before* the MediaElementAudioSourceNode tap —
|
|
// a muted element feeds zero samples into the WebAudio graph, which
|
|
// silences the peer even though createMediaElementSource already
|
|
// diverts the element's direct playback path. The diversion itself
|
|
// is sufficient to stop the element from double-playing to the
|
|
// default output; explicit muting is the bug.
|
|
const pipeline: RemoteAudioPipeline = {
|
|
trackSid: info.trackSid,
|
|
participantId: info.participantId,
|
|
trackSource: info.trackSource,
|
|
audio,
|
|
ctx,
|
|
source,
|
|
gain,
|
|
};
|
|
pipelines.set(info.trackSid, pipeline);
|
|
return pipeline;
|
|
} catch (err: unknown) {
|
|
console.warn('createPipeline failed', err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function destroyPipeline(trackSid: string): void {
|
|
const p = pipelines.get(trackSid);
|
|
if (!p) return;
|
|
pipelines.delete(trackSid);
|
|
try {
|
|
p.source.disconnect();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
try {
|
|
p.gain.disconnect();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
void p.ctx.close().catch(() => {
|
|
/* ignore */
|
|
});
|
|
}
|
|
|
|
export function allPipelines(): IterableIterator<RemoteAudioPipeline> {
|
|
return pipelines.values();
|
|
}
|
|
|
|
export function getPipeline(trackSid: string): RemoteAudioPipeline | undefined {
|
|
return pipelines.get(trackSid);
|
|
}
|
|
|
|
export function pipelinesFor(
|
|
participantId: string,
|
|
source?: RemoteTrackSource,
|
|
): RemoteAudioPipeline[] {
|
|
const out: RemoteAudioPipeline[] = [];
|
|
for (const p of pipelines.values()) {
|
|
if (p.participantId !== participantId) continue;
|
|
if (source && p.trackSource !== source) continue;
|
|
out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Ramp the gain slightly (~20ms) so 0→2x doesn't introduce a click and so
|
|
// rapid slider drags stay smooth. ctx.currentTime is the right anchor —
|
|
// setValueAtTime jumps abruptly.
|
|
export function setPipelineGain(pipeline: RemoteAudioPipeline, value: number): void {
|
|
const v = clampGain(value);
|
|
try {
|
|
pipeline.gain.gain.setTargetAtTime(v, pipeline.ctx.currentTime, 0.02);
|
|
} catch {
|
|
// Some contexts in a closed state throw — just direct-assign as a
|
|
// fallback; worst case the next valid call smooths it out.
|
|
pipeline.gain.gain.value = v;
|
|
}
|
|
}
|
|
|
|
export function clampGain(v: number): number {
|
|
if (!Number.isFinite(v)) return 0;
|
|
if (v < 0) return 0;
|
|
// 2.0 matches Discord's upper bound (200%). Going higher invites clipping
|
|
// since the source is already peaking at 1.0 for most mics.
|
|
if (v > 2) return 2;
|
|
return v;
|
|
}
|
|
|
|
// Route every pipeline's AudioContext to the given sink. Feature-detects
|
|
// `AudioContext.setSinkId` (Chrome 115+); older runtimes silently keep the
|
|
// default sink, which is the behaviour we had before the WebAudio refactor
|
|
// so this is a strict improvement rather than a regression.
|
|
export async function setAllPipelinesSinkId(deviceId: string): Promise<void> {
|
|
const maybeId = deviceId.length === 0 ? 'default' : deviceId;
|
|
for (const p of pipelines.values()) {
|
|
const ctxAny = p.ctx as unknown as {
|
|
setSinkId?: (id: string) => Promise<void>;
|
|
};
|
|
if (typeof ctxAny.setSinkId !== 'function') continue;
|
|
try {
|
|
await ctxAny.setSinkId(maybeId);
|
|
} catch (err: unknown) {
|
|
console.warn('AudioContext.setSinkId failed', { trackSid: p.trackSid, err });
|
|
}
|
|
}
|
|
}
|