From e68a8a3d831ec41c8d4021178784f688e3ec4415 Mon Sep 17 00:00:00 2001 From: Optic00 Date: Sun, 2 Aug 2026 19:58:50 +0200 Subject: [PATCH 1/2] feat(overview): show which notes still have their original audio keep_recordings defaults off, so for most notes the recording is gone - and everything that needs it is then quietly unavailable: re-transcribe, the speaker panel's listening samples, and any future re-diarization. Until now nothing said so until you opened a note and found the action missing. list-meetings gains `has_audio`, derived from ONE directory listing of recordings/ rather than a per-meeting existence check: this command is on the cold-start path and its own docstring calls it "optimized for fast loading", so the cost must not scale with library size. A regression test counts listdir calls to hold it to that. Matching is on the stem alone, never an extension whitelist - the capture pipeline saves whatever the source produced (.webm, .wav, .m4a, .mp3), so a whitelist would silently mark imported meetings audio-less. The row shows a small audio glyph beside the duration, and only when the audio is present: absence is the normal case, so a "missing" marker on most rows would be noise rather than information. --- app/e2e-mock-ipc.js | 32 +++++ .../src/components/home/PreviousRow.tsx | 25 +++- app/renderer/src/lib/ipc.ts | 6 + e2e/specs/overview-audio-indicator.t1.spec.ts | 47 +++++++ simple_recorder.py | 35 ++++- tests/test_list_meetings_has_audio.py | 126 ++++++++++++++++++ 6 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 e2e/specs/overview-audio-indicator.t1.spec.ts create mode 100644 tests/test_list_meetings_has_audio.py diff --git a/app/e2e-mock-ipc.js b/app/e2e-mock-ipc.js index 1ed12fb5..6270a076 100644 --- a/app/e2e-mock-ipc.js +++ b/app/e2e-mock-ipc.js @@ -165,6 +165,35 @@ function seedPriorSegments(rec) { ]; } +// Two notes differing ONLY in whether their original recording still exists, +// for the overview's audio indicator (STENOAI_E2E_SEED_AUDIO_MEETINGS=1). +// keep_recordings defaults off, so "no audio" is the normal case -- the pair +// is what proves the icon tracks the flag rather than always rendering. +const AUDIO_SEED_MEETINGS = [ + { + session_info: { + name: 'With audio', + summary_file: 'with-audio_summary.md', + processed_at: '2026-08-02T12:00:00Z', + duration_seconds: 600, + }, + summary: 'A note whose recording was kept.', + has_audio: true, + key_points: [], action_items: [], discussion_areas: [], participants: [], + }, + { + session_info: { + name: 'Without audio', + summary_file: 'without-audio_summary.md', + processed_at: '2026-08-02T11:00:00Z', + duration_seconds: 900, + }, + summary: 'A note whose recording was discarded after processing.', + has_audio: false, + key_points: [], action_items: [], discussion_areas: [], participants: [], + }, +]; + function install({ ipcMain }) { // In-memory stand-in for the org session + provider config that the real // handlers persist to disk. Mutated by the org-login / org-logout / set-ai @@ -377,6 +406,9 @@ function install({ ipcMain }) { // lives in MOCKS, which shadows DEFAULTS, so it is the single source for the // channel. 'list-meetings': async () => { + if (process.env.STENOAI_E2E_SEED_AUDIO_MEETINGS === '1') { + return { success: true, meetings: AUDIO_SEED_MEETINGS }; + } if (process.env.STENOAI_E2E_SEED_PENDING_NOTE === '1') { return { success: true, meetings: [PENDING_MEETING] }; } diff --git a/app/renderer/src/components/home/PreviousRow.tsx b/app/renderer/src/components/home/PreviousRow.tsx index 5647cc5c..c3278f65 100644 --- a/app/renderer/src/components/home/PreviousRow.tsx +++ b/app/renderer/src/components/home/PreviousRow.tsx @@ -1,4 +1,4 @@ -import { Folder as FolderIcon, Loader2 } from 'lucide-react'; +import { AudioLines, Folder as FolderIcon, Loader2 } from 'lucide-react'; import type { Meeting } from '@/lib/ipc'; import { navigate } from '@/lib/router'; import { useMeetingsList } from '@/lib/meetingsListContext'; @@ -116,7 +116,28 @@ export function PreviousRow({ meeting, folderName }: PreviousRowProps) { style={{ color: 'var(--fg-2)' }} > {isSynthetic ? 'Now' : (when ?? '')} - {duration && {duration}} + {/* Original audio still on disk. Shown only when present, never as a + crossed-out "missing" marker: keep_recordings defaults off, so + absence is the normal case and flagging it on most rows would be + noise. What the icon buys is knowing, without opening the note, + which recordings can still be re-transcribed or listened to for + speaker review — the actions that silently disappear once the + audio is gone. Sits beside the duration because both describe + the recording rather than the note. */} + {(duration || (!isSynthetic && meeting.has_audio)) && ( + + {!isSynthetic && meeting.has_audio && ( + + Original audio still available + + )} + {duration} + + )} ); diff --git a/app/renderer/src/lib/ipc.ts b/app/renderer/src/lib/ipc.ts index 04f35f3b..71407b74 100644 --- a/app/renderer/src/lib/ipc.ts +++ b/app/renderer/src/lib/ipc.ts @@ -58,6 +58,12 @@ export interface Meeting { is_diarised?: boolean; diarised_text?: string | null; folders?: string[]; + /** Whether the ORIGINAL recording is still on disk (list-meetings only — + * derived from one directory listing, extension-agnostic). keep_recordings + * defaults off, so this is false for most notes, and everything that needs + * the audio (re-transcribe, speaker samples, any future re-diarization) is + * quietly unavailable without it. */ + has_audio?: boolean; /** User notes as persisted + returned by the backend (`_parse_meeting_markdown` -> `user_notes`). */ user_notes?: string | null; /** Renderer-side notes for the in-progress / draft recording (live + processing views). */ diff --git a/e2e/specs/overview-audio-indicator.t1.spec.ts b/e2e/specs/overview-audio-indicator.t1.spec.ts new file mode 100644 index 00000000..de342742 --- /dev/null +++ b/e2e/specs/overview-audio-indicator.t1.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from '../fixtures/electron'; + +/** + * T1 — renderer-only, mock IPC. The overview's "original audio still exists" + * indicator. + * + * Why it earns a UI spec on top of the backend's own tests: the value of the + * icon is entirely in it being ABSENT for notes whose recording is gone. + * keep_recordings defaults off, so most rows have no audio — an icon that + * rendered unconditionally would look correct on a screenshot and tell the + * user nothing. Only a pair of rows differing solely in `has_audio` can show + * the difference. + */ + +test('only a note whose recording still exists shows the audio indicator', async ({ + launchApp, +}) => { + const { page } = await launchApp({ + mockIpc: true, + env: { + STENOAI_E2E_SEED_AUDIO_MEETINGS: '1', + // Without an installed model App.tsx's first-run gate redirects a + // neutral route to /setup before Home ever renders (same seam the + // pill-dock T1 uses). + STENOAI_E2E_MOCK_PARAKEET_INSTALLED: '1', + }, + }); + + const rows = page.getByTestId('previous-row'); + await expect(rows).toHaveCount(2); + + const withAudio = rows.filter({ hasText: 'With audio' }); + const withoutAudio = rows.filter({ hasText: 'Without audio' }); + + await expect(withAudio.getByTestId('previous-row-has-audio')).toHaveCount(1); + await expect(withoutAudio.getByTestId('previous-row-has-audio')).toHaveCount(0); + + // The duration still renders on both -- the icon sits BESIDE it rather + // than replacing it. + await expect(withAudio).toContainText('10m'); + await expect(withoutAudio).toContainText('15m'); + + // Named for screen readers, not a bare decorative glyph. + await expect( + withAudio.getByLabel('Original audio still available'), + ).toBeVisible(); +}); diff --git a/simple_recorder.py b/simple_recorder.py index 0dba117f..59180349 100644 --- a/simple_recorder.py +++ b/simple_recorder.py @@ -19,6 +19,7 @@ import asyncio import logging import json +import os import re import sys import time @@ -2751,6 +2752,24 @@ def list_meetings(): # Ensure output directory exists output_dir.mkdir(parents=True, exist_ok=True) + # Stems that still have their source recording on disk. Read as ONE + # directory listing rather than a per-meeting existence check, because + # this command is on the app's cold-start path and is deliberately + # "optimized for fast loading" -- with a listing the cost is a single + # syscall regardless of library size, and each meeting is then a set + # lookup. Extension-agnostic on purpose: the capture pipeline saves + # whatever the source produced (.webm system audio, .wav native + # captures, .m4a/.mp3 imports), so only the stem is meaningful. + def _recorded_stems(recordings_dir) -> set: + try: + return {Path(name).stem for name in os.listdir(recordings_dir)} + except OSError: + # No recordings dir yet (fresh install) is not an error -- it + # just means nothing has audio. + return set() + + audio_stems = _recorded_stems(dirs["recordings"]) + # Collect summary files from current output dir (JSON preferred over MD) seen_files = set() seen_stems = set() @@ -2760,7 +2779,7 @@ def list_meetings(): for f in output_dir.glob(pattern): stem = f.stem.replace('_summary', '') if stem not in seen_stems: - summaries.append(f) + summaries.append((f, stem)) seen_files.add(f.resolve()) seen_stems.add(stem) @@ -2774,18 +2793,21 @@ def list_meetings(): else: default_output = Path(__file__).parent / "output" if default_output.exists(): + # Meetings found here keep their audio in the DEFAULT recordings + # dir, not the custom one -- they predate the path change. + audio_stems |= _recorded_stems(default_output.parent / "recordings") for pattern in ("*_summary.json", "*_summary.md"): for f in default_output.glob(pattern): stem = f.stem.replace('_summary', '') if f.resolve() not in seen_files and stem not in seen_stems: - summaries.append(f) + summaries.append((f, stem)) seen_files.add(f.resolve()) seen_stems.add(stem) meetings = [] # Single-pass: read each file once, extract sort key and data together - for summary_file in summaries: + for summary_file, stem in summaries: try: if summary_file.suffix == '.md': parsed = _parse_meeting_markdown(summary_file) @@ -2815,6 +2837,13 @@ def list_meetings(): "folders": data.get("folders", []), "user_notes": data.get("user_notes"), } + # Whether the ORIGINAL audio is still on disk. keep_recordings + # defaults off, so for most notes it is not -- and everything + # that needs the audio (re-transcribe, speaker samples, any + # future re-diarization) is silently unavailable without it, + # with nothing in the list saying so until you open the note + # and find the action missing. + essential_meeting['has_audio'] = stem in audio_stems meetings.append((sort_key, essential_meeting)) except Exception as e: logger.warning(f"Failed to load {summary_file}: {e}") diff --git a/tests/test_list_meetings_has_audio.py b/tests/test_list_meetings_has_audio.py new file mode 100644 index 00000000..247f862e --- /dev/null +++ b/tests/test_list_meetings_has_audio.py @@ -0,0 +1,126 @@ +"""`has_audio` on list-meetings: does this note's original recording still +exist on disk? + +keep_recordings defaults OFF, so for most notes it does not -- and +everything that depends on the audio (re-transcribe, the speaker panel's +listening samples, any future re-diarization) is quietly unavailable +without it, with nothing in the list saying so until you open the note and +find the action missing. + +Derived from ONE directory listing rather than a per-meeting existence +check: this command is on the app's cold-start path and its docstring +calls it "optimized for fast loading", so the cost must not scale with +library size. +""" + +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from click.testing import CliRunner + +import simple_recorder +from src.config import Config + + +def _run(tmp, cfg=None): + cfg = cfg or Config(config_path=Path(tmp) / "config.json") + with mock.patch("src.config.get_config", return_value=cfg), \ + mock.patch.dict("os.environ", {"STENOAI_USER_DATA_DIR": tmp}): + result = CliRunner().invoke(simple_recorder.list_meetings, []) + return json.loads( + [ln for ln in result.output.splitlines() if ln.strip().startswith("[")][-1] + ) + + +def _write_note(tmp, stem, title): + output_dir = Path(tmp) / "output" + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / f"{stem}_summary.md").write_text( + f'---\ntitle: "{title}"\ndate: "2026-08-02T10:00:00"\n' + f'duration_seconds: 600\nlanguage: "en"\nis_diarised: false\n---\n\n' + "## Summary\n\nbody\n", + encoding="utf-8", + ) + + +def _write_recording(tmp, name): + recordings = Path(tmp) / "recordings" + recordings.mkdir(parents=True, exist_ok=True) + (recordings / name).write_bytes(b"stub") + + +class ListMeetingsHasAudioTests(unittest.TestCase): + def test_flags_only_the_notes_whose_recording_survives(self): + with tempfile.TemporaryDirectory() as tmp: + _write_note(tmp, "kept", "Kept") + _write_note(tmp, "gone", "Gone") + _write_recording(tmp, "kept.webm") + + by_title = {m["session_info"]["name"]: m for m in _run(tmp)} + self.assertTrue(by_title["Kept"]["has_audio"]) + self.assertFalse(by_title["Gone"]["has_audio"]) + + def test_any_recording_format_counts(self): + # The capture pipeline saves whatever the source produced -- .webm + # for system audio, .wav for native captures, .m4a/.mp3 for + # imports. Matching on the stem alone is what makes this correct + # for all of them; an extension whitelist would silently mark + # imported meetings as audio-less. + for extension in ("webm", "wav", "m4a", "mp3"): + with tempfile.TemporaryDirectory() as tmp: + _write_note(tmp, "note", "Note") + _write_recording(tmp, f"note.{extension}") + self.assertTrue( + _run(tmp)[0]["has_audio"], f".{extension} was not recognised", + ) + + def test_a_recordings_dir_that_does_not_exist_is_not_an_error(self): + # Fresh install: nothing has audio, and the list must still render. + with tempfile.TemporaryDirectory() as tmp: + _write_note(tmp, "note", "Note") + self.assertFalse(Path(tmp, "recordings").exists()) + meetings = _run(tmp) + self.assertEqual(len(meetings), 1) + self.assertFalse(meetings[0]["has_audio"]) + + def test_a_similarly_named_recording_does_not_count_for_another_note(self): + # Prefix matching would wrongly flag "note" from "note-2.wav". + with tempfile.TemporaryDirectory() as tmp: + _write_note(tmp, "note", "Note") + _write_recording(tmp, "note-2.wav") + self.assertFalse(_run(tmp)[0]["has_audio"]) + + def test_the_recordings_dir_is_listed_once_not_once_per_meeting(self): + # The guard on the "optimized for fast loading" promise: a + # per-meeting existence check would make cold start scale with + # library size. Counting listdir calls is what actually holds the + # implementation to a single listing. + with tempfile.TemporaryDirectory() as tmp: + for i in range(12): + _write_note(tmp, f"note{i}", f"Note {i}") + _write_recording(tmp, "note3.wav") + + real_listdir = os.listdir + calls = [] + + def counting_listdir(path): + calls.append(str(path)) + return real_listdir(path) + + with mock.patch("os.listdir", side_effect=counting_listdir): + meetings = _run(tmp) + + recordings_dir = str(Path(tmp) / "recordings") + self.assertEqual( + [c for c in calls if c == recordings_dir], [recordings_dir], + "the recordings dir must be listed exactly once for the whole list", + ) + self.assertEqual(sum(1 for m in meetings if m["has_audio"]), 1) + + +if __name__ == '__main__': + unittest.main() From 8c03202a94ce418769064c37827494882559ec88 Mon Sep 17 00:00:00 2001 From: Optic00 Date: Sun, 2 Aug 2026 22:06:01 +0200 Subject: [PATCH 2/2] fix(overview): derive the audio-match stem correctly From a cross-family review (Codex) of the previous commit. A directory in recordings/ named like a recording ("note.wav/") made os.listdir report the note as having audio it does not have. Now files only, via scandir so it stays a single listing. The stem came from `f.stem.replace('_summary', '')`, which strips EVERY occurrence: a note actually named "client_summary.v1" came back as "client.v1". That was harmless while the stem only fed the dedup set, where a wrong-but-consistent key still dedups - matching it against real filenames on disk is what makes it visible. Now strips only a trailing marker, the same rule reprocess and app/main.js's delete path already use. Two further copies of the replace() form live in _enumerate_meeting_stems (used by the speaker backfill). Left alone deliberately: different function, outside this change, and worth its own look rather than being folded into an unrelated PR. --- simple_recorder.py | 26 +++++++++++++++++--- tests/test_list_meetings_has_audio.py | 35 ++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/simple_recorder.py b/simple_recorder.py index 59180349..881d4867 100644 --- a/simple_recorder.py +++ b/simple_recorder.py @@ -2762,12 +2762,32 @@ def list_meetings(): # captures, .m4a/.mp3 imports), so only the stem is meaningful. def _recorded_stems(recordings_dir) -> set: try: - return {Path(name).stem for name in os.listdir(recordings_dir)} + # FILES only. A directory that happens to be named like a + # recording (`recordings/note.wav/`) would otherwise report the + # note as having audio it does not have. scandir keeps this a + # single listing -- the is_file() check comes from the entry + # already returned, not from an extra stat per name. + return { + Path(entry.name).stem + for entry in os.scandir(recordings_dir) + if entry.is_file() + } except OSError: # No recordings dir yet (fresh install) is not an error -- it # just means nothing has audio. return set() + def _summary_stem(summary_path) -> str: + """`_summary.{md,json}` -> ``, stripping only a TRAILING + marker. `str.replace` removes every occurrence, so a note whose own + name contains the marker (`client_summary.v1_summary.md`) came back + as `client.v1`. That was harmless while the stem only fed the + dedup set, where a wrong-but-consistent key still dedups; matching + it against real filenames on disk is what makes it visible. Same + rule reprocess and app/main.js's delete path already use.""" + name = summary_path.stem + return name[:-len('_summary')] if name.endswith('_summary') else name + audio_stems = _recorded_stems(dirs["recordings"]) # Collect summary files from current output dir (JSON preferred over MD) @@ -2777,7 +2797,7 @@ def _recorded_stems(recordings_dir) -> set: # JSON first — if both .json and .md exist, JSON wins (it has structured data) for pattern in ("*_summary.json", "*_summary.md"): for f in output_dir.glob(pattern): - stem = f.stem.replace('_summary', '') + stem = _summary_stem(f) if stem not in seen_stems: summaries.append((f, stem)) seen_files.add(f.resolve()) @@ -2798,7 +2818,7 @@ def _recorded_stems(recordings_dir) -> set: audio_stems |= _recorded_stems(default_output.parent / "recordings") for pattern in ("*_summary.json", "*_summary.md"): for f in default_output.glob(pattern): - stem = f.stem.replace('_summary', '') + stem = _summary_stem(f) if f.resolve() not in seen_files and stem not in seen_stems: summaries.append((f, stem)) seen_files.add(f.resolve()) diff --git a/tests/test_list_meetings_has_audio.py b/tests/test_list_meetings_has_audio.py index 247f862e..a9e8bf9c 100644 --- a/tests/test_list_meetings_has_audio.py +++ b/tests/test_list_meetings_has_audio.py @@ -94,24 +94,47 @@ def test_a_similarly_named_recording_does_not_count_for_another_note(self): _write_recording(tmp, "note-2.wav") self.assertFalse(_run(tmp)[0]["has_audio"]) + def test_a_directory_named_like_a_recording_does_not_count(self): + # os.listdir returns directories too, so a folder called + # "note.wav" would otherwise report audio that does not exist. + with tempfile.TemporaryDirectory() as tmp: + _write_note(tmp, "note", "Note") + (Path(tmp) / "recordings" / "note.wav").mkdir(parents=True) + self.assertFalse(_run(tmp)[0]["has_audio"]) + + def test_a_note_whose_own_name_contains_the_summary_marker(self): + # `str.replace` strips EVERY occurrence, so + # "client_summary.v1_summary.md" used to yield the stem + # "client.v1" -- harmless while the stem only fed the dedup set, + # wrong the moment it is matched against real filenames. + with tempfile.TemporaryDirectory() as tmp: + _write_note(tmp, "client_summary.v1", "Client") + _write_recording(tmp, "client_summary.v1.wav") + meetings = _run(tmp) + self.assertEqual(len(meetings), 1) + self.assertTrue( + meetings[0]["has_audio"], + "the stem must keep everything but a TRAILING _summary", + ) + def test_the_recordings_dir_is_listed_once_not_once_per_meeting(self): # The guard on the "optimized for fast loading" promise: a # per-meeting existence check would make cold start scale with - # library size. Counting listdir calls is what actually holds the - # implementation to a single listing. + # library size. Counting the directory listings is what actually + # holds the implementation to a single one. with tempfile.TemporaryDirectory() as tmp: for i in range(12): _write_note(tmp, f"note{i}", f"Note {i}") _write_recording(tmp, "note3.wav") - real_listdir = os.listdir + real_scandir = os.scandir calls = [] - def counting_listdir(path): + def counting_scandir(path): calls.append(str(path)) - return real_listdir(path) + return real_scandir(path) - with mock.patch("os.listdir", side_effect=counting_listdir): + with mock.patch("os.scandir", side_effect=counting_scandir): meetings = _run(tmp) recordings_dir = str(Path(tmp) / "recordings")