File size: 19,119 Bytes
8441515
 
 
 
7f3fca2
 
 
 
8441515
 
 
6d79beb
8441515
 
 
 
b3a9316
85b7ab1
a0c97db
 
 
 
9979934
8441515
 
7f3fca2
8441515
b5948c4
8441515
7f3fca2
7f1a5da
d4b437a
8441515
 
 
7f3fca2
8441515
 
 
 
 
 
 
 
 
 
 
a5aa376
8441515
 
a5aa376
8441515
7f1a5da
8441515
 
 
 
 
 
 
fbc8ee4
8441515
7f3fca2
8441515
094a4d0
 
 
8441515
 
 
094a4d0
8441515
 
 
 
 
 
094a4d0
a0c97db
8441515
a0c97db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8441515
835eb03
8441515
7f3fca2
8441515
094a4d0
8441515
094a4d0
860538c
 
 
 
7f3fca2
 
 
 
 
2824942
 
 
7f3fca2
 
 
 
8441515
 
 
094a4d0
0d22830
75b5807
 
8441515
 
fc3f305
7f1a5da
 
8441515
14b9c76
 
8441515
9134636
51100a7
1504552
 
 
860538c
14b9c76
8441515
 
15487ba
7f3fca2
 
8441515
7f3fca2
8441515
7f3fca2
8441515
a86d5c1
 
 
 
 
 
 
 
 
 
 
 
8441515
 
 
 
 
 
 
860538c
8441515
 
9979934
6a8a763
8441515
6a8a763
8441515
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f3fca2
8441515
 
cf16727
8441515
7f1a5da
 
 
 
 
7f3fca2
cf16727
31597a6
 
7f3fca2
 
8441515
7f1a5da
 
 
 
 
 
8441515
 
 
31597a6
 
 
 
3f04e6e
 
 
8441515
3f04e6e
 
860538c
3f04e6e
 
860538c
3f04e6e
31597a6
8441515
 
 
 
 
7f3fca2
 
 
 
 
 
31597a6
8441515
 
 
ac958ee
8441515
 
 
cf16727
551882a
 
14b9c76
 
2068e7a
094a4d0
8441515
 
 
 
 
d31fca1
2068e7a
 
8441515
ff96b8f
7f1a5da
8441515
 
 
 
 
 
 
 
 
a86d5c1
 
 
8441515
 
 
 
 
 
a980c3f
 
 
 
 
 
8441515
7f3fca2
 
8441515
 
 
 
 
 
 
7f3fca2
c5f7be3
 
8441515
c5f7be3
 
 
 
 
8441515
 
7f3fca2
 
8441515
 
7f3fca2
7fd5c99
 
8441515
 
 
 
 
 
1fb0de9
2824942
 
 
1fb0de9
 
 
2824942
 
 
 
 
 
 
 
 
 
 
7fd5c99
8441515
 
7f3fca2
8441515
 
7fd5c99
 
8441515
 
 
 
 
 
14b0f84
 
170c898
14b0f84
170c898
 
 
 
14b0f84
170c898
14b0f84
8814211
 
 
 
8441515
 
a86d5c1
8441515
 
 
 
d4b437a
 
 
a86d5c1
 
 
 
 
 
 
d4b437a
8dad41f
 
 
 
 
 
 
8441515
7f3fca2
8441515
 
 
 
 
 
7f3fca2
8441515
 
 
 
 
7f3fca2
8441515
7f3fca2
8441515
 
 
 
 
7f3fca2
8441515
7f3fca2
8441515
 
 
 
 
7f3fca2
8441515
7f3fca2
8441515
 
 
 
 
7f3fca2
8441515
7f3fca2
0587214
8441515
 
 
 
7f3fca2
8441515
aa72991
 
 
ac958ee
8441515
7f3fca2
8441515
 
1504552
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
"""
app.py — Streamlit entry point.

Responsibilities:
  - Constants and pipeline initialisation
  - Page header and file upload
  - Demo / Analyze buttons and analysis loop (via state.py)
  - Per-file view: sidebar + tabs (via ui.py and utils.py)
"""

import os
import tempfile
from pathlib import Path

import streamlit as st
import torch
import pandas as pd
from pyannote.audio import Pipeline
from pyannote.audio.pipelines import SpeakerDiarization
from pyannote.audio.models.segmentation import PyanNet
from pyannote.audio import Inference
from pyannote.pipeline.parameter import ParamDict

import sonogram_utility as su
import utils
import ui
from state import (
    init_session_state,
    get_display_name, apply_speaker_renames_to_df, convert_df,
    updateMultiSelect, store_speaker_clips, register_file, analyze,
    load_demo_single, load_demo_single_sample, load_demo_multi, run_analysis_loop,
    build_all_csv_zip,
)

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

SUPPORTED_FILE_TYPES = (".wav", ".mp3", ".mp4", ".txt", ".rttm", ".csv")
ENABLE_DENOISE = False
EARLY_CLEANUP  = True
GAIN_WINDOW    = 4
MINIMUM_GAIN   = -45
MAXIMUM_GAIN   = -5
ATTEN_LIM_DB   = 3

PLOTLY_CONFIG = {"displayModeBar": True, "modeBarButtonsToRemove": []}

PARQUET_DATASET_DIR = Path("parquet_dataset")
PARQUET_DATASET_DIR.mkdir(parents=True, exist_ok=True)

DEMO_PATH        = "sample.rttm"
DEMO_SAMPLE_PATH = "sample_short.rttm"
MULTI_DEMO_PATHS = [
    "audioSamples/media-afc-cal-afc1986022_sr01a05.rttm",
    "audioSamples/media-afc-cal-afc1986022_sr34a01.rttm",
    "audioSamples/media-afc-cal-afc1986022_sr14b02.rttm",
    "audioSamples/media-afc-cal-afc1986022_sr52a02.rttm",
    "audioSamples/media-afc-cal-afc1986022_sr14b01.rttm",
]

# ---------------------------------------------------------------------------
# Pipeline initialisation  (once per server process)
# ---------------------------------------------------------------------------

torch.classes.__path__ = [os.path.join(torch.__path__[0], torch.classes.__file__)]

isGPU  = torch.cuda.is_available()
device = torch.device("cuda" if isGPU else "cpu")
print(f"Using {device}")

if ENABLE_DENOISE:
    from df import init_df
    dfModel, dfState, _ = init_df(model_base_dir="DeepFilterNet3")
    dfModel.to(device)
else:
    dfModel = dfState = None

# Load baseline model
pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1")

# Generate replacement segmentation model
newSpecs = pipeline._segmentation.model.specifications
segModel = PyanNet.from_pretrained('20251208_Sonogram_Segmentation.ckpt')
segModel.specifications = newSpecs
segmentation_duration = segModel.specifications.duration

# Update baseline with training
pipeline._segmentation = Inference(
    segModel,
    duration=segmentation_duration,
    step=pipeline.segmentation_step * segmentation_duration,
    skip_aggregation=True,
    batch_size=1,
)

pipeline.to(device)

# ---------------------------------------------------------------------------
# Session state + global styles
# ---------------------------------------------------------------------------

init_session_state()

# Uploader key is rotated on reset to force the widget to clear
if "uploader_key" not in st.session_state:
    st.session_state.uploader_key = 0

st.markdown(
    "<style>"
    "details > summary { font-size: 1rem; font-weight: 500; }"
    ".stTabs [data-baseweb='tab'] { font-size: 1rem; }"
    ".stFileUploader label { font-size: 1rem; }"
    "[data-baseweb='tag'] span { cursor: default; }"
    "[data-baseweb='menu'] li { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 260px; }"
    "[data-baseweb='menu'] li:hover { overflow: visible; white-space: normal; z-index: 9999; }"
    "</style>",
    unsafe_allow_html=True,
)

# ---------------------------------------------------------------------------
# Page header
# ---------------------------------------------------------------------------

st.title("Instructor Support Tool")
if not isGPU:
    st.warning("TOOL CURRENTLY USING CPU, ANALYSIS EXTREMELY SLOW")

st.write(
    'If you would like to see a sample result or multiple sample results generated from '
    'real classroom audio, select "Single File Demo (Sample)", "Single File Demo (Full)", '
    'or "Multiple Files Demo" on the left sidebar.'
)
st.markdown(
    "<p style='margin-bottom:4px;'>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 "
    "<a href='mailto:czyoung@ualr.edu'>czyoung@ualr.edu</a> if you need help using the tool!</p>"
    "<p style='margin-top:16px; margin-bottom:0px;'>Would you like additional data, charts, or features? "
    "<a href='https://forms.gle/A32CdfGYSZoMPyyX9'>Tell us about our project!</a></p>"
    "<p style='margin-bottom:4px;'>If you would like to learn more or work with us, contact Dr. Mark Baillie at "
    "<a href='mailto:mtbaillie@ualr.edu'>mtbaillie@ualr.edu</a></p>"
    "<hr style='margin-top:16px; margin-bottom:16px;'>",
    unsafe_allow_html=True,
)

with st.expander("Instructions and additional details"):
    st.write("Thank you for viewing our experimental app! The overall presentations and features are expected to be improved over time.")
    st.write("To use this app:\n1. Upload an audio file for live analysis. Alternatively, upload an already generated [rttm file](https://stackoverflow.com/questions/30975084/rttm-file-format)")
    st.write("2. Press Analyze All. No data is saved on our side.")
    st.write("3. Use the sidebar to select your file. Multiple files are supported for more comprehensive analysis.")
    st.write("4. Use the tabs to view different visualizations. Each can be downloaded.")
    st.write("4a. Graphs are built with [plotly](https://plotly.com/). Double-click to reset. [More examples](https://plotly.com/python/basic-charts/).")

with st.expander("(Potentially) FAQ"):
    st.write("**1. I tried analyzing a file, but the page refreshed and nothing happened! Why?**")
    st.write("You may need to select a file using the sidebar on the left.")
    st.write("**2. I don't see a sidebar! Where is it?**")
    st.write("Press the '>' in the upper left to expand the sidebar.")
    st.write("**3. I still don't have a file to select in the dropdown! Why?**")
    st.write("Your file may be too large. We currently support approximately 1.5 hours of audio.")
    st.write("**4. I want to view my previously analyzed data. How?**")
    st.write("Download a CSV copy from the Download tab and re-upload it later.")
    st.write("**5. The app is extremely slow. What is wrong?**")
    st.write("We are securing funding for permanent GPU access. Until then, CPU analysis may take a very long time.")

# ---------------------------------------------------------------------------
# File upload
# ---------------------------------------------------------------------------

uploaded_file_paths = st.file_uploader(
    "Upload an audio of classroom activity to analyze",
    accept_multiple_files=True,
    key=f"uploader_{st.session_state.uploader_key}",
)

temp_dir = tempfile.mkdtemp()

if uploaded_file_paths:
    for uploaded_file in uploaded_file_paths:
        if not uploaded_file.name.lower().endswith(SUPPORTED_FILE_TYPES):
            st.error(f"File must be of type: {SUPPORTED_FILE_TYPES}")
            continue
        fname = uploaded_file.name
        path  = os.path.join(temp_dir, fname)
        with open(path, "wb") as f:
            f.write(uploaded_file.getvalue())
        if fname not in st.session_state.file_names:
            register_file(fname)
        st.session_state.file_paths[fname] = path
    st.session_state.valid_files = list(st.session_state.file_names)

file_names      = st.session_state.file_names
file_paths_dict = st.session_state.file_paths

# ---------------------------------------------------------------------------
# Sidebar: demo buttons
# ---------------------------------------------------------------------------

isDemo = False

if st.sidebar.button("Single File Demo (Sample)"):
    load_demo_single_sample(DEMO_SAMPLE_PATH)
    isDemo = True

if st.sidebar.button("Single File Demo (Full)"):
    load_demo_single(DEMO_PATH)
    isDemo = True

if st.sidebar.button("Multiple Files Demo"):
    load_demo_multi(MULTI_DEMO_PATHS)
    isDemo = True

st.sidebar.caption(
    "Single File Demo (Sample) analyzes a 10-minute sample, while "
    "Single File Demo (Full) analyzes the entire file. "
    "The latter can take a while to finish."
)

# ---------------------------------------------------------------------------
# Analyze All / Reset buttons
# ---------------------------------------------------------------------------

if len(file_names) == 0:
    st.text("Upload file(s) to enable analysis")
else:
    col_analyze, col_spacer, col_reset = st.columns([3, 5, 2])
    with col_analyze:
        if st.button("Analyze All New Audio", key="button_all"):
            st.session_state.analyzeAllToggle = True
    with col_reset:
        if st.button("🗑️ Reset App", key="button_reset", type="secondary", use_container_width=True):
            next_key = st.session_state.uploader_key + 1
            for key in list(st.session_state.keys()):
                del st.session_state[key]
            st.session_state.uploader_key = next_key
            st.rerun()

# ---------------------------------------------------------------------------
# Analysis loop
# ---------------------------------------------------------------------------

if st.session_state.analyzeAllToggle:
    run_analysis_loop(
        file_names, file_paths_dict, pipeline,
        ENABLE_DENOISE, EARLY_CLEANUP,
        GAIN_WINDOW, MINIMUM_GAIN, MAXIMUM_GAIN,
        dfModel, dfState, ATTEN_LIM_DB,
    )

# ---------------------------------------------------------------------------
# File selector
# ---------------------------------------------------------------------------

currFile = st.sidebar.selectbox(
    "Current File", file_names, on_change=updateMultiSelect, key="select_currFile"
)
if isDemo:
    currFile = file_names[0]

st.sidebar.divider()

if currFile is None:
    st.write("Select a file to view from the sidebar")

# ---------------------------------------------------------------------------
# Per-file analysis view
# ---------------------------------------------------------------------------

try:
    if currFile is None:
        raise ValueError("No file selected")

    st.session_state.resetResult = False
    currPlainName = currFile.rsplit(".", 1)[0]

    if not (
        currFile in st.session_state.results
        and currFile in st.session_state.summaries
        and len(st.session_state.results[currFile]) > 0
    ):
        raise ValueError("File not yet analyzed")

    st.header(f"Analysis of file {currFile}")
    TAB_NAMES = ["Speakers & Roles", "Pie Chart", "Sunburst",
                 "Treemap", "Time Spoken", "Timeline", "Download"]
    renameTab, pie2, sunburst1, treemap1, bar1, timeline, dataTab = st.tabs(TAB_NAMES)

    currAnnotation, currTotalTime = st.session_state.results[currFile]
    speakerNames       = currAnnotation.labels()
    speakers_dataFrame = st.session_state.summaries[currFile]["speakers_dataFrame"]
    currDF, _          = su.annotationToSimpleDataFrame(currAnnotation)
    unusedSpeakers     = st.session_state.unusedSpeakers[currFile]
    # categorySelect is global tokens; extract raw speaker IDs for currFile
    _prefix = currFile + ": "
    categorySelections = [
        [t[len(_prefix):] for t in tokens if t.startswith(_prefix)]
        for tokens in st.session_state.categorySelect
    ]

    _saved_renames       = st.session_state.speakerRenames.get(currFile, {})
    raw_to_display       = {sp: _saved_renames.get(sp, sp) for sp in speakerNames}
    all_speakers_display = [raw_to_display[sp] for sp in speakerNames]

    catTypeColors = su.colorsCSS(3)
    allColors     = su.colorsCSS(len(speakerNames) + len(st.session_state.categories))
    speakerColors = allColors[:len(speakerNames)]
    catColors     = allColors[len(speakerNames):]

    # Rebuild live df4
    # Guard: trim categorySelections to match categories length in case they
    # are momentarily out of sync during rapid role assignments.
    nameList    = st.session_state.categories
    _selections = categorySelections[:len(nameList)]
    while len(_selections) < len(nameList):
        _selections.append([])
    valueList   = [su.sumTimes(currAnnotation.subset(s)) for s in _selections]
    categorySelections = _selections
    extraNames  = list(unusedSpeakers)
    extraValues = [su.sumTimes(currAnnotation.subset([sp])) for sp in unusedSpeakers]
    st.session_state.summaries[currFile]["df4"] = pd.DataFrame(
        {"names": nameList + extraNames, "values": valueList + extraValues}
    )

    # Build all_speaker_tokens for rename sidebar
    # raw token    : "file: SPEAKER_00"  (stored in data model)
    # display token: "file: John"        (shown in dropdowns)
    all_speaker_tokens = [
        f"{fn}: {sp}"
        for fn in st.session_state.file_names
        if fn in st.session_state.results and len(st.session_state.results[fn]) == 2
        for sp in st.session_state.results[fn][0].labels()
    ]
    # Map display label -> raw token so ui.py can translate back after selection.
    # If two speakers in the same file share a display name, append the raw ID
    # to disambiguate both entries.
    token_display_map = {}
    for fn in st.session_state.file_names:
        if not (fn in st.session_state.results and len(st.session_state.results[fn]) == 2):
            continue
        sp_labels = {sp: f"{fn}: {get_display_name(sp, fn)}"
                     for sp in st.session_state.results[fn][0].labels()}
        label_counts = {}
        for disp in sp_labels.values():
            label_counts[disp] = label_counts.get(disp, 0) + 1
        for sp, disp in sp_labels.items():
            raw = f"{fn}: {sp}"
            if label_counts[disp] > 1:
                token_display_map[f"{fn}: {get_display_name(sp, fn)} ({sp})"] = raw
            else:
                token_display_map[disp] = raw
    display_speaker_tokens = list(token_display_map.keys())

    # -----------------------------------------------------------------------
    # Sidebar
    # -----------------------------------------------------------------------

    ui.render_role_sidebar(display_speaker_tokens, token_display_map)
    ui.render_rename_sidebar(currFile, speakerNames, display_speaker_tokens, token_display_map)

    # -----------------------------------------------------------------------
    # Tab: Data
    # -----------------------------------------------------------------------

    with dataTab:
        # Build raw-speaker -> role lookup against the RAW currDF (before renames
        # are applied) so the map keys match the original SPEAKER_## labels.
        raw_to_role = {
            token.split(": ", 1)[1]: st.session_state.categories[i]
            for i, tokens in enumerate(st.session_state.categorySelect)
            for token in tokens
            if token.startswith(f"{currFile}: ")
        }
        displayDF = currDF.copy()
        displayDF["Role"] = displayDF["Resource"].map(raw_to_role).fillna("")
        displayDF = apply_speaker_renames_to_df(displayDF, currFile, column="Resource")
        displayDF = displayDF.drop(columns=["Task"], errors="ignore")
        displayDF = displayDF.rename(columns={"Resource": "Speaker"})
        if "Start" in displayDF.columns:
            displayDF = displayDF.sort_values("Start").reset_index(drop=True)
        csv = convert_df(displayDF)
        st.download_button(
            f"Download {currPlainName}.csv", csv,
            f"sonogram-analysis-{currPlainName}.csv", "text/csv",
            key="download-csv", on_click="ignore",
        )

        analyzed_count = sum(
            1 for r in st.session_state.results.values() if len(r) == 2
        )
        zip_bytes = build_all_csv_zip() if analyzed_count > 1 else b""
        st.download_button(
            "Download all analyzed data in .csv (.zip)", zip_bytes,
            "sonogram-analysis-all.zip", "application/zip",
            key="download-all-zip", on_click="ignore",
            disabled=(analyzed_count <= 1),
        )

    # -----------------------------------------------------------------------
    # Tab: Rename Speaker
    # -----------------------------------------------------------------------

    with renameTab:
        ui.render_speaker_samples_tab(speakerNames, raw_to_display, currFile)

    # -----------------------------------------------------------------------
    # Charts
    # -----------------------------------------------------------------------

    df4 = st.session_state.summaries[currFile]["df4"].copy()
    df5 = st.session_state.summaries[currFile]["df5"].copy()
    df2 = st.session_state.summaries[currFile]["df2"].copy()

    ui.render_chart(
        utils.build_fig_pie2(df4, speakerNames, speakerColors, catColors, get_display_name, currFile),
        pie2,
        "ascn_pie2.pdf", "ascn_pie2.svg",
        f"sonogram-speaker-percent-{currPlainName}.pdf",
        f"sonogram-speaker-percent-{currPlainName}.svg",
        "download-pdf2", "download-svg2", PLOTLY_CONFIG,
    )
    ui.render_chart(
        utils.build_fig_sunburst(df5, catTypeColors, speakerColors, get_display_name, currFile),
        sunburst1,
        "ascn_sunburst.pdf", "ascn_sunburst.svg",
        f"sonogram-speaker-categories-{currPlainName}.pdf",
        f"sonogram-speaker-categories-{currPlainName}.svg",
        "download-pdf3", "download-svg3", PLOTLY_CONFIG,
    )
    ui.render_chart(
        utils.build_fig_treemap(df5, catTypeColors, speakerColors, get_display_name, currFile),
        treemap1,
        "ascn_treemap.pdf", "ascn_treemap.svg",
        f"sonogram-treemap-{currPlainName}.pdf",
        f"sonogram-treemap-{currPlainName}.svg",
        "download-pdf4", "download-svg4", PLOTLY_CONFIG,
    )
    ui.render_chart(
        utils.build_fig_timeline(speakers_dataFrame, currTotalTime, speakerColors, get_display_name, currFile),
        timeline,
        "ascn_timeline.pdf", "ascn_timeline.svg",
        f"sonogram-timeline-{currPlainName}.pdf",
        f"sonogram-timeline-{currPlainName}.svg",
        "download-pdf5", "download-svg5", PLOTLY_CONFIG,
    )
    ui.render_chart(
        utils.build_fig_bar(df2, speakerNames, catColors, speakerColors, get_display_name, currFile),
        bar1,
        "ascn_bar.pdf", "ascn_bar.svg",
        f"sonogram-speaker-time-{currPlainName}.pdf",
        f"sonogram-speaker-time-{currPlainName}.svg",
        "download-pdf6", "download-svg6", PLOTLY_CONFIG,
    )

except ValueError:
    pass

# ---------------------------------------------------------------------------
# Multi-file summary + footer
# ---------------------------------------------------------------------------

ui.render_multifile_summary(PLOTLY_CONFIG)