Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions app/e2e-mock-ipc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] };
}
Expand Down
25 changes: 23 additions & 2 deletions app/renderer/src/components/home/PreviousRow.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -116,7 +116,28 @@ export function PreviousRow({ meeting, folderName }: PreviousRowProps) {
style={{ color: 'var(--fg-2)' }}
>
<span>{isSynthetic ? 'Now' : (when ?? '')}</span>
{duration && <span className="text-[11.5px] opacity-70">{duration}</span>}
{/* 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)) && (
<span className="flex items-center gap-1 text-[11.5px] opacity-70">
{!isSynthetic && meeting.has_audio && (
<AudioLines
className="size-3"
aria-label="Original audio still available"
data-testid="previous-row-has-audio"
>
<title>Original audio still available</title>
</AudioLines>
)}
{duration}
</span>
)}
</div>
</div>
);
Expand Down
6 changes: 6 additions & 0 deletions app/renderer/src/lib/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
47 changes: 47 additions & 0 deletions e2e/specs/overview-audio-indicator.t1.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
59 changes: 54 additions & 5 deletions simple_recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import asyncio
import logging
import json
import os
import re
import sys
import time
Expand Down Expand Up @@ -2751,16 +2752,54 @@ 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:
# 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:
"""`<stem>_summary.{md,json}` -> `<stem>`, 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)
seen_files = set()
seen_stems = set()
summaries = []
# 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)
summaries.append((f, stem))
seen_files.add(f.resolve())
seen_stems.add(stem)

Expand All @@ -2774,18 +2813,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', '')
stem = _summary_stem(f)
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)
Expand Down Expand Up @@ -2815,6 +2857,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}")
Expand Down
149 changes: 149 additions & 0 deletions tests/test_list_meetings_has_audio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""`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_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 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_scandir = os.scandir
calls = []

def counting_scandir(path):
calls.append(str(path))
return real_scandir(path)

with mock.patch("os.scandir", side_effect=counting_scandir):
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()
Loading