| """ |
| 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 random |
| import tempfile |
| from pathlib import Path |
|
|
| import streamlit as st |
| import torch |
| import pandas as pd |
| from sonogram import Sonogram |
|
|
| 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, |
| build_xml_download, |
| build_all_xml_zip, |
| rename_sample_demo_speakers, |
| ) |
|
|
| |
| |
| |
|
|
| 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 = "audioSamples/sample.rttm" |
| DEMO_SAMPLE_PATH = "audioSamples/sample_short.rttm" |
| MULTI_DEMO_PATHS = [ |
| f"audioSamples/class{i:02d}.rttm" for i in range(1, 11) |
| ] |
|
|
| |
| |
| |
|
|
| torch.classes.__path__ = [os.path.join(torch.__path__[0], torch.classes.__file__)] |
|
|
| if "pipeline" not in st.session_state or st.session_state.pipeline is None: |
| pipeline = Sonogram() |
| st.session_state.pipeline = pipeline |
| pipeline = st.session_state.pipeline |
| |
| |
| |
|
|
| init_session_state() |
|
|
| |
| 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, |
| ) |
|
|
| |
| |
| |
|
|
| st.title("Instructor Support Tool") |
| if not (pipeline.isGPU or pipeline.isTPU): |
| st.warning(''' |
| TOOL CURRENTLY USING CPU, ANALYSIS EXTREMELY SLOW |
| After performing one action, a RUNNING... icon will appear at the top right corner. |
| Please wait for this icon to disappear before performing another action. |
| ''') |
|
|
| DEMO_VIDEO_URL = "https://www.youtube.com/watch?v=n7Z_BzU7yOY" |
|
|
| with st.expander("TL;DR"): |
| st.write("Please watch this video for a walkthrough of this app:") |
| st.video(DEMO_VIDEO_URL) |
| 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 Thien Duong at " |
| "<a href='mailto:tduong@ualr.edu'>tduong@ualr.edu</a> if you need help using the tool!</p>" |
| "<p style='margin-top:8px; margin-bottom:4px;'>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>", |
| 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("Preliminary 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.") |
|
|
| |
| |
| |
|
|
| 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 |
|
|
| |
| |
| |
|
|
| isDemo = False |
|
|
| if st.sidebar.button("Single File Demo (Sample)"): |
| load_demo_single_sample(DEMO_SAMPLE_PATH) |
| isDemo = True |
| st.session_state["_sample_demo_loaded"] = True |
|
|
| |
| _sample_demo_fname = DEMO_SAMPLE_PATH.split("/")[-1] |
| if (st.session_state.get("_sample_demo_loaded", False) |
| and _sample_demo_fname in st.session_state.file_names): |
| if st.sidebar.button("Rename Sample Speakers"): |
| rename_sample_demo_speakers(_sample_demo_fname) |
|
|
| 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. " |
| "Multiple Files Demo loads ten recording samples." |
| ) |
| st.sidebar.caption( |
| """After Single File Demo (Sample) is loaded, the Rename Sample Speakers button will appear. |
| Click on Rename Sample Speakers to try out the rename feature. |
| """) |
|
|
| |
| |
| |
|
|
| 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() |
|
|
| |
| |
| |
|
|
| if st.session_state.analyzeAllToggle: |
| run_analysis_loop( |
| file_names, file_paths_dict |
| ) |
|
|
| |
| |
| |
|
|
| 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") |
|
|
| |
| |
| |
|
|
| 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 |
| and st.session_state.summaries[currFile].get("speakers_dataFrame") is not None |
| ): |
| raise ValueError("File not yet analyzed") |
|
|
| st.header(f"Analysis of file {currFile}") |
| TAB_NAMES = ["Population", "Speakers & Roles", "Pie Chart", "Sunburst", |
| "Treemap", "Time Spoken", "Timeline", "Download"] |
| populationTab, 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] |
| |
| _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 = utils.colorsCSS(3) |
| |
| |
| |
| speaker_color_map = { |
| get_display_name(sp, currFile): utils._SPEAKER_PALETTE[i % len(utils._SPEAKER_PALETTE)] |
| for i, sp in enumerate(speakerNames) |
| } |
| speakerColors = list(speaker_color_map.values()) |
| catColors = utils.colorsCSS(len(st.session_state.categories)) |
|
|
| |
| |
| |
| 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} |
| ) |
|
|
| |
| |
| |
| 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() |
| ] |
| |
| |
| |
| 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"{get_display_name(sp, fn)}: {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"{get_display_name(sp, fn)} ({sp}): {fn}"] = raw |
| else: |
| token_display_map[disp] = raw |
| display_speaker_tokens = list(token_display_map.keys()) |
|
|
| |
| |
| |
|
|
| ui.render_role_sidebar(display_speaker_tokens, token_display_map) |
| ui.render_rename_sidebar(currFile, speakerNames, display_speaker_tokens, token_display_map) |
|
|
| |
| |
| |
|
|
| |
| |
| |
|
|
| with populationTab: |
| st.markdown("Enter the number of students present in each recording.") |
| pop_header_cols = st.columns([4, 2, 1, 1]) |
| pop_header_cols[0].markdown( |
| "<p style='margin:0; font-weight:600;'>File Name</p>", |
| unsafe_allow_html=True, |
| ) |
| pop_header_cols[1].markdown( |
| "<p style='margin:0; font-weight:600;'>Number of Students</p>", |
| unsafe_allow_html=True, |
| ) |
| st.markdown("<hr style='margin-top:2px; margin-bottom:4px;'>", |
| unsafe_allow_html=True) |
| 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 |
| plain = fn.rsplit(".", 1)[0] |
| edit_key = f"pop_editing_{fn}" |
| input_key = f"pop_input_{fn}" |
| current_pop = st.session_state.studentPopulations.get(fn) |
|
|
| row_cols = st.columns([4, 2, 1, 1]) |
| row_cols[0].write(plain) |
|
|
| if st.session_state.get(edit_key, False): |
| |
| |
| row_cols[1].number_input( |
| "Students", min_value=0, step=1, |
| value=int(current_pop) if current_pop is not None else 0, |
| key=input_key, label_visibility="collapsed", |
| ) |
| if row_cols[2].button("✓", key=f"pop_confirm_{fn}"): |
| st.session_state.studentPopulations[fn] = int( |
| st.session_state.get(input_key, current_pop or 0) |
| ) |
| st.session_state[edit_key] = False |
| st.rerun() |
| if row_cols[3].button("✕", key=f"pop_cancel_{fn}"): |
| st.session_state[edit_key] = False |
| st.rerun() |
| else: |
| row_cols[1].write(str(current_pop) if current_pop is not None else "—") |
| if row_cols[2].button("✎", key=f"pop_edit_btn_{fn}", |
| help="Edit student count"): |
| st.session_state[edit_key] = True |
| st.rerun() |
|
|
| |
| |
| |
|
|
| with dataTab: |
| |
| |
| 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) |
|
|
| xml_bytes = build_xml_download(currFile) |
| st.download_button( |
| f"Download {currPlainName}.xml", xml_bytes, |
| f"sonogram-analysis-{currPlainName}.xml", "application/xml", |
| key="download-xml", on_click="ignore", |
| ) |
|
|
| analyzed_count = sum( |
| 1 for r in st.session_state.results.values() if len(r) == 2 |
| ) |
| zip_bytes = build_all_xml_zip() if analyzed_count > 1 else b"" |
| st.download_button( |
| "Download all analyzed datas.xml in .zip archive", zip_bytes, |
| "sonogram-analysis-all.zip", "application/zip", |
| key="download-all-zip", on_click="ignore", |
| disabled=(analyzed_count <= 1), |
| ) |
|
|
| |
| |
| |
|
|
| with renameTab: |
| ui.render_speaker_samples_tab(speakerNames, raw_to_display, currFile) |
|
|
| |
| |
| |
|
|
| 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, speaker_color_map, 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, speaker_color_map, 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, |
| ) |
| with sunburst1: |
| sv_pct = df5.loc[df5["ids"] == "OV", "percentiles"].values |
| mv_pct = df5.loc[df5["ids"] == "MV", "percentiles"].values |
| sv_nonzero = len(sv_pct) > 0 and sv_pct[0] > 0 |
| mv_nonzero = len(mv_pct) > 0 and mv_pct[0] > 0 |
| |
| |
| |
| if sv_nonzero and mv_nonzero: |
| fig_sun_single = utils.build_fig_sunburst_single( |
| df5, speaker_color_map, get_display_name, currFile) |
| if fig_sun_single is not None: |
| st.plotly_chart(fig_sun_single, use_container_width=True, |
| config=PLOTLY_CONFIG) |
| fig_sun_multi = utils.build_fig_sunburst_multi( |
| df5, speaker_color_map, get_display_name, currFile) |
| if fig_sun_multi is not None: |
| st.plotly_chart(fig_sun_multi, use_container_width=True, |
| config=PLOTLY_CONFIG) |
| ui.render_chart( |
| utils.build_fig_treemap(df5, catTypeColors, speaker_color_map, 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, speaker_color_map, get_display_name, currFile, |
| mv_intervals=st.session_state.summaries[currFile].get("mv_intervals", [])), |
| 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, speaker_color_map, get_display_name, currFile, |
| mv_per_speaker=st.session_state.summaries[currFile].get("mv_per_speaker", {})), |
| 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 |
|
|
| |
| |
| |
|
|
| ui.render_multifile_summary(PLOTLY_CONFIG) |