mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-12 09:01:18 +00:00
fix call microphone disconnect recovery
This commit is contained in:
parent
29541cbb52
commit
51056f9b80
3 changed files with 204 additions and 16 deletions
|
|
@ -12,6 +12,7 @@
|
|||
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import VideoInputMenu from './CallOverlay/VideoInputMenu.svelte';
|
||||
import { hasLiveAudioTrack, singleFlight, watchAudioTrackEnd } from './CallOverlay/audio-stream';
|
||||
import { KokoroWorker } from '$lib/workers/KokoroWorker';
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
|
|
@ -44,6 +45,9 @@
|
|||
let mediaRecorder;
|
||||
let audioStream = null;
|
||||
let audioChunks = [];
|
||||
let audioStatus: 'listening' | 'reconnecting' | 'unavailable' = 'reconnecting';
|
||||
let stopWatchingAudioTrack = () => {};
|
||||
let audioAnalysisGeneration = 0;
|
||||
|
||||
let videoInputDevices = [];
|
||||
let selectedVideoInputDeviceId = null;
|
||||
|
|
@ -194,7 +198,7 @@
|
|||
mediaRecorder = false;
|
||||
|
||||
if (_continue) {
|
||||
startRecording();
|
||||
await startRecording();
|
||||
}
|
||||
|
||||
if (confirmed) {
|
||||
|
|
@ -232,21 +236,44 @@
|
|||
|
||||
const startRecording = async () => {
|
||||
if ($showCallOverlay) {
|
||||
if (!audioStream) {
|
||||
audioStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
}
|
||||
if (!hasLiveAudioTrack(audioStream)) {
|
||||
audioStatus = 'reconnecting';
|
||||
|
||||
try {
|
||||
audioStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error accessing media devices.', error);
|
||||
audioStream = null;
|
||||
audioStatus = 'unavailable';
|
||||
toast.error($i18n.t('Error accessing media devices.'));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$showCallOverlay) {
|
||||
audioStream.getAudioTracks().forEach((track) => track.stop());
|
||||
audioStream = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
stopWatchingAudioTrack();
|
||||
stopWatchingAudioTrack = watchAudioTrackEnd(audioStream, () => {
|
||||
void recoverAudioStream();
|
||||
});
|
||||
}
|
||||
|
||||
if (audioStream) {
|
||||
// hardware track muting disabled to prevent backend translation errors with malformed WebM files
|
||||
if (!hasLiveAudioTrack(audioStream)) {
|
||||
audioStatus = 'unavailable';
|
||||
return false;
|
||||
}
|
||||
|
||||
mediaRecorder = new MediaRecorder(audioStream);
|
||||
audioStatus = 'listening';
|
||||
|
||||
mediaRecorder.onstart = () => {
|
||||
console.log('Recording started');
|
||||
|
|
@ -264,19 +291,30 @@
|
|||
stopRecordingCallback();
|
||||
};
|
||||
|
||||
analyseAudio(audioStream);
|
||||
analyseAudio(audioStream, ++audioAnalysisGeneration);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const stopAudioStream = async () => {
|
||||
const recorder = mediaRecorder;
|
||||
mediaRecorder = false;
|
||||
audioAnalysisGeneration += 1;
|
||||
|
||||
try {
|
||||
if (mediaRecorder) {
|
||||
mediaRecorder.stop();
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
recorder.onstop = null;
|
||||
recorder.stop();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error stopping audio stream:', error);
|
||||
}
|
||||
|
||||
stopWatchingAudioTrack();
|
||||
stopWatchingAudioTrack = () => {};
|
||||
|
||||
if (!audioStream) return;
|
||||
|
||||
audioStream.getAudioTracks().forEach(function (track) {
|
||||
|
|
@ -286,6 +324,26 @@
|
|||
audioStream = null;
|
||||
};
|
||||
|
||||
const recoverAudioStream = singleFlight(async () => {
|
||||
if (!$showCallOverlay) {
|
||||
return false;
|
||||
}
|
||||
|
||||
audioStatus = 'reconnecting';
|
||||
hasStartedSpeaking = false;
|
||||
confirmed = false;
|
||||
audioChunks = [];
|
||||
|
||||
await stopAudioStream();
|
||||
return await startRecording();
|
||||
});
|
||||
|
||||
const handleAudioDeviceChange = () => {
|
||||
if ($showCallOverlay && !hasLiveAudioTrack(audioStream)) {
|
||||
void recoverAudioStream();
|
||||
}
|
||||
};
|
||||
|
||||
// Function to calculate the RMS level from time domain data
|
||||
const calculateRMS = (data: Uint8Array) => {
|
||||
let sumSquares = 0;
|
||||
|
|
@ -296,7 +354,7 @@
|
|||
return Math.sqrt(sumSquares / data.length);
|
||||
};
|
||||
|
||||
const analyseAudio = (stream) => {
|
||||
const analyseAudio = (stream, generation) => {
|
||||
const audioContext = new AudioContext();
|
||||
const audioStreamSource = audioContext.createMediaStreamSource(stream);
|
||||
|
||||
|
|
@ -316,7 +374,7 @@
|
|||
|
||||
const detectSound = () => {
|
||||
const processFrame = () => {
|
||||
if (!mediaRecorder || !$showCallOverlay) {
|
||||
if (!mediaRecorder || !$showCallOverlay || generation !== audioAnalysisGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -713,7 +771,8 @@
|
|||
|
||||
model = $models.find((m) => m.id === modelId);
|
||||
|
||||
startRecording();
|
||||
await startRecording();
|
||||
navigator.mediaDevices.addEventListener('devicechange', handleAudioDeviceChange);
|
||||
|
||||
eventTarget.addEventListener('chat:start', chatStartHandler);
|
||||
eventTarget.addEventListener('chat', chatEventHandler);
|
||||
|
|
@ -731,6 +790,7 @@
|
|||
eventTarget.removeEventListener('chat:finish', chatFinishHandler);
|
||||
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
navigator.mediaDevices.removeEventListener('devicechange', handleAudioDeviceChange);
|
||||
|
||||
audioAbortController.abort();
|
||||
await tick();
|
||||
|
|
@ -753,6 +813,7 @@
|
|||
eventTarget.removeEventListener('chat:finish', chatFinishHandler);
|
||||
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
navigator.mediaDevices.removeEventListener('devicechange', handleAudioDeviceChange);
|
||||
|
||||
audioAbortController.abort();
|
||||
|
||||
|
|
@ -957,6 +1018,8 @@
|
|||
on:click={() => {
|
||||
if (assistantSpeaking) {
|
||||
stopAllAudio();
|
||||
} else if (audioStatus === 'unavailable') {
|
||||
void recoverAudioStream();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
@ -967,6 +1030,10 @@
|
|||
{$i18n.t('Muted')}
|
||||
{:else if assistantSpeaking}
|
||||
{$i18n.t('Tap to interrupt')}
|
||||
{:else if audioStatus === 'reconnecting'}
|
||||
{$i18n.t('Connection lost. Reconnecting...')}
|
||||
{:else if audioStatus === 'unavailable'}
|
||||
{$i18n.t('Error accessing media devices.')} {$i18n.t('Retry')}
|
||||
{:else}
|
||||
{$i18n.t('Listening...')}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { hasLiveAudioTrack, singleFlight, watchAudioTrackEnd } from './audio-stream';
|
||||
|
||||
class FakeAudioTrack extends EventTarget {
|
||||
readyState: MediaStreamTrackState;
|
||||
|
||||
constructor(readyState: MediaStreamTrackState) {
|
||||
super();
|
||||
this.readyState = readyState;
|
||||
}
|
||||
|
||||
end() {
|
||||
this.readyState = 'ended';
|
||||
this.dispatchEvent(new Event('ended'));
|
||||
}
|
||||
}
|
||||
|
||||
const stream = (...tracks: FakeAudioTrack[]) => ({
|
||||
getAudioTracks: () => tracks
|
||||
});
|
||||
|
||||
describe('call audio stream lifecycle', () => {
|
||||
it('requires at least one live audio track', () => {
|
||||
expect(hasLiveAudioTrack(null)).toBe(false);
|
||||
expect(hasLiveAudioTrack(stream(new FakeAudioTrack('ended')))).toBe(false);
|
||||
expect(hasLiveAudioTrack(stream(new FakeAudioTrack('ended'), new FakeAudioTrack('live')))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a disconnected stream only once', () => {
|
||||
const first = new FakeAudioTrack('live');
|
||||
const second = new FakeAudioTrack('live');
|
||||
const onEnded = vi.fn();
|
||||
|
||||
watchAudioTrackEnd(stream(first, second), onEnded);
|
||||
first.end();
|
||||
second.end();
|
||||
|
||||
expect(onEnded).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not report intentional shutdown after cleanup', () => {
|
||||
const track = new FakeAudioTrack('live');
|
||||
const onEnded = vi.fn();
|
||||
const cleanup = watchAudioTrackEnd(stream(track), onEnded);
|
||||
|
||||
cleanup();
|
||||
track.end();
|
||||
|
||||
expect(onEnded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('coalesces simultaneous recovery signals and permits a later retry', async () => {
|
||||
let finishRecovery: (() => void) | undefined;
|
||||
const recover = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishRecovery = resolve;
|
||||
})
|
||||
);
|
||||
const runRecovery = singleFlight(recover);
|
||||
|
||||
const trackEndedRecovery = runRecovery();
|
||||
const deviceChangeRecovery = runRecovery();
|
||||
|
||||
expect(recover).toHaveBeenCalledOnce();
|
||||
expect(deviceChangeRecovery).toBe(trackEndedRecovery);
|
||||
|
||||
finishRecovery?.();
|
||||
await trackEndedRecovery;
|
||||
|
||||
const laterRecovery = runRecovery();
|
||||
expect(recover).toHaveBeenCalledTimes(2);
|
||||
finishRecovery?.();
|
||||
await laterRecovery;
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
type AudioTrack = Pick<MediaStreamTrack, 'addEventListener' | 'removeEventListener' | 'readyState'>;
|
||||
|
||||
type AudioStream = {
|
||||
getAudioTracks: () => AudioTrack[];
|
||||
};
|
||||
|
||||
export const hasLiveAudioTrack = (stream: AudioStream | null) =>
|
||||
stream?.getAudioTracks().some((track) => track.readyState === 'live') ?? false;
|
||||
|
||||
export const watchAudioTrackEnd = (stream: AudioStream, onEnded: () => void) => {
|
||||
const tracks = stream.getAudioTracks();
|
||||
let handled = false;
|
||||
|
||||
const handleEnded = () => {
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
|
||||
handled = true;
|
||||
onEnded();
|
||||
};
|
||||
|
||||
tracks.forEach((track) => track.addEventListener('ended', handleEnded));
|
||||
|
||||
return () => {
|
||||
handled = true;
|
||||
tracks.forEach((track) => track.removeEventListener('ended', handleEnded));
|
||||
};
|
||||
};
|
||||
|
||||
export const singleFlight = <T>(task: () => Promise<T>) => {
|
||||
let active: Promise<T> | null = null;
|
||||
|
||||
return () => {
|
||||
if (active === null) {
|
||||
active = task().finally(() => {
|
||||
active = null;
|
||||
});
|
||||
}
|
||||
|
||||
return active;
|
||||
};
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue