""" ui.py — Reusable Streamlit UI rendering helpers. Keeps all layout/widget code that isn't tied to a single page out of app.py, while keeping utils.py pure-Python and state.py focused on callbacks. """ import os import streamlit as st import plotly.express as px import utils from state import ( updateCategoryOptions, removeCategory, addCategory, addGlobalRename, removeGlobalRename, _global_rename_key, on_grename_change, randomize_speaker_clip, apply_inline_rename, ) # --------------------------------------------------------------------------- # Chart rendering # --------------------------------------------------------------------------- def render_chart(fig, tab, pdf_path, svg_path, pdf_name, svg_name, pdf_key, svg_key, plotly_config=None): """Render a Plotly figure inside a tab with PDF/SVG download buttons.""" cfg = plotly_config or {"displayModeBar": True, "modeBarButtonsToRemove": []} with tab: st.plotly_chart(fig, use_container_width=True, config=cfg) col_l, col_r = st.columns(2) try: fig.write_image(pdf_path) fig.write_image(svg_path) except Exception: pass with col_l: if os.path.exists(pdf_path): with open(pdf_path, "rb") as f: st.download_button("Save As PDF", f, pdf_name, "application/pdf", key=pdf_key, on_click="ignore") with col_r: if os.path.exists(svg_path): with open(svg_path, "rb") as f: st.download_button("Save As SVG", f, svg_name, "image/svg+xml", key=svg_key, on_click="ignore") # --------------------------------------------------------------------------- # Sidebar — categories section # --------------------------------------------------------------------------- def render_role_sidebar(display_speaker_tokens, token_display_map): """Render cross-file role multiselects. display_speaker_tokens : list of display strings shown in the dropdown e.g. ["file: John", "file: SPEAKER_01"] token_display_map : dict {display_label -> raw_token} e.g. {"file: John": "file: SPEAKER_00"} The data model (categorySelect) always stores raw tokens; this function converts between display labels and raw tokens at the boundary. """ raw_to_display = {v: k for k, v in token_display_map.items()} st.sidebar.subheader("Add Speaker's Role") st.sidebar.markdown( "

" "Add speaker(s) into the Instructor or Students role. You can also add a new role " "by typing in the \"Add custom role\" textbox.

", unsafe_allow_html=True, ) st.sidebar.text(" ") valid_display = set(display_speaker_tokens) for i, category in enumerate(st.session_state.categories): ms_key = f"multiselect_{category}" # Sync widget key from data model (raw -> display), dropping any stale # labels that are no longer valid (e.g. speaker was just renamed) raw_vals = st.session_state.categorySelect[i] if i < len(st.session_state.categorySelect) else [] st.session_state[ms_key] = [ raw_to_display.get(t, t) for t in raw_vals if raw_to_display.get(t, t) in valid_display ] # Build claimed set (display labels) from widget keys all_claimed = { token for i, category in enumerate(st.session_state.categories) for token in st.session_state.get(f"multiselect_{category}", []) } for i, category in enumerate(st.session_state.categories): ms_key = f"multiselect_{category}" label_col, trash_col = st.sidebar.columns([5, 1]) label_col.markdown( f"
{category}
", unsafe_allow_html=True, ) trash_col.button( "🗑️", key=f"remove_{category}", on_click=removeCategory, args=(i,), help=f"Remove {category}", ) this_role_display = set(st.session_state.get(ms_key, [])) available_tokens = [ t for t in display_speaker_tokens if t not in all_claimed or t in this_role_display ] st.sidebar.multiselect( category, available_tokens, key=ms_key, on_change=updateCategoryOptions, args=(token_display_map,), label_visibility="collapsed", ) st.sidebar.text_input("Add custom role", key="categoryInput", on_change=addCategory) # --------------------------------------------------------------------------- # Sidebar — rename speakers section # --------------------------------------------------------------------------- def render_rename_sidebar(currFile, speakerNames, display_speaker_tokens, token_display_map): """Render the full Rename Multiple Speakers sidebar section. display_speaker_tokens : list of display strings shown in the dropdown token_display_map : dict {display_label -> raw_token} The data model (entry["speakers"]) always stores raw tokens. """ raw_to_display = {v: k for k, v in token_display_map.items()} st.sidebar.divider() st.sidebar.subheader("Rename Cross-file Speakers") st.sidebar.markdown( "

" "This option works best for renaming multiple speakers across multiple files. " "For a simple rename for a single file, use the Rename Speaker tab in the center.

", unsafe_allow_html=True, ) st.sidebar.text(" ") # Build claimed set (display labels) from the data model — one speaker per entry all_claimed = { raw_to_display.get(token, token) for entry in st.session_state.globalRenames for token in entry["speakers"] } valid_display = set(display_speaker_tokens) for idx, entry in enumerate(st.session_state.globalRenames): grkey = _global_rename_key(idx) # Sync widget key from data model (raw -> display), dropping stale labels st.session_state[grkey] = [ raw_to_display.get(t, t) for t in entry["speakers"] if raw_to_display.get(t, t) in valid_display ] label_col, trash_col = st.sidebar.columns([5, 1]) label_col.markdown( f"
{entry['name']}
", unsafe_allow_html=True, ) trash_col.button( "🗑️", key=f"remove_grename_{idx}", on_click=removeGlobalRename, args=(idx,), help=f"Remove '{entry['name']}'", ) this_entry_display = set(st.session_state[grkey]) available_tokens = [ t for t in display_speaker_tokens if t not in all_claimed or t in this_entry_display ] st.sidebar.multiselect( f"Speakers for {entry['name']}", options=available_tokens, key=grkey, on_change=on_grename_change, args=(idx, token_display_map), label_visibility="collapsed", ) # Show error if two speakers from the same file are in this entry files_in_entry = [token.split(": ", 1)[0] for token in entry["speakers"]] for fname in set(files_in_entry): if files_in_entry.count(fname) > 1: st.sidebar.error( f"Conflicted name for two speakers in one same file. Please remove one speaker." ) break st.sidebar.text_input( "Add rename", placeholder="e.g. John", key="globalRenameInput", on_change=addGlobalRename, ) # --------------------------------------------------------------------------- # Rename Speaker tab — speaker / audio sample table # --------------------------------------------------------------------------- def render_speaker_samples_tab(speakerNames, raw_to_display, currFile): """Render a table: Speaker (with inline ✎ rename + history fill) | Audio Sample | ↺ button.""" file_samples = st.session_state.speakerClips.get(currFile, {}) has_waveform = currFile in st.session_state.speakerWaveforms has_samples = bool(file_samples) if "inline_rename_active" not in st.session_state: st.session_state.inline_rename_active = {} # Build the history list fresh each render: # 1. Names currently in globalRenames (sidebar rename entries) # 2. Names actively used in speakerRenames (confirmed via rename tab) # Deleted sidebar entries are excluded automatically since we rebuild from scratch. global_names = {entry["name"] for entry in st.session_state.globalRenames} active_names = { name for renames in st.session_state.speakerRenames.values() for name in renames.values() } # Preserve any tab-confirmed names that aren't in either set (typed manually) # but drop names that were only ever in globalRenames and have since been removed. prev_history = st.session_state.get("inline_rename_history", []) st.session_state.inline_rename_history = [ name for name in prev_history if name in global_names or name in active_names ] # Add any new names from globalRenames not yet in history for name in global_names: if name not in st.session_state.inline_rename_history: st.session_state.inline_rename_history.append(name) def _get_role(sp): """Return the role name assigned to this speaker, or empty string.""" token = f"{currFile}: {sp}" for i, tokens in enumerate(st.session_state.categorySelect): if token in tokens: return st.session_state.categories[i] return "" # Header header_cols = st.columns([3, 2, 2, 1]) header_cols[0].markdown("**Speaker**") header_cols[1].markdown("**Role**") header_cols[2].markdown("**Audio Sample**") header_cols[3].markdown("** **", unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) if not has_samples: st.info("Audio samples are only available for files analyzed from audio (not RTTM/CSV/TXT).") for sp in speakerNames: display_name = raw_to_display.get(sp, sp) edit_key = (currFile, sp) is_editing = st.session_state.inline_rename_active.get(edit_key, False) row_cols = st.columns([3, 2, 2, 1]) # --- Speaker cell --- if is_editing: select_key = f"inline_rename_select_{currFile}_{sp}" input_key = f"inline_rename_input_{currFile}_{sp}" confirm_key = f"inline_rename_confirm_{currFile}_{sp}" cancel_key = f"inline_rename_cancel_{currFile}_{sp}" reset_key = f"inline_rename_reset_{currFile}_{sp}" form_key = f"inline_rename_form_{currFile}_{sp}" history = st.session_state.inline_rename_history current_val = st.session_state.speakerRenames.get(currFile, {}).get(sp, "") _NEW_OPTION = "+ Enter a new name" with row_cols[0]: # The selectbox MUST live outside the form: inside a form, # widget state is only committed on submit, so choosing # _NEW_OPTION would never make the text input appear until # after an unrelated submit. We track the live choice in # chosen_key and only put the text input + buttons in the form. chosen_key = f"inline_rename_chosen_{currFile}_{sp}" show_input = False new_name = "" if history: options = history + [_NEW_OPTION] default_idx = ( history.index(current_val) if current_val in history else len(options) - 1 ) if chosen_key not in st.session_state: st.session_state[chosen_key] = options[default_idx] # Clamp stored value in case history changed if st.session_state[chosen_key] not in options: st.session_state[chosen_key] = options[default_idx] st.selectbox( "Rename", options=options, key=chosen_key, label_visibility="collapsed", ) chosen = st.session_state[chosen_key] show_input = (chosen == _NEW_OPTION) if not show_input: new_name = chosen with st.form(key=form_key, border=False): if not history: # No history — plain text input is the only field new_name = st.text_input( "Rename", value=current_val, key=input_key, label_visibility="collapsed", placeholder=f"Rename {sp}…", ) elif show_input: # User picked "+ Enter a new name" — show free-text field new_name = st.text_input( "New name", value="", key=input_key, label_visibility="collapsed", placeholder=f"Rename {sp}…", ) _, btn_col1, btn_col2, btn_col3 = st.columns([4, 1, 1, 1]) confirmed_submit = btn_col1.form_submit_button("✓", help="Confirm rename (or press Enter)") cancel_clicked = btn_col2.form_submit_button("✕", help="Cancel") reset_clicked = btn_col3.form_submit_button("⟳", help="Reset to original speaker label") def _cleanup(): st.session_state.inline_rename_active[edit_key] = False st.session_state.pop(select_key, None) st.session_state.pop(chosen_key, None) st.session_state.pop(input_key, None) if confirmed_submit: confirmed = (new_name or "").strip() apply_inline_rename(currFile, sp, confirmed) if confirmed and confirmed not in st.session_state.inline_rename_history: st.session_state.inline_rename_history.append(confirmed) _cleanup() st.rerun() if cancel_clicked: _cleanup() st.rerun() if reset_clicked: apply_inline_rename(currFile, sp, "") _cleanup() st.rerun() else: with row_cols[0]: name_col, pencil_col = st.columns([4, 1]) name_col.write(display_name) if pencil_col.button( "✎", key=f"inline_rename_edit_{currFile}_{sp}", help=f"Rename {sp}", ): st.session_state.inline_rename_active[edit_key] = True st.rerun() # --- Role cell --- row_cols[1].write(_get_role(sp)) # --- Audio sample cell --- if sp in file_samples: row_cols[2].audio(file_samples[sp], format="audio/wav") sp_segs = st.session_state.speakerSegments.get(currFile, {}).get(sp, []) if has_waveform and sp_segs: if row_cols[3].button( "↺", key=f"sample_randomize_{currFile}_{sp}", help="Try a different audio sample for this speaker", ): randomize_speaker_clip(currFile, sp) st.rerun() else: row_cols[2].write("—") # --------------------------------------------------------------------------- # Multi-file summary expander # --------------------------------------------------------------------------- def render_multifile_summary(plotly_config=None): """Render the Multi-file summary data expander if enough files are analyzed.""" cfg = plotly_config or {"displayModeBar": True, "modeBarButtonsToRemove": []} if not st.session_state.results: return with st.expander("Multi-file summary data"): st.header("Multi-file summary data") with st.spinner("Processing summary results..."): validNames = [ fn for fn in st.session_state.file_names if fn in st.session_state.results and len(st.session_state.results[fn]) == 2 ] if len(validNames) <= 1: return df6, allCategories = utils.build_multifile_category_df( validNames, st.session_state.results, st.session_state.summaries, st.session_state.categories, st.session_state.categorySelect, speakerRenames=st.session_state.speakerRenames, ) df7, _ = utils.build_multifile_voice_df(validNames, st.session_state.summaries) # Build consistent color maps using the same _PALETTE as per-file charts # Voice category colors — always fixed voice_color_map = { "Single Voice": utils._PALETTE[0], # reserved "Multi Voice": utils._PALETTE[9], # reserved "No Voice": utils._PALETTE[24], # reserved "Unassigned": utils._PALETTE[31] if len(utils._PALETTE) > 31 else "#777a7d", } # Role/speaker colors — cycle through _SPEAKER_PALETTE by position role_color_map = { col: utils._SPEAKER_PALETTE[i % len(utils._SPEAKER_PALETTE)] for i, col in enumerate(allCategories) } # Chart 1 fig6_sort_options = { "File Name": ("files", True), "% of Instructor": ("Instructor", False), } selected_fig6_sort = st.selectbox( "Sort Percent of File Duration by:", options=list(fig6_sort_options.keys()), key="multifile_role_sort", ) fig6_sort_col, fig6_ascending = fig6_sort_options[selected_fig6_sort] if fig6_sort_col not in df6.columns: fig6_sort_col, fig6_ascending = "files", True fig6 = px.bar(df6.sort_values(fig6_sort_col, ascending=fig6_ascending), x="files", y=allCategories, title=f"Percent of file duration spoken by each speaker/role sorted by {selected_fig6_sort}", labels={"files": "Files"}, color_discrete_map=role_color_map, hover_data={"files": True}) fig6.update_layout(yaxis_title="% of File Duration") fig6.update_xaxes(showticklabels=False) st.plotly_chart(fig6, use_container_width=True, config=cfg) # Chart 2 sort_options = { "File Name": ("files", True), "% of Single Voice": ("Single Voice", False), } selected_sort = st.selectbox( "Sort Cross-file Voice Categories by:", options=list(sort_options.keys()), key="multifile_voice_sort", ) sort_col, ascending = sort_options[selected_sort] fig7 = px.bar( df7.sort_values(by=sort_col, ascending=ascending), x="files", y=["Single Voice", "Multi Voice", "No Voice"], title=f"Cross-file voice categories sorted by {selected_sort}", labels={"files": "Files"}, color_discrete_map=voice_color_map, hover_data={"files": True}, ) fig7.update_layout(yaxis_title="% of File Duration") fig7.update_xaxes(showticklabels=False) st.plotly_chart(fig7, use_container_width=True, config=cfg) st.divider() # Chart 3 df8, role_voice_cols = utils.build_multifile_role_voice_df( validNames, st.session_state.results, st.session_state.summaries, st.session_state.categories, st.session_state.categorySelect, speakerRenames=st.session_state.speakerRenames, ) role_voice_color_map = { col: utils._SPEAKER_PALETTE[i % len(utils._SPEAKER_PALETTE)] for i, col in enumerate(role_voice_cols) if col not in voice_color_map } role_voice_color_map.update(voice_color_map) role_sort_options = { "File Name": ("files", True), "% of Multi Voice": ("Multi Voice", True), } selected_role_sort = st.selectbox( "Sort Cross-file Role & Voice Breakdown by:", options=list(role_sort_options.keys()), key="multifile_role_voice_sort", ) sort_col8, ascending8 = role_sort_options[selected_role_sort] fig8 = px.bar( df8.sort_values(by=sort_col8, ascending=ascending8), x="files", y=role_voice_cols, color_discrete_map=role_voice_color_map, labels={"files": "Files"}, hover_data={"files": True}, title=f"Cross-file role and voice breakdown sorted by {selected_role_sort}", ) fig8.update_layout(yaxis_title="% of File Duration") fig8.update_xaxes(showticklabels=False) st.plotly_chart(fig8, use_container_width=True, config=cfg)