| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """Minimal Gradio demo for real-time speech transcription using the vLLM Realtime API. |
| 4 | |
| 5 | Start the vLLM server first: |
| 6 | |
| 7 | vllm serve mistralai/Voxtral-Mini-4B-Realtime-2602 --enforce-eager |
| 8 | |
| 9 | Then run this script: |
| 10 | |
| 11 | python openai_realtime_microphone_client.py --host localhost --port 8000 |
| 12 | |
| 13 | Use --share to create a public Gradio link. |
| 14 | |
| 15 | Requirements: websockets, numpy, gradio |
| 16 | """ |
| 17 | |
| 18 | import argparse |
| 19 | import asyncio |
| 20 | import json |
| 21 | import queue |
| 22 | import threading |
| 23 | |
| 24 | import gradio as gr |
| 25 | import numpy as np |
| 26 | import pybase64 as base64 |
| 27 | import websockets |
| 28 | |
| 29 | SAMPLE_RATE = 16_000 |
| 30 | |
| 31 | # Global state |
| 32 | audio_queue: queue.Queue = queue.Queue() |
| 33 | transcription_text = "" |
| 34 | is_running = False |
| 35 | ws_url = "" |
| 36 | model = "" |
| 37 | |
| 38 | |
| 39 | async def websocket_handler(): |
| 40 | """Connect to WebSocket and handle audio streaming + transcription.""" |
| 41 | global transcription_text, is_running |
| 42 | |
| 43 | async with websockets.connect(ws_url) as ws: |
| 44 | # Wait for session.created |
| 45 | await ws.recv() |
| 46 | |
| 47 | # Validate model |
| 48 | await ws.send(json.dumps({"type": "session.update", "model": model})) |
| 49 | |
| 50 | # Signal ready |
| 51 | await ws.send(json.dumps({"type": "input_audio_buffer.commit"})) |
| 52 | |
| 53 | async def send_audio(): |
| 54 | while is_running: |
| 55 | try: |
| 56 | chunk = await asyncio.get_event_loop().run_in_executor( |
| 57 | None, lambda: audio_queue.get(timeout=0.1) |
| 58 | ) |
| 59 | await ws.send( |
| 60 | json.dumps( |
| 61 | {"type": "input_audio_buffer.append", "audio": chunk} |
| 62 | ) |
| 63 | ) |
| 64 | except queue.Empty: |
| 65 | continue |
| 66 | |
| 67 | async def receive_transcription(): |
| 68 | global transcription_text |
| 69 | async for message in ws: |
| 70 | data = json.loads(message) |
| 71 | if data.get("type") == "transcription.delta": |
| 72 | transcription_text += data["delta"] |
| 73 | |
| 74 | await asyncio.gather(send_audio(), receive_transcription()) |
| 75 | |
| 76 | |
| 77 | def start_websocket(): |
| 78 | """Start WebSocket connection in background thread.""" |
| 79 | global is_running |
| 80 | is_running = True |
| 81 | loop = asyncio.new_event_loop() |
| 82 | asyncio.set_event_loop(loop) |
| 83 | try: |
| 84 | loop.run_until_complete(websocket_handler()) |
| 85 | except Exception as e: |
| 86 | print(f"WebSocket error: {e}") |
| 87 | |
| 88 | |
| 89 | def start_recording(): |
| 90 | """Start the transcription service.""" |
| 91 | global transcription_text |
| 92 | transcription_text = "" |
| 93 | thread = threading.Thread(target=start_websocket, daemon=True) |
| 94 | thread.start() |
| 95 | return gr.update(interactive=False), gr.update(interactive=True), "" |
| 96 | |
| 97 | |
| 98 | def stop_recording(): |
| 99 | """Stop the transcription service.""" |
| 100 | global is_running |
| 101 | is_running = False |
| 102 | return gr.update(interactive=True), gr.update(interactive=False), transcription_text |
| 103 | |
| 104 | |
| 105 | def process_audio(audio): |
| 106 | """Process incoming audio and queue for streaming.""" |
| 107 | global transcription_text |
| 108 | |
| 109 | if audio is None or not is_running: |
| 110 | return transcription_text |
| 111 | |
| 112 | sample_rate, audio_data = audio |
| 113 | |
| 114 | # Convert to mono if stereo |
| 115 | if len(audio_data.shape) > 1: |
| 116 | audio_data = audio_data.mean(axis=1) |
| 117 | |
| 118 | # Normalize to float |
| 119 | if audio_data.dtype == np.int16: |
| 120 | audio_float = audio_data.astype(np.float32) / 32767.0 |
| 121 | else: |
| 122 | audio_float = audio_data.astype(np.float32) |
| 123 | |
| 124 | # Resample to 16kHz if needed |
| 125 | if sample_rate != SAMPLE_RATE: |
| 126 | num_samples = int(len(audio_float) * SAMPLE_RATE / sample_rate) |
| 127 | audio_float = np.interp( |
| 128 | np.linspace(0, len(audio_float) - 1, num_samples), |
| 129 | np.arange(len(audio_float)), |
| 130 | audio_float, |
| 131 | ) |
| 132 | |
| 133 | # Convert to PCM16 and base64 encode |
| 134 | pcm16 = (audio_float * 32767).astype(np.int16) |
| 135 | b64_chunk = base64.b64encode(pcm16.tobytes()).decode("utf-8") |
| 136 | audio_queue.put(b64_chunk) |
| 137 | |
| 138 | return transcription_text |
| 139 | |
| 140 | |
| 141 | # Gradio interface |
| 142 | with gr.Blocks(title="Real-time Speech Transcription") as demo: |
| 143 | gr.Markdown("# Real-time Speech Transcription") |
| 144 | gr.Markdown("Click **Start** and speak into your microphone.") |
| 145 | |
| 146 | with gr.Row(): |
| 147 | start_btn = gr.Button("Start", variant="primary") |
| 148 | stop_btn = gr.Button("Stop", variant="stop", interactive=False) |
| 149 | |
| 150 | audio_input = gr.Audio(sources=["microphone"], streaming=True, type="numpy") |
| 151 | transcription_output = gr.Textbox(label="Transcription", lines=5) |
| 152 | |
| 153 | start_btn.click( |
| 154 | start_recording, outputs=[start_btn, stop_btn, transcription_output] |
| 155 | ) |
| 156 | stop_btn.click(stop_recording, outputs=[start_btn, stop_btn, transcription_output]) |
| 157 | audio_input.stream( |
| 158 | process_audio, inputs=[audio_input], outputs=[transcription_output] |
| 159 | ) |
| 160 | |
| 161 | if __name__ == "__main__": |
| 162 | parser = argparse.ArgumentParser( |
| 163 | description="Realtime WebSocket Transcription with Gradio" |
| 164 | ) |
| 165 | parser.add_argument( |
| 166 | "--model", |
| 167 | type=str, |
| 168 | default="mistralai/Voxtral-Mini-4B-Realtime-2602", |
| 169 | help="Model that is served and should be pinged.", |
| 170 | ) |
| 171 | parser.add_argument( |
| 172 | "--host", type=str, default="localhost", help="vLLM server host" |
| 173 | ) |
| 174 | parser.add_argument("--port", type=int, default=8000, help="vLLM server port") |
| 175 | parser.add_argument( |
| 176 | "--share", action="store_true", help="Create public Gradio link" |
| 177 | ) |
| 178 | args = parser.parse_args() |
| 179 | |
| 180 | ws_url = f"ws://{args.host}:{args.port}/v1/realtime" |
| 181 | model = args.model |
| 182 | demo.launch(share=args.share) |