Luis Küffner commited on
Commit
e368e99
·
1 Parent(s): be23752

Push latest updates

Browse files
.gitignore CHANGED
@@ -1,13 +1,27 @@
 
 
 
 
 
 
1
 
2
- pyproject.toml
3
  __pycache__/
4
- .gradio/
5
- app.py.lprof
6
- .DS_Store
7
  .venv/
 
8
  .ruff_cache/
9
  pyrightconfig.json
 
 
 
 
10
  .env
11
- .python-version
 
 
 
 
 
 
 
12
  *.aicmodel
13
- uv.lock
 
1
+ # Local / editor
2
+ .vscode/
3
+ .idea/
4
+ *.swp
5
+ *.swo
6
+ .DS_Store
7
 
8
+ # Python
9
  __pycache__/
 
 
 
10
  .venv/
11
+ .python-version
12
  .ruff_cache/
13
  pyrightconfig.json
14
+ pyproject.toml
15
+ uv.lock
16
+
17
+ # App / env
18
  .env
19
+ .env.*
20
+ !.env.example
21
+
22
+ # Gradio / profiling
23
+ .gradio/
24
+ app.py.lprof
25
+
26
+ # Models / assets (large or local-only)
27
  *.aicmodel
 
README.md CHANGED
@@ -4,7 +4,7 @@ emoji: 🌍
4
  colorFrom: red
5
  colorTo: pink
6
  sdk: gradio
7
- sdk_version: 5.47.0
8
  app_file: app.py
9
  pinned: false
10
  short_description: Try out ai-coustics speech enhancement models.
 
4
  colorFrom: red
5
  colorTo: pink
6
  sdk: gradio
7
+ sdk_version: "6.0"
8
  app_file: app.py
9
  pinned: false
10
  short_description: Try out ai-coustics speech enhancement models.
app.py CHANGED
@@ -1,9 +1,16 @@
 
 
 
1
  import gradio as gr
2
- from constants import STREAM_EVERY
3
- from hf_dataset_utils import ALL_FILES
 
 
4
 
5
  from stream_pipeline import (
6
  clear_ui,
 
 
7
  set_stt_streamer,
8
  stop_online_backend,
9
  transcribe_stream,
@@ -12,17 +19,140 @@ from stream_pipeline import (
12
  from offline_pipeline import (
13
  load_file_from_dataset,
14
  load_local_file,
15
- denoise_audio,
16
- retrieve_audio_information,
17
  )
 
18
  from clean_up import purge_tmp_directory, cleanup_previous_run
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  with gr.Blocks() as demo:
22
  sample_stem = gr.State("")
23
  last_sample_stem = gr.State("")
24
  input_array = gr.State()
25
  enhanced_array = gr.State()
 
26
 
27
  gr.HTML(
28
  '<a href="https://ai-coustics.com/" target="_blank">'
@@ -39,12 +169,12 @@ with gr.Blocks() as demo:
39
  value="Soniox STT-RT v3",
40
  interactive=True,
41
  )
42
- set_stt_streamer("Soniox STT-RT v3")
43
  enhancement_level = gr.Slider(
44
  minimum=0,
45
  maximum=100,
46
  step=1,
47
- value=100,
48
  label="Enhancement level (%)",
49
  scale=2,
50
  )
@@ -54,38 +184,73 @@ with gr.Blocks() as demo:
54
  gr.Markdown(open("docs/online.md", "r", encoding="utf-8").read())
55
  with gr.Group(elem_classes="panel"):
56
  stream_state = gr.State(None)
57
- audio_stream = gr.Audio(
58
- sources=["microphone"], streaming=True, elem_id="audio_stream"
 
 
 
 
 
 
59
  )
 
 
 
 
 
 
 
60
  with gr.Group(elem_classes="panel"):
61
  with gr.Column(scale=5, min_width=320):
62
  enhanced_text = gr.Textbox(
63
- label="Enhanced Transcribed Text", lines=6
64
  )
65
  with gr.Column(scale=5, min_width=320):
66
- raw_text = gr.Textbox(label="Raw Transcribed Text", lines=6)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
  with gr.Tab("Dataset: Dawn Chorus") as dataset_tab:
69
  with gr.Row():
70
  gr.Markdown(open("docs/dawn_chorus.md", "r", encoding="utf-8").read())
71
- dataset_dropdown = gr.Dropdown(choices=ALL_FILES, value=None)
 
 
72
  audio_file_from_dataset = gr.Audio(
73
- type="filepath", interactive=False, show_download_button=True
74
  )
75
 
76
  with gr.Tab("Upload local file") as upload_tab:
77
  with gr.Row():
78
  gr.Markdown(open("docs/local_file.md", "r", encoding="utf-8").read())
79
  audio_file_upload = gr.Audio(
80
- type="filepath", sources=["upload"], show_download_button=True
81
  )
82
 
83
- enhance_btn = gr.Button("Enhance", scale=2, visible=False)
84
 
85
  with gr.Group(elem_classes="panel results-card", visible=False) as results_card:
86
  result_title = gr.Markdown("")
87
  enhanced_audio = gr.Audio(
88
- type="numpy", interactive=False, show_download_button=True
 
 
 
89
  )
90
 
91
  with gr.Row(equal_height=True, elem_classes="results-row"):
@@ -111,32 +276,56 @@ with gr.Blocks() as demo:
111
  # ------------------------------------------------------
112
  # GLOBAL EVENTS (TAB CHANGES, MODEL CHANGES)
113
  # ------------------------------------------------------
 
 
 
 
 
 
 
 
114
  stream_tab.select(
115
  lambda: (
116
  gr.update(visible=False),
117
  gr.update(visible=False),
118
- gr.update(visible=True, interactive=True, streaming=True),
 
 
 
 
119
  ),
120
  inputs=None,
121
- outputs=[results_card, enhance_btn, audio_stream],
122
  )
123
 
124
  upload_tab.select(
125
- stop_online_backend,
 
 
 
 
126
  inputs=None,
127
- outputs=[stream_state, enhanced_text, raw_text, audio_stream],
128
  ).then(
129
  lambda: gr.update(visible=True),
130
  outputs=enhance_btn,
131
  )
132
 
133
  dataset_tab.select(
134
- stop_online_backend,
 
 
 
 
135
  inputs=None,
136
- outputs=[stream_state, enhanced_text, raw_text, audio_stream],
137
  ).then(
138
  lambda: gr.update(visible=True),
139
  outputs=enhance_btn,
 
 
 
 
140
  )
141
 
142
  stt_model.change(
@@ -157,7 +346,7 @@ with gr.Blocks() as demo:
157
 
158
  audio_stream.stream(
159
  fn=transcribe_stream,
160
- inputs=[stream_state, audio_stream, enhancement_level],
161
  outputs=[stream_state, enhanced_text, raw_text],
162
  stream_every=STREAM_EVERY,
163
  time_limit=60 * 2,
@@ -165,7 +354,7 @@ with gr.Blocks() as demo:
165
  )
166
 
167
  audio_stream.stop_recording(
168
- shutdown_streamers,
169
  )
170
 
171
  audio_stream.start_recording(
@@ -199,40 +388,33 @@ with gr.Blocks() as demo:
199
  inputs=None,
200
  outputs=results_card,
201
  ).then(
202
- load_local_file, inputs=[audio_file_upload], outputs=[input_array, sample_stem]
 
 
203
  )
204
 
205
- # Enhancement button triggers the audio processing and and shows results when done.
206
  enhance_btn.click(
207
- cleanup_previous_run,
208
- inputs=[last_sample_stem],
209
  outputs=[
 
 
210
  enhanced_image,
211
  noisy_image,
212
- enhanced_transcript,
213
  original_transcript,
214
  noisy_transcript,
215
- ],
216
- ).success(
217
- denoise_audio,
218
- inputs=[input_array, enhancement_level],
219
- outputs=[enhanced_array, enhanced_audio],
220
- ).success(
221
- lambda: gr.update(visible=True),
222
- inputs=None,
223
- outputs=results_card,
224
- ).success(
225
- retrieve_audio_information,
226
- inputs=[input_array, enhanced_array, sample_stem, stt_model],
227
- outputs=[
228
- enhanced_image,
229
- noisy_image,
230
  enhanced_transcript,
231
- original_transcript,
232
- noisy_transcript,
233
  last_sample_stem,
 
 
234
  ],
235
  )
236
 
237
- purge_tmp_directory(max_age_minutes=0, skip_substrings=[])
238
- demo.launch(allowed_paths=["/tmp", "/"])
 
 
 
 
 
 
1
+ import os
2
+ import threading
3
+ import time
4
  import gradio as gr
5
+ from pathlib import Path
6
+
7
+ from constants import STREAM_EVERY, WARMUP_SECONDS, APP_TMP_DIR
8
+ from hf_dataset_utils import ALL_FILES, get_transcript
9
 
10
  from stream_pipeline import (
11
  clear_ui,
12
+ get_live_transcripts,
13
+ on_stop_recording,
14
  set_stt_streamer,
15
  stop_online_backend,
16
  transcribe_stream,
 
19
  from offline_pipeline import (
20
  load_file_from_dataset,
21
  load_local_file,
22
+ run_offline_pipeline_streaming,
 
23
  )
24
+ from utils import spec_image
25
  from clean_up import purge_tmp_directory, cleanup_previous_run
26
 
27
+ # Active light HTML: whole container is the light (gray = warming up, red = ready)
28
+ ACTIVE_LIGHT_GRAY = (
29
+ '<div class="active-light active-light--off" title="Warming up" style="display:flex;justify-content:center;align-items:center;width:100%;height:100%;min-height:3rem;">'
30
+ '<span class="active-light__label">Warming up...</span></div>'
31
+ )
32
+ ACTIVE_LIGHT_RED = (
33
+ '<div class="active-light active-light--on" title="Ready" style="display:flex;justify-content:center;align-items:center;width:100%;height:100%;min-height:3rem;">'
34
+ '<span class="active-light__label">Ready!</span></div>'
35
+ )
36
+ WARMUP_TICKS = max(1, int(WARMUP_SECONDS * 2)) # timer ticks every 0.5s
37
+
38
+
39
+ def warmup_tick(on_stream_tab, warmup_elapsed, _current_html):
40
+ if not on_stream_tab:
41
+ return 0, ACTIVE_LIGHT_GRAY, gr.update(active=True)
42
+ if warmup_elapsed >= WARMUP_TICKS:
43
+ return warmup_elapsed, ACTIVE_LIGHT_RED, gr.update(active=False)
44
+ return warmup_elapsed + 1, ACTIVE_LIGHT_GRAY, gr.update(active=True)
45
+
46
+
47
+ def process_with_live_transcript(
48
+ input_array,
49
+ enhancement_level,
50
+ sample_stem,
51
+ stt_model,
52
+ last_sample_stem,
53
+ ):
54
+ """Generator that runs the offline pipeline in real time (chunked): enhanced audio and
55
+ both transcripts stream from the first chunk so playback and transcription start immediately."""
56
+ progress_state = {}
57
+ result_holder = {}
58
+
59
+ def worker():
60
+ try:
61
+ result_holder["result"] = run_offline_pipeline_streaming(
62
+ input_array,
63
+ enhancement_level,
64
+ sample_stem,
65
+ stt_model,
66
+ progress_state,
67
+ )
68
+ except Exception as e:
69
+ result_holder["error"] = e
70
+
71
+ # 1) First yield: ground truth + input spectrogram only (no audio, no enhanced spec, no transcripts yet)
72
+ cleanup_out = cleanup_previous_run(last_sample_stem)
73
+ noisy_spec_path = f"{APP_TMP_DIR}/{sample_stem}_noisy_spectrogram.png"
74
+ if input_array is not None:
75
+ try:
76
+ spec_image(input_array).save(noisy_spec_path)
77
+ except Exception:
78
+ noisy_spec_path = None
79
+ else:
80
+ noisy_spec_path = None
81
+ try:
82
+ original_transcript = get_transcript(sample_stem)
83
+ except Exception:
84
+ original_transcript = "Unavailable"
85
+
86
+ yield (
87
+ gr.update(visible=True),
88
+ None, # enhanced_audio: set only in final yield (smooth playback)
89
+ gr.update(value=None), # enhanced_image: clear until step 3 (last)
90
+ gr.update(value=noisy_spec_path), # noisy_image: input spectrogram (step 1)
91
+ original_transcript,
92
+ "",
93
+ "",
94
+ sample_stem,
95
+ None,
96
+ "",
97
+ )
98
+ # Let the UI render step 1 before we flood with polling updates
99
+ time.sleep(0.2)
100
+
101
+ thread = threading.Thread(target=worker, daemon=True)
102
+ thread.start()
103
+
104
+ poll_interval = 0.05
105
+ while "result" not in result_holder and "error" not in result_holder:
106
+ time.sleep(poll_interval)
107
+ # 2) Realtime: stream transcripts only; audio set in final yield for smooth playback
108
+ yield (
109
+ gr.update(visible=True),
110
+ gr.update(), # enhanced_audio: set only in final yield, then autoplay
111
+ gr.update(), # enhanced_image: reveal only in step 3 (final yield)
112
+ gr.update(), # noisy_image already set in step 1
113
+ gr.update(), # original_transcript unchanged
114
+ gr.update(value=progress_state.get("noisy", "")),
115
+ gr.update(value=progress_state.get("enhanced", "")),
116
+ gr.update(),
117
+ gr.update(),
118
+ gr.update(),
119
+ )
120
+
121
+ if "error" in result_holder:
122
+ raise result_holder["error"]
123
+
124
+ (
125
+ enhanced_spec_path,
126
+ enhanced_transcript,
127
+ noisy_transcript_with_wer,
128
+ enhanced_audio,
129
+ last_stem,
130
+ enhanced_array,
131
+ precomputed_noisy,
132
+ ) = result_holder["result"]
133
 
134
+ # 3) Last: reveal enhanced spectrogram (and final audio/transcripts)
135
+ yield (
136
+ gr.update(visible=True),
137
+ enhanced_audio,
138
+ enhanced_spec_path, # enhanced_image: show only now
139
+ noisy_spec_path,
140
+ original_transcript,
141
+ noisy_transcript_with_wer,
142
+ enhanced_transcript,
143
+ last_stem,
144
+ enhanced_array,
145
+ precomputed_noisy,
146
+ )
147
+
148
+
149
+ _CSS_DIR = Path(__file__).resolve().parent / "assets"
150
  with gr.Blocks() as demo:
151
  sample_stem = gr.State("")
152
  last_sample_stem = gr.State("")
153
  input_array = gr.State()
154
  enhanced_array = gr.State()
155
+ precomputed_noisy_transcript = gr.State("")
156
 
157
  gr.HTML(
158
  '<a href="https://ai-coustics.com/" target="_blank">'
 
169
  value="Soniox STT-RT v3",
170
  interactive=True,
171
  )
172
+ # Streamers are created on first start_recording to avoid Soniox "No audio received" timeout
173
  enhancement_level = gr.Slider(
174
  minimum=0,
175
  maximum=100,
176
  step=1,
177
+ value=80,
178
  label="Enhancement level (%)",
179
  scale=2,
180
  )
 
184
  gr.Markdown(open("docs/online.md", "r", encoding="utf-8").read())
185
  with gr.Group(elem_classes="panel"):
186
  stream_state = gr.State(None)
187
+ on_stream_tab = gr.State(True) # True on load: stream tab is the default first tab
188
+ warmup_elapsed = gr.State(0)
189
+ input_gain_db = gr.Slider(
190
+ minimum=0,
191
+ maximum=20,
192
+ step=0.5,
193
+ value=0,
194
+ label="Input gain (dB)",
195
  )
196
+ with gr.Row(elem_classes="stream-row"):
197
+ with gr.Column(scale=4, min_width=200):
198
+ audio_stream = gr.Audio(
199
+ sources=["microphone"], streaming=True, elem_id="audio_stream"
200
+ )
201
+ with gr.Column(scale=1, min_width=120, elem_classes="active-light-column"):
202
+ active_light = gr.HTML(value=ACTIVE_LIGHT_GRAY)
203
  with gr.Group(elem_classes="panel"):
204
  with gr.Column(scale=5, min_width=320):
205
  enhanced_text = gr.Textbox(
206
+ label="Enhanced Transcribed Text", lines=6, autoscroll=False
207
  )
208
  with gr.Column(scale=5, min_width=320):
209
+ raw_text = gr.Textbox(
210
+ label="Raw Transcribed Text", lines=6, autoscroll=False
211
+ )
212
+ # Poll transcript globals so interim results show live (streamers update in background)
213
+ transcript_timer = gr.Timer(0.1, active=True)
214
+ transcript_timer.tick(
215
+ get_live_transcripts,
216
+ inputs=None,
217
+ outputs=[enhanced_text, raw_text],
218
+ show_progress="hidden",
219
+ )
220
+ warmup_timer = gr.Timer(0.5, active=True)
221
+ warmup_timer.tick(
222
+ warmup_tick,
223
+ inputs=[on_stream_tab, warmup_elapsed, active_light],
224
+ outputs=[warmup_elapsed, active_light, warmup_timer],
225
+ show_progress="hidden",
226
+ )
227
 
228
  with gr.Tab("Dataset: Dawn Chorus") as dataset_tab:
229
  with gr.Row():
230
  gr.Markdown(open("docs/dawn_chorus.md", "r", encoding="utf-8").read())
231
+ dataset_dropdown = gr.Dropdown(
232
+ choices=ALL_FILES, value="en_00412_i_h_36", label="Sample"
233
+ )
234
  audio_file_from_dataset = gr.Audio(
235
+ type="filepath", interactive=False, buttons=["download"], autoplay=True
236
  )
237
 
238
  with gr.Tab("Upload local file") as upload_tab:
239
  with gr.Row():
240
  gr.Markdown(open("docs/local_file.md", "r", encoding="utf-8").read())
241
  audio_file_upload = gr.Audio(
242
+ type="filepath", sources=["upload"], buttons=["download"], autoplay=True
243
  )
244
 
245
+ enhance_btn = gr.Button("Enhance with Quail Voice Focus 2.0", scale=2, visible=False)
246
 
247
  with gr.Group(elem_classes="panel results-card", visible=False) as results_card:
248
  result_title = gr.Markdown("")
249
  enhanced_audio = gr.Audio(
250
+ type="numpy",
251
+ interactive=False,
252
+ buttons=["download"],
253
+ autoplay=True,
254
  )
255
 
256
  with gr.Row(equal_height=True, elem_classes="results-row"):
 
276
  # ------------------------------------------------------
277
  # GLOBAL EVENTS (TAB CHANGES, MODEL CHANGES)
278
  # ------------------------------------------------------
279
+ DEFAULT_DATASET_SAMPLE = "en_00412_i_h_36"
280
+
281
+ def load_dataset_sample_on_tab_visit(dropdown_value):
282
+ """Load the selected sample when visiting the Dataset tab; use default if dropdown is empty."""
283
+ sample_id = dropdown_value or DEFAULT_DATASET_SAMPLE
284
+ audio_path, arr, stem = load_file_from_dataset(sample_id)
285
+ return sample_id, audio_path, arr, stem
286
+
287
  stream_tab.select(
288
  lambda: (
289
  gr.update(visible=False),
290
  gr.update(visible=False),
291
+ gr.update(visible=True, interactive=True, streaming=True, sources=["microphone"]),
292
+ True,
293
+ 0,
294
+ ACTIVE_LIGHT_GRAY,
295
+ gr.update(active=True),
296
  ),
297
  inputs=None,
298
+ outputs=[results_card, enhance_btn, audio_stream, on_stream_tab, warmup_elapsed, active_light, warmup_timer],
299
  )
300
 
301
  upload_tab.select(
302
+ lambda: (
303
+ *stop_online_backend(),
304
+ False,
305
+ ACTIVE_LIGHT_GRAY,
306
+ ),
307
  inputs=None,
308
+ outputs=[stream_state, enhanced_text, raw_text, audio_stream, on_stream_tab, active_light],
309
  ).then(
310
  lambda: gr.update(visible=True),
311
  outputs=enhance_btn,
312
  )
313
 
314
  dataset_tab.select(
315
+ lambda: (
316
+ *stop_online_backend(),
317
+ False,
318
+ ACTIVE_LIGHT_GRAY,
319
+ ),
320
  inputs=None,
321
+ outputs=[stream_state, enhanced_text, raw_text, audio_stream, on_stream_tab, active_light],
322
  ).then(
323
  lambda: gr.update(visible=True),
324
  outputs=enhance_btn,
325
+ ).then(
326
+ load_dataset_sample_on_tab_visit,
327
+ inputs=[dataset_dropdown],
328
+ outputs=[dataset_dropdown, audio_file_from_dataset, input_array, sample_stem],
329
  )
330
 
331
  stt_model.change(
 
346
 
347
  audio_stream.stream(
348
  fn=transcribe_stream,
349
+ inputs=[stream_state, audio_stream, enhancement_level, input_gain_db],
350
  outputs=[stream_state, enhanced_text, raw_text],
351
  stream_every=STREAM_EVERY,
352
  time_limit=60 * 2,
 
354
  )
355
 
356
  audio_stream.stop_recording(
357
+ on_stop_recording,
358
  )
359
 
360
  audio_stream.start_recording(
 
388
  inputs=None,
389
  outputs=results_card,
390
  ).then(
391
+ load_local_file,
392
+ inputs=[audio_file_upload],
393
+ outputs=[input_array, sample_stem]
394
  )
395
 
396
+ # Enhancement button: run pipeline with live transcript progress (dataset + local file modes).
397
  enhance_btn.click(
398
+ process_with_live_transcript,
399
+ inputs=[input_array, enhancement_level, sample_stem, stt_model, last_sample_stem],
400
  outputs=[
401
+ results_card,
402
+ enhanced_audio,
403
  enhanced_image,
404
  noisy_image,
 
405
  original_transcript,
406
  noisy_transcript,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  enhanced_transcript,
 
 
408
  last_sample_stem,
409
+ enhanced_array,
410
+ precomputed_noisy_transcript,
411
  ],
412
  )
413
 
414
+ os.makedirs(APP_TMP_DIR, exist_ok=True)
415
+ purge_tmp_directory(max_age_minutes=0, tmp_dir=APP_TMP_DIR)
416
+ demo.queue()
417
+ demo.launch(
418
+ css=(_CSS_DIR / "active_light.css").read_text(encoding="utf-8"),
419
+ allowed_paths=[APP_TMP_DIR, "/tmp", "/"],
420
+ )
assets/active_light.css ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Stream row: stretch columns to match audio height */
2
+ .stream-row {
3
+ align-items: stretch !important;
4
+ }
5
+
6
+ /* Active light column: flex container, fill height */
7
+ .stream-row > div:last-child {
8
+ display: flex !important;
9
+ align-items: stretch !important;
10
+ min-height: 100%;
11
+ }
12
+
13
+ /* Gradio block wrapper: fill and flex so child can stretch */
14
+ .stream-row > div:last-child > div {
15
+ display: flex !important;
16
+ width: 100% !important;
17
+ min-height: 100% !important;
18
+ height: 100% !important;
19
+ box-sizing: border-box;
20
+ }
21
+
22
+ /* All divs in light column: fill so panel stretches */
23
+ .stream-row > div:last-child div {
24
+ width: 100% !important;
25
+ min-height: 100% !important;
26
+ height: 100% !important;
27
+ box-sizing: border-box;
28
+ }
29
+
30
+ /* Active light panel: fill container, base styles */
31
+ .active-light {
32
+ width: 100% !important;
33
+ min-height: 100% !important;
34
+ height: 100% !important;
35
+ box-sizing: border-box;
36
+ border-radius: 8px;
37
+ border: 1px solid var(--border-color-primary);
38
+ transition: background 0.2s, box-shadow 0.2s;
39
+ padding: 0.5rem;
40
+ }
41
+
42
+ /* Warming up state */
43
+ .active-light--off {
44
+ background: #555;
45
+ box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.4);
46
+ }
47
+
48
+ /* Ready state */
49
+ .active-light--on {
50
+ background: #c00;
51
+ box-shadow:
52
+ 0 0 16px rgba(220, 0, 0, 0.5),
53
+ inset 0 0 12px rgba(255, 80, 80, 0.3);
54
+ }
55
+
56
+ /* Label text */
57
+ .active-light__label {
58
+ font-size: 12px;
59
+ font-weight: 500;
60
+ }
61
+
62
+ .active-light--off .active-light__label {
63
+ color: rgba(255, 255, 255, 0.85);
64
+ }
65
+
66
+ .active-light--on .active-light__label {
67
+ color: #fff;
68
+ }
clean_up.py CHANGED
@@ -1,24 +1,25 @@
1
  import os
2
  import time
3
  from loguru import logger
4
- from constants import MINUTES_KEEP
5
  import gradio as gr
6
 
7
 
8
  def purge_tmp_directory(
9
  max_age_minutes: int = MINUTES_KEEP,
10
  skip_substrings: list[str] | None = None,
11
- tmp_dir: str = "/tmp",
12
  ) -> tuple[int, int]:
13
  """
14
  Delete files in `tmp_dir` older than `max_age_minutes`, optionally skipping paths
15
- that contain any substring in `skip_substrings`.
 
16
 
17
  Args:
18
  max_age_minutes: Files older than this many minutes will be deleted.
19
  skip_substrings: If provided, any file path containing one of these substrings
20
  will NOT be deleted.
21
- tmp_dir: Directory to scan (default: /tmp).
22
 
23
  Returns:
24
  (removed, skipped): Number of files removed and skipped.
@@ -28,7 +29,8 @@ def purge_tmp_directory(
28
  removed = 0
29
 
30
  if not os.path.exists(tmp_dir):
31
- logger.info(f"Temp directory does not exist: {tmp_dir}")
 
32
  return removed, skipped
33
 
34
  now = time.time()
@@ -64,7 +66,7 @@ def purge_tmp_directory(
64
  return removed, skipped
65
 
66
 
67
- def remove_files_related_to(stem: str, tmp_dir: str = "/tmp") -> int:
68
  deleted = 0
69
 
70
  if not stem:
@@ -89,7 +91,7 @@ def remove_files_related_to(stem: str, tmp_dir: str = "/tmp") -> int:
89
 
90
  def cleanup_previous_run(
91
  sample_stem: str,
92
- tmp_dir: str = "/tmp",
93
  max_age_minutes: int = MINUTES_KEEP,
94
  ) -> tuple[None, None, str, str, str]:
95
  gr.Info("Processing started. This may take a moment. Please do not refresh or close the window.")
 
1
  import os
2
  import time
3
  from loguru import logger
4
+ from constants import MINUTES_KEEP, APP_TMP_DIR
5
  import gradio as gr
6
 
7
 
8
  def purge_tmp_directory(
9
  max_age_minutes: int = MINUTES_KEEP,
10
  skip_substrings: list[str] | None = None,
11
+ tmp_dir: str = APP_TMP_DIR,
12
  ) -> tuple[int, int]:
13
  """
14
  Delete files in `tmp_dir` older than `max_age_minutes`, optionally skipping paths
15
+ that contain any substring in `skip_substrings`. Use APP_TMP_DIR so we only
16
+ touch our own temp files (spectrograms, audio, etc.), not other apps' files in /tmp.
17
 
18
  Args:
19
  max_age_minutes: Files older than this many minutes will be deleted.
20
  skip_substrings: If provided, any file path containing one of these substrings
21
  will NOT be deleted.
22
+ tmp_dir: Directory to scan (default: APP_TMP_DIR).
23
 
24
  Returns:
25
  (removed, skipped): Number of files removed and skipped.
 
29
  removed = 0
30
 
31
  if not os.path.exists(tmp_dir):
32
+ os.makedirs(tmp_dir, exist_ok=True)
33
+ logger.info(f"Temp directory created: {tmp_dir}")
34
  return removed, skipped
35
 
36
  now = time.time()
 
66
  return removed, skipped
67
 
68
 
69
+ def remove_files_related_to(stem: str, tmp_dir: str = APP_TMP_DIR) -> int:
70
  deleted = 0
71
 
72
  if not stem:
 
91
 
92
  def cleanup_previous_run(
93
  sample_stem: str,
94
+ tmp_dir: str = APP_TMP_DIR,
95
  max_age_minutes: int = MINUTES_KEEP,
96
  ) -> tuple[None, None, str, str, str]:
97
  gr.Info("Processing started. This may take a moment. Please do not refresh or close the window.")
constants.py CHANGED
@@ -10,6 +10,9 @@ MODEL_ID: Final = "quail-vf-2.0-l-16khz"
10
 
11
  MINUTES_KEEP: Final = 60
12
 
 
 
 
13
  DATASET_NAME: Final = "ai-coustics/dawn_chorus_en"
14
  DEFAULT_SPLIT: Final = "eval"
15
  MIX_DIR: Final = "mix"
@@ -18,6 +21,7 @@ TRANS_DIR: Final = "transcripts"
18
 
19
  DEFAULT_SR: Final = 16000
20
  STREAM_EVERY: Final = 0.2
 
21
 
22
  STREAMER_CLASSES: Final = {
23
  "Deepgram Nova-3 RT": DeepgramStreamer,
 
10
 
11
  MINUTES_KEEP: Final = 60
12
 
13
+ # All app temp files (spectrograms, audio, etc.) go here so we only purge our own files.
14
+ APP_TMP_DIR: Final = "/tmp/voicefocus"
15
+
16
  DATASET_NAME: Final = "ai-coustics/dawn_chorus_en"
17
  DEFAULT_SPLIT: Final = "eval"
18
  MIX_DIR: Final = "mix"
 
21
 
22
  DEFAULT_SR: Final = 16000
23
  STREAM_EVERY: Final = 0.2
24
+ WARMUP_SECONDS: Final = 2 # seconds before "recording ready" light turns on
25
 
26
  STREAMER_CLASSES: Final = {
27
  "Deepgram Nova-3 RT": DeepgramStreamer,
docs/dawn_chorus.md CHANGED
@@ -1 +1 @@
1
- Choose a sample from our open-source [Dawn Chorus evaluation dataset](https://huggingface.co/datasets/ai-coustics/dawn_chorus_en). This dataset includes audio with varying levels of background voice activity, ideal for testing the model performance. To learn more about the dataset, visit *placeholder to upcoming website/blog about dataset*.
 
1
+ Choose a sample from our open-source [Dawn Chorus evaluation dataset](https://huggingface.co/datasets/ai-coustics/dawn_chorus_en). This dataset includes audio with varying levels of background voice activity, ideal for testing the model performance.
offline_pipeline.py CHANGED
@@ -1,28 +1,42 @@
1
  import os
 
 
2
 
3
  import gradio as gr
4
  import librosa
5
  from sdk import SDKWrapper
6
  from utils import spec_image, compute_wer, transcribe_audio, to_gradio_audio
7
  from hf_dataset_utils import get_audio, get_transcript
8
- from constants import DEFAULT_SR
9
- import numpy as np
 
10
 
11
  def retrieve_audio_information(
12
  original_array: np.ndarray,
13
  enhanced_array: np.ndarray,
14
  sample_id: str,
15
  stt_model: str,
 
 
16
  ) -> tuple[str, str, str, str, str, str]:
 
 
 
17
  if original_array is None or enhanced_array is None:
18
  raise ValueError("Audio arrays are not available.")
19
- noisy_spec_path = f"/tmp/{sample_id}_noisy_spectrogram.png"
20
- enhanced_spec_path = f"/tmp/{sample_id}_enhanced_spectrogram.png"
21
  spec_image(original_array).save(noisy_spec_path)
22
  spec_image(enhanced_array).save(enhanced_spec_path)
23
- enhanced_transcript = transcribe_audio(enhanced_array, DEFAULT_SR, stt_model, stream_name=f"{sample_id}_enhanced")
24
- noisy_transcript = transcribe_audio(original_array, DEFAULT_SR, stt_model, stream_name=f"{sample_id}_noisy")
25
- try:
 
 
 
 
 
 
26
  original_transcript = get_transcript(sample_id)
27
  wer_enhanced = compute_wer(original_transcript, enhanced_transcript)
28
  wer_noisy = compute_wer(original_transcript, noisy_transcript)
@@ -33,10 +47,64 @@ def retrieve_audio_information(
33
  return enhanced_spec_path, noisy_spec_path, enhanced_transcript, original_transcript, noisy_transcript, sample_id
34
 
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  def denoise_audio(
37
  sample_16k: np.ndarray,
38
  enhancement_level: float = 50.0,
39
  ) -> tuple[np.ndarray | None , tuple[int, np.ndarray]| None]:
 
 
 
 
 
 
40
  if sample_16k is None:
41
  raise ValueError("No audio to enhance. Please upload a file first.")
42
  try:
@@ -50,6 +118,207 @@ def denoise_audio(
50
  return enhanced_array, gradio_enhanced_audio
51
 
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  def load_local_file(
54
  sample_path: str
55
  ) -> tuple[np.ndarray, str]:
@@ -63,10 +332,10 @@ def load_local_file(
63
  y_16k, _ = librosa.load(sample_path, sr=DEFAULT_SR, dtype="float32", mono=True)
64
  return y_16k, new_sample_stem
65
 
66
- def load_file_from_dataset(sample_id: str) -> tuple[tuple, np.ndarray, str]:
67
  if not sample_id:
68
  gr.Warning("Please select a sample from the dropdown.")
69
- raise ValueError("No sample selected. Please select a sample from the dropdown.")
70
  new_sample_stem = sample_id
71
  try:
72
  y_16k, sr = get_audio(sample_id, prefix="mix")
 
1
  import os
2
+ from concurrent.futures import ThreadPoolExecutor
3
+ from typing import Optional
4
 
5
  import gradio as gr
6
  import librosa
7
  from sdk import SDKWrapper
8
  from utils import spec_image, compute_wer, transcribe_audio, to_gradio_audio
9
  from hf_dataset_utils import get_audio, get_transcript
10
+ from constants import DEFAULT_SR, APP_TMP_DIR, STREAMER_CLASSES
11
+ import numpy as np
12
+
13
 
14
  def retrieve_audio_information(
15
  original_array: np.ndarray,
16
  enhanced_array: np.ndarray,
17
  sample_id: str,
18
  stt_model: str,
19
+ noisy_transcript: str,
20
+ progress_state: Optional[dict] = None,
21
  ) -> tuple[str, str, str, str, str, str]:
22
+ """Build spectrograms, transcribe enhanced audio, and compute WER. Caller must supply
23
+ noisy_transcript (transcription of original_array) so STT on the original is never run here.
24
+ If progress_state is provided, progress_state['enhanced'] is updated with partial transcript as enhanced STT streams."""
25
  if original_array is None or enhanced_array is None:
26
  raise ValueError("Audio arrays are not available.")
27
+ noisy_spec_path = f"{APP_TMP_DIR}/{sample_id}_noisy_spectrogram.png"
28
+ enhanced_spec_path = f"{APP_TMP_DIR}/{sample_id}_enhanced_spectrogram.png"
29
  spec_image(original_array).save(noisy_spec_path)
30
  spec_image(enhanced_array).save(enhanced_spec_path)
31
+ on_enhanced = (lambda t: progress_state.__setitem__("enhanced", t)) if progress_state is not None else None
32
+ enhanced_transcript = transcribe_audio(
33
+ enhanced_array,
34
+ DEFAULT_SR,
35
+ stt_model,
36
+ stream_name=f"{sample_id}_enhanced",
37
+ on_update=on_enhanced,
38
+ )
39
+ try:
40
  original_transcript = get_transcript(sample_id)
41
  wer_enhanced = compute_wer(original_transcript, enhanced_transcript)
42
  wer_noisy = compute_wer(original_transcript, noisy_transcript)
 
47
  return enhanced_spec_path, noisy_spec_path, enhanced_transcript, original_transcript, noisy_transcript, sample_id
48
 
49
 
50
+ def denoise_and_transcribe_noisy_parallel(
51
+ sample_16k: np.ndarray,
52
+ enhancement_level: float,
53
+ sample_id: str,
54
+ stt_model: str,
55
+ progress_state: Optional[dict] = None,
56
+ ) -> tuple[np.ndarray | None, tuple[int, np.ndarray] | None, str]:
57
+ """Run denoising and noisy transcription in parallel. Returns (enhanced_array, gradio_audio, noisy_transcript).
58
+ If progress_state is provided, progress_state['noisy'] is updated with partial transcript as noisy STT streams."""
59
+ if sample_16k is None:
60
+ raise ValueError("No audio to enhance. Please upload a file first.")
61
+ on_noisy = (lambda t: progress_state.__setitem__("noisy", t)) if progress_state is not None else None
62
+ with ThreadPoolExecutor(max_workers=2) as executor:
63
+ future_denoise = executor.submit(
64
+ _denoise_audio_impl, sample_16k, float(enhancement_level) / 100.0
65
+ )
66
+ future_noisy = executor.submit(
67
+ transcribe_audio,
68
+ sample_16k,
69
+ DEFAULT_SR,
70
+ stt_model,
71
+ stream_name=f"{sample_id}_noisy",
72
+ on_update=on_noisy,
73
+ )
74
+ enhanced_array, gradio_enhanced_audio = future_denoise.result()
75
+ noisy_transcript = future_noisy.result()
76
+ return enhanced_array, gradio_enhanced_audio, noisy_transcript
77
+
78
+
79
+ def _denoise_audio_impl(
80
+ sample_16k: np.ndarray, enhancement_level_frac: float
81
+ ) -> tuple[np.ndarray, tuple[int, np.ndarray]]:
82
+ """Run the enhancer on a single array and return both the array and Gradio-ready audio.
83
+
84
+ Used by denoise_and_transcribe_noisy_parallel so the denoise step can run in a
85
+ thread without Gradio UI calls. Expects enhancement_level_frac in [0, 1] (not percent).
86
+ Returns (enhanced_array, (sample_rate, numpy_array)) for Gradio Audio.
87
+ """
88
+ sdk = SDKWrapper()
89
+ sdk.init_processor(
90
+ sample_rate=DEFAULT_SR,
91
+ enhancement_level=enhancement_level_frac,
92
+ )
93
+ enhanced_array = sdk.process_sync(sample_16k)
94
+ gradio_enhanced_audio = to_gradio_audio(enhanced_array, DEFAULT_SR)
95
+ return enhanced_array, gradio_enhanced_audio
96
+
97
+
98
  def denoise_audio(
99
  sample_16k: np.ndarray,
100
  enhancement_level: float = 50.0,
101
  ) -> tuple[np.ndarray | None , tuple[int, np.ndarray]| None]:
102
+ """Enhance-only entry point: run the SDK on the given audio and return enhanced array + Gradio audio.
103
+
104
+ The main app uses denoise_and_transcribe_noisy_parallel (denoise + noisy STT in parallel)
105
+ instead. This function remains for backward compatibility, scripts, or any caller that
106
+ needs only enhancement without transcription (e.g. backup flows, tests).
107
+ """
108
  if sample_16k is None:
109
  raise ValueError("No audio to enhance. Please upload a file first.")
110
  try:
 
118
  return enhanced_array, gradio_enhanced_audio
119
 
120
 
121
+ def run_offline_pipeline_ordered(
122
+ sample_16k: np.ndarray,
123
+ enhancement_level: float,
124
+ sample_id: str,
125
+ stt_model: str,
126
+ progress_state: dict,
127
+ ) -> tuple[str, str, str, tuple[int, np.ndarray], str, np.ndarray, str]:
128
+ """Run pipeline in UI order: 1) Denoise only. 2) When done, set progress_state['enhanced_spec_path']
129
+ and progress_state['enhanced_audio']. 3) Run noisy STT and enhanced STT in parallel (both stream via
130
+ progress_state['noisy'] and progress_state['enhanced']). 4) Return final transcripts with WER.
131
+
132
+ Returns: (enhanced_spec_path, enhanced_transcript, noisy_transcript_with_wer, enhanced_audio,
133
+ last_stem, enhanced_array, precomputed_noisy).
134
+ """
135
+ if sample_16k is None:
136
+ raise ValueError("No audio to enhance. Please upload a file first.")
137
+ # 1) Denoise only
138
+ enhanced_array, gradio_enhanced_audio = _denoise_audio_impl(
139
+ sample_16k, float(enhancement_level) / 100.0
140
+ )
141
+ # 2) As soon as enhanced audio is ready: build enhanced spectrogram and expose to UI
142
+ enhanced_spec_path = f"{APP_TMP_DIR}/{sample_id}_enhanced_spectrogram.png"
143
+ spec_image(enhanced_array).save(enhanced_spec_path)
144
+ progress_state["enhanced_spec_path"] = enhanced_spec_path
145
+ progress_state["enhanced_audio"] = gradio_enhanced_audio
146
+ # 3) Noisy and enhanced transcription starting both at the same time (parallel, both with on_update).
147
+ # Sync so the UI shows both boxes updating together: only write to noisy/enhanced once both have sent at least one update.
148
+ progress_state["noisy_pending"] = ""
149
+ progress_state["enhanced_pending"] = ""
150
+ progress_state["noisy_has_sent"] = False
151
+ progress_state["enhanced_has_sent"] = False
152
+
153
+ def _flush_both():
154
+ if progress_state.get("noisy_has_sent") and progress_state.get("enhanced_has_sent"):
155
+ progress_state["noisy"] = progress_state.get("noisy_pending", "")
156
+ progress_state["enhanced"] = progress_state.get("enhanced_pending", "")
157
+
158
+ def on_noisy(t: str):
159
+ progress_state["noisy_pending"] = t
160
+ progress_state["noisy_has_sent"] = True
161
+ _flush_both()
162
+
163
+ def on_enhanced(t: str):
164
+ progress_state["enhanced_pending"] = t
165
+ progress_state["enhanced_has_sent"] = True
166
+ _flush_both()
167
+
168
+ with ThreadPoolExecutor(max_workers=2) as executor:
169
+ future_noisy = executor.submit(
170
+ transcribe_audio,
171
+ sample_16k,
172
+ DEFAULT_SR,
173
+ stt_model,
174
+ stream_name=f"{sample_id}_noisy",
175
+ on_update=on_noisy,
176
+ )
177
+ future_enhanced = executor.submit(
178
+ transcribe_audio,
179
+ enhanced_array,
180
+ DEFAULT_SR,
181
+ stt_model,
182
+ stream_name=f"{sample_id}_enhanced",
183
+ on_update=on_enhanced,
184
+ )
185
+ noisy_transcript = future_noisy.result()
186
+ enhanced_transcript = future_enhanced.result()
187
+ # 4) WER and final strings
188
+ precomputed_noisy = noisy_transcript
189
+ try:
190
+ original_transcript = get_transcript(sample_id)
191
+ wer_enhanced = compute_wer(original_transcript, enhanced_transcript)
192
+ wer_noisy = compute_wer(original_transcript, noisy_transcript)
193
+ enhanced_transcript += f" (WER: {wer_enhanced:.2f})"
194
+ noisy_transcript += f" (WER: {wer_noisy:.2f})"
195
+ except Exception:
196
+ original_transcript = "Unavailable"
197
+ return (
198
+ enhanced_spec_path,
199
+ enhanced_transcript,
200
+ noisy_transcript,
201
+ gradio_enhanced_audio,
202
+ sample_id,
203
+ enhanced_array,
204
+ precomputed_noisy,
205
+ )
206
+
207
+
208
+ def _close_stt_stream(streamer) -> None:
209
+ """Signal end-of-stream; streamer type may be Soniox (close_stream) or Deepgram (close)."""
210
+ if hasattr(streamer, "close_stream"):
211
+ streamer.close_stream()
212
+ else:
213
+ streamer.close()
214
+
215
+
216
+ def run_offline_pipeline_streaming(
217
+ sample_16k: np.ndarray,
218
+ enhancement_level: float,
219
+ sample_id: str,
220
+ stt_model: str,
221
+ progress_state: dict,
222
+ ) -> tuple[str, str, str, tuple[int, np.ndarray], str, np.ndarray, str]:
223
+ """Run enhancement and both STTs in real time by processing in chunks. Transcripts stream
224
+ via progress_state['noisy'] and progress_state['enhanced']. Enhanced audio is returned
225
+ only at the end; the app plays it automatically when processing is complete.
226
+ Returns same tuple as run_offline_pipeline_ordered."""
227
+ if sample_16k is None:
228
+ raise ValueError("No audio to enhance. Please upload a file first.")
229
+ sample_16k = np.asarray(sample_16k, dtype=np.float32).flatten()
230
+
231
+ sdk = SDKWrapper()
232
+ sdk.init_processor(
233
+ sample_rate=DEFAULT_SR,
234
+ enhancement_level=float(enhancement_level) / 100.0,
235
+ )
236
+ chunk_size = sdk.num_frames
237
+
238
+ # Sync transcript callbacks so both boxes update together
239
+ progress_state["noisy_pending"] = ""
240
+ progress_state["enhanced_pending"] = ""
241
+ progress_state["noisy_has_sent"] = False
242
+ progress_state["enhanced_has_sent"] = False
243
+
244
+ def _flush_both():
245
+ if progress_state.get("noisy_has_sent") and progress_state.get("enhanced_has_sent"):
246
+ progress_state["noisy"] = progress_state.get("noisy_pending", "")
247
+ progress_state["enhanced"] = progress_state.get("enhanced_pending", "")
248
+
249
+ def on_noisy(t: str):
250
+ progress_state["noisy_pending"] = t
251
+ progress_state["noisy_has_sent"] = True
252
+ _flush_both()
253
+
254
+ def on_enhanced(t: str):
255
+ progress_state["enhanced_pending"] = t
256
+ progress_state["enhanced_has_sent"] = True
257
+ _flush_both()
258
+
259
+ if stt_model not in STREAMER_CLASSES:
260
+ raise ValueError(f"Unknown STT model: {stt_model}")
261
+ StreamerClass = STREAMER_CLASSES[stt_model]
262
+ streamer_noisy = StreamerClass(DEFAULT_SR, f"{sample_id}_noisy", on_update=on_noisy)
263
+ streamer_enhanced = StreamerClass(DEFAULT_SR, f"{sample_id}_enhanced", on_update=on_enhanced)
264
+
265
+ accumulated_enhanced: list[np.ndarray] = []
266
+ n = len(sample_16k)
267
+
268
+ for i in range(0, n, chunk_size):
269
+ raw_chunk = sample_16k[i : i + chunk_size]
270
+ if raw_chunk.size < chunk_size:
271
+ raw_chunk = np.pad(
272
+ raw_chunk,
273
+ (0, chunk_size - raw_chunk.size),
274
+ mode="constant",
275
+ constant_values=0.0,
276
+ )
277
+ raw_2d = raw_chunk.reshape(1, -1)
278
+ enhanced_chunk = sdk.process_chunk(raw_2d)
279
+ enhanced_1d = np.asarray(enhanced_chunk).flatten()
280
+ streamer_noisy.process_chunk(raw_chunk)
281
+ streamer_enhanced.process_chunk(enhanced_1d)
282
+ accumulated_enhanced.append(enhanced_1d)
283
+
284
+ _close_stt_stream(streamer_noisy)
285
+ _close_stt_stream(streamer_enhanced)
286
+ streamer_noisy.finished_event.wait()
287
+ streamer_enhanced.finished_event.wait()
288
+
289
+ with streamer_noisy.lock:
290
+ noisy_transcript = streamer_noisy.render_tokens(streamer_noisy.final_tokens, [])
291
+ with streamer_enhanced.lock:
292
+ enhanced_transcript = streamer_enhanced.render_tokens(streamer_enhanced.final_tokens, [])
293
+
294
+ enhanced_array = np.concatenate(accumulated_enhanced).astype(np.float32)
295
+ gradio_enhanced_audio = to_gradio_audio(enhanced_array, DEFAULT_SR)
296
+
297
+ enhanced_spec_path = f"{APP_TMP_DIR}/{sample_id}_enhanced_spectrogram.png"
298
+ spec_image(enhanced_array).save(enhanced_spec_path)
299
+ progress_state["enhanced_spec_path"] = enhanced_spec_path
300
+
301
+ precomputed_noisy = noisy_transcript
302
+ try:
303
+ original_transcript = get_transcript(sample_id)
304
+ wer_enhanced = compute_wer(original_transcript, enhanced_transcript)
305
+ wer_noisy = compute_wer(original_transcript, noisy_transcript)
306
+ enhanced_transcript += f" (WER: {wer_enhanced:.2f})"
307
+ noisy_transcript += f" (WER: {wer_noisy:.2f})"
308
+ except Exception:
309
+ pass
310
+
311
+ return (
312
+ enhanced_spec_path,
313
+ enhanced_transcript,
314
+ noisy_transcript,
315
+ gradio_enhanced_audio,
316
+ sample_id,
317
+ enhanced_array,
318
+ precomputed_noisy,
319
+ )
320
+
321
+
322
  def load_local_file(
323
  sample_path: str
324
  ) -> tuple[np.ndarray, str]:
 
332
  y_16k, _ = librosa.load(sample_path, sr=DEFAULT_SR, dtype="float32", mono=True)
333
  return y_16k, new_sample_stem
334
 
335
+ def load_file_from_dataset(sample_id: str) -> tuple[tuple | None, np.ndarray | None, str]:
336
  if not sample_id:
337
  gr.Warning("Please select a sample from the dropdown.")
338
+ return None, None, ""
339
  new_sample_stem = sample_id
340
  try:
341
  y_16k, sr = get_audio(sample_id, prefix="mix")
requirements.txt CHANGED
@@ -1,4 +1,5 @@
1
  # Requires Python 3.11
 
2
  matplotlib>=3.8,<3.10
3
  soundfile>=0.12.1
4
  aiohttp>=3.9,<4
 
1
  # Requires Python 3.11
2
+ gradio>=6.0
3
  matplotlib>=3.8,<3.10
4
  soundfile>=0.12.1
5
  aiohttp>=3.9,<4
stream_pipeline.py CHANGED
@@ -12,7 +12,6 @@ from dataclasses import dataclass
12
  _ENHANCED_TRANSCRIPT: str = ""
13
  _RAW_TRANSCRIPT: str = ""
14
 
15
-
16
  def _set_transcript_enhanced(text: str) -> None:
17
  """Deepgram callback: update latest transcript text (no printing)."""
18
  global _ENHANCED_TRANSCRIPT
@@ -24,14 +23,24 @@ def _set_transcript_raw(text: str) -> None:
24
  _RAW_TRANSCRIPT = text
25
 
26
 
 
 
 
 
 
27
  SDK = SDKWrapper()
28
  SDK.init_processor(
29
- sample_rate=DEFAULT_SR,
30
- enhancement_level=1.0,
31
- allow_variable_frames=False,
32
  num_channels=1,
33
- )
34
 
 
 
 
 
 
35
 
36
 
37
  @dataclass
@@ -66,15 +75,25 @@ def _to_float32_mono(y: np.ndarray) -> np.ndarray:
66
  return y
67
 
68
 
69
- def transcribe_stream(session: StreamSession | None, new_chunk, enhancement_level):
70
- if Streamer_enhanced.ws is None or Streamer_raw.ws is None:
71
- print("Streamers not connected. Ignoring audio chunk.")
 
 
 
 
72
  return session, _ENHANCED_TRANSCRIPT, _RAW_TRANSCRIPT
73
  if new_chunk is None or new_chunk[1] is None:
74
  return session, _ENHANCED_TRANSCRIPT, _RAW_TRANSCRIPT
75
 
76
  sr, y = new_chunk
77
  y = _to_float32_mono(y)
 
 
 
 
 
 
78
  session = _get_or_init_session(session, sr)
79
  SDK.change_enhancement_level(float(enhancement_level) / 100.0)
80
  if session.resampler is not None:
@@ -82,28 +101,58 @@ def transcribe_stream(session: StreamSession | None, new_chunk, enhancement_leve
82
  else:
83
  y_16k = y
84
 
 
 
 
85
  # Ringbuffer (nicht unendlich konkatenieren)
86
  if y_16k.size > 0:
87
  tail = np.concatenate([session.tail_16k, y_16k])
88
  if tail.size > session.tail_max:
89
  tail = tail[-session.tail_max:]
90
  session.tail_16k = tail
 
 
 
 
 
 
 
 
91
  enhanced_chunk_16k = SDK.process_sync(y_16k)
92
- Streamer_enhanced.process_chunk(enhanced_chunk_16k.flatten())
93
- Streamer_raw.process_chunk(y_16k.flatten())
 
 
 
 
94
  return session, _ENHANCED_TRANSCRIPT, _RAW_TRANSCRIPT
95
 
96
 
97
- def shutdown_streamers():
 
 
 
 
 
 
98
  try:
99
- if Streamer_enhanced.ws is not None:
100
  Streamer_enhanced.shutdown()
101
- if Streamer_raw.ws is not None:
102
  Streamer_raw.shutdown()
103
  except Exception:
104
- print("Failed to shutdown raw streamer.")
105
- pass
 
 
 
 
106
 
 
 
 
 
 
107
  def clear_ui():
108
  global _ENHANCED_TRANSCRIPT, _RAW_TRANSCRIPT
109
  _ENHANCED_TRANSCRIPT = ""
@@ -111,22 +160,32 @@ def clear_ui():
111
  return None, _ENHANCED_TRANSCRIPT, _RAW_TRANSCRIPT
112
 
113
  def stop_online_backend():
 
 
 
114
  shutdown_streamers()
115
  session, enhanced_transcript, raw_transcript = clear_ui()
116
- return session, enhanced_transcript, raw_transcript, gr.update(streaming=False, interactive=False)
117
 
118
 
119
  def set_stt_streamer(model_name):
120
  StreamerCls = STREAMER_CLASSES.get(model_name, DeepgramStreamer)
121
- global Streamer_enhanced, Streamer_raw
122
- Streamer_enhanced = StreamerCls(
 
 
 
 
123
  fs_hz=DEFAULT_SR,
124
  stream_name="enhanced",
125
  on_update=_set_transcript_enhanced,
126
  )
127
- Streamer_raw = StreamerCls(
128
  fs_hz=DEFAULT_SR,
129
  stream_name="raw",
130
  on_update=_set_transcript_raw,
131
  )
 
 
 
132
 
 
12
  _ENHANCED_TRANSCRIPT: str = ""
13
  _RAW_TRANSCRIPT: str = ""
14
 
 
15
  def _set_transcript_enhanced(text: str) -> None:
16
  """Deepgram callback: update latest transcript text (no printing)."""
17
  global _ENHANCED_TRANSCRIPT
 
23
  _RAW_TRANSCRIPT = text
24
 
25
 
26
+ def get_live_transcripts() -> tuple[str, str]:
27
+ """Return current enhanced and raw transcript for live UI updates."""
28
+ return _ENHANCED_TRANSCRIPT, _RAW_TRANSCRIPT
29
+
30
+
31
  SDK = SDKWrapper()
32
  SDK.init_processor(
33
+ sample_rate=DEFAULT_SR,
34
+ enhancement_level=1.0,
35
+ allow_variable_frames=True, # streaming chunks are variable-sized
36
  num_channels=1,
37
+ )
38
 
39
+ # Created on first start_recording (lazy) to avoid Soniox "No audio received" timeout at app load
40
+ Streamer_enhanced = None
41
+ Streamer_raw = None
42
+ _streamer_generation = 0
43
+ _last_stop_generation = 1 # so first stop doesn't skip (1 > 1 is False)
44
 
45
 
46
  @dataclass
 
75
  return y
76
 
77
 
78
+ def transcribe_stream(session: StreamSession | None, new_chunk, enhancement_level, input_gain_db: float = 0.0):
79
+ if (
80
+ Streamer_enhanced is None
81
+ or Streamer_raw is None
82
+ or Streamer_enhanced.ws is None
83
+ or Streamer_raw.ws is None
84
+ ):
85
  return session, _ENHANCED_TRANSCRIPT, _RAW_TRANSCRIPT
86
  if new_chunk is None or new_chunk[1] is None:
87
  return session, _ENHANCED_TRANSCRIPT, _RAW_TRANSCRIPT
88
 
89
  sr, y = new_chunk
90
  y = _to_float32_mono(y)
91
+ # Apply input gain: linear = 10^(dB/20), clip to avoid overflow
92
+ if input_gain_db is not None and input_gain_db > 0:
93
+ gain_linear = np.float32(10.0 ** (float(input_gain_db) / 20.0))
94
+ y = (y * gain_linear).astype(np.float32)
95
+ y = np.clip(y, -1.0, 1.0)
96
+
97
  session = _get_or_init_session(session, sr)
98
  SDK.change_enhancement_level(float(enhancement_level) / 100.0)
99
  if session.resampler is not None:
 
101
  else:
102
  y_16k = y
103
 
104
+ # Ensure 1D float32 for SDK and streamers (resample_chunk can return 0 samples or 2D)
105
+ y_16k = np.asarray(y_16k, dtype=np.float32).flatten()
106
+
107
  # Ringbuffer (nicht unendlich konkatenieren)
108
  if y_16k.size > 0:
109
  tail = np.concatenate([session.tail_16k, y_16k])
110
  if tail.size > session.tail_max:
111
  tail = tail[-session.tail_max:]
112
  session.tail_16k = tail
113
+
114
+ # Only send when we have samples (resample_chunk can return empty; SDK needs valid input)
115
+ if y_16k.size == 0:
116
+ return session, _ENHANCED_TRANSCRIPT, _RAW_TRANSCRIPT
117
+
118
+ # Parallel path: send raw to STT immediately, then enhance and send enhanced.
119
+ # SDK requires fixed num_frames (AudioConfigMismatchError if we use process_chunk with variable size).
120
+ Streamer_raw.process_chunk(y_16k)
121
  enhanced_chunk_16k = SDK.process_sync(y_16k)
122
+ out_1d = np.asarray(enhanced_chunk_16k, dtype=np.float32).flatten()
123
+ # Always send something to enhanced so Soniox doesn't close with "No audio received"
124
+ if out_1d.size > 0:
125
+ Streamer_enhanced.process_chunk(out_1d)
126
+ else:
127
+ Streamer_enhanced.process_chunk(np.zeros(160, dtype=np.float32))
128
  return session, _ENHANCED_TRANSCRIPT, _RAW_TRANSCRIPT
129
 
130
 
131
+ def shutdown_streamers(from_stop_recording: bool = False):
132
+ """Shut down STT streamers. If from_stop_recording, skip when streamers were
133
+ created after the last stop (avoids delayed stop killing new streamers)."""
134
+ global Streamer_enhanced, Streamer_raw, _streamer_generation, _last_stop_generation
135
+ if from_stop_recording and _streamer_generation > _last_stop_generation:
136
+ return
137
+ gen = _streamer_generation
138
  try:
139
+ if Streamer_enhanced is not None and Streamer_enhanced.ws is not None:
140
  Streamer_enhanced.shutdown()
141
+ if Streamer_raw is not None and Streamer_raw.ws is not None:
142
  Streamer_raw.shutdown()
143
  except Exception:
144
+ print("Failed to shutdown streamers.")
145
+ finally:
146
+ Streamer_enhanced = None
147
+ Streamer_raw = None
148
+ if from_stop_recording:
149
+ _last_stop_generation = gen
150
 
151
+ def on_stop_recording():
152
+ """Call from Gradio stop_recording so streamers shut down when user clicks Stop."""
153
+ shutdown_streamers(from_stop_recording=True)
154
+
155
+
156
  def clear_ui():
157
  global _ENHANCED_TRANSCRIPT, _RAW_TRANSCRIPT
158
  _ENHANCED_TRANSCRIPT = ""
 
160
  return None, _ENHANCED_TRANSCRIPT, _RAW_TRANSCRIPT
161
 
162
  def stop_online_backend():
163
+ """Stop streamers and clear transcripts. Do not update the Audio component:
164
+ toggling streaming=False then back to True can make the frontend lose the
165
+ microphone (getUserMedia not re-called), so we leave it unchanged."""
166
  shutdown_streamers()
167
  session, enhanced_transcript, raw_transcript = clear_ui()
168
+ return session, enhanced_transcript, raw_transcript, gr.update()
169
 
170
 
171
  def set_stt_streamer(model_name):
172
  StreamerCls = STREAMER_CLASSES.get(model_name, DeepgramStreamer)
173
+ global Streamer_enhanced, Streamer_raw, _streamer_generation
174
+ # Shut down current streamers first so we don't leak
175
+ if Streamer_enhanced is not None or Streamer_raw is not None:
176
+ shutdown_streamers()
177
+ # Create both before assigning so transcribe_stream never sees one new and one old
178
+ new_enhanced = StreamerCls(
179
  fs_hz=DEFAULT_SR,
180
  stream_name="enhanced",
181
  on_update=_set_transcript_enhanced,
182
  )
183
+ new_raw = StreamerCls(
184
  fs_hz=DEFAULT_SR,
185
  stream_name="raw",
186
  on_update=_set_transcript_raw,
187
  )
188
+ _streamer_generation += 1
189
+ Streamer_enhanced = new_enhanced
190
+ Streamer_raw = new_raw
191
 
stt_streamers/soniox_streamer.py CHANGED
@@ -172,7 +172,9 @@ class SonioxStreamer:
172
  self.ws.send(json.dumps({"type": "keepalive"})) # Soniox-format citeturn2view0
173
  self._last_send_ts = time.monotonic()
174
  except Exception as e:
175
- print(f"[Soniox:{self.stream_name}] keepalive failed: {e}")
 
 
176
  self.ws = None
177
  return
178
 
 
172
  self.ws.send(json.dumps({"type": "keepalive"})) # Soniox-format citeturn2view0
173
  self._last_send_ts = time.monotonic()
174
  except Exception as e:
175
+ # Normal closure (1000 OK) when stream ends; don't log as failure
176
+ if "1000" not in str(e):
177
+ print(f"[Soniox:{self.stream_name}] keepalive failed: {e}")
178
  self.ws = None
179
  return
180
 
utils.py CHANGED
@@ -1,4 +1,4 @@
1
- from typing import Optional
2
 
3
  import numpy as np
4
  import librosa
@@ -9,6 +9,8 @@ import resampy
9
  from constants import DEFAULT_SR, STREAMER_CLASSES
10
 
11
  def to_gradio_audio(x: np.ndarray, sr: int) -> tuple[int, np.ndarray]:
 
 
12
  x = np.asarray(x)
13
 
14
  # Remove extra dims like (1, n, 1) etc.
@@ -22,9 +24,10 @@ def to_gradio_audio(x: np.ndarray, sr: int) -> tuple[int, np.ndarray]:
22
  if x.ndim == 2 and x.shape[1] == 1:
23
  x = x[:, 0]
24
 
25
- # Return float32 in [-1, 1] (simplest for Gradio)
26
  x = x.astype(np.float32)
27
  x = np.clip(x, -1.0, 1.0)
 
 
28
 
29
  return (sr, x)
30
 
@@ -94,7 +97,13 @@ def compute_wer(reference: str, hypothesis: str) -> float:
94
 
95
 
96
 
97
- def transcribe_audio(audio_array: np.ndarray, sr: int, streamer_type: str = "Deepgram", stream_name: str = "RAW"):
 
 
 
 
 
 
98
  """
99
  Transcribe an audio array using the specified STT streamer.
100
 
@@ -103,15 +112,14 @@ def transcribe_audio(audio_array: np.ndarray, sr: int, streamer_type: str = "Dee
103
  sr (int): Sample rate of the audio array
104
  streamer_type (str): "Soniox" or "Deepgram"
105
  stream_name (str): Optional label for streamer instance ("RAW", "ENHANCED", etc.)
 
106
 
107
  Returns:
108
- str: Transcript text
109
  """
110
-
111
  if sr != DEFAULT_SR:
112
  audio_array = resampy.resample(audio_array, sr, DEFAULT_SR)
113
  sr = DEFAULT_SR
114
-
115
 
116
  if streamer_type not in STREAMER_CLASSES:
117
  raise ValueError(
@@ -120,7 +128,7 @@ def transcribe_audio(audio_array: np.ndarray, sr: int, streamer_type: str = "Dee
120
  )
121
 
122
  StreamerClass = STREAMER_CLASSES[streamer_type]
123
- streamer = StreamerClass(sr, stream_name)
124
 
125
  transcript = streamer.stream_array(audio_array)
126
 
 
1
+ from typing import Callable, Optional
2
 
3
  import numpy as np
4
  import librosa
 
9
  from constants import DEFAULT_SR, STREAMER_CLASSES
10
 
11
  def to_gradio_audio(x: np.ndarray, sr: int) -> tuple[int, np.ndarray]:
12
+ """Return (sample_rate, int16 mono array) for Gradio Audio. Gradio expects int16;
13
+ passing float32 triggers an internal conversion and a warning."""
14
  x = np.asarray(x)
15
 
16
  # Remove extra dims like (1, n, 1) etc.
 
24
  if x.ndim == 2 and x.shape[1] == 1:
25
  x = x[:, 0]
26
 
 
27
  x = x.astype(np.float32)
28
  x = np.clip(x, -1.0, 1.0)
29
+ # Gradio Audio expects int16; convert here so Gradio doesn't convert and warn
30
+ x = (x * 32767).astype(np.int16)
31
 
32
  return (sr, x)
33
 
 
97
 
98
 
99
 
100
+ def transcribe_audio(
101
+ audio_array: np.ndarray,
102
+ sr: int,
103
+ streamer_type: str = "Deepgram",
104
+ stream_name: str = "RAW",
105
+ on_update: Optional[Callable[[str], None]] = None,
106
+ ):
107
  """
108
  Transcribe an audio array using the specified STT streamer.
109
 
 
112
  sr (int): Sample rate of the audio array
113
  streamer_type (str): "Soniox" or "Deepgram"
114
  stream_name (str): Optional label for streamer instance ("RAW", "ENHANCED", etc.)
115
+ on_update: Optional callback(text: str) called with partial transcript as results stream in.
116
 
117
  Returns:
118
+ str: Final transcript text
119
  """
 
120
  if sr != DEFAULT_SR:
121
  audio_array = resampy.resample(audio_array, sr, DEFAULT_SR)
122
  sr = DEFAULT_SR
 
123
 
124
  if streamer_type not in STREAMER_CLASSES:
125
  raise ValueError(
 
128
  )
129
 
130
  StreamerClass = STREAMER_CLASSES[streamer_type]
131
+ streamer = StreamerClass(sr, stream_name, on_update=on_update)
132
 
133
  transcript = streamer.stream_array(audio_array)
134