-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathstreaming.py
More file actions
239 lines (187 loc) · 8.56 KB
/
Copy pathstreaming.py
File metadata and controls
239 lines (187 loc) · 8.56 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
"""
Streaming transcription module for ctrlSPEAK.
Handles real-time streaming transcription for models that support it
(e.g., Nemotron). Uses a worker thread to process audio chunks without
blocking the audio callback.
"""
import logging
import threading
import queue
import numpy as np
import state
logger = logging.getLogger("ctrlspeak.streaming")
# Streaming worker thread state
_streaming_queue = None
_streaming_worker_thread = None
_streaming_stop_event = None
def _streaming_worker():
"""Worker thread for processing streaming audio chunks.
Runs in a separate thread to avoid blocking the audio callback.
Pulls chunks from the queue and processes them through the model.
"""
global _streaming_queue, _streaming_stop_event
logger.info("Streaming worker thread started")
chunk_count = 0
while not _streaming_stop_event.is_set():
try:
# Wait for a chunk with timeout to allow checking stop event
queue_item = _streaming_queue.get(timeout=0.1)
if queue_item is None:
# Sentinel value - stop processing
logger.debug("[WORKER] Received stop sentinel")
break
# Unpack tuple (audio_samples, is_final)
audio_samples, is_final = queue_item
chunk_count += 1
duration_ms = len(audio_samples) / 16000 * 1000
# Calculate RMS to verify audio level
rms = np.sqrt(np.mean(audio_samples**2))
max_amp = np.abs(audio_samples).max()
logger.debug(f"[WORKER] Processing chunk #{chunk_count}: {len(audio_samples)} samples ({duration_ms:.0f}ms), RMS={rms:.4f}, max={max_amp:.4f}, is_final={is_final}")
if state.stt_model is None:
logger.warning("Streaming chunk received but no model loaded")
_streaming_queue.task_done()
continue
# Process chunk through model's streaming API
text = state.stt_model.stream_chunk(audio_samples, is_final=is_final)
# Update accumulated text (streaming returns cumulative text)
if text:
# For streaming, the model returns cumulative text, not deltas
# So we replace rather than append
if state.transcribed_chunks:
state.transcribed_chunks[-1] = text
else:
state.transcribed_chunks.append(text)
# Update UI if available
if hasattr(state, 'app_state_ref') and state.app_state_ref:
state.app_state_ref.accumulated_text = text
if len(text) > 50:
logger.debug(f"[WORKER] Chunk #{chunk_count} result: \"{text[:50]}...\"")
else:
logger.debug(f"[WORKER] Chunk #{chunk_count} result: \"{text}\"")
logger.debug(f"[WORKER] Chunk #{chunk_count} done, calling task_done()")
_streaming_queue.task_done()
except queue.Empty:
# Timeout - just continue to check stop event
continue
except Exception as e:
logger.error(f"Error in streaming worker: {e}")
try:
_streaming_queue.task_done()
except ValueError:
pass
logger.info(f"[WORKER] Streaming worker thread stopped after processing {chunk_count} chunks")
def on_streaming_chunk(audio_samples, is_final=False):
"""Callback for streaming mode - queues audio chunk for processing.
Called by AudioManager with fixed-interval audio chunks.
Queues the chunk for the streaming worker thread to process.
Args:
audio_samples: numpy array of float32 audio samples (16kHz mono)
is_final: if True, this is the last chunk and decoder should flush
"""
global _streaming_queue
if _streaming_queue is not None:
try:
qsize = _streaming_queue.qsize()
duration_ms = len(audio_samples) / 16000 * 1000
logger.debug(f"[QUEUE_ADD] Queueing chunk: {len(audio_samples)} samples ({duration_ms:.0f}ms), is_final={is_final}, queue size: {qsize}")
# Queue as tuple (audio, is_final)
_streaming_queue.put_nowait((audio_samples, is_final))
except queue.Full:
logger.warning("Streaming queue full, dropping chunk")
else:
logger.warning("Streaming chunk received but queue not initialized")
def start_streaming():
"""Start streaming transcription session.
Initializes the model's streaming state, starts the worker thread,
and begins audio collection in streaming mode.
"""
global _streaming_queue, _streaming_worker_thread, _streaming_stop_event
logger.info("Starting streaming recording session...")
# Initialize model's streaming state
state.stt_model.init_streaming()
# Get chunk size from model (if available)
# 1120ms (14 frames) gives best accuracy, 560ms is faster but lower quality
chunk_size_ms = 1120 # Default to best accuracy
if hasattr(state.stt_model, 'chunk_size_ms'):
chunk_size_ms = state.stt_model.chunk_size_ms
# Initialize transcription storage with empty string for streaming
state.transcribed_chunks.clear()
state.transcribed_chunks.append("") # Placeholder for cumulative text
# Reset accumulated text for UI
if hasattr(state, 'app_state_ref') and state.app_state_ref:
state.app_state_ref.accumulated_text = ""
# Initialize streaming queue and worker thread
_streaming_queue = queue.Queue(maxsize=50) # Buffer up to 50 chunks
_streaming_stop_event = threading.Event()
_streaming_worker_thread = threading.Thread(
target=_streaming_worker,
name="StreamingWorker",
daemon=True
)
_streaming_worker_thread.start()
# Start streaming audio collection
state.audio_manager.start_streaming(
chunk_size_ms=chunk_size_ms,
on_chunk_callback=on_streaming_chunk
)
def stop_streaming():
"""Stop streaming transcription and return final text.
Stops audio collection, waits for worker thread to finish,
and finalizes the model's streaming state.
Returns:
Final transcribed text from the streaming session.
"""
global _streaming_queue, _streaming_worker_thread, _streaming_stop_event
logger.info("[STOP] Stopping streaming recording session...")
# Stop audio collection (processes remaining buffer and queues final chunk)
logger.debug("[STOP] Calling audio_manager.stop_streaming()...")
state.audio_manager.stop_streaming()
logger.debug("[STOP] audio_manager.stop_streaming() completed")
# Wait for all queued chunks to be processed BEFORE stopping worker
# This fixes the race condition where final chunk wasn't transcribed
if _streaming_queue:
qsize = _streaming_queue.qsize()
logger.info(f"[STOP] Waiting for queue to drain ({qsize} items remaining)...")
try:
_streaming_queue.join()
logger.info("[STOP] Queue drained successfully - all chunks processed")
except Exception as e:
logger.warning(f"[STOP] Error waiting for queue: {e}")
# Now stop the streaming worker thread
if _streaming_stop_event:
_streaming_stop_event.set()
# Send sentinel to wake up worker if waiting
if _streaming_queue:
try:
_streaming_queue.put_nowait(None)
except queue.Full:
pass
# Wait for worker thread to finish
if _streaming_worker_thread and _streaming_worker_thread.is_alive():
logger.debug("Waiting for streaming worker thread to finish...")
_streaming_worker_thread.join(timeout=2.0)
if _streaming_worker_thread.is_alive():
logger.warning("Streaming worker thread did not stop in time")
# Finalize model's streaming state and get final text
logger.debug("[STOP] Calling model.finalize_streaming()...")
final_text = state.stt_model.finalize_streaming()
if final_text:
logger.info(f"[STOP] Final text ({len(final_text)} chars): \"{final_text[:80]}{'...' if len(final_text) > 80 else ''}\"")
else:
logger.warning("[STOP] finalize_streaming returned empty text")
# Cleanup
_streaming_queue = None
_streaming_worker_thread = None
_streaming_stop_event = None
return final_text
def is_model_streaming_capable():
"""Check if the current model supports streaming transcription.
Returns:
True if model supports streaming, False otherwise.
"""
return (
state.stt_model is not None and
hasattr(state.stt_model, 'supports_streaming') and
state.stt_model.supports_streaming
)