feat: stream back audio to user (test)

This commit is contained in:
Marco Beretta 2025-01-03 23:14:18 +01:00 committed by Danny Avila
parent 964d47cfa3
commit 7aed891838
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
6 changed files with 151 additions and 49 deletions

View file

@ -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",

View file

@ -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',
});
} }
} }

View file

@ -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>

View file

@ -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);
}), }),
]; ];

View file

@ -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 (candidate) {
this.sendSignalingMessage({
type: 'icecandidate',
payload: candidate.toJSON(),
}); });
if (track.kind === 'audio') {
// Create remote stream if needed
if (!this.remoteStream) {
this.remoteStream = new MediaStream();
}
// 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,10 +275,18 @@ 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);
// Don't set failed state if we're already connected
if (this.connectionState !== ConnectionState.CONNECTED) {
this.setConnectionState(ConnectionState.FAILED); this.setConnectionState(ConnectionState.FAILED);
this.emit('error', errorMessage); this.emit('error', errorMessage);
}
// Only close if we're not connected
if (this.connectionState !== ConnectionState.CONNECTED) {
this.close(); this.close();
} }
}
public close() { public close() {
this.clearConnectionTimeout(); this.clearConnectionTimeout();

1
package-lock.json generated
View file

@ -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",