duongthienz commited on
Commit
8441515
·
verified ·
1 Parent(s): 2d68ee3

Updated app.py to utilize utils.py and state.py

Browse files
Files changed (1) hide show
  1. app.py +485 -1281
app.py CHANGED
@@ -1,699 +1,205 @@
1
- import streamlit as st
2
- import matplotlib.pyplot as plt
3
- import numpy as np
4
- import torchaudio
5
- import sonogram_utility as su
 
 
 
 
 
 
6
  import time
7
- import ParquetScheduler as ps
8
- from pathlib import Path
9
- from typing import Any, Dict, List, Optional, Union
10
- import copy
11
- import datetime
12
  import tempfile
13
- import os
14
- import shutil
 
 
15
  import pandas as pd
16
  import plotly.express as px
17
- import plotly.graph_objects as go
18
- from plotly.subplots import make_subplots
19
- import torch
20
- #import torch_xla.core.xla_model as xm
21
  from pyannote.audio import Pipeline
22
- from pyannote.core import Annotation, Segment, Timeline
23
- import datetime as dt
24
-
25
- enableDenoise = False
26
- earlyCleanup = True
27
-
28
- # [None,Low,Medium,High,Debug]
29
- # [0,1,2,3,4]
30
- verbosity=4
31
-
32
- config = {
33
- 'displayModeBar': True,
34
- 'modeBarButtonsToRemove':[],
35
- }
36
-
37
- def printV(message,verbosityLevel):
38
- global verbosity
39
- if verbosity>=verbosityLevel:
40
- print(message)
41
-
42
- def get_display_name(speaker, fileName):
43
- """Return the user-assigned display name for a speaker, or the original label."""
44
- renames = st.session_state.speakerRenames
45
- return renames.get(fileName, {}).get(speaker, speaker)
46
-
47
- def apply_speaker_renames_to_df(df, fileName, column="task"):
48
- """Replace speaker_## labels in a DataFrame column with display names."""
49
- if column not in df.columns:
50
- return df
51
- df = df.copy()
52
- df[column] = df[column].apply(lambda s: get_display_name(s, fileName))
53
- return df
54
-
55
- @st.cache_data
56
- def convert_df(df):
57
- return df.to_csv(index=False).encode('utf-8')
58
-
59
- def save_data(
60
- config_dict: Dict[str,str], audio_paths: List[str], userid: str,
61
- ) -> None:
62
- """Save data, i.e. move audio to a new folder and send paths+config to scheduler."""
63
-
64
- save_dir = PARQUET_DATASET_DIR / f"{userid}"
65
- save_dir.mkdir(parents=True, exist_ok=True)
66
-
67
- data = copy.deepcopy(config_dict)
68
-
69
- # Add timestamp
70
- data["timestamp"] = datetime.datetime.utcnow().isoformat()
71
-
72
- # Copy and add audio
73
- for i,p in enumerate(audio_paths):
74
- name = f"{i:03d}"
75
- dst_path = save_dir / f"{name}{Path(p).suffix}"
76
- shutil.copyfile(p, dst_path)
77
- data[f"audio_{name}"] = dst_path
78
-
79
- # Send to scheduler
80
- scheduler.append(data)
81
-
82
- def processFile(filePath):
83
- global attenLimDb
84
- global gainWindow
85
- global minimumGain
86
- global maximumGain
87
- print("Loading file")
88
- waveformList, sampleRate = su.splitIntoTimeSegments(filePath,600)
89
- print("File loaded")
90
- enhancedWaveformList = []
91
- if (enableDenoise):
92
- print("Denoising")
93
- for w in waveformList:
94
- if (enableDenoise):
95
- newW = enhance(dfModel,dfState,w,atten_lim_db=attenLimDB).detach().cpu()
96
- enhancedWaveformList.append(newW)
97
- else:
98
- enhancedWaveformList.append(w)
99
- if (enableDenoise):
100
- print("Audio denoised")
101
- waveformEnhanced = su.combineWaveforms(enhancedWaveformList)
102
- if (earlyCleanup):
103
- del enhancedWaveformList
104
- print("Equalizing Audio")
105
- waveform_gain_adjusted = su.equalizeVolume()(waveformEnhanced,sampleRate,gainWindow,minimumGain,maximumGain)
106
- if (earlyCleanup):
107
- del waveformEnhanced
108
- print("Audio Equalized")
109
- print("Detecting speakers")
110
- diarization_output = pipeline({"waveform": waveform_gain_adjusted, "sample_rate": sampleRate})
111
- annotations = diarization_output.speaker_diarization
112
- print("Speakers Detected")
113
- totalTimeInSeconds = int(waveform_gain_adjusted.shape[-1]/sampleRate)
114
- print("Time in seconds calculated")
115
- return annotations, totalTimeInSeconds, waveform_gain_adjusted, sampleRate
116
-
117
- def _extract_clip_bytes(waveform, sample_rate, seg_start, seg_end):
118
- """
119
- Extract a 3–5 s clip from [seg_start, seg_end] by finding the loudest
120
- RMS window within that range. Returns raw WAV bytes.
121
- """
122
- import io
123
- import soundfile as sf
124
-
125
- CLIP_MIN = 3.0
126
- CLIP_MAX = 5.0
127
- STEP = 0.5 # scanning step in seconds
128
-
129
- total_samples = waveform.shape[-1]
130
- seg_start_s = int(seg_start * sample_rate)
131
- seg_end_s = min(int(seg_end * sample_rate), total_samples)
132
- seg_len_s = seg_end_s - seg_start_s
133
-
134
- # Duration of this segment in seconds
135
- seg_dur = (seg_end_s - seg_start_s) / sample_rate
136
-
137
- # Clip duration: between CLIP_MIN and CLIP_MAX, capped by segment length
138
- clip_dur = min(max(min(seg_dur, CLIP_MAX), CLIP_MIN), seg_dur)
139
- clip_samples = int(clip_dur * sample_rate)
140
-
141
- best_start = seg_start_s
142
- best_rms = -1.0
143
-
144
- # Slide a window and pick the loudest position
145
- step_samples = int(STEP * sample_rate)
146
- pos = seg_start_s
147
- while pos + clip_samples <= seg_end_s:
148
- window = waveform[:, pos: pos + clip_samples].float()
149
- rms = float(window.pow(2).mean().sqrt())
150
- if rms > best_rms:
151
- best_rms = rms
152
- best_start = pos
153
- pos += step_samples
154
-
155
- clip_waveform = waveform[:, best_start: best_start + clip_samples]
156
- clip_np = clip_waveform.numpy().T # (samples, channels)
157
- buf = io.BytesIO()
158
- sf.write(buf, clip_np, sample_rate, format="WAV", subtype="PCM_16")
159
- buf.seek(0)
160
- return buf.read()
161
-
162
-
163
- def generate_speaker_clips(annotations, waveform, sample_rate, file_index):
164
- """
165
- For each unique speaker in `annotations`:
166
- - Store all their segments in st.session_state.speakerSegments[file_index][speaker].
167
- - Pick the loudest 3–5 s window within their longest segment as the default clip.
168
- Saves clips as WAV bytes in st.session_state.speakerClips[file_index].
169
- """
170
- # Initialise speakerSegments store if needed
171
- if 'speakerSegments' not in st.session_state:
172
- st.session_state.speakerSegments = {}
173
-
174
- clips = {}
175
- segments = {}
176
-
177
- for speaker in annotations.labels():
178
- speaker_segments = [
179
- segment for segment, _, label in annotations.itertracks(yield_label=True)
180
- if label == speaker
181
- ]
182
- if not speaker_segments:
183
- continue
184
 
185
- # Persist all segments so the randomize button can draw from them later
186
- segments[speaker] = [(s.start, s.end) for s in speaker_segments]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
 
188
- longest = max(speaker_segments, key=lambda s: s.duration)
189
- clips[speaker] = _extract_clip_bytes(
190
- waveform, sample_rate, longest.start, longest.end
191
- )
192
 
193
- st.session_state.speakerClips[file_index] = clips
194
- st.session_state.speakerSegments[file_index] = segments
195
- print(f"Generated {len(clips)} speaker clips for {file_index}")
196
-
197
-
198
- def randomize_speaker_clip(file_index, speaker):
199
- """
200
- Pick a random segment (weighted by duration) for `speaker` and extract
201
- a random 3–5 s window from it. Updates speakerClips in session_state.
202
- Requires that st.session_state.speakerWaveforms[file_index] is present.
203
- """
204
- import random
205
-
206
- segs = st.session_state.speakerSegments.get(file_index, {}).get(speaker)
207
- waveform_data = st.session_state.speakerWaveforms.get(file_index)
208
- if not segs or waveform_data is None:
209
- return
210
-
211
- waveform, sample_rate = waveform_data
212
-
213
- CLIP_MIN = 3.0
214
- CLIP_MAX = 5.0
215
-
216
- # Weight selection by segment duration so longer segments are more likely
217
- durations = [max(e - s, 0.01) for s, e in segs]
218
- total_dur = sum(durations)
219
- rand_val = random.random() * total_dur
220
- cumulative = 0.0
221
- chosen_start, chosen_end = segs[0]
222
- for (seg_s, seg_e), dur in zip(segs, durations):
223
- cumulative += dur
224
- if rand_val <= cumulative:
225
- chosen_start, chosen_end = seg_s, seg_e
226
- break
227
-
228
- seg_dur = chosen_end - chosen_start
229
- clip_dur = min(max(min(seg_dur, CLIP_MAX), CLIP_MIN), seg_dur)
230
-
231
- # Random offset within the chosen segment
232
- max_offset = max(seg_dur - clip_dur, 0.0)
233
- offset = random.uniform(0.0, max_offset)
234
- clip_start = chosen_start + offset
235
- clip_end = clip_start + clip_dur
236
-
237
- new_clip = _extract_clip_bytes(waveform, sample_rate, clip_start, clip_end)
238
- st.session_state.speakerClips[file_index][speaker] = new_clip
239
- print(f"Randomized clip for {speaker} in {file_index}: {clip_start:.2f}–{clip_end:.2f}s")
240
-
241
- def addCategory():
242
- newCategory = st.session_state.categoryInput
243
- st.toast(f"Adding {newCategory}")
244
- st.session_state[f'multiselect_{newCategory}'] = []
245
- st.session_state.categories.append(newCategory)
246
- st.session_state.categoryInput = ''
247
- for fname in st.session_state.categorySelect:
248
- st.session_state.categorySelect[fname].append([])
249
-
250
- def removeCategory(index):
251
- categoryName = st.session_state.categories[index]
252
- st.toast(f"Removing {categoryName}")
253
- del st.session_state[f'multiselect_{categoryName}']
254
- del st.session_state[f'remove_{categoryName}']
255
- del st.session_state.categories[index]
256
- for fname in st.session_state.categorySelect:
257
- del st.session_state.categorySelect[fname][index]
258
-
259
- def _global_rename_key(index):
260
- return f"grename_speakers_{index}"
261
-
262
- def applyGlobalRenames():
263
- """Write all globalRenames entries into speakerRenames and refresh widget keys."""
264
- # Clear all existing renames first, then re-apply so removals take effect
265
- for fname in st.session_state.speakerRenames:
266
- st.session_state.speakerRenames[fname] = {}
267
- for entry in st.session_state.globalRenames:
268
- display_name = entry["name"]
269
- for token in entry["speakers"]:
270
- # token format: "filename: SPEAKER_##"
271
- if ": " not in token:
272
- continue
273
- fname, raw_sp = token.split(": ", 1)
274
- if fname in st.session_state.speakerRenames:
275
- st.session_state.speakerRenames[fname][raw_sp] = display_name
276
- # Refresh rename widget keys for the currently viewed file
277
- curr = st.session_state.get("select_currFile")
278
- if curr and curr in st.session_state.speakerRenames:
279
- saved = st.session_state.speakerRenames[curr]
280
- results = st.session_state.results.get(curr)
281
- if results:
282
- for sp in results[0].labels():
283
- wk = f"rename_{curr}_{sp}"
284
- st.session_state[wk] = saved.get(sp, "")
285
-
286
- def addGlobalRename():
287
- new_name = st.session_state.globalRenameInput.strip()
288
- if not new_name:
289
- return
290
- st.toast(f"Adding rename '{new_name}'")
291
- st.session_state.globalRenames.append({"name": new_name, "speakers": []})
292
- st.session_state[_global_rename_key(len(st.session_state.globalRenames) - 1)] = []
293
- st.session_state.globalRenameInput = ""
294
-
295
- def removeGlobalRename(index):
296
- entry = st.session_state.globalRenames[index]
297
- st.toast(f"Removing rename '{entry['name']}'")
298
- del st.session_state.globalRenames[index]
299
- # Rebuild widget keys for remaining entries to stay in sync
300
- for i in range(index, len(st.session_state.globalRenames)):
301
- next_key = _global_rename_key(i)
302
- st.session_state[next_key] = [s for s in st.session_state.globalRenames[i]["speakers"]]
303
- applyGlobalRenames()
304
-
305
- def updateCategoryOptions(fileName):
306
- if st.session_state.resetResult:
307
- return
308
- currAnnotation, _ = st.session_state.results[fileName]
309
- speakerNames = list(currAnnotation.labels())
310
- # Build reverse map from speakerRenames (source of truth): display name -> SPEAKER_##
311
- saved_renames = st.session_state.speakerRenames.get(fileName, {})
312
- display_to_raw = {}
313
- for sp in speakerNames:
314
- display = saved_renames.get(sp, sp)
315
- display_to_raw[display] = sp
316
- unusedSpeakers = copy.deepcopy(speakerNames)
317
- for i, category in enumerate(st.session_state['categories']):
318
- display_choices = list(st.session_state[f'multiselect_{category}'])
319
- raw_choices = [display_to_raw.get(d, d) for d in display_choices]
320
- st.session_state["categorySelect"][fileName][i] = raw_choices
321
- for sp in raw_choices:
322
- try:
323
- unusedSpeakers.remove(sp)
324
- except:
325
- continue
326
- st.session_state.unusedSpeakers[fileName] = unusedSpeakers
327
-
328
- def updateMultiSelect():
329
- fileName = st.session_state["select_currFile"]
330
- st.session_state.resetResult = True
331
- result = st.session_state.results.get(fileName)
332
- if result:
333
- currAnnotation, _ = result
334
- speakerNames = list(currAnnotation.labels())
335
-
336
- # Always restore rename widgets from the persistent speakerRenames dict
337
- # so that coming back to a file after visiting another shows saved names.
338
- saved_renames = st.session_state.speakerRenames.get(fileName, {})
339
- raw_to_display = {}
340
- for sp in speakerNames:
341
- wk = f"rename_{fileName}_{sp}"
342
- saved = saved_renames.get(sp, "")
343
- st.session_state[wk] = saved # unconditionally restore
344
- raw_to_display[sp] = saved if saved else sp
345
-
346
- for i, category in enumerate(st.session_state['categories']):
347
- raw_choices = st.session_state['categorySelect'][fileName][i]
348
- st.session_state[f'multiselect_{category}'] = [raw_to_display.get(sp, sp) for sp in raw_choices]
349
-
350
- def analyze(inFileName):
351
- try:
352
- print(f"Start analyzing {inFileName}")
353
- st.session_state.resetResult = False
354
- if inFileName in st.session_state.results and inFileName in st.session_state.summaries and len(st.session_state.results[inFileName]) > 0:
355
-
356
- printV(f'In if',4)
357
- currAnnotation, currTotalTime = st.session_state.results[inFileName]
358
- speakerNames = currAnnotation.labels()
359
- printV(f'Loaded results',4)
360
- unusedSpeakers = st.session_state.unusedSpeakers[inFileName]
361
- categorySelections = st.session_state["categorySelect"][inFileName]
362
- printV(f'Loaded speaker selections',4)
363
- noVoice, oneVoice, multiVoice = su.calcSpeakingTypes(currAnnotation,currTotalTime)
364
- sumNoVoice = su.sumTimes(noVoice)
365
- sumOneVoice = su.sumTimes(oneVoice)
366
- sumMultiVoice = su.sumTimes(multiVoice)
367
- printV(f'Calculated speaking types',4)
368
-
369
- df3 = pd.DataFrame(
370
- {
371
- "values": [sumNoVoice,
372
- sumOneVoice,
373
- sumMultiVoice],
374
- "names": ["No Voice","One Voice","Multi Voice"],
375
- }
376
- )
377
- df3.name = "df3"
378
- st.session_state.summaries[inFileName]["df3"] = df3
379
- printV(f'Set df3',4)
380
-
381
- # --- Build df4 ---
382
- nameList = st.session_state.categories
383
- extraNames = []
384
- valueList = [0 for i in range(len(nameList))]
385
- extraValues = []
386
-
387
- for sp in speakerNames:
388
- foundSp = False
389
- for i, categoryName in enumerate(nameList):
390
- if sp in categorySelections[i]:
391
- valueList[i] += su.sumTimes(currAnnotation.subset([sp]))
392
- foundSp = True
393
- break
394
- if not foundSp:
395
- extraNames.append(sp)
396
- extraValues.append(su.sumTimes(currAnnotation.subset([sp])))
397
-
398
- if extraNames:
399
- extraPairsSorted = sorted(zip(extraNames, extraValues), key=lambda pair: pair[0])
400
- extraNames, extraValues = list(zip(*extraPairsSorted))
401
- extraNames = list(extraNames)
402
- extraValues = list(extraValues)
403
- else:
404
- extraNames, extraValues = [], []
405
-
406
- df4_dict = {
407
- "values": valueList + extraValues,
408
- "names": nameList + extraNames,
409
- }
410
- df4 = pd.DataFrame(data=df4_dict)
411
- df4.name = "df4"
412
- st.session_state.summaries[inFileName]["df4"] = df4
413
- printV(f'Set df4', 4)
414
-
415
- # --- Build df5 ---
416
- speakerList, timeList = su.sumTimesPerSpeaker(oneVoice)
417
- multiSpeakerList, multiTimeList = su.sumMultiTimesPerSpeaker(multiVoice)
418
-
419
- speakerList = list(speakerList) if speakerList else []
420
- timeList = list(timeList) if timeList else []
421
- multiSpeakerList = list(multiSpeakerList) if multiSpeakerList else []
422
- multiTimeList = list(multiTimeList) if multiTimeList else []
423
-
424
- summativeMultiSpeaker = sum(multiTimeList) if multiTimeList else 1
425
- safeOneVoice = sumOneVoice if sumOneVoice > 0 else 1
426
-
427
- basePercentiles = [
428
- sumNoVoice / currTotalTime,
429
- sumOneVoice / currTotalTime,
430
- sumMultiVoice / currTotalTime,
431
- ]
432
 
433
- timeStrings = su.timeToString(timeList) if timeList else []
434
- multiTimeStrings = su.timeToString(multiTimeList) if multiTimeList else []
435
- if isinstance(timeStrings, str):
436
- timeStrings = [timeStrings]
437
- if isinstance(multiTimeStrings, str):
438
- multiTimeStrings = [multiTimeStrings]
439
-
440
- n_ov = len(speakerList)
441
- n_mv = len(multiSpeakerList)
442
-
443
- df5 = pd.DataFrame({
444
- "ids": ["NV", "OV", "MV"] + [f"OV_{i}" for i in range(n_ov)] + [f"MV_{i}" for i in range(n_mv)],
445
- "labels": ["No Voice", "One Voice", "Multi Voice"] + speakerList + multiSpeakerList,
446
- "parents": ["", "", ""] + ["OV"] * n_ov + ["MV"] * n_mv,
447
- "parentNames": ["Total", "Total", "Total"] + ["One Voice"] * n_ov + ["Multi Voice"] * n_mv,
448
- "values": [sumNoVoice, sumOneVoice, sumMultiVoice] + timeList + multiTimeList,
449
- "valueStrings": [
450
- su.timeToString(sumNoVoice),
451
- su.timeToString(sumOneVoice),
452
- su.timeToString(sumMultiVoice),
453
- ] + timeStrings + multiTimeStrings,
454
- "percentiles": [
455
- basePercentiles[0] * 100,
456
- basePercentiles[1] * 100,
457
- basePercentiles[2] * 100,
458
- ] + [(t * 100) / safeOneVoice * basePercentiles[1] for t in timeList]
459
- + [(t * 100) / summativeMultiSpeaker * basePercentiles[2] for t in multiTimeList],
460
- "parentPercentiles": [
461
- basePercentiles[0] * 100,
462
- basePercentiles[1] * 100,
463
- basePercentiles[2] * 100,
464
- ] + [(t * 100) / safeOneVoice for t in timeList]
465
- + [(t * 100) / summativeMultiSpeaker for t in multiTimeList],
466
- })
467
- df5.name = "df5"
468
- st.session_state.summaries[inFileName]["df5"] = df5
469
- printV(f'Set df5', 4)
470
-
471
- # --- Build speakers_dataFrame, df2 ---
472
- speakers_dataFrame, speakers_times = su.annotationToDataFrame(currAnnotation)
473
- st.session_state.summaries[inFileName]["speakers_dataFrame"] = speakers_dataFrame
474
- st.session_state.summaries[inFileName]["speakers_times"] = speakers_times
475
-
476
- df2_dict = {
477
- "values": [100 * t / currTotalTime for t in df4_dict["values"]],
478
- "names": df4_dict["names"],
479
- }
480
- df2 = pd.DataFrame(df2_dict)
481
- st.session_state.summaries[inFileName]["df2"] = df2
482
- printV(f'Set df2', 4)
483
- except Exception as e:
484
- import traceback
485
- print(f"Error in analyze: {e}")
486
- traceback.print_exc()
487
- st.error(f"Debug - analyze() failed: {e}")
488
-
489
- #----------------------------------------------------------------------------------------------------------------------
490
 
491
  torch.classes.__path__ = [os.path.join(torch.__path__[0], torch.classes.__file__)]
492
 
493
- PARQUET_DATASET_DIR = Path("parquet_dataset")
494
- PARQUET_DATASET_DIR.mkdir(parents=True,exist_ok=True)
 
495
 
496
- sample_data = [f"CHEM1402_gt/24F_CHEM1402_Night_Class_Week_{i}_gt.rttm" for i in range(1,11)]
497
-
498
-
499
- scheduler = ps.ParquetScheduler(repo_id="Sonogram/SampleDataset")
 
 
500
 
501
- secondDifference = 5
502
- gainWindow = 4
503
- minimumGain = -45
504
- maximumGain = -5
505
- attenLimDB = 3
506
 
507
- isGPU = False
 
 
508
 
509
- try:
510
- raise(RuntimeError("Not an error"))
511
- #device = xm.xla_device()
512
- print("TPU is available.")
513
- isGPU = True
514
- except RuntimeError as e:
515
- print(f"TPU is not available: {e}")
516
- # Fallback to CPU or other devices if needed
517
- isGPU = torch.cuda.is_available()
518
- device = torch.device("cuda" if isGPU else "cpu")
519
- print(f"Using {device} instead.")
520
- #device = xm.xla_device()
521
-
522
- if (enableDenoise):
523
- # Instantiate and prepare model for training.
524
- dfModel, dfState, _ = init_df(model_base_dir="DeepFilterNet3")
525
- dfModel.to(device)#torch.device("cuda"))
526
- pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1")
527
- pipeline.to(device)#torch.device("cuda"))
528
-
529
- # Store results for viewing and further processing
530
- # All per-file state is keyed by filename (str) so it survives upload order changes.
531
- if 'results' not in st.session_state:
532
- st.session_state.results = {} # {filename: (annotations, totalSeconds)}
533
- if 'speakerRenames' not in st.session_state:
534
- st.session_state.speakerRenames = {} # {filename: {speaker: name}}
535
- if 'summaries' not in st.session_state:
536
- st.session_state.summaries = {} # {filename: {df2, df3, ...}}
537
- if 'categories' not in st.session_state:
538
- st.session_state.categories = []
539
- st.session_state.categorySelect = {} # {filename: [[], [], ...]}
540
- if 'removeCategory' not in st.session_state:
541
- st.session_state.removeCategory = None
542
- if 'resetResult' not in st.session_state:
543
- st.session_state.resetResult = False
544
- if 'unusedSpeakers' not in st.session_state:
545
- st.session_state.unusedSpeakers = {} # {filename: [speaker, ...]}
546
- if 'file_names' not in st.session_state:
547
- st.session_state.file_names = []
548
- if 'valid_files' not in st.session_state:
549
- st.session_state.valid_files = []
550
- if 'file_paths' not in st.session_state:
551
- st.session_state.file_paths = {} # {filename: path}
552
- if 'showSummary' not in st.session_state:
553
- st.session_state.showSummary = 'No'
554
- if 'speakerClips' not in st.session_state:
555
- st.session_state.speakerClips = {} # {filename: {speaker: wav_bytes}}
556
- if 'speakerSegments' not in st.session_state:
557
- st.session_state.speakerSegments = {} # {filename: {speaker: [(start,end), ...]}}
558
- if 'speakerWaveforms' not in st.session_state:
559
- st.session_state.speakerWaveforms = {} # {filename: (waveform_tensor, sample_rate)}
560
- if 'globalRenames' not in st.session_state:
561
- st.session_state.globalRenames = [] # [{"name": str, "speakers": ["file:SPEAKER_##", ...]}]
562
- if 'analyzeAllToggle' not in st.session_state:
563
- st.session_state.analyzeAllToggle = False
564
-
565
 
566
-
 
 
567
 
568
-
569
- #st.set_page_config(layout="wide")
570
  st.title("Instructor Support Tool")
571
  if not isGPU:
572
  st.warning("TOOL CURRENTLY USING CPU, ANALYSIS EXTREMELY SLOW")
573
- st.write('If you would like to see a sample result generated from real classroom audio, use the sidebar on the left and press "Load Demo Example"')
574
- st.write('Keep in mind that this is a very early draft of the tool. Please be patient with any bugs/errors, and email Connor Young at czyoung@ualr.edu if you need help using the tool!')
 
 
 
 
 
 
 
 
575
  st.divider()
 
576
  with st.expander("Instructions and additional details"):
577
- st.write("Thank you for viewing our experimental app! The overall presentations and features are expected to be improved over time, you can think of this as our first rough draft!")
578
- st.write("To use this app:\n1. Upload an audio file for live analysis. Alternatively, you can upload an already generated [rttm file](https://stackoverflow.com/questions/30975084/rttm-file-format)")
579
- st.write("2. Press Analyze All. Note that no data is saved on our side, so we will not have access to your recordings. Future versions of this app will support donating audio to us for aid in our research.")
580
- st.write("3. Use the side bar on the left to select your file (may have to be expanded by clicking the > ). Our app supports uploading multiple files for more comprehensive analysis.")
581
- st.write("4. Use the tabs provided to view different visualizations of your audio. Each example can be downloaded for personal use.")
582
- st.write("4a. The graphs are built using [plotly](https://plotly.com/). This allows for a high degree of interaction. Feel free to experiment with the graphs, as you can always return to the original view by double-clicking on the graph. For more examples of easily supported visualizations, see [here](https://plotly.com/python/basic-charts/)")
583
- st.write("Would you like additional data, charts, or features? We would love to hear more from you [about our project!](https://forms.gle/A32CdfGYSZoMPyyX9)")
584
- st.write("If you would like to learn more or work with us, please contact Dr. Mark Baillie at mtbaillie@ualr.edu")
585
- uploaded_file_paths = st.file_uploader("Upload an audio of classroom activity to analyze", accept_multiple_files=True)
586
-
587
- supported_file_types = ('.wav','.mp3','.mp4','.txt','.rttm','.csv')
588
- viewChoices = ["Voice Categories","Custom Categories","Detailed Voice Categories","Voice Category Treemap","Speaker Timeline","Time per Speaker"]
589
-
590
- valid_files = st.session_state.valid_files
591
- file_paths = st.session_state.file_paths
592
- currDF = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
593
  temp_dir = tempfile.mkdtemp()
594
 
595
- if uploaded_file_paths is not None and len(uploaded_file_paths) > 0:
596
- print("Found file paths")
597
  for uploaded_file in uploaded_file_paths:
598
- if not uploaded_file.name.lower().endswith(supported_file_types):
599
- st.error('File must be of type: {}'.format(supported_file_types))
600
- else:
601
- fname = uploaded_file.name
602
- print(f"Valid file: {fname}")
603
- # Write to disk (always refresh so file bytes are current)
604
- path = os.path.join(temp_dir, fname)
605
- with open(path, "wb") as f:
606
- f.write(uploaded_file.getvalue())
607
- # Add to master lists only if not already tracked
608
- if fname not in st.session_state.file_names:
609
- st.session_state.file_names.append(fname)
610
- st.session_state.results.setdefault(fname, [])
611
- st.session_state.summaries.setdefault(fname, [])
612
- st.session_state.unusedSpeakers.setdefault(fname, [])
613
- st.session_state.categorySelect.setdefault(fname, [[] for _ in st.session_state.categories])
614
- st.session_state.speakerRenames.setdefault(fname, {})
615
- st.session_state.speakerClips.setdefault(fname, {})
616
- st.session_state.file_paths[fname] = path
617
- # Rebuild valid_files / file_paths lists from tracked state
618
- valid_files = [f for f in st.session_state.file_names]
619
- file_paths = [st.session_state.file_paths[f] for f in valid_files]
620
- file_names = valid_files
621
- st.session_state.valid_files = valid_files
622
- st.session_state.file_paths = {f: st.session_state.file_paths[f] for f in valid_files}
623
-
624
- file_names = st.session_state.file_names
625
- file_paths_dict = st.session_state.file_paths # dict {fname: path}
626
-
627
- class FakeUpload:
628
- def __init__(self,filepath):
629
- self.path = filepath
630
- self.name = filepath.split('/')[-1
631
- ]
632
- demoPath = "sample.rttm"
633
  isDemo = False
 
634
  if st.sidebar.button("Single File Demo"):
635
- demoName = demoPath.split('/')[-1]
636
- start_time = time.time()
637
- if demoName not in st.session_state.file_names:
638
- st.session_state.file_names.append(demoName)
639
- st.session_state.file_paths[demoName] = demoPath
640
- st.session_state.results.setdefault(demoName, [])
641
- st.session_state.summaries.setdefault(demoName, {})
642
- st.session_state.unusedSpeakers.setdefault(demoName, [])
643
- st.session_state.categorySelect.setdefault(demoName, [[] for _ in st.session_state.categories])
644
- st.session_state.speakerRenames.setdefault(demoName, {})
645
- st.session_state.speakerClips.setdefault(demoName, {})
646
  file_names = st.session_state.file_names
647
-
648
- with st.spinner(text=f'Loading Demo Sample'):
649
- speakerList, annotations = su.loadAudioRTTM(demoPath)
650
- totalSeconds = 0
651
- for segment in annotations.itersegments():
652
- if segment.end > totalSeconds:
653
- totalSeconds = segment.end
654
- st.session_state.results[demoName] = (annotations, totalSeconds)
655
- st.session_state.summaries[demoName] = {}
656
- st.session_state.unusedSpeakers[demoName] = list(annotations.labels())
657
- with st.spinner(text=f'Analyzing Demo Data'):
658
- analyze(demoName)
659
- st.success(f"Took {time.time() - start_time} seconds to analyze the demo file!")
660
- st.session_state.select_currFile = demoName
661
  isDemo = True
662
 
663
- multiFileDemoPaths = ["audioSamples/media-afc-cal-afc1986022_sr01a05.rttm","audioSamples/media-afc-cal-afc1986022_sr34a01.rttm","audioSamples/media-afc-cal-afc1986022_sr14b02.rttm",
664
- "audioSamples/media-afc-cal-afc1986022_sr52a02.rttm","audioSamples/media-afc-cal-afc1986022_sr14b01.rttm"]
665
- # TODO: prepare audio for playback of audio
666
- multiFileAudioPaths = ["audioSamples/media-afc-cal-afc1986022_sr01a05.mp3","audioSamples/media-afc-cal-afc1986022_sr34a01.mp3","audioSamples/media-afc-cal-afc1986022_sr14b02.mp3",
667
- "audioSamples/media-afc-cal-afc1986022_sr52a02.mp3","audioSamples/media-afc-cal-afc1986022_sr14b01.mp3"]
668
-
669
  if st.sidebar.button("Multiple Files Demo"):
670
- for demoPath in multiFileDemoPaths:
671
- demoName = demoPath.split('/')[-1]
672
- start_time = time.time()
673
- if demoName not in st.session_state.file_names:
674
- st.session_state.file_names.append(demoName)
675
- st.session_state.file_paths[demoName] = demoPath
676
- st.session_state.results.setdefault(demoName, [])
677
- st.session_state.summaries.setdefault(demoName, {})
678
- st.session_state.unusedSpeakers.setdefault(demoName, [])
679
- st.session_state.categorySelect.setdefault(demoName, [[] for _ in st.session_state.categories])
680
- st.session_state.speakerRenames.setdefault(demoName, {})
681
- st.session_state.speakerClips.setdefault(demoName, {})
682
  file_names = st.session_state.file_names
683
-
684
- with st.spinner(text=f'Loading Demo Sample'):
685
- speakerList, annotations = su.loadAudioRTTM(demoPath)
686
- totalSeconds = 0
687
- for segment in annotations.itersegments():
688
- if segment.end > totalSeconds:
689
- totalSeconds = segment.end
690
- st.session_state.results[demoName] = (annotations, totalSeconds)
691
- st.session_state.summaries[demoName] = {}
692
- st.session_state.unusedSpeakers[demoName] = list(annotations.labels())
693
- # TODO: Remove if not necessary
694
- #st.session_state.select_currFile = demoName
695
  isDemo = True
696
- st.session_state.analyzeAllToggle = True
 
 
 
 
697
 
698
  if len(file_names) == 0:
699
  st.text("Upload file(s) to enable analysis")
@@ -701,668 +207,366 @@ else:
701
  col_analyze, col_spacer, col_reset = st.columns([3, 5, 2])
702
  with col_analyze:
703
  if st.button("Analyze All New Audio", key="button_all"):
704
- if len(file_names) == 0:
705
- st.error('Upload file(s) first!')
706
- else:
707
- st.session_state.analyzeAllToggle = True
708
  with col_reset:
709
  if st.button("🗑️ Reset App", key="button_reset", type="secondary", use_container_width=True):
710
  for key in list(st.session_state.keys()):
711
  del st.session_state[key]
712
  st.rerun()
713
 
714
- if st.session_state.analyzeAllToggle == True:
715
- print("Start analyzing")
 
 
 
716
  start_time = time.time()
717
  totalFiles = len(file_names)
 
718
  for i, fname in enumerate(file_names):
719
- printV(f'On {i} : {fname}',4)
720
  fpath = file_paths_dict.get(fname, "")
721
- printV(f'Path : {fpath}',4)
722
- # TODO: Fix shortcut for already analyzed files here
723
- #if fname in st.session_state.results and fname in st.session_state.summaries and len(st.session_state.results[fname]) > 0:
724
- #continue
725
- if fpath.lower().endswith('.txt'):
726
- with st.spinner(text=f'Loading Demo File {i+1} of {totalFiles}'):
727
- speakerList, annotations = su.loadAudioTXT(fpath)
728
- printV(annotations,4)
729
- totalSeconds = 0
730
- for segment in annotations.itersegments():
731
- if segment.end > totalSeconds:
732
- totalSeconds = segment.end
733
- st.session_state.results[fname] = (annotations, totalSeconds)
734
- st.session_state.summaries[fname] = {}
735
  st.session_state.unusedSpeakers[fname] = list(annotations.labels())
736
- elif fpath.lower().endswith('.rttm'):
737
- with st.spinner(text=f'Loading File {i+1} of {totalFiles}'):
738
- speakerList, annotations = su.loadAudioRTTM(fpath)
739
- printV(annotations,4)
740
- totalSeconds = 0
741
- for segment in annotations.itersegments():
742
- if segment.end > totalSeconds:
743
- totalSeconds = segment.end
744
- st.session_state.results[fname] = (annotations, totalSeconds)
745
- st.session_state.summaries[fname] = {}
746
  st.session_state.unusedSpeakers[fname] = list(annotations.labels())
747
- elif fpath.lower().endswith('.csv'):
748
- with st.spinner(text=f'Loading File {i+1} of {totalFiles}'):
749
- speakerList, annotations = su.loadAudioCSV(fpath)
750
- printV(annotations,4)
751
- totalSeconds = 0
752
- for segment in annotations.itersegments():
753
- if segment.end > totalSeconds:
754
- totalSeconds = segment.end
755
- st.session_state.results[fname] = (annotations, totalSeconds)
756
- st.session_state.summaries[fname] = {}
757
  st.session_state.unusedSpeakers[fname] = list(annotations.labels())
 
758
  else:
759
- with st.spinner(text=f'Processing File {i+1} of {totalFiles}'):
760
- annotations, totalSeconds, waveform, sample_rate = processFile(fpath)
761
- print(f"Finished processing {fpath}")
762
- st.session_state.results[fname] = (annotations, totalSeconds)
763
- st.session_state.summaries[fname] = {}
 
 
 
764
  st.session_state.unusedSpeakers[fname] = list(annotations.labels())
765
- with st.spinner(text=f'Generating speaker clips for File {i+1} of {totalFiles}'):
766
- generate_speaker_clips(annotations, waveform, sample_rate, fname)
767
- # Keep a reference so the "Try Another Clip" button can re-sample later
768
- st.session_state.speakerWaveforms[fname] = (waveform, sample_rate)
769
  del waveform
770
- print(f"Speaker clips generated for {fpath}")
771
- with st.spinner(text=f'Analyzing File {i+1} of {totalFiles}'):
772
  analyze(fname)
773
- print(f"Finished analyzing {fpath}")
774
- print(f"Took {time.time() - start_time} seconds to analyze {totalFiles} files!")
775
- st.success(f"Took {time.time() - start_time} seconds to analyze {totalFiles} files!")
776
  st.session_state.analyzeAllToggle = False
777
 
778
- currFile = st.sidebar.selectbox('Current File', file_names, on_change=updateMultiSelect, key="select_currFile")
 
 
779
 
 
 
 
780
  if isDemo:
781
  currFile = file_names[0]
782
- isDemo = False
783
 
784
  if currFile is None:
785
  st.write("Select a file to view from the sidebar")
 
 
 
 
 
786
  try:
787
  if currFile is None:
788
  raise ValueError("No file selected")
 
789
  st.session_state.resetResult = False
790
- currPlainName = currFile.split('.')[0]
791
- if currFile in st.session_state.results and currFile in st.session_state.summaries and len(st.session_state.results[currFile]) > 0:
792
- st.header(f"Analysis of file {currFile}")
793
- graphNames = ["Data","Voice Categories","Speaker Percentage","Speakers with Categories","Treemap","Timeline","Time Spoken"]
794
- dataTab, pie1, pie2, sunburst1, treemap1, timeline, bar1 = st.tabs(graphNames)
795
- currAnnotation, currTotalTime = st.session_state.results[currFile]
796
- speakerNames = currAnnotation.labels()
797
-
798
- speakers_dataFrame = st.session_state.summaries[currFile]["speakers_dataFrame"]
799
- currDF, _ = su.annotationToSimpleDataFrame(currAnnotation)
800
- speakers_times = st.session_state.summaries[currFile]["speakers_times"]
801
-
802
- unusedSpeakers = st.session_state.unusedSpeakers[currFile]
803
- categorySelections = st.session_state["categorySelect"][currFile]
804
- # Build raw->display map from speakerRenames (source of truth, written by applyGlobalRenames)
805
- _saved_renames = st.session_state.speakerRenames.get(currFile, {})
806
- raw_to_display = {sp: (_saved_renames.get(sp, sp)) for sp in speakerNames}
807
- all_speakers_display = [raw_to_display[sp] for sp in speakerNames]
808
- for i,category in enumerate(st.session_state.categories):
809
- ms_key = f"multiselect_{category}"
810
- speakerSet = categorySelections[i] # SPEAKER_## internally
811
- default_display = [raw_to_display.get(sp, sp) for sp in speakerSet]
812
- # Seed widget state once with display names; omit default= to let Streamlit own state
813
- if ms_key not in st.session_state:
814
- st.session_state[ms_key] = default_display
815
- st.sidebar.multiselect(category,
816
- all_speakers_display,
817
- key=ms_key,
818
- on_change=updateCategoryOptions,
819
- args=(currFile,))
820
- st.sidebar.button(f"Remove {category}",key=f"remove_{category}",on_click=removeCategory,args=(i,))
821
-
822
-
823
-
824
- newCategory = st.sidebar.text_input('Add category', key='categoryInput',on_change=addCategory)
825
-
826
- st.sidebar.divider()
827
- st.sidebar.subheader("Rename Speakers")
828
- st.sidebar.caption(
829
- "Assign a name and select which speaker labels (across all files) it applies to. "
830
- "Changes apply to all matched speakers instantly."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
831
  )
832
 
833
- # --- Speaker clip preview (identification aid) ---
834
- file_clips = st.session_state.speakerClips.get(currFile, {})
835
- if file_clips:
836
- st.sidebar.caption("🎧 Listen to clips to help identify speakers:")
837
- current_renames = st.session_state.speakerRenames[currFile]
838
- for sp in speakerNames:
839
- widget_key = f"rename_{currFile}_{sp}"
840
- if widget_key not in st.session_state:
841
- st.session_state[widget_key] = current_renames.get(sp, "")
842
- live_name = st.session_state[widget_key].strip()
843
- display_label = live_name if live_name else sp
844
- st.sidebar.markdown(f"**{display_label}**")
845
- if sp in file_clips:
846
- st.sidebar.audio(file_clips[sp], format="audio/wav")
847
- sp_segs = st.session_state.speakerSegments.get(currFile, {}).get(sp, [])
848
- has_waveform = currFile in st.session_state.speakerWaveforms
849
- if has_waveform and len(sp_segs) >= 1:
850
- if st.sidebar.button(
851
- "🔀 Try Another Clip",
852
- key=f"randomize_{currFile}_{sp}",
853
- help="Pick a random clip from a different part of this speaker's audio",
854
- ):
855
- randomize_speaker_clip(currFile, sp)
856
- st.rerun()
857
-
858
- # Build the full list of "filename: SPEAKER_##" tokens across all analyzed files
859
- all_speaker_tokens = []
860
- for fn in st.session_state.file_names:
861
- if fn in st.session_state.results and len(st.session_state.results[fn]) == 2:
862
- ann, _ = st.session_state.results[fn]
863
- for sp in ann.labels():
864
- all_speaker_tokens.append(f"{fn}: {sp}")
865
-
866
- st.sidebar.divider()
867
-
868
- # --- Render existing global rename entries ---
869
- def _on_grename_change(idx):
870
- key = _global_rename_key(idx)
871
- st.session_state.globalRenames[idx]["speakers"] = list(st.session_state[key])
872
- applyGlobalRenames()
873
-
874
- for idx, entry in enumerate(st.session_state.globalRenames):
875
- grkey = _global_rename_key(idx)
876
- if grkey not in st.session_state:
877
- st.session_state[grkey] = list(entry["speakers"])
878
- st.sidebar.markdown(f"**{entry['name']}**")
879
- st.sidebar.multiselect(
880
- f"Speakers for {entry['name']}",
881
- options=all_speaker_tokens,
882
- key=grkey,
883
- on_change=_on_grename_change,
884
- args=(idx,),
885
- label_visibility="collapsed",
886
- )
887
- st.sidebar.button(
888
- f"Remove '{entry['name']}'",
889
- key=f"remove_grename_{idx}",
890
- on_click=removeGlobalRename,
891
- args=(idx,),
892
- )
893
-
894
- # --- Add new global rename ---
895
- st.sidebar.text_input(
896
- "Add rename",
897
- placeholder="e.g. John",
898
- key="globalRenameInput",
899
- on_change=addGlobalRename,
900
  )
901
 
902
- catTypeColors = su.colorsCSS(3)
903
- allColors = su.colorsCSS(len(speakerNames)+len(st.session_state.categories))
904
- speakerColors = allColors[:len(speakerNames)]
905
- catColors = allColors[len(speakerNames):]
906
-
907
- df4_dict = {}
908
- nameList = st.session_state.categories
909
- extraNames = []
910
- valueList = [0 for i in range(len(nameList))]
911
- extraValues = []
912
-
913
- for i,speakerSet in enumerate(categorySelections):
914
- valueList[i] += su.sumTimes(currAnnotation.subset(speakerSet))
915
-
916
- for sp in unusedSpeakers:
917
- extraNames.append(sp)
918
- extraValues.append(su.sumTimes(currAnnotation.subset([sp])))
919
-
920
-
921
- df4_dict = {
922
- "names": nameList+extraNames,
923
- "values": valueList+extraValues,
924
- }
925
- df4 = pd.DataFrame(data=df4_dict)
926
- df4.name = "df4"
927
- st.session_state.summaries[currFile]["df4"] = df4
928
-
929
- with dataTab:
930
- displayDF = apply_speaker_renames_to_df(currDF, currFile, column="Resource")
931
- csv = convert_df(displayDF)
932
-
933
- st.download_button(
934
- "Press to Download analysis data",
935
- csv,
936
- 'sonogram-analysis-'+currPlainName+'.csv',
937
- "text/csv",
938
- key='download-csv',
939
- on_click="ignore",
940
- )
941
- st.dataframe(displayDF)
942
- with pie1:
943
- printV("In Pie1",4)
944
- df3 = st.session_state.summaries[currFile]["df3"]
945
- fig1 = go.Figure()
946
- fig1.update_layout(
947
- title_text="Percentage of each Voice Category",
948
- colorway=catTypeColors,
949
- plot_bgcolor='rgba(0, 0, 0, 0)',
950
- paper_bgcolor='rgba(0, 0, 0, 0)',
951
- )
952
- printV("Pie1 Pretrace",4)
953
- fig1.add_trace(go.Pie(values=df3["values"],labels=df3["names"],sort=False))
954
- printV("Pie1 Posttrace",4)
955
- st.plotly_chart(fig1, use_container_width=True, config=config)
956
- col1_1, col1_2 = st.columns(2)
957
- try:
958
- fig1.write_image("ascn_pie1.pdf")
959
- fig1.write_image("ascn_pie1.svg")
960
- except Exception:
961
- pass
962
- printV("Pie1 files written",4)
963
- with col1_1:
964
- if os.path.exists('ascn_pie1.pdf'):
965
- printV("Pie1 in col1_1",4)
966
- with open('ascn_pie1.pdf','rb') as f:
967
- printV("Pie1 in file open",4)
968
- st.download_button(
969
- "Save As PDF",
970
- f,
971
- 'sonogram-voice-category-'+currPlainName+'.pdf',
972
- 'application/pdf',
973
- key='download-pdf1',
974
- on_click="ignore",
975
- )
976
- printV("Pie1 after col1_1",4)
977
- with col1_2:
978
- if os.path.exists('ascn_pie1.svg'):
979
- with open('ascn_pie1.svg','rb') as f:
980
- st.download_button(
981
- "Save As SVG",
982
- f,
983
- 'sonogram-voice-category-'+currPlainName+'.svg',
984
- 'image/svg+xml',
985
- key='download-svg1',
986
- on_click="ignore",
987
- )
988
- printV("Pie1 in col1_2",4)
989
- printV("Pie1 post plotly",4)
990
-
991
- with pie2:
992
- printV("In Pie2",4)
993
- df4 = st.session_state.summaries[currFile]["df4"].copy()
994
-
995
- # Some speakers may be missing, so fix colors
996
- figColors = []
997
- for n in df4["names"]:
998
- if n in speakerNames:
999
- figColors.append(speakerColors[speakerNames.index(n)])
1000
- df4["names"] = df4["names"].apply(lambda s: get_display_name(s, currFile))
1001
- fig2 = go.Figure()
1002
- fig2.update_layout(
1003
- title_text="Percentage of Speakers and Custom Categories",
1004
- colorway=catColors+figColors,
1005
- plot_bgcolor='rgba(0, 0, 0, 0)',
1006
- paper_bgcolor='rgba(0, 0, 0, 0)',
1007
- )
1008
- printV("Pie2 Pretrace",4)
1009
- fig2.add_trace(go.Pie(values=df4["values"],labels=df4["names"],sort=False))
1010
- printV("Pie2 Posttrace",4)
1011
- st.plotly_chart(fig2, use_container_width=True, config=config)
1012
- col2_1, col2_2 = st.columns(2)
1013
- try:
1014
- fig2.write_image("ascn_pie2.pdf")
1015
- fig2.write_image("ascn_pie2.svg")
1016
- except Exception:
1017
- pass
1018
- with col2_1:
1019
- if os.path.exists('ascn_pie2.pdf'):
1020
- with open('ascn_pie2.pdf','rb') as f:
1021
- st.download_button(
1022
- "Save As PDF",
1023
- f,
1024
- 'sonogram-speaker-percent-'+currPlainName+'.pdf',
1025
- 'application/pdf',
1026
- key='download-pdf2',
1027
- on_click="ignore",
1028
- )
1029
- with col2_2:
1030
- if os.path.exists('ascn_pie2.svg'):
1031
- with open('ascn_pie2.svg','rb') as f:
1032
- st.download_button(
1033
- "Save As SVG",
1034
- f,
1035
- 'sonogram-speaker-percent-'+currPlainName+'.svg',
1036
- 'image/svg+xml',
1037
- key='download-svg2',
1038
- on_click="ignore",
1039
- )
1040
-
1041
- with sunburst1:
1042
- df5 = st.session_state.summaries[currFile]["df5"].copy()
1043
- df5["labels"] = df5["labels"].apply(lambda s: get_display_name(s, currFile))
1044
- df5["parentNames"] = df5["parentNames"].apply(lambda s: get_display_name(s, currFile))
1045
- fig3_1 = px.sunburst(df5,
1046
- branchvalues = 'total',
1047
- names = "labels",
1048
- ids = "ids",
1049
- parents = "parents",
1050
- values = "percentiles",
1051
- custom_data=['labels','valueStrings','percentiles','parentNames','parentPercentiles'],
1052
- color = 'labels',
1053
- title="Percentage of each Voice Category with Speakers",
1054
- color_discrete_sequence=catTypeColors+speakerColors,
1055
- )
1056
- fig3_1.update_traces(
1057
- hovertemplate="<br>".join([
1058
- '<b>%{customdata[0]}</b>',
1059
- 'Duration: %{customdata[1]}s',
1060
- 'Percentage of Total: %{customdata[2]:.2f}%',
1061
- 'Parent: %{customdata[3]}',
1062
- 'Percentage of Parent: %{customdata[4]:.2f}%'
1063
- ])
1064
- )
1065
- fig3_1.update_layout(
1066
- plot_bgcolor='rgba(0, 0, 0, 0)',
1067
- paper_bgcolor='rgba(0, 0, 0, 0)',
1068
- )
1069
- st.plotly_chart(fig3_1, use_container_width=True, config=config)
1070
- col3_1, col3_2 = st.columns(2)
1071
- try:
1072
- fig3_1.write_image("ascn_sunburst.pdf")
1073
- fig3_1.write_image("ascn_sunburst.svg")
1074
- except Exception:
1075
- pass
1076
- with col3_1:
1077
- if os.path.exists('ascn_sunburst.pdf'):
1078
- with open('ascn_sunburst.pdf','rb') as f:
1079
- st.download_button(
1080
- "Save As PDF",
1081
- f,
1082
- 'sonogram-speaker-categories-'+currPlainName+'.pdf',
1083
- 'application/pdf',
1084
- key='download-pdf3',
1085
- on_click="ignore",
1086
- )
1087
- with col3_2:
1088
- if os.path.exists('ascn_sunburst.svg'):
1089
- with open('ascn_sunburst.svg','rb') as f:
1090
- st.download_button(
1091
- "Save As SVG",
1092
- f,
1093
- 'sonogram-speaker-categories-'+currPlainName+'.svg',
1094
- 'image/svg+xml',
1095
- key='download-svg3',
1096
- on_click="ignore",
1097
- )
1098
-
1099
- with treemap1:
1100
- df5 = st.session_state.summaries[currFile]["df5"].copy()
1101
- df5["labels"] = df5["labels"].apply(lambda s: get_display_name(s, currFile))
1102
- df5["parentNames"] = df5["parentNames"].apply(lambda s: get_display_name(s, currFile))
1103
- fig3 = px.treemap(df5,
1104
- branchvalues = "total",
1105
- names = "labels",
1106
- parents = "parents",
1107
- ids="ids",
1108
- values = "percentiles",
1109
- custom_data=['labels','valueStrings','percentiles','parentNames','parentPercentiles'],
1110
- color='labels',
1111
- title="Division of Speakers in each Voice Category",
1112
- color_discrete_sequence=catTypeColors+speakerColors,
1113
- )
1114
- fig3.update_traces(
1115
- hovertemplate="<br>".join([
1116
- '<b>%{customdata[0]}</b>',
1117
- 'Duration: %{customdata[1]}s',
1118
- 'Percentage of Total: %{customdata[2]:.2f}%',
1119
- 'Parent: %{customdata[3]}',
1120
- 'Percentage of Parent: %{customdata[4]:.2f}%'
1121
- ])
1122
- )
1123
- fig3.update_layout(
1124
- plot_bgcolor='rgba(0, 0, 0, 0)',
1125
- paper_bgcolor='rgba(0, 0, 0, 0)',
1126
- )
1127
- st.plotly_chart(fig3, use_container_width=True, config=config)
1128
- col4_1, col4_2 = st.columns(2)
1129
- try:
1130
- fig3.write_image("ascn_treemap.pdf")
1131
- fig3.write_image("ascn_treemap.svg")
1132
- except Exception:
1133
- pass
1134
- with col4_1:
1135
- if os.path.exists('ascn_treemap.pdf'):
1136
- with open('ascn_treemap.pdf','rb') as f:
1137
- st.download_button(
1138
- "Save As PDF",
1139
- f,
1140
- 'sonogram-treemap-'+currPlainName+'.pdf',
1141
- 'application/pdf',
1142
- key='download-pdf4',
1143
- on_click="ignore",
1144
- )
1145
- with col4_2:
1146
- if os.path.exists('ascn_treemap.svg'):
1147
- with open('ascn_treemap.svg','rb') as f:
1148
- st.download_button(
1149
- "Save As SVG",
1150
- f,
1151
- 'sonogram-treemap-'+currPlainName+'.svg',
1152
- 'image/svg+xml',
1153
- key='download-svg4',
1154
- on_click="ignore",
1155
- )
1156
-
1157
- # generate plotting window
1158
-
1159
-
1160
- with timeline:
1161
- timeline_df = speakers_dataFrame.copy()
1162
- timeline_df["Resource"] = timeline_df["Resource"].apply(lambda s: get_display_name(s, currFile))
1163
- base = dt.datetime.combine(dt.date.today(), dt.time.min)
1164
- def to_audio_datetime(s):
1165
- # If already a datetime/Timestamp, extract seconds since midnight of that date
1166
- if isinstance(s, (dt.datetime, pd.Timestamp)):
1167
- midnight = s.replace(hour=0, minute=0, second=0, microsecond=0)
1168
- seconds = (s - midnight).total_seconds()
1169
- else:
1170
- seconds = float(s)
1171
- return base + dt.timedelta(seconds=seconds)
1172
- timeline_df["Start"] = timeline_df["Start"].apply(to_audio_datetime)
1173
- timeline_df["Finish"] = timeline_df["Finish"].apply(to_audio_datetime)
1174
- fig_la = px.timeline(timeline_df, x_start="Start", x_end="Finish", y="Resource", color="Resource",title="Timeline of Audio with Speakers",
1175
- color_discrete_sequence=speakerColors)
1176
- fig_la.update_yaxes(autorange="reversed")
1177
-
1178
- hMax = int(currTotalTime//3600)
1179
- mMax = int(currTotalTime%3600//60)
1180
- sMax = int(currTotalTime%60)
1181
- msMax = int(currTotalTime*1000000%1000000)
1182
- timeMax = dt.time(hMax,mMax,sMax,msMax)
1183
-
1184
- fig_la.update_layout(
1185
- xaxis_tickformatstops = [
1186
- dict(dtickrange=[None, 1000], value="%H:%M:%S.%L"),
1187
- dict(dtickrange=[1000, None], value="%H:%M:%S")
1188
- ],
1189
- xaxis=dict(
1190
- range=[dt.datetime.combine(dt.date.today(), dt.time.min),dt.datetime.combine(dt.date.today(), timeMax)]
1191
- ),
1192
- xaxis_title="Time",
1193
- yaxis_title="Speaker",
1194
- legend_title=None,
1195
- plot_bgcolor='rgba(0, 0, 0, 0)',
1196
- paper_bgcolor='rgba(0, 0, 0, 0)',
1197
- legend={'traceorder':'reversed'},
1198
- yaxis= {'showticklabels': False},
1199
- )
1200
- st.plotly_chart(fig_la, use_container_width=True, config=config)
1201
- col5_1, col5_2 = st.columns(2)
1202
- try:
1203
- fig_la.write_image("ascn_timeline.pdf")
1204
- fig_la.write_image("ascn_timeline.svg")
1205
- except Exception:
1206
- pass
1207
- with col5_1:
1208
- if os.path.exists('ascn_timeline.pdf'):
1209
- with open('ascn_timeline.pdf','rb') as f:
1210
- st.download_button(
1211
- "Save As PDF",
1212
- f,
1213
- 'sonogram-timeline-'+currPlainName+'.pdf',
1214
- 'application/pdf',
1215
- key='download-pdf5',
1216
- on_click="ignore",
1217
- )
1218
- with col5_2:
1219
- if os.path.exists('ascn_timeline.svg'):
1220
- with open('ascn_timeline.svg','rb') as f:
1221
- st.download_button(
1222
- "Save As SVG",
1223
- f,
1224
- 'sonogram-timeline-'+currPlainName+'.svg',
1225
- 'image/svg+xml',
1226
- key='download-svg5',
1227
- on_click="ignore",
1228
- )
1229
-
1230
- with bar1:
1231
- df2 = st.session_state.summaries[currFile]["df2"].copy()
1232
- df2["names"] = df2["names"].apply(lambda s: get_display_name(s, currFile))
1233
- fig2_la = px.bar(df2, x="values", y="names", color="names", orientation='h',
1234
- custom_data=["names","values"],title="Time Spoken by each Speaker",
1235
- color_discrete_sequence=catColors+speakerColors)
1236
- fig2_la.update_xaxes(ticksuffix="%")
1237
- fig2_la.update_yaxes(autorange="reversed")
1238
- fig2_la.update_layout(
1239
- xaxis_title="Percentage Time Spoken",
1240
- yaxis_title=None,
1241
- plot_bgcolor='rgba(0, 0, 0, 0)',
1242
- paper_bgcolor='rgba(0, 0, 0, 0)',
1243
- showlegend=False,
1244
- yaxis={'showticklabels': True},
1245
- )
1246
- fig2_la.update_traces(
1247
- hovertemplate="<br>".join([
1248
- '<b>%{customdata[0]}</b>',
1249
- 'Percentage of Time: %{customdata[1]:.2f}%'
1250
- ])
1251
- )
1252
- st.plotly_chart(fig2_la, use_container_width=True, config=config)
1253
- col6_1, col6_2 = st.columns(2)
1254
  try:
1255
- fig2_la.write_image("ascn_bar.pdf")
1256
- fig2_la.write_image("ascn_bar.svg")
1257
  except Exception:
1258
  pass
1259
- with col6_1:
1260
- if os.path.exists('ascn_bar.pdf'):
1261
- with open('ascn_bar.pdf','rb') as f:
1262
- st.download_button(
1263
- "Save As PDF",
1264
- f,
1265
- 'sonogram-speaker-time-'+currPlainName+'.pdf',
1266
- 'application/pdf',
1267
- key='download-pdf6',
1268
- on_click="ignore",
1269
- )
1270
- with col6_2:
1271
- if os.path.exists('ascn_bar.svg'):
1272
- with open('ascn_bar.svg','rb') as f:
1273
- st.download_button(
1274
- "Save As SVG",
1275
- f,
1276
- 'sonogram-speaker-time-'+currPlainName+'.svg',
1277
- 'image/svg+xml',
1278
- key='download-svg6',
1279
- on_click="ignore",
1280
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1281
 
1282
  except ValueError:
1283
  pass
1284
 
 
 
 
 
1285
  if len(st.session_state.results) > 0:
1286
  with st.expander("Multi-file Summary Data"):
1287
  st.header("Multi-file Summary Data")
1288
- with st.spinner(text='Processing summary results...'):
1289
- fileNames = st.session_state.file_names
1290
- validNames = [fn for fn in fileNames if fn in st.session_state.results and len(st.session_state.results[fn]) == 2]
 
 
 
1291
  if len(validNames) > 1:
1292
-
1293
- df6_dict = {"files": validNames}
1294
- allCategories = copy.deepcopy(st.session_state.categories)
1295
- for fn in validNames:
1296
- currAnnotation, currTotalTime = st.session_state.results[fn]
1297
- categorySelections = st.session_state["categorySelect"][fn]
1298
- catSummary, extraCats = su.calcCategories(currAnnotation, categorySelections)
1299
- st.session_state.summaries[fn]["categories"] = (catSummary, extraCats)
1300
- for extra in extraCats:
1301
- df6_dict[extra] = []
1302
- if extra not in allCategories:
1303
- allCategories.append(extra)
1304
-
1305
- for category in st.session_state.categories:
1306
- df6_dict[category] = []
1307
- for fn in validNames:
1308
- summary, extras = st.session_state.summaries[fn]["categories"]
1309
- theseCategories = st.session_state.categories + extras
1310
- for j, timeSlots in enumerate(summary):
1311
- df6_dict[theseCategories[j]].append(sum([t.duration for _,t in timeSlots])/st.session_state.results[fn][1])
1312
- for category in allCategories:
1313
- if category not in theseCategories:
1314
- df6_dict[category].append(0)
1315
- df6 = pd.DataFrame(df6_dict)
1316
- summFig = px.bar(df6, x="files", y=allCategories,title="Time Spoken by Each Speaker in Each File")
1317
- st.plotly_chart(summFig, use_container_width=True,config=config)
1318
-
1319
-
1320
- voiceNames = ["No Voice","One Voice","Multi Voice"]
1321
- df7_dict = {
1322
- "files": validNames,
1323
- }
1324
- for category in voiceNames:
1325
- df7_dict[category] = []
1326
- for fn in validNames:
1327
- partialDf = st.session_state.summaries[fn]["df5"]
1328
- for i in range(len(voiceNames)):
1329
- df7_dict[voiceNames[i]].append(partialDf["percentiles"][i])
1330
- df7 = pd.DataFrame(df7_dict)
1331
- sorted_df7 = df7.sort_values(by=['One Voice', 'Multi Voice'])
1332
- summFig2 = px.bar(sorted_df7, x="files", y=["One Voice","Multi Voice","No Voice",],title="Cross-file Voice Categories sorted for One Voice")
1333
- st.plotly_chart(summFig2, use_container_width=True,config=config)
1334
- sorted_df7_3 = df7.sort_values(by=['Multi Voice','One Voice'])
1335
- summFig3 = px.bar(sorted_df7_3, x="files", y=["One Voice","Multi Voice","No Voice",],title="Cross-file Voice Categories sorted for Multi Voice")
1336
- st.plotly_chart(summFig3, use_container_width=True,config=config)
1337
- sorted_df7_4 = df7.sort_values(by=['No Voice', 'Multi Voice'],ascending=False)
1338
- summFig4 = px.bar(sorted_df7_4, x="files", y=["One Voice","Multi Voice","No Voice",],title="Cross-file Voice Categories sorted for Any Voice")
1339
- st.plotly_chart(summFig4, use_container_width=True,config=config)
1340
-
1341
-
1342
-
1343
- old = '''userid = st.text_input("user id:", "Guest")
1344
- colorPref = st.text_input("Favorite color?", "None")
1345
- radio = st.radio('Pick one:', ['Left','Right'])
1346
- selection = st.selectbox('Select', [1,2,3])
1347
- if st.button("Upload Files to Dataset"):
1348
- save_data({"color":colorPref,"direction":radio,"number":selection},
1349
- file_paths,
1350
- userid)
1351
- st.success('I think it worked!')
1352
- '''
1353
- @st.cache_data
1354
- def convert_df(df):
1355
- return df.to_csv(index=False).encode('utf-8')
1356
-
1357
 
1358
  with st.expander("(Potentially) FAQ"):
1359
- st.write(f"**1. I tried analyzing a file, but the page refreshed and nothing happened! Why?**\n\t")
1360
- st.write("You may need to select a file using the side bar on the left. This app supports multiple files, so we require that you select which file to view after analysis.")
1361
- st.write(f"**2. I don't see a sidebar! Where is it?**\n\t")
1362
- st.write("The side bar may start by being minimized. Press the '>' in the upper left to expand the side bar.")
1363
- st.write(f"**3. I still don't have a file to select in the dropdown! Why?**\n\t")
1364
- st.write("If you are sure that you have run Analyze All and after refresh no files may be selected, then your file is likely too large. We currently have a limitation of approximately 1.5 hours of audio. This is a known issue that requires additional time **or** money to solve, and is expected to be fixed by the next update of this app. Please be patient!")
1365
- st.write(f"**4. I want to be able to view my previously analyzed data! How can I do this?**\n\t")
1366
- st.write("You can download a CSV copy of the data using the first tab. From there, you can reupload the CSV copy at a later date to view the data visualizations without having to use your original audio file. Future versions of this app will support creating optional logins for long term storage and analysis.")
1367
- st.write(f"**5. The app says 'TOOL CURRENTLY USING CPU, ANALYSIS EXTREMELY SLOW' and takes forever to analyze audio! What is wrong?**\n\t")
1368
- st.write("We are currently in the process of securing funding to allow permanent public access to this tool. Until then, we can provide an interface to view already analyzed data without cost to you or us. While this mode will technically still work, it may take over a day to analyze your audio. Feel free to reach out to us to discuss temporary solutions to this until the app's funding is secured!")
 
1
+ """
2
+ app.py Streamlit entry point.
3
+
4
+ Responsibilities:
5
+ - App-level config and constants
6
+ - Pipeline / device initialisation
7
+ - Top-level UI layout and navigation flow
8
+ - Delegates all logic to state.py (callbacks) and utils.py (pure helpers)
9
+ """
10
+
11
+ import os
12
  import time
 
 
 
 
 
13
  import tempfile
14
+ from pathlib import Path
15
+
16
+ import streamlit as st
17
+ import torch
18
  import pandas as pd
19
  import plotly.express as px
 
 
 
 
20
  from pyannote.audio import Pipeline
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
+ import sonogram_utility as su
23
+ import utils
24
+ import state
25
+ from state import (
26
+ init_session_state, printV,
27
+ get_display_name, apply_speaker_renames_to_df, convert_df,
28
+ addCategory, removeCategory, updateCategoryOptions,
29
+ applyGlobalRenames, addGlobalRename, removeGlobalRename, _global_rename_key,
30
+ updateMultiSelect, store_speaker_clips, randomize_speaker_clip,
31
+ register_file, analyze,
32
+ )
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # App-level constants
36
+ # ---------------------------------------------------------------------------
37
+
38
+ SUPPORTED_FILE_TYPES = (".wav", ".mp3", ".mp4", ".txt", ".rttm", ".csv")
39
+ ENABLE_DENOISE = False
40
+ EARLY_CLEANUP = True
41
+ GAIN_WINDOW = 4
42
+ MINIMUM_GAIN = -45
43
+ MAXIMUM_GAIN = -5
44
+ ATTEN_LIM_DB = 3
45
+
46
+ PLOTLY_CONFIG = {"displayModeBar": True, "modeBarButtonsToRemove": []}
47
 
48
+ PARQUET_DATASET_DIR = Path("parquet_dataset")
49
+ PARQUET_DATASET_DIR.mkdir(parents=True, exist_ok=True)
 
 
50
 
51
+ DEMO_PATH = "sample.rttm"
52
+ MULTI_DEMO_PATHS = [
53
+ "audioSamples/media-afc-cal-afc1986022_sr01a05.rttm",
54
+ "audioSamples/media-afc-cal-afc1986022_sr34a01.rttm",
55
+ "audioSamples/media-afc-cal-afc1986022_sr14b02.rttm",
56
+ "audioSamples/media-afc-cal-afc1986022_sr52a02.rttm",
57
+ "audioSamples/media-afc-cal-afc1986022_sr14b01.rttm",
58
+ ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
+ # ---------------------------------------------------------------------------
61
+ # Device / pipeline initialisation (runs once per server process)
62
+ # ---------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
  torch.classes.__path__ = [os.path.join(torch.__path__[0], torch.classes.__file__)]
65
 
66
+ isGPU = torch.cuda.is_available()
67
+ device = torch.device("cuda" if isGPU else "cpu")
68
+ print(f"Using {device}")
69
 
70
+ if ENABLE_DENOISE:
71
+ from df import init_df
72
+ dfModel, dfState, _ = init_df(model_base_dir="DeepFilterNet3")
73
+ dfModel.to(device)
74
+ else:
75
+ dfModel = dfState = None
76
 
77
+ pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1")
78
+ pipeline.to(device)
 
 
 
79
 
80
+ # ---------------------------------------------------------------------------
81
+ # Session state
82
+ # ---------------------------------------------------------------------------
83
 
84
+ init_session_state()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
+ # ---------------------------------------------------------------------------
87
+ # Page header
88
+ # ---------------------------------------------------------------------------
89
 
 
 
90
  st.title("Instructor Support Tool")
91
  if not isGPU:
92
  st.warning("TOOL CURRENTLY USING CPU, ANALYSIS EXTREMELY SLOW")
93
+
94
+ st.write(
95
+ 'If you would like to see a sample result generated from real classroom audio, '
96
+ 'use the sidebar on the left and press "Load Demo Example"'
97
+ )
98
+ st.write(
99
+ "Keep in mind that this is a very early draft of the tool. "
100
+ "Please be patient with any bugs/errors, and email Connor Young at "
101
+ "czyoung@ualr.edu if you need help using the tool!"
102
+ )
103
  st.divider()
104
+
105
  with st.expander("Instructions and additional details"):
106
+ st.write(
107
+ "Thank you for viewing our experimental app! "
108
+ "The overall presentations and features are expected to be improved over time."
109
+ )
110
+ st.write(
111
+ "To use this app:\n"
112
+ "1. Upload an audio file for live analysis. Alternatively, upload an already "
113
+ "generated [rttm file](https://stackoverflow.com/questions/30975084/rttm-file-format)"
114
+ )
115
+ st.write("2. Press Analyze All. No data is saved on our side.")
116
+ st.write(
117
+ "3. Use the sidebar to select your file. "
118
+ "Multiple files are supported for more comprehensive analysis."
119
+ )
120
+ st.write("4. Use the tabs to view different visualizations. Each can be downloaded.")
121
+ st.write(
122
+ "4a. Graphs are built with [plotly](https://plotly.com/). "
123
+ "Double-click to reset. "
124
+ "[More examples](https://plotly.com/python/basic-charts/)."
125
+ )
126
+
127
+ st.write(
128
+ "Would you like additional data, charts, or features? "
129
+ "[Tell us about our project!](https://forms.gle/A32CdfGYSZoMPyyX9)"
130
+ )
131
+ st.write("If you would like to learn more or work with us, contact Dr. Mark Baillie at mtbaillie@ualr.edu")
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # File upload
135
+ # ---------------------------------------------------------------------------
136
+
137
+ uploaded_file_paths = st.file_uploader(
138
+ "Upload an audio of classroom activity to analyze",
139
+ accept_multiple_files=True,
140
+ )
141
+
142
  temp_dir = tempfile.mkdtemp()
143
 
144
+ if uploaded_file_paths:
 
145
  for uploaded_file in uploaded_file_paths:
146
+ if not uploaded_file.name.lower().endswith(SUPPORTED_FILE_TYPES):
147
+ st.error(f"File must be of type: {SUPPORTED_FILE_TYPES}")
148
+ continue
149
+ fname = uploaded_file.name
150
+ path = os.path.join(temp_dir, fname)
151
+ with open(path, "wb") as f:
152
+ f.write(uploaded_file.getvalue())
153
+ if fname not in st.session_state.file_names:
154
+ register_file(fname)
155
+ st.session_state.file_paths[fname] = path
156
+ st.session_state.valid_files = list(st.session_state.file_names)
157
+
158
+ file_names = st.session_state.file_names
159
+ file_paths_dict = st.session_state.file_paths
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Sidebar demo buttons
163
+ # ---------------------------------------------------------------------------
164
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  isDemo = False
166
+
167
  if st.sidebar.button("Single File Demo"):
168
+ dname = DEMO_PATH.split("/")[-1]
169
+ register_file(dname)
170
+ st.session_state.file_paths[dname] = DEMO_PATH
 
 
 
 
 
 
 
 
171
  file_names = st.session_state.file_names
172
+ start_time = time.time()
173
+ with st.spinner("Loading Demo Sample"):
174
+ _, annotations = su.loadAudioRTTM(DEMO_PATH)
175
+ totalSeconds = max((seg.end for seg in annotations.itersegments()), default=0)
176
+ st.session_state.results[dname] = (annotations, totalSeconds)
177
+ st.session_state.summaries[dname] = {}
178
+ st.session_state.unusedSpeakers[dname] = list(annotations.labels())
179
+ with st.spinner("Analyzing Demo Data"):
180
+ analyze(dname)
181
+ st.success(f"Took {time.time() - start_time:.1f}s to analyze the demo file!")
182
+ st.session_state.select_currFile = dname
 
 
 
183
  isDemo = True
184
 
 
 
 
 
 
 
185
  if st.sidebar.button("Multiple Files Demo"):
186
+ for demo_path in MULTI_DEMO_PATHS:
187
+ dname = demo_path.split("/")[-1]
188
+ register_file(dname)
189
+ st.session_state.file_paths[dname] = demo_path
 
 
 
 
 
 
 
 
190
  file_names = st.session_state.file_names
191
+ with st.spinner(f"Loading: {dname}"):
192
+ _, annotations = su.loadAudioRTTM(demo_path)
193
+ totalSeconds = max((seg.end for seg in annotations.itersegments()), default=0)
194
+ st.session_state.results[dname] = (annotations, totalSeconds)
195
+ st.session_state.summaries[dname] = {}
196
+ st.session_state.unusedSpeakers[dname] = list(annotations.labels())
 
 
 
 
 
 
197
  isDemo = True
198
+ st.session_state.analyzeAllToggle = True
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Analyze All / Reset buttons
202
+ # ---------------------------------------------------------------------------
203
 
204
  if len(file_names) == 0:
205
  st.text("Upload file(s) to enable analysis")
 
207
  col_analyze, col_spacer, col_reset = st.columns([3, 5, 2])
208
  with col_analyze:
209
  if st.button("Analyze All New Audio", key="button_all"):
210
+ st.session_state.analyzeAllToggle = True
 
 
 
211
  with col_reset:
212
  if st.button("🗑️ Reset App", key="button_reset", type="secondary", use_container_width=True):
213
  for key in list(st.session_state.keys()):
214
  del st.session_state[key]
215
  st.rerun()
216
 
217
+ # ---------------------------------------------------------------------------
218
+ # Analysis loop
219
+ # ---------------------------------------------------------------------------
220
+
221
+ if st.session_state.analyzeAllToggle:
222
  start_time = time.time()
223
  totalFiles = len(file_names)
224
+
225
  for i, fname in enumerate(file_names):
 
226
  fpath = file_paths_dict.get(fname, "")
227
+ ext = fpath.lower()
228
+
229
+ if ext.endswith(".txt"):
230
+ with st.spinner(f"Loading TXT {i+1}/{totalFiles}"):
231
+ _, annotations = su.loadAudioTXT(fpath)
232
+ totalSeconds = max((s.end for s in annotations.itersegments()), default=0)
233
+ st.session_state.results[fname] = (annotations, totalSeconds)
234
+ st.session_state.summaries[fname] = {}
 
 
 
 
 
 
235
  st.session_state.unusedSpeakers[fname] = list(annotations.labels())
236
+
237
+ elif ext.endswith(".rttm"):
238
+ with st.spinner(f"Loading RTTM {i+1}/{totalFiles}"):
239
+ _, annotations = su.loadAudioRTTM(fpath)
240
+ totalSeconds = max((s.end for s in annotations.itersegments()), default=0)
241
+ st.session_state.results[fname] = (annotations, totalSeconds)
242
+ st.session_state.summaries[fname] = {}
 
 
 
243
  st.session_state.unusedSpeakers[fname] = list(annotations.labels())
244
+
245
+ elif ext.endswith(".csv"):
246
+ with st.spinner(f"Loading CSV {i+1}/{totalFiles}"):
247
+ _, annotations = su.loadAudioCSV(fpath)
248
+ totalSeconds = max((s.end for s in annotations.itersegments()), default=0)
249
+ st.session_state.results[fname] = (annotations, totalSeconds)
250
+ st.session_state.summaries[fname] = {}
 
 
 
251
  st.session_state.unusedSpeakers[fname] = list(annotations.labels())
252
+
253
  else:
254
+ with st.spinner(f"Processing Audio {i+1}/{totalFiles}"):
255
+ annotations, totalSeconds, waveform, sample_rate = utils.processFile(
256
+ fpath, pipeline, ENABLE_DENOISE, EARLY_CLEANUP,
257
+ GAIN_WINDOW, MINIMUM_GAIN, MAXIMUM_GAIN,
258
+ dfModel, dfState, ATTEN_LIM_DB,
259
+ )
260
+ st.session_state.results[fname] = (annotations, totalSeconds)
261
+ st.session_state.summaries[fname] = {}
262
  st.session_state.unusedSpeakers[fname] = list(annotations.labels())
263
+ with st.spinner(f"Generating clips {i+1}/{totalFiles}"):
264
+ store_speaker_clips(fname, annotations, waveform, sample_rate)
 
 
265
  del waveform
266
+
267
+ with st.spinner(f"Analyzing {i+1}/{totalFiles}"):
268
  analyze(fname)
269
+
270
+ st.success(f"Analyzed {totalFiles} file(s) in {time.time() - start_time:.1f}s")
 
271
  st.session_state.analyzeAllToggle = False
272
 
273
+ # ---------------------------------------------------------------------------
274
+ # File selector
275
+ # ---------------------------------------------------------------------------
276
 
277
+ currFile = st.sidebar.selectbox(
278
+ "Current File", file_names, on_change=updateMultiSelect, key="select_currFile"
279
+ )
280
  if isDemo:
281
  currFile = file_names[0]
 
282
 
283
  if currFile is None:
284
  st.write("Select a file to view from the sidebar")
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # Per-file analysis view
288
+ # ---------------------------------------------------------------------------
289
+
290
  try:
291
  if currFile is None:
292
  raise ValueError("No file selected")
293
+
294
  st.session_state.resetResult = False
295
+ currPlainName = currFile.split(".")[0]
296
+
297
+ if not (
298
+ currFile in st.session_state.results
299
+ and currFile in st.session_state.summaries
300
+ and len(st.session_state.results[currFile]) > 0
301
+ ):
302
+ raise ValueError("File not yet analyzed")
303
+
304
+ st.header(f"Analysis of file {currFile}")
305
+ TAB_NAMES = ["Data", "Voice Categories", "Speaker Percentage",
306
+ "Speakers with Categories", "Treemap", "Timeline", "Time Spoken"]
307
+ dataTab, pie1, pie2, sunburst1, treemap1, timeline, bar1 = st.tabs(TAB_NAMES)
308
+
309
+ currAnnotation, currTotalTime = st.session_state.results[currFile]
310
+ speakerNames = currAnnotation.labels()
311
+ speakers_dataFrame = st.session_state.summaries[currFile]["speakers_dataFrame"]
312
+ currDF, _ = su.annotationToSimpleDataFrame(currAnnotation)
313
+ unusedSpeakers = st.session_state.unusedSpeakers[currFile]
314
+ categorySelections = st.session_state.categorySelect[currFile]
315
+
316
+ _saved_renames = st.session_state.speakerRenames.get(currFile, {})
317
+ raw_to_display = {sp: _saved_renames.get(sp, sp) for sp in speakerNames}
318
+ all_speakers_display = [raw_to_display[sp] for sp in speakerNames]
319
+
320
+ catTypeColors = su.colorsCSS(3)
321
+ allColors = su.colorsCSS(len(speakerNames) + len(st.session_state.categories))
322
+ speakerColors = allColors[:len(speakerNames)]
323
+ catColors = allColors[len(speakerNames):]
324
+
325
+ # Rebuild live df4 to reflect current category selections
326
+ nameList = st.session_state.categories
327
+ valueList = [su.sumTimes(currAnnotation.subset(s)) for s in categorySelections]
328
+ extraNames = list(unusedSpeakers)
329
+ extraValues = [su.sumTimes(currAnnotation.subset([sp])) for sp in unusedSpeakers]
330
+ df4_live = pd.DataFrame({"names": nameList + extraNames, "values": valueList + extraValues})
331
+ st.session_state.summaries[currFile]["df4"] = df4_live
332
+
333
+ # -----------------------------------------------------------------------
334
+ # Sidebar categories
335
+ # -----------------------------------------------------------------------
336
+
337
+ for i, category in enumerate(st.session_state.categories):
338
+ ms_key = f"multiselect_{category}"
339
+ speakerSet = categorySelections[i]
340
+ default_disp = [raw_to_display.get(sp, sp) for sp in speakerSet]
341
+ if ms_key not in st.session_state:
342
+ st.session_state[ms_key] = default_disp
343
+ st.sidebar.multiselect(
344
+ category, all_speakers_display,
345
+ key=ms_key, on_change=updateCategoryOptions, args=(currFile,),
346
+ )
347
+ st.sidebar.button(
348
+ f"Remove {category}", key=f"remove_{category}",
349
+ on_click=removeCategory, args=(i,),
350
  )
351
 
352
+ st.sidebar.text_input("Add category", key="categoryInput", on_change=addCategory)
353
+
354
+ # -----------------------------------------------------------------------
355
+ # Sidebar rename speakers
356
+ # -----------------------------------------------------------------------
357
+
358
+ st.sidebar.divider()
359
+ st.sidebar.subheader("Rename Speakers")
360
+ st.sidebar.caption(
361
+ "Assign a name and select which speaker labels (across all files) it applies to. "
362
+ "Changes apply to all matched speakers instantly."
363
+ )
364
+
365
+ file_clips = st.session_state.speakerClips.get(currFile, {})
366
+ if file_clips:
367
+ st.sidebar.caption("🎧 Listen to clips to help identify speakers:")
368
+ current_renames = st.session_state.speakerRenames[currFile]
369
+ for sp in speakerNames:
370
+ wk = f"rename_{currFile}_{sp}"
371
+ if wk not in st.session_state:
372
+ st.session_state[wk] = current_renames.get(sp, "")
373
+ display_label = st.session_state[wk].strip() or sp
374
+ st.sidebar.markdown(f"**{display_label}**")
375
+ if sp in file_clips:
376
+ st.sidebar.audio(file_clips[sp], format="audio/wav")
377
+ sp_segs = st.session_state.speakerSegments.get(currFile, {}).get(sp, [])
378
+ has_waveform = currFile in st.session_state.speakerWaveforms
379
+ if has_waveform and sp_segs:
380
+ if st.sidebar.button(
381
+ "🔀 Try Another Clip",
382
+ key=f"randomize_{currFile}_{sp}",
383
+ help="Pick a random clip from a different part of this speaker's audio",
384
+ ):
385
+ randomize_speaker_clip(currFile, sp)
386
+ st.rerun()
387
+
388
+ all_speaker_tokens = [
389
+ f"{fn}: {sp}"
390
+ for fn in st.session_state.file_names
391
+ if fn in st.session_state.results and len(st.session_state.results[fn]) == 2
392
+ for sp in st.session_state.results[fn][0].labels()
393
+ ]
394
+
395
+ st.sidebar.divider()
396
+
397
+ def _on_grename_change(idx):
398
+ st.session_state.globalRenames[idx]["speakers"] = list(
399
+ st.session_state[_global_rename_key(idx)]
400
+ )
401
+ applyGlobalRenames()
402
+
403
+ for idx, entry in enumerate(st.session_state.globalRenames):
404
+ grkey = _global_rename_key(idx)
405
+ if grkey not in st.session_state:
406
+ st.session_state[grkey] = list(entry["speakers"])
407
+ st.sidebar.markdown(f"**{entry['name']}**")
408
+ st.sidebar.multiselect(
409
+ f"Speakers for {entry['name']}", options=all_speaker_tokens,
410
+ key=grkey, on_change=_on_grename_change, args=(idx,),
411
+ label_visibility="collapsed",
412
+ )
413
+ st.sidebar.button(
414
+ f"Remove '{entry['name']}'", key=f"remove_grename_{idx}",
415
+ on_click=removeGlobalRename, args=(idx,),
 
 
 
416
  )
417
 
418
+ st.sidebar.text_input(
419
+ "Add rename", placeholder="e.g. John",
420
+ key="globalRenameInput", on_change=addGlobalRename,
421
+ )
422
+
423
+ # -----------------------------------------------------------------------
424
+ # Shared helper: render figure + PDF/SVG download buttons
425
+ # -----------------------------------------------------------------------
426
+
427
+ def _render_chart(fig, tab, pdf_path, svg_path, pdf_name, svg_name, pdf_key, svg_key):
428
+ with tab:
429
+ st.plotly_chart(fig, use_container_width=True, config=PLOTLY_CONFIG)
430
+ col_l, col_r = st.columns(2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  try:
432
+ fig.write_image(pdf_path)
433
+ fig.write_image(svg_path)
434
  except Exception:
435
  pass
436
+ with col_l:
437
+ if os.path.exists(pdf_path):
438
+ with open(pdf_path, "rb") as f:
439
+ st.download_button("Save As PDF", f, pdf_name, "application/pdf",
440
+ key=pdf_key, on_click="ignore")
441
+ with col_r:
442
+ if os.path.exists(svg_path):
443
+ with open(svg_path, "rb") as f:
444
+ st.download_button("Save As SVG", f, svg_name, "image/svg+xml",
445
+ key=svg_key, on_click="ignore")
446
+
447
+ # -----------------------------------------------------------------------
448
+ # Tab: Data
449
+ # -----------------------------------------------------------------------
450
+
451
+ with dataTab:
452
+ displayDF = apply_speaker_renames_to_df(currDF, currFile, column="Resource")
453
+ csv = convert_df(displayDF)
454
+ st.download_button(
455
+ "Press to Download analysis data", csv,
456
+ f"sonogram-analysis-{currPlainName}.csv", "text/csv",
457
+ key="download-csv", on_click="ignore",
458
+ )
459
+ st.dataframe(displayDF)
460
+
461
+ # -----------------------------------------------------------------------
462
+ # Charts (pie1, pie2, sunburst, treemap, timeline, bar)
463
+ # -----------------------------------------------------------------------
464
+
465
+ df3 = st.session_state.summaries[currFile]["df3"]
466
+ df4 = st.session_state.summaries[currFile]["df4"].copy()
467
+ df5 = st.session_state.summaries[currFile]["df5"].copy()
468
+ df2 = st.session_state.summaries[currFile]["df2"].copy()
469
+
470
+ _render_chart(
471
+ utils.build_fig_pie1(df3, catTypeColors), pie1,
472
+ "ascn_pie1.pdf", "ascn_pie1.svg",
473
+ f"sonogram-voice-category-{currPlainName}.pdf",
474
+ f"sonogram-voice-category-{currPlainName}.svg",
475
+ "download-pdf1", "download-svg1",
476
+ )
477
+ _render_chart(
478
+ utils.build_fig_pie2(df4, speakerNames, speakerColors, catColors, get_display_name, currFile),
479
+ pie2,
480
+ "ascn_pie2.pdf", "ascn_pie2.svg",
481
+ f"sonogram-speaker-percent-{currPlainName}.pdf",
482
+ f"sonogram-speaker-percent-{currPlainName}.svg",
483
+ "download-pdf2", "download-svg2",
484
+ )
485
+ _render_chart(
486
+ utils.build_fig_sunburst(df5, catTypeColors, speakerColors, get_display_name, currFile),
487
+ sunburst1,
488
+ "ascn_sunburst.pdf", "ascn_sunburst.svg",
489
+ f"sonogram-speaker-categories-{currPlainName}.pdf",
490
+ f"sonogram-speaker-categories-{currPlainName}.svg",
491
+ "download-pdf3", "download-svg3",
492
+ )
493
+ _render_chart(
494
+ utils.build_fig_treemap(df5, catTypeColors, speakerColors, get_display_name, currFile),
495
+ treemap1,
496
+ "ascn_treemap.pdf", "ascn_treemap.svg",
497
+ f"sonogram-treemap-{currPlainName}.pdf",
498
+ f"sonogram-treemap-{currPlainName}.svg",
499
+ "download-pdf4", "download-svg4",
500
+ )
501
+ _render_chart(
502
+ utils.build_fig_timeline(speakers_dataFrame, currTotalTime, speakerColors, get_display_name, currFile),
503
+ timeline,
504
+ "ascn_timeline.pdf", "ascn_timeline.svg",
505
+ f"sonogram-timeline-{currPlainName}.pdf",
506
+ f"sonogram-timeline-{currPlainName}.svg",
507
+ "download-pdf5", "download-svg5",
508
+ )
509
+ _render_chart(
510
+ utils.build_fig_bar(df2, catColors, speakerColors, get_display_name, currFile),
511
+ bar1,
512
+ "ascn_bar.pdf", "ascn_bar.svg",
513
+ f"sonogram-speaker-time-{currPlainName}.pdf",
514
+ f"sonogram-speaker-time-{currPlainName}.svg",
515
+ "download-pdf6", "download-svg6",
516
+ )
517
 
518
  except ValueError:
519
  pass
520
 
521
+ # ---------------------------------------------------------------------------
522
+ # Multi-file summary
523
+ # ---------------------------------------------------------------------------
524
+
525
  if len(st.session_state.results) > 0:
526
  with st.expander("Multi-file Summary Data"):
527
  st.header("Multi-file Summary Data")
528
+ with st.spinner("Processing summary results..."):
529
+ validNames = [
530
+ fn for fn in st.session_state.file_names
531
+ if fn in st.session_state.results
532
+ and len(st.session_state.results[fn]) == 2
533
+ ]
534
  if len(validNames) > 1:
535
+ df6, allCategories = utils.build_multifile_category_df(
536
+ validNames, st.session_state.results, st.session_state.summaries,
537
+ st.session_state.categories, st.session_state.categorySelect,
538
+ )
539
+ st.plotly_chart(
540
+ px.bar(df6, x="files", y=allCategories,
541
+ title="Time Spoken by Each Speaker in Each File"),
542
+ use_container_width=True, config=PLOTLY_CONFIG,
543
+ )
544
+
545
+ df7, _ = utils.build_multifile_voice_df(validNames, st.session_state.summaries)
546
+ for sort_cols, ascending, title in [
547
+ (["One Voice", "Multi Voice"], True, "Cross-file Voice Categories sorted for One Voice"),
548
+ (["Multi Voice","One Voice"], True, "Cross-file Voice Categories sorted for Multi Voice"),
549
+ (["No Voice", "Multi Voice"], False, "Cross-file Voice Categories sorted for Any Voice"),
550
+ ]:
551
+ st.plotly_chart(
552
+ px.bar(df7.sort_values(by=sort_cols, ascending=ascending),
553
+ x="files", y=["One Voice", "Multi Voice", "No Voice"],
554
+ title=title),
555
+ use_container_width=True, config=PLOTLY_CONFIG,
556
+ )
557
+
558
+ # ---------------------------------------------------------------------------
559
+ # FAQ
560
+ # ---------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
561
 
562
  with st.expander("(Potentially) FAQ"):
563
+ st.write("**1. I tried analyzing a file, but the page refreshed and nothing happened! Why?**")
564
+ st.write("You may need to select a file using the sidebar on the left.")
565
+ st.write("**2. I don't see a sidebar! Where is it?**")
566
+ st.write("Press the '>' in the upper left to expand the sidebar.")
567
+ st.write("**3. I still don't have a file to select in the dropdown! Why?**")
568
+ st.write("Your file may be too large. We currently support approximately 1.5 hours of audio.")
569
+ st.write("**4. I want to view my previously analyzed data. How?**")
570
+ st.write("Download a CSV copy from the Data tab and re-upload it later.")
571
+ st.write("**5. The app is extremely slow. What is wrong?**")
572
+ st.write("We are securing funding for permanent GPU access. Until then, CPU analysis may take a very long time.")