-
Notifications
You must be signed in to change notification settings - Fork 143
feat(audio): implement foundational full-duplex WebSocket audio pipeline for STT/TTS (fixes #2278) #2283
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(audio): implement foundational full-duplex WebSocket audio pipeline for STT/TTS (fixes #2278) #2283
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| // audioPipelineController.js | ||
| // Handles full-duplex WebSocket connections for STT/LLM/TTS streaming. | ||
|
|
||
| const setupAudioPipeline = (wss) => { | ||
| wss.on('connection', (ws) => { | ||
| console.log('New Audio Pipeline WebSocket Connection Established'); | ||
|
|
||
| // In a real implementation, we would pipe these chunks to a streaming STT API | ||
| // (e.g., Whisper, Deepgram, Google Speech-to-Text). | ||
| ws.on('message', (message) => { | ||
| // 1. Receive binary audio chunks from the frontend client. | ||
| // 2. STT Provider converts to text. | ||
|
|
||
| // MOCK: Echo back a mock transcript and simulated audio chunk | ||
| const mockTranscript = "That's a great question regarding system design."; | ||
|
|
||
| // Send the text transcript back to the frontend for UI display | ||
| ws.send(JSON.stringify({ type: 'transcript', text: mockTranscript })); | ||
|
|
||
| // 3. The transcript is passed to the LLM (e.g., OpenAI, Gemini) to generate an AI response. | ||
| // 4. The LLM response is passed to a TTS Provider (e.g., ElevenLabs) to generate audio. | ||
|
|
||
| // MOCK: Send simulated binary audio bytes back to the client | ||
| const mockAudioBytes = Buffer.from('mock_audio_bytes_representing_speech'); | ||
| ws.send(mockAudioBytes); | ||
| }); | ||
|
|
||
| ws.on('close', () => { | ||
| console.log('Audio Pipeline WebSocket Connection Closed'); | ||
| }); | ||
|
|
||
| ws.on('error', (error) => { | ||
| console.error('Audio Pipeline WebSocket Error:', error); | ||
| }); | ||
| }); | ||
| }; | ||
|
|
||
| module.exports = { setupAudioPipeline }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| import { useState, useEffect, useRef } from 'react'; | ||
|
|
||
| export const useAudioPipeline = (wsUrl) => { | ||
| const [isRecording, setIsRecording] = useState(false); | ||
| const [transcript, setTranscript] = useState(''); | ||
| const wsRef = useRef(null); | ||
| const mediaRecorderRef = useRef(null); | ||
| const audioContextRef = useRef(null); | ||
|
|
||
| useEffect(() => { | ||
| // Initialize WebSocket connection to the backend | ||
| wsRef.current = new WebSocket(wsUrl); | ||
|
|
||
| wsRef.current.onopen = () => { | ||
| console.log('Connected to Audio Pipeline WebSockets'); | ||
| }; | ||
|
|
||
| wsRef.current.onmessage = async (event) => { | ||
| // Differentiate between JSON (transcript text) and Blob (audio bytes) | ||
| if (typeof event.data === 'string') { | ||
| try { | ||
| const data = JSON.parse(event.data); | ||
| if (data.type === 'transcript') { | ||
| setTranscript(data.text); | ||
| } | ||
| } catch (e) { | ||
| console.error("Failed to parse JSON transcript", e); | ||
| } | ||
| } else if (event.data instanceof Blob) { | ||
| // We received binary audio bytes from the TTS API via backend | ||
| console.log('Received TTS audio chunk'); | ||
| await playAudioChunk(event.data); | ||
| } | ||
| }; | ||
|
|
||
| return () => { | ||
| if (wsRef.current) wsRef.current.close(); | ||
| }; | ||
|
Comment on lines
+36
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Stop microphone capture during cleanup. Line 36 closes only the WebSocket. If the component unmounts while recording, Stop the recorder, stop every stream track, and close the 🤖 Prompt for AI Agents |
||
| }, [wsUrl]); | ||
|
|
||
| const playAudioChunk = async (blob) => { | ||
| if (!audioContextRef.current) { | ||
| audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)(); | ||
| } | ||
| const arrayBuffer = await blob.arrayBuffer(); | ||
| // In a full implementation, you decode AudioData. | ||
| // For now, this is a stub. | ||
| console.log(`Stub: Playing audio chunk of size ${arrayBuffer.byteLength} bytes`); | ||
| }; | ||
|
|
||
| const startRecording = async () => { | ||
| try { | ||
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | ||
| mediaRecorderRef.current = new MediaRecorder(stream, { mimeType: 'audio/webm' }); | ||
|
|
||
| mediaRecorderRef.current.ondataavailable = (event) => { | ||
| if (event.data.size > 0 && wsRef.current?.readyState === WebSocket.OPEN) { | ||
| // Stream raw audio chunks to backend | ||
| wsRef.current.send(event.data); | ||
| } | ||
| }; | ||
|
|
||
| // Request chunks every 250ms for low latency | ||
| mediaRecorderRef.current.start(250); | ||
|
Comment on lines
+57
to
+64
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Wait for the WebSocket before recording. If the user starts recording while the socket is connecting, Line 57 discards each chunk but Line 65 sets Disable recording until 🤖 Prompt for AI Agents |
||
| setIsRecording(true); | ||
| } catch (err) { | ||
| console.error('Microphone permission denied', err); | ||
| } | ||
| }; | ||
|
|
||
| const stopRecording = () => { | ||
| if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') { | ||
| mediaRecorderRef.current.stop(); | ||
| mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop()); | ||
| } | ||
| setIsRecording(false); | ||
| }; | ||
|
|
||
| return { isRecording, startRecording, stopRecording, transcript }; | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: Canopus-Labs/PrepPilot
Length of output: 13398
Denial of Service (CWE-400): Uncontrolled Resource Consumption
Reachability: External · Exploitability: Trivial
Reachability path
Add WebSocket connection and message limits.
Each inbound message creates two outbound frames without rate, byte, or backpressure limits. Enforce per-connection message and byte quotas, cap active connections, and close clients that exceed those limits.
🤖 Prompt for AI Agents