Skip to content

Zerm 2.8.0 — meeting recording, speaker identification, dashboard scroll fix - #291

Merged
thefourcraft merged 7 commits into
Productionfrom
feat/meeting-recording
Jul 31, 2026
Merged

Zerm 2.8.0 — meeting recording, speaker identification, dashboard scroll fix#291
thefourcraft merged 7 commits into
Productionfrom
feat/meeting-recording

Conversation

@thefourcraft

Copy link
Copy Markdown
Member

Adds a Recording tab that captures whole conversations — in the room, or a call on Teams, Meet, Signal or anything else — alongside the short push-to-talk dictation Zerm already does.

Closes #289. Refs #288, #290.

Recording

  • Two separate tracks. Microphone and system output are captured independently rather than mixed. The local room and the remote participants are therefore already separated on disk, which gives speaker attribution for free and lets either side be re-transcribed alone. System audio uses a Core Audio process tap — no driver, no kernel extension, no screen-recording permission.
  • Live transcription over overlapping windows rather than a streaming socket. Over a two-hour meeting sockets drop, cloud streaming meters by the minute, and a lost connection loses the tail; a failed window costs only itself, and it works with every model Zerm supports.
  • Speaker identification via FluidAudio LS-EEND, fed the microphone track alone. Lines are attributed by overlap, since transcription windows and diarisation turns are cut on different boundaries.
  • Summaries with action items and chapters, folded for long transcripts so a two-hour meeting fits the same small-context local model as a short one. Local Ollama by default.
  • Auto-detection offers to record when a call app opens. Matches browser calls on the join path, not the bare domain.
  • Import of wav, mp3, m4a, mp4, flac, ogg, aac, caf and aiff, converted to the recorder format so imported and captured recordings are indistinguishable downstream.
  • List–detail UI with transcript-synced playback; clicking a line seeks to it.
  • Crash recovery — every transcript line is journalled as it lands, so a crash costs the window in flight rather than the meeting.

Also

Dashboard scroll performance (#288) and two upstream VoiceInk ports (#290) — symlink-following app discovery, and Apple's native model being a dead end when locale assets were missing.

Seven defects fixed, all of which failed silently

  1. Transcript falling progressively behind on long meetings
  2. Chapters wrongly discarded as hallucinations
  3. System capture dying on a mid-call output-device change
  4. didAttemptLoad reporting false after teardown
  5. Tests writing into the user's real recordings library
  6. Unsafe queue.sync state handling in the diarizer
  7. Diarisation fed at 16 kHz to an 8 kHz model — speakers were never separated, and every line would have read "Speaker 1"

None would have been caught by the build.

Verification

74/74 tests passing, warning-free build. Capture, conversion, disk writing, session lifecycle and two-speaker separation all exercised against real audio.

Not yet validated: a real meeting between two people. Speaker separation is proven on two synthetic voices, which are easier to tell apart than two humans sharing one microphone in a room with crosstalk.

The release workflow's new drift check caught this on its first run: BUILDING.md
gained the post-release changelog instructions, but docs/building.html is
generated from it and was never rebuilt, so the published page did not show them.

Exactly the failure mode the check exists for.
Chart selection follows the pointer, so scrolling with the cursor over a
chart rewrote the selection on every event. Each write rebuilt both charts,
and the nearest-bucket search was read from inside the mark builders, making
it O(n) per plotted element.

Selection now snaps to a bucket and is written only when the bucket actually
changes, so pointer movement inside one bar costs nothing. Everything derived
from the buckets — rate points, peak, x-domain, the bucket index — is
computed once in UsageChartSeries rather than on every body pass, and the
nearest-bucket lookup is a binary search.

Card materials are replaced with an opaque surface: eight blur passes inside
one scroll view were redrawn every frame for a result that reads almost
identically over the window background. The hero shadow drops from 30pt to
12pt for the same reason. The data table is lazy, and reloads triggered by
usage notifications are coalesced.

Refs #288
App discovery resolved symlinks and recursed into whatever they pointed at,
guarded only by a depth limit. A link into a large or remote tree turned
opening the Power Mode app picker into a long filesystem walk, and the same
bundle could be collected several times under different paths. Replaced with
a non-following enumeration that dedupes by standardised path.

Selecting Apple's native model with a locale whose assets were not downloaded
was a permanent dead end: the service threw an error naming the missing
language, and nothing anywhere in the app could fetch it. It now requests the
installation and re-checks installedLocales, surfacing the old error only if
the download genuinely fails.

Refs #290
A Recording tab that captures a whole conversation — in the room, or a call
on Teams, Meet, Signal or anything else — as opposed to the short push-to-talk
dictation Zerm does today.

The microphone and the system output are captured as separate tracks. Mixing
them at capture time would be cheaper, but two tracks mean the local room and
the remote participants are already separated on disk, which is speaker
attribution without diarisation and lets either side be re-transcribed alone.
System audio uses a Core Audio process tap: no driver to install, no kernel
extension, and no screen-recording permission.

Transcription cuts the audio into overlapping windows and transcribes each as
a file rather than holding a streaming socket open. Over a two-hour meeting
that matters — sockets drop, cloud streaming meters by the minute, and a
dropped connection loses the tail. A window that fails costs only itself, and
the approach works with every model Zerm supports.

Speaker identification uses FluidAudio's LS-EEND diarizer, already a
dependency, fed the microphone track alone. Transcript lines are attributed
by overlap rather than index, since windows and turns are cut on different
boundaries.

Summaries fold long transcripts — chunk summaries, then a summary of those —
so a two-hour meeting works on the same small-context local model as a short
one. Routed to local Ollama by default: a meeting transcript is the most
sensitive text Zerm handles.

Recordings are filesystem-backed rather than another store: a recording is its
folder, so nothing can drift out of sync and moving or deleting in Finder does
what the user expects. Every transcript line is journalled as it lands, so a
crash costs the window in flight rather than the whole meeting.

Also handles the failure modes that are silent otherwise: an unauthorised
process tap returns success and delivers pure silence, so an all-zero capture
is detected and surfaced; the default output device changing mid-call is
followed by rebuilding the aggregate, which previously left the far side
recording nothing for the rest of the meeting.

Refs #289
Six alternating turns from two different system voices, fed through the live
chunking path. The diarizer produces turns but labels both voices as the same
speaker, so every transcript line would be attributed to "Speaker 1".

Committed failing on purpose. Softening it would hide the only question that
matters about speaker identification: whether it actually separates speakers.
Whether this is a limitation of synthetic voices or of the configuration is
not yet known — two real voices will settle it.

Refs #289
The LS-EEND model runs at 8 kHz. Everything else in Zerm — the tap writer,
the transcriber, the microphone — is 16 kHz, and addAudio(_:) means "these
samples are already at the model rate". So 16 kHz audio was being fed to an
8 kHz model with no resampling.

Nothing failed. The model still produced turns, so the feature looked wired
and working, but two clearly different voices decoded as a single speaker and
every transcript line would have been attributed to "Speaker 1".

Declaring sourceSampleRate lets the library resample, and the two-speaker test
that was committed failing now passes.

The capacity diagnostic that found this is kept as a regression guard on the
sample-rate contract, since the same mistake would degrade silently again.

Refs #289
Meeting recording, speaker identification and summaries, plus the dashboard
scroll fix and two upstream ports. A new tab and a new capture engine is a
feature release, not a patch.
@thefourcraft
thefourcraft merged commit c8b4df5 into Production Jul 31, 2026
6 of 7 checks passed
@thefourcraft
thefourcraft deleted the feat/meeting-recording branch July 31, 2026 22:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Meeting recording: long-duration capture with live transcription

1 participant