huang_de_jun commited on
Commit Β·
e8ee7ad
1
Parent(s): d951f86
feat: add background threads for transcription and show statuses concurrently
Browse files
app.py
CHANGED
|
@@ -40,10 +40,72 @@ def get_initial_state():
|
|
| 40 |
"questions_html": format_questions_html([]),
|
| 41 |
"log_html": format_activity_log([]),
|
| 42 |
"speaker_name": "",
|
| 43 |
-
"generation_in_progress": False # Track if background generation is running
|
|
|
|
| 44 |
}
|
| 45 |
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
def generate_questions_background(state: dict):
|
| 48 |
"""
|
| 49 |
Generate questions in background thread.
|
|
@@ -103,7 +165,6 @@ def process_streaming_audio(
|
|
| 103 |
if "context" not in state:
|
| 104 |
state = get_initial_state()
|
| 105 |
|
| 106 |
-
ctx = state["context"]
|
| 107 |
activity_log = state.get("activity_log", [])
|
| 108 |
|
| 109 |
# Convert to float32 if needed
|
|
@@ -122,11 +183,36 @@ def process_streaming_audio(
|
|
| 122 |
# Accumulate audio
|
| 123 |
state["audio_buffer"] = np.concatenate([state.get("audio_buffer", np.array([], dtype=np.float32)), audio_data])
|
| 124 |
|
| 125 |
-
#
|
| 126 |
samples_needed = sample_rate * CHUNK_DURATION
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
return (
|
| 131 |
state.get("transcript", ""),
|
| 132 |
state.get("log_html", format_activity_log(activity_log)),
|
|
@@ -136,107 +222,31 @@ def process_streaming_audio(
|
|
| 136 |
)
|
| 137 |
|
| 138 |
# Process the buffered audio with overlap
|
| 139 |
-
audio_to_process = state["audio_buffer"][:samples_needed]
|
| 140 |
# Keep last OVERLAP_DURATION seconds for next chunk to prevent word cutoffs
|
| 141 |
overlap_samples = sample_rate * OVERLAP_DURATION
|
| 142 |
state["audio_buffer"] = state["audio_buffer"][samples_needed - overlap_samples:]
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
wav.writeframes((audio_to_process * 32767).astype(np.int16).tobytes())
|
| 153 |
-
temp_path = f.name
|
| 154 |
-
|
| 155 |
-
# Transcribe
|
| 156 |
-
new_text = transcribe_audio(temp_path)
|
| 157 |
-
os.unlink(temp_path)
|
| 158 |
-
|
| 159 |
-
if new_text.strip():
|
| 160 |
-
ctx.add_transcript(new_text)
|
| 161 |
-
state["transcript"] = ctx.get_full_transcript()
|
| 162 |
-
|
| 163 |
-
current_word_count = len(state["transcript"].split())
|
| 164 |
-
words_since_last = current_word_count - state.get("last_word_count", 0)
|
| 165 |
-
|
| 166 |
-
# Add transcription to activity log
|
| 167 |
-
activity_log.append({"type": "transcribe", "words": current_word_count})
|
| 168 |
-
state["activity_log"] = activity_log
|
| 169 |
-
|
| 170 |
-
# Check if word cap reached
|
| 171 |
-
if current_word_count >= MAX_WORDS_CAP:
|
| 172 |
-
status = f'<div class="status-bar" style="border-color:#f59e0b;">β οΈ Word limit reached ({MAX_WORDS_CAP} words). Clear session to continue.</div>'
|
| 173 |
-
state["log_html"] = format_activity_log(activity_log)
|
| 174 |
-
return (
|
| 175 |
-
state["transcript"],
|
| 176 |
-
state["log_html"],
|
| 177 |
-
state.get("questions_html", format_questions_html([])),
|
| 178 |
-
status,
|
| 179 |
-
state
|
| 180 |
-
)
|
| 181 |
-
|
| 182 |
-
status = f'<div class="status-bar">π€ Listening... {current_word_count} words captured</div>'
|
| 183 |
-
|
| 184 |
-
# Check if we should generate questions
|
| 185 |
-
should_generate = (
|
| 186 |
-
current_word_count >= MIN_WORDS_FOR_QUESTIONS and
|
| 187 |
-
words_since_last >= WORDS_BETWEEN_GENERATIONS and
|
| 188 |
-
not state.get("generation_in_progress", False) # Don't start if already generating
|
| 189 |
-
)
|
| 190 |
-
|
| 191 |
-
if should_generate:
|
| 192 |
-
state["last_word_count"] = current_word_count
|
| 193 |
-
state["generation_in_progress"] = True
|
| 194 |
-
|
| 195 |
-
# Start background thread for question generation (non-blocking)
|
| 196 |
-
thread = threading.Thread(
|
| 197 |
-
target=generate_questions_background,
|
| 198 |
-
args=(state,),
|
| 199 |
-
daemon=True
|
| 200 |
-
)
|
| 201 |
-
thread.start()
|
| 202 |
-
|
| 203 |
-
status = f'<div class="status-bar">π€ Listening... {current_word_count} words | βοΈ Generating questions...</div>'
|
| 204 |
-
else:
|
| 205 |
-
# Update log HTML
|
| 206 |
-
state["log_html"] = format_activity_log(activity_log)
|
| 207 |
-
|
| 208 |
-
# Show appropriate status
|
| 209 |
-
if state.get("generation_in_progress", False):
|
| 210 |
-
status = f'<div class="status-bar">π€ Listening... {current_word_count} words | βοΈ Generating questions...</div>'
|
| 211 |
-
elif current_word_count < MIN_WORDS_FOR_QUESTIONS:
|
| 212 |
-
need_more = MIN_WORDS_FOR_QUESTIONS - current_word_count
|
| 213 |
-
status = f'<div class="status-bar">π€ Listening... {current_word_count} words (need {need_more} more to start generating)</div>'
|
| 214 |
-
|
| 215 |
-
return (
|
| 216 |
-
state["transcript"],
|
| 217 |
-
state["log_html"],
|
| 218 |
-
state.get("questions_html", format_questions_html([])),
|
| 219 |
-
status,
|
| 220 |
-
state
|
| 221 |
-
)
|
| 222 |
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
state.get("transcript", ""),
|
| 229 |
-
state["log_html"],
|
| 230 |
-
state.get("questions_html", format_questions_html([])),
|
| 231 |
-
f'<div class="status-bar" style="border-color:#ef4444;">β Error: {str(e)}</div>',
|
| 232 |
-
state
|
| 233 |
-
)
|
| 234 |
|
| 235 |
return (
|
| 236 |
state.get("transcript", ""),
|
| 237 |
-
state.get("log_html", format_activity_log(
|
| 238 |
-
state.get("questions_html", format_questions_html([])),
|
| 239 |
-
|
| 240 |
state
|
| 241 |
)
|
| 242 |
|
|
|
|
| 40 |
"questions_html": format_questions_html([]),
|
| 41 |
"log_html": format_activity_log([]),
|
| 42 |
"speaker_name": "",
|
| 43 |
+
"generation_in_progress": False, # Track if background generation is running
|
| 44 |
+
"transcription_in_progress": False # Track if background transcription is running
|
| 45 |
}
|
| 46 |
|
| 47 |
|
| 48 |
+
def transcribe_audio_background(audio_data: np.ndarray, sample_rate: int, state: dict):
|
| 49 |
+
"""
|
| 50 |
+
Transcribe audio in background thread.
|
| 51 |
+
Updates state dict directly (shared reference).
|
| 52 |
+
After transcription, may trigger question generation.
|
| 53 |
+
"""
|
| 54 |
+
try:
|
| 55 |
+
import wave
|
| 56 |
+
|
| 57 |
+
# Save to temp file for Whisper
|
| 58 |
+
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
|
| 59 |
+
with wave.open(f.name, 'wb') as wav:
|
| 60 |
+
wav.setnchannels(1)
|
| 61 |
+
wav.setsampwidth(2)
|
| 62 |
+
wav.setframerate(sample_rate)
|
| 63 |
+
wav.writeframes((audio_data * 32767).astype(np.int16).tobytes())
|
| 64 |
+
temp_path = f.name
|
| 65 |
+
|
| 66 |
+
# Transcribe
|
| 67 |
+
new_text = transcribe_audio(temp_path)
|
| 68 |
+
os.unlink(temp_path)
|
| 69 |
+
|
| 70 |
+
if new_text.strip():
|
| 71 |
+
ctx = state["context"]
|
| 72 |
+
ctx.add_transcript(new_text)
|
| 73 |
+
state["transcript"] = ctx.get_full_transcript()
|
| 74 |
+
|
| 75 |
+
current_word_count = len(state["transcript"].split())
|
| 76 |
+
words_since_last = current_word_count - state.get("last_word_count", 0)
|
| 77 |
+
|
| 78 |
+
# Add transcription to activity log
|
| 79 |
+
state["activity_log"].append({"type": "transcribe", "words": current_word_count})
|
| 80 |
+
state["log_html"] = format_activity_log(state["activity_log"])
|
| 81 |
+
|
| 82 |
+
# Check if we should generate questions (and not already generating)
|
| 83 |
+
should_generate = (
|
| 84 |
+
current_word_count >= MIN_WORDS_FOR_QUESTIONS and
|
| 85 |
+
current_word_count < MAX_WORDS_CAP and
|
| 86 |
+
words_since_last >= WORDS_BETWEEN_GENERATIONS and
|
| 87 |
+
not state.get("generation_in_progress", False)
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
if should_generate:
|
| 91 |
+
state["last_word_count"] = current_word_count
|
| 92 |
+
state["generation_in_progress"] = True
|
| 93 |
+
|
| 94 |
+
# Start question generation in another thread
|
| 95 |
+
thread = threading.Thread(
|
| 96 |
+
target=generate_questions_background,
|
| 97 |
+
args=(state,),
|
| 98 |
+
daemon=True
|
| 99 |
+
)
|
| 100 |
+
thread.start()
|
| 101 |
+
|
| 102 |
+
except Exception as e:
|
| 103 |
+
state["activity_log"].append({"type": "error", "msg": f"Transcription error: {str(e)}"})
|
| 104 |
+
state["log_html"] = format_activity_log(state["activity_log"])
|
| 105 |
+
finally:
|
| 106 |
+
state["transcription_in_progress"] = False
|
| 107 |
+
|
| 108 |
+
|
| 109 |
def generate_questions_background(state: dict):
|
| 110 |
"""
|
| 111 |
Generate questions in background thread.
|
|
|
|
| 165 |
if "context" not in state:
|
| 166 |
state = get_initial_state()
|
| 167 |
|
|
|
|
| 168 |
activity_log = state.get("activity_log", [])
|
| 169 |
|
| 170 |
# Convert to float32 if needed
|
|
|
|
| 183 |
# Accumulate audio
|
| 184 |
state["audio_buffer"] = np.concatenate([state.get("audio_buffer", np.array([], dtype=np.float32)), audio_data])
|
| 185 |
|
| 186 |
+
# Calculate status info
|
| 187 |
samples_needed = sample_rate * CHUNK_DURATION
|
| 188 |
+
buffer_samples = len(state["audio_buffer"])
|
| 189 |
+
buffer_pct = int(buffer_samples / samples_needed * 100)
|
| 190 |
+
word_count = len(state.get("transcript", "").split())
|
| 191 |
+
is_transcribing = state.get("transcription_in_progress", False)
|
| 192 |
+
is_generating = state.get("generation_in_progress", False)
|
| 193 |
+
|
| 194 |
+
# Build status parts
|
| 195 |
+
status_parts = [f"π€ Buffer: {buffer_pct}%", f"π {word_count} words"]
|
| 196 |
+
if is_transcribing:
|
| 197 |
+
status_parts.append("π Transcribing...")
|
| 198 |
+
if is_generating:
|
| 199 |
+
status_parts.append("βοΈ Generating...")
|
| 200 |
+
|
| 201 |
+
status = f'<div class="status-bar">{" | ".join(status_parts)}</div>'
|
| 202 |
+
|
| 203 |
+
# Check if we have enough audio to process (CHUNK_DURATION seconds)
|
| 204 |
+
if buffer_samples < samples_needed:
|
| 205 |
+
return (
|
| 206 |
+
state.get("transcript", ""),
|
| 207 |
+
state.get("log_html", format_activity_log(activity_log)),
|
| 208 |
+
state.get("questions_html", format_questions_html(state.get("questions", []))),
|
| 209 |
+
status,
|
| 210 |
+
state
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
# Check if transcription is already running (sequential queue)
|
| 214 |
+
if is_transcribing:
|
| 215 |
+
# Keep buffering, don't start new transcription yet
|
| 216 |
return (
|
| 217 |
state.get("transcript", ""),
|
| 218 |
state.get("log_html", format_activity_log(activity_log)),
|
|
|
|
| 222 |
)
|
| 223 |
|
| 224 |
# Process the buffered audio with overlap
|
| 225 |
+
audio_to_process = state["audio_buffer"][:samples_needed].copy() # Copy for thread safety
|
| 226 |
# Keep last OVERLAP_DURATION seconds for next chunk to prevent word cutoffs
|
| 227 |
overlap_samples = sample_rate * OVERLAP_DURATION
|
| 228 |
state["audio_buffer"] = state["audio_buffer"][samples_needed - overlap_samples:]
|
| 229 |
|
| 230 |
+
# Start background transcription (non-blocking)
|
| 231 |
+
state["transcription_in_progress"] = True
|
| 232 |
+
thread = threading.Thread(
|
| 233 |
+
target=transcribe_audio_background,
|
| 234 |
+
args=(audio_to_process, sample_rate, state),
|
| 235 |
+
daemon=True
|
| 236 |
+
)
|
| 237 |
+
thread.start()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
|
| 239 |
+
# Update status to show transcribing started
|
| 240 |
+
status_parts = [f"π€ Buffer: {int(overlap_samples / samples_needed * 100)}%", f"π {word_count} words", "π Transcribing..."]
|
| 241 |
+
if is_generating:
|
| 242 |
+
status_parts.append("βοΈ Generating...")
|
| 243 |
+
status = f'<div class="status-bar">{" | ".join(status_parts)}</div>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
|
| 245 |
return (
|
| 246 |
state.get("transcript", ""),
|
| 247 |
+
state.get("log_html", format_activity_log(activity_log)),
|
| 248 |
+
state.get("questions_html", format_questions_html(state.get("questions", []))),
|
| 249 |
+
status,
|
| 250 |
state
|
| 251 |
)
|
| 252 |
|