""" 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, ) # --------------------------------------------------------------------------- # 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 = "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) ] # --------------------------------------------------------------------------- # Pipeline initialisation (once per server process) # --------------------------------------------------------------------------- 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 # --------------------------------------------------------------------------- # 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( "", unsafe_allow_html=True, ) # --------------------------------------------------------------------------- # Page header # --------------------------------------------------------------------------- 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( "
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 " "tduong@ualr.edu if you need help using the tool!
" "Would you like additional data, charts, or features? " "Tell us about our project!
" "If you would like to learn more or work with us, contact Dr. Mark Baillie at " "mtbaillie@ualr.edu
", 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.") # --------------------------------------------------------------------------- # 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}", ) # Make temp directory temp_dir = tempfile.mkdtemp() # If files have been uploaded if uploaded_file_paths: for uploaded_file in uploaded_file_paths: # Check for supported file types via extension if not uploaded_file.name.lower().endswith(SUPPORTED_FILE_TYPES): st.error(f"File must be of type: {SUPPORTED_FILE_TYPES}") continue # Save file locally to server fname = uploaded_file.name path = os.path.join(temp_dir, fname) with open(path, "wb") as f: f.write(uploaded_file.getvalue()) # Add file to list of known files 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 st.session_state["_sample_demo_loaded"] = True # Only show between Sample and Full buttons once Sample has been loaded _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. """) # --------------------------------------------------------------------------- # 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 ) # --------------------------------------------------------------------------- # 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 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] # 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 = utils.colorsCSS(3) # Assign each speaker a fixed color by their position in speakerNames so # the same speaker always gets the same color across all charts. # Map by display name so renames are also covered. 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)) # 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 # Display label puts speaker name first so it's visible when truncated. # Raw token keeps "fn: sp" format — all parsing logic depends on this. 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()) # ----------------------------------------------------------------------- # 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 # ----------------------------------------------------------------------- # ----------------------------------------------------------------------- # Tab: Population # ----------------------------------------------------------------------- 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( "File Name
", unsafe_allow_html=True, ) pop_header_cols[1].markdown( "Number of Students
", unsafe_allow_html=True, ) st.markdown("