| """ |
| 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, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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") |
|
|
|
|
| |
| |
| |
|
|
| 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( |
| "<p style='font-size:0.85rem; color:gray; margin-bottom:2px;'>" |
| "Add speaker(s) into the Instructor or Students role. You can also add a new role " |
| "by typing in the \"Add custom role\" textbox.</p>", |
| 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}" |
| |
| |
| 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 |
| ] |
|
|
| |
| 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"<div style='padding-top:16px;'><strong>{category}</strong></div>", |
| 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) |
|
|
|
|
| |
| |
| |
|
|
| 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( |
| "<p style='font-size:0.85rem; color:gray; margin-bottom:2px;'>" |
| "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.</p>", |
| unsafe_allow_html=True, |
| ) |
|
|
| st.sidebar.text(" ") |
|
|
| |
| 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) |
| |
| 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"<div style='padding-top:16px;'><strong>{entry['name']}</strong></div>", |
| 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", |
| ) |
| |
| 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, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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 = {} |
|
|
| |
| |
| |
| |
| 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() |
| } |
| |
| |
| 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 |
| ] |
| |
| 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_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("<hr style='margin-top:2px; margin-bottom:4px;'>", 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]) |
|
|
| |
| 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]: |
| |
| |
| |
| |
| |
| 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] |
| |
| 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: |
| |
| new_name = st.text_input( |
| "Rename", |
| value=current_val, |
| key=input_key, |
| label_visibility="collapsed", |
| placeholder=f"Rename {sp}…", |
| ) |
| elif show_input: |
| |
| 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() |
|
|
| |
| row_cols[1].write(_get_role(sp)) |
|
|
| |
| 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("—") |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
| |
| |
| voice_color_map = { |
| "Single Voice": utils._PALETTE[0], |
| "Multi Voice": utils._PALETTE[9], |
| "No Voice": utils._PALETTE[24], |
| "Unassigned": utils._PALETTE[31] if len(utils._PALETTE) > 31 else "#777a7d", |
| } |
| |
| role_color_map = { |
| col: utils._SPEAKER_PALETTE[i % len(utils._SPEAKER_PALETTE)] |
| for i, col in enumerate(allCategories) |
| } |
|
|
| |
| 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) |
|
|
| |
| 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() |
|
|
| |
| 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) |