mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-20 18:30:15 +01:00
✨ feat: stream back audio to user (test)
This commit is contained in:
parent
964d47cfa3
commit
7aed891838
6 changed files with 151 additions and 49 deletions
|
|
@ -82,7 +82,6 @@
|
||||||
"mongoose": "^8.9.5",
|
"mongoose": "^8.9.5",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"nanoid": "^3.3.7",
|
"nanoid": "^3.3.7",
|
||||||
"node-pre-gyp": "^0.17.0",
|
|
||||||
"nodemailer": "^6.9.15",
|
"nodemailer": "^6.9.15",
|
||||||
"ollama": "^0.5.0",
|
"ollama": "^0.5.0",
|
||||||
"openai": "^4.47.1",
|
"openai": "^4.47.1",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
const { Server } = require('socket.io');
|
const { Server } = require('socket.io');
|
||||||
const { RTCPeerConnection, RTCIceCandidate } = require('wrtc');
|
const { RTCPeerConnection, RTCIceCandidate, MediaStream } = require('wrtc');
|
||||||
|
|
||||||
class WebRTCConnection {
|
class WebRTCConnection {
|
||||||
constructor(socket, config) {
|
constructor(socket, config) {
|
||||||
|
|
@ -14,38 +14,31 @@ class WebRTCConnection {
|
||||||
|
|
||||||
async handleOffer(offer) {
|
async handleOffer(offer) {
|
||||||
try {
|
try {
|
||||||
// Create new peer connection if needed
|
|
||||||
if (!this.peerConnection) {
|
if (!this.peerConnection) {
|
||||||
this.peerConnection = new RTCPeerConnection(this.config.rtcConfig);
|
this.peerConnection = new RTCPeerConnection(this.config.rtcConfig);
|
||||||
this.setupPeerConnectionListeners();
|
this.setupPeerConnectionListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the remote description (client's offer)
|
|
||||||
await this.peerConnection.setRemoteDescription(offer);
|
await this.peerConnection.setRemoteDescription(offer);
|
||||||
|
|
||||||
// Set up audio transceiver for two-way audio
|
// Create MediaStream instance properly
|
||||||
|
const mediaStream = new MediaStream();
|
||||||
|
|
||||||
this.audioTransceiver = this.peerConnection.addTransceiver('audio', {
|
this.audioTransceiver = this.peerConnection.addTransceiver('audio', {
|
||||||
direction: 'sendrecv',
|
direction: 'sendrecv',
|
||||||
|
streams: [mediaStream],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create and set local description (answer)
|
|
||||||
const answer = await this.peerConnection.createAnswer();
|
const answer = await this.peerConnection.createAnswer();
|
||||||
await this.peerConnection.setLocalDescription(answer);
|
await this.peerConnection.setLocalDescription(answer);
|
||||||
|
|
||||||
// Send answer to client
|
|
||||||
this.socket.emit('webrtc-answer', answer);
|
this.socket.emit('webrtc-answer', answer);
|
||||||
|
|
||||||
// Process any pending ICE candidates
|
|
||||||
while (this.pendingCandidates.length) {
|
|
||||||
const candidate = this.pendingCandidates.shift();
|
|
||||||
await this.addIceCandidate(candidate);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state = 'connecting';
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.log(`Error handling offer: ${error}`, 'error');
|
this.log(`Error handling offer: ${error}`, 'error');
|
||||||
this.socket.emit('error', { message: 'Failed to process offer' });
|
// Don't throw, handle gracefully
|
||||||
this.cleanup();
|
this.socket.emit('webrtc-error', {
|
||||||
|
message: error.message,
|
||||||
|
code: 'OFFER_ERROR',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ export const Call: React.FC = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (remoteAudioRef.current && remoteStream) {
|
if (remoteAudioRef.current && remoteStream) {
|
||||||
remoteAudioRef.current.srcObject = remoteStream;
|
remoteAudioRef.current.srcObject = remoteStream;
|
||||||
|
|
||||||
|
remoteAudioRef.current.play().catch((err) => console.error('Error playing audio:', err));
|
||||||
}
|
}
|
||||||
}, [remoteStream]);
|
}, [remoteStream]);
|
||||||
|
|
||||||
|
|
@ -98,6 +100,36 @@ export const Call: React.FC = () => {
|
||||||
const isActive = callState === CallState.ACTIVE;
|
const isActive = callState === CallState.ACTIVE;
|
||||||
const isError = callState === CallState.ERROR;
|
const isError = callState === CallState.ERROR;
|
||||||
|
|
||||||
|
// TESTS
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (remoteAudioRef.current && remoteStream) {
|
||||||
|
console.log('Setting up remote audio:', {
|
||||||
|
tracks: remoteStream.getTracks().length,
|
||||||
|
active: remoteStream.active,
|
||||||
|
});
|
||||||
|
|
||||||
|
remoteAudioRef.current.srcObject = remoteStream;
|
||||||
|
remoteAudioRef.current.muted = false;
|
||||||
|
remoteAudioRef.current.volume = 1.0;
|
||||||
|
|
||||||
|
const playPromise = remoteAudioRef.current.play();
|
||||||
|
if (playPromise) {
|
||||||
|
playPromise.catch((err) => {
|
||||||
|
console.error('Error playing audio:', err);
|
||||||
|
// Retry play on user interaction
|
||||||
|
document.addEventListener(
|
||||||
|
'click',
|
||||||
|
() => {
|
||||||
|
remoteAudioRef.current?.play();
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [remoteStream]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OGDialog open={open} onOpenChange={setOpen}>
|
<OGDialog open={open} onOpenChange={setOpen}>
|
||||||
<OGDialogContent className="w-[28rem] p-8">
|
<OGDialogContent className="w-[28rem] p-8">
|
||||||
|
|
@ -200,7 +232,11 @@ export const Call: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hidden Audio Element */}
|
{/* Hidden Audio Element */}
|
||||||
<audio ref={remoteAudioRef} autoPlay>
|
<audio
|
||||||
|
ref={remoteAudioRef}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
>
|
||||||
<track kind="captions" />
|
<track kind="captions" />
|
||||||
</audio>
|
</audio>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,46 @@ const useCall = () => {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleRemoteStream = (stream: MediaStream | null) => {
|
||||||
|
console.log('[WebRTC] Remote stream received:', {
|
||||||
|
stream: stream,
|
||||||
|
active: stream?.active,
|
||||||
|
tracks: stream?.getTracks().map((t) => ({
|
||||||
|
kind: t.kind,
|
||||||
|
enabled: t.enabled,
|
||||||
|
muted: t.muted,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!stream) {
|
||||||
|
console.error('[WebRTC] Received null remote stream');
|
||||||
|
updateStatus({
|
||||||
|
error: {
|
||||||
|
code: 'NO_REMOTE_STREAM',
|
||||||
|
message: 'No remote stream received',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioTracks = stream.getAudioTracks();
|
||||||
|
if (!audioTracks.length) {
|
||||||
|
console.error('[WebRTC] No audio tracks in remote stream');
|
||||||
|
updateStatus({
|
||||||
|
error: {
|
||||||
|
code: 'NO_AUDIO_TRACKS',
|
||||||
|
message: 'Remote stream contains no audio',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus({
|
||||||
|
remoteStream: stream,
|
||||||
|
callState: CallState.ACTIVE,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleConnectionStateChange = useCallback(
|
const handleConnectionStateChange = useCallback(
|
||||||
(state: ConnectionState) => {
|
(state: ConnectionState) => {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
|
|
@ -123,6 +163,7 @@ const useCall = () => {
|
||||||
|
|
||||||
const startCall = useCallback(async () => {
|
const startCall = useCallback(async () => {
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
|
console.log('Cannot start call - not connected to server');
|
||||||
updateStatus({
|
updateStatus({
|
||||||
callState: CallState.ERROR,
|
callState: CallState.ERROR,
|
||||||
error: {
|
error: {
|
||||||
|
|
@ -134,7 +175,10 @@ const useCall = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('Starting new call...');
|
||||||
|
|
||||||
if (webrtcServiceRef.current) {
|
if (webrtcServiceRef.current) {
|
||||||
|
console.log('Cleaning up existing WebRTC connection');
|
||||||
webrtcServiceRef.current.close();
|
webrtcServiceRef.current.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,13 +193,15 @@ const useCall = () => {
|
||||||
debug: true,
|
debug: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
webrtcServiceRef.current.on('connectionStateChange', handleConnectionStateChange);
|
webrtcServiceRef.current.on('connectionStateChange', (state: ConnectionState) => {
|
||||||
|
console.log('WebRTC connection state changed:', state);
|
||||||
webrtcServiceRef.current.on('remoteStream', (stream: MediaStream) => {
|
handleConnectionStateChange(state);
|
||||||
updateStatus({ remoteStream: stream });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
webrtcServiceRef.current.on('remoteStream', handleRemoteStream);
|
||||||
|
|
||||||
webrtcServiceRef.current.on('error', (error: string) => {
|
webrtcServiceRef.current.on('error', (error: string) => {
|
||||||
|
console.error('WebRTC error:', error);
|
||||||
updateStatus({
|
updateStatus({
|
||||||
callState: CallState.ERROR,
|
callState: CallState.ERROR,
|
||||||
isConnecting: false,
|
isConnecting: false,
|
||||||
|
|
@ -166,9 +212,13 @@ const useCall = () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('Initializing WebRTC connection...');
|
||||||
await webrtcServiceRef.current.initialize();
|
await webrtcServiceRef.current.initialize();
|
||||||
|
console.log('WebRTC initialization complete');
|
||||||
|
|
||||||
startConnectionMonitoring();
|
startConnectionMonitoring();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Failed to start call:', error);
|
||||||
updateStatus({
|
updateStatus({
|
||||||
callState: CallState.ERROR,
|
callState: CallState.ERROR,
|
||||||
isConnecting: false,
|
isConnecting: false,
|
||||||
|
|
@ -203,9 +253,11 @@ const useCall = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cleanupFns = [
|
const cleanupFns = [
|
||||||
addEventListener(WebSocketEvents.WEBRTC_ANSWER, (answer: RTCSessionDescriptionInit) => {
|
addEventListener(WebSocketEvents.WEBRTC_ANSWER, (answer: RTCSessionDescriptionInit) => {
|
||||||
|
console.log('Received WebRTC answer:', answer);
|
||||||
webrtcServiceRef.current?.handleAnswer(answer);
|
webrtcServiceRef.current?.handleAnswer(answer);
|
||||||
}),
|
}),
|
||||||
addEventListener(WebSocketEvents.ICE_CANDIDATE, (candidate: RTCIceCandidateInit) => {
|
addEventListener(WebSocketEvents.ICE_CANDIDATE, (candidate: RTCIceCandidateInit) => {
|
||||||
|
console.log('Received ICE candidate:', candidate);
|
||||||
webrtcServiceRef.current?.addIceCandidate(candidate);
|
webrtcServiceRef.current?.addIceCandidate(candidate);
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export class WebRTCService extends EventEmitter {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
maxReconnectAttempts: 3,
|
maxReconnectAttempts: 3,
|
||||||
connectionTimeout: 15000,
|
connectionTimeout: 30000,
|
||||||
debug: false,
|
debug: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -124,30 +124,49 @@ export class WebRTCService extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.peerConnection.ontrack = ({ track, streams }) => {
|
this.peerConnection.ontrack = ({ track, streams }) => {
|
||||||
this.log('Received remote track:', track.kind);
|
this.log('Track received:', {
|
||||||
this.remoteStream = streams[0];
|
kind: track.kind,
|
||||||
this.emit('remoteStream', this.remoteStream);
|
enabled: track.enabled,
|
||||||
};
|
readyState: track.readyState,
|
||||||
|
});
|
||||||
|
|
||||||
this.peerConnection.onicecandidate = ({ candidate }) => {
|
if (track.kind === 'audio') {
|
||||||
if (candidate) {
|
// Create remote stream if needed
|
||||||
this.sendSignalingMessage({
|
if (!this.remoteStream) {
|
||||||
type: 'icecandidate',
|
this.remoteStream = new MediaStream();
|
||||||
payload: candidate.toJSON(),
|
}
|
||||||
|
|
||||||
|
// Add incoming track to remote stream
|
||||||
|
this.remoteStream.addTrack(track);
|
||||||
|
|
||||||
|
// Echo back the track
|
||||||
|
if (this.peerConnection) {
|
||||||
|
this.peerConnection.addTrack(track, this.remoteStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log('Audio track added to remote stream', {
|
||||||
|
tracks: this.remoteStream.getTracks().length,
|
||||||
|
active: this.remoteStream.active,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.emit('remoteStream', this.remoteStream);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.peerConnection.onconnectionstatechange = () => {
|
this.peerConnection.onconnectionstatechange = () => {
|
||||||
const state = this.peerConnection?.connectionState;
|
if (!this.peerConnection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = this.peerConnection.connectionState;
|
||||||
this.log('Connection state changed:', state);
|
this.log('Connection state changed:', state);
|
||||||
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case 'connected':
|
case 'connected':
|
||||||
|
this.clearConnectionTimeout(); // Clear timeout when connected
|
||||||
this.setConnectionState(ConnectionState.CONNECTED);
|
this.setConnectionState(ConnectionState.CONNECTED);
|
||||||
this.clearConnectionTimeout();
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
break;
|
break;
|
||||||
|
case 'disconnected':
|
||||||
case 'failed':
|
case 'failed':
|
||||||
if (this.reconnectAttempts < this.config.maxReconnectAttempts) {
|
if (this.reconnectAttempts < this.config.maxReconnectAttempts) {
|
||||||
this.attemptReconnection();
|
this.attemptReconnection();
|
||||||
|
|
@ -155,19 +174,11 @@ export class WebRTCService extends EventEmitter {
|
||||||
this.handleError(new Error('Connection failed after max reconnection attempts'));
|
this.handleError(new Error('Connection failed after max reconnection attempts'));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'disconnected':
|
|
||||||
this.setConnectionState(ConnectionState.RECONNECTING);
|
|
||||||
this.attemptReconnection();
|
|
||||||
break;
|
|
||||||
case 'closed':
|
case 'closed':
|
||||||
this.setConnectionState(ConnectionState.CLOSED);
|
this.setConnectionState(ConnectionState.CLOSED);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.peerConnection.oniceconnectionstatechange = () => {
|
|
||||||
this.log('ICE connection state:', this.peerConnection?.iceConnectionState);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createAndSendOffer() {
|
private async createAndSendOffer() {
|
||||||
|
|
@ -221,7 +232,11 @@ export class WebRTCService extends EventEmitter {
|
||||||
private startConnectionTimeout() {
|
private startConnectionTimeout() {
|
||||||
this.clearConnectionTimeout();
|
this.clearConnectionTimeout();
|
||||||
this.connectionTimeoutId = setTimeout(() => {
|
this.connectionTimeoutId = setTimeout(() => {
|
||||||
if (this.connectionState !== ConnectionState.CONNECTED) {
|
// Only timeout if we're not in a connected or connecting state
|
||||||
|
if (
|
||||||
|
this.connectionState !== ConnectionState.CONNECTED &&
|
||||||
|
this.connectionState !== ConnectionState.CONNECTING
|
||||||
|
) {
|
||||||
this.handleError(new Error('Connection timeout'));
|
this.handleError(new Error('Connection timeout'));
|
||||||
}
|
}
|
||||||
}, this.config.connectionTimeout);
|
}, this.config.connectionTimeout);
|
||||||
|
|
@ -260,9 +275,17 @@ export class WebRTCService extends EventEmitter {
|
||||||
private handleError(error: Error | unknown) {
|
private handleError(error: Error | unknown) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||||
this.log('Error:', errorMessage);
|
this.log('Error:', errorMessage);
|
||||||
this.setConnectionState(ConnectionState.FAILED);
|
|
||||||
this.emit('error', errorMessage);
|
// Don't set failed state if we're already connected
|
||||||
this.close();
|
if (this.connectionState !== ConnectionState.CONNECTED) {
|
||||||
|
this.setConnectionState(ConnectionState.FAILED);
|
||||||
|
this.emit('error', errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only close if we're not connected
|
||||||
|
if (this.connectionState !== ConnectionState.CONNECTED) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public close() {
|
public close() {
|
||||||
|
|
|
||||||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -98,7 +98,6 @@
|
||||||
"mongoose": "^8.9.5",
|
"mongoose": "^8.9.5",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"nanoid": "^3.3.7",
|
"nanoid": "^3.3.7",
|
||||||
"node-pre-gyp": "^0.17.0",
|
|
||||||
"nodemailer": "^6.9.15",
|
"nodemailer": "^6.9.15",
|
||||||
"ollama": "^0.5.0",
|
"ollama": "^0.5.0",
|
||||||
"openai": "^4.47.1",
|
"openai": "^4.47.1",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue