docs / build with the engine

Speaker transcripts & notes

Who said what, cleaned one turn at a time.

Cleanup Engine is an unreleased developer preview. These guides are generated from the SDK repository, which is private for now; file paths are shown as text. Request access.

Two optional aspects for a host that records more than one voice: a speaker-attributed transcript that goes through the ordinary cleaner one turn at a time, and deterministic notes over the result. Neither changes the dictation path. Nothing here runs unless a host calls it.

AspectModuleWhat it is
TranscriptCleanupCore (PomvoxCleanup)SpeakerLabel, TimeSpan, SpeakerSegment, TimedWord, SpeakerTurn, Transcript, Transcript.attribute, TranscriptResult
Transcript cleaningPomvoxCleanupCleaning.clean(_ transcript:options:), TranscriptCleanOptions
NotesPomvoxNotes (implementation in CleanupNotes)Notes.extract, NoteItem, Participant, NoteHeuristics, NotesRenderer.markdown
Diarization contractPomvoxDiarization (implementation in CleanupDiarization)Diarizing, DiarizationRequest, SpeakerSegmentation, DiarizerPackLoader
Local diarizerRuntime/Diarization (product PomvoxDiarizationFluid, optional)FluidDiarizer over FluidAudio 0.15.5's offline pyannote community-1 pipeline
Cloud diarizerPomvoxCleanupCloudCloudDiarizer: explicit endpoint and credentials, wire protocol in docs/cloud.md
Model notesPomvoxNotes + PomvoxCleanupCloudModelNotes, CloudNotes: cited notes from an explicit endpoint, validated against the transcript

The SDK still owns no microphone, STT or UI. The host captures audio, runs its speech recognizer with word timings, and obtains speaker segments from a diarizer. The engine takes it from there:

import PomvoxCleanup
import PomvoxNotes

// 1. Host side: recognized words with timings, and who-spoke-when segments.
let words: [TimedWord] = sttTokens.map { TimedWord($0.text, span: TimeSpan(start: $0.start, end: $0.end)) }
let segments: [SpeakerSegment] = diarization.segments

// 2. Pure alignment: words become speaker turns. No model, no state.
let transcript = Transcript.attribute(words: words, segments: segments)

// 3. One ordinary cleanup request per turn, through the opened cleaner.
let cleaned = try await cleaner.clean(transcript, options: TranscriptCleanOptions(
    vocabulary: selectedTerms, transforms: [.spokenLayout],
    turnBudget: .measuredM1, overallDeadline: .seconds(120)))

// 4. Deterministic notes, rendered as Markdown.
let notes = Notes.extract(from: cleaned)
let markdown = NotesRenderer.markdown(notes, options: .init(speakerNames: [SpeakerLabel("S1"): "Abhi"]))

Attribution

Transcript.attribute(words:segments:options:) assigns each word to the segment it overlaps most (ties go to the earlier segment). A word nothing overlaps goes to the nearest segment within nearestTolerance (1 s), else to the previous word's speaker; leading unclaimed words take the first speaker that follows. Consecutive same-speaker words form one turn; a silence of at least splitGap (2 s) starts a new turn for the same speaker. Words keep their input order and text, empty texts are dropped, and with no segments the result is one unattributed turn.

The property the tests pin: the turn texts, split on the joining space, are exactly the nonempty input words in order, for any timings and any segments. Invalid segments (non-finite or reversed spans) are ignored, never fatal.

Cleaning a transcript

clean(_ transcript:) is an extension on Cleaning, so it works on the local Cleaner, on CloudCleaner, and on any test double. Each turn is one CleanupRequest with the options' vocabulary and transforms; each goes through the same admission, guards, deadline and fallback as a dictation. The result has one TurnResult per input turn, in order, with the same speaker and span. The engine never merges, splits, reorders or relabels turns.

Why one request per turn: the frozen model was trained on single utterances. A multi-speaker prompt with speaker prefixes would change the prompt bytes and put the model off-distribution; the length and list guards would misfire on it. Per-turn requests keep parity with the shipped engine exactly.

Budgets. Without turnBudget each turn gets the request's default budget (2 + 0.012·chars seconds, 5 to 60 s). With a CleanupBudgetPolicy, each turn gets deadline(for:base:); a turn the policy calls hopeless is skipped. An overallDeadline caps the whole call: once spent, remaining turns are returned as .fallback(.timedOut) without inference. Per-turn deadlines are also clamped to what remains of the overall budget.

After a turn times out, its worker can keep running until the model returns. The cleaner is quarantined meanwhile and falls back on anything sent to it at once. So before each turn (and each segment) the loop waits for a quarantined cleaner to recover, at most for that turn's own budget and within the overall deadline. It never starts a second worker. A turn whose wait runs out keeps its exact bytes with the warning cleaner-quarantined. Cleaning.availability (default .ready) is how the loop sees this.

Turns the model never sees carry Provenance.skipped (route skipped) and one warning naming why:

WarningStatusCause
transcript-budget-exhausted.fallback(.timedOut)the overall deadline was spent before this turn
turn-exceeds-admission-limit.fallback(.unavailable)the turn's text is over CleanupRequest.maxTextBytes (16,384 bytes); without opt-in segmentation it is not split or truncated
turn-budget-hopeless.fallback(.timedOut)the budget policy's ceiling cannot fit the estimated work
cleaner-quarantined.fallback(.unavailable)an earlier timed-out request was still running when this turn's wait ran out

Every such turn is its exact bytes with zero edits. Cancellation throws and delivers nothing, as for a single request. A transcript may have at most Transcript.maxTurns (2,048) turns; longer input is refused before any inference, as are invalid spans, invalid vocabulary and a non-positive overall deadline.

This is a batch call on the same one-request worker as live dictation. Run it when no dictation is waiting on the cleaner, or accept that dictations queue behind it (the queue holds two; the third is busy). Timing is proportional to the number and length of turns; a figure for a given transcript comes from TranscriptResult.elapsedMS and the per-turn timings, not from this document.

Long turns: opt-in segmentation

A monologue can produce one turn longer than a request admits (16,384 bytes). A shorter turn can still be longer than the model can return: the MLX runtime caps output at max(64, min(2 × input tokens, 1,024)) tokens, so a long turn that is admitted can run until the cap and then fall back (#13). Setting TranscriptCleanOptions.segmentation splits such turns into ordinary requests:

let options = TranscriptCleanOptions(turnBudget: .measuredM1, segmentation: TurnSegmentation())

Incremental cleaning and resuming

cleanIncrementally(_:options:resuming:) runs the same loop as clean(_:options:), but yields each turn's result as a CleanedTurn (index and TurnResult) as soon as it is final. Because it is the same loop, the yielded results are the ones the batch call returns. A host can show turns as they finish and save them as it goes.

let identity = try await cleaner.checkpointIdentity(for: transcript, options: options)
var accepted = store.loadAcceptedTurns(for: identity) ?? []   // the host's persistence
let resume = accepted.isEmpty ? nil : TranscriptCheckpoint(identity: identity, accepted: accepted)
for try await cleaned in cleaner.cleanIncrementally(transcript, options: options, resuming: resume) {
    accepted.append(cleaned.result)                             // cleaned.index == accepted.count - 1
    store.save(TranscriptCheckpoint(identity: identity, accepted: accepted))
}

A checkpoint holds the results accepted for turns 0..<n and the identity they were made under. When resuming, the engine cleans and yields only turns n.... Before cleaning anything, it refuses a checkpoint unless every check passes:

CheckRefusal
Same transcript: turn count and a SHA-256 over every turn's speaker, UTF-8 text bytes and span. A canonically equivalent but differently encoded text is a different transcript.invalidRequest
Same output-affecting options: vocabulary, transforms, turnBudget, turnBaseinvalidRequest
Same pack: id, version and, for a local pack, manifest digestincompatible
Each accepted turn matches its transcript turn byte for byte. A fallback or unchanged result is the exact input with no edits, a cleaned result's edits rebuild its text, and a result's provenance names the same pack (or is skipped on a fallback).invalidRequest

overallDeadline and progress are per call and not part of the identity. A resumed call's ceiling covers only the turns it cleans, and progress counts the accepted turns as done. A cleaner whose packIdentity is nil (a custom Cleaning that does not implement it) can stream but cannot resume: checkpointIdentity throws incompatible. Cleaner reports its manifest id, version and digest. CloudCleaner reports the remote pack's id and version.

Cancelling the consuming task, or leaving the loop, cancels the turn in flight. That turn is never yielded, and turns already yielded stay final. The engine stores nothing: where and when checkpoints are saved is the host's decision.

Notes

Notes.extract(from:) runs no model. It produces:

Classification is deliberately simple and visible: a list line (- item, 1. item) is a list item; a sentence ending in ? is a question; a sentence matching an action cue (I'll, we need to, follow up, by Friday, deadline, ...) is an action item; one matching a decision cue (decided, agreed, let's go with, approved, ...) is a decision; action wins over decision. Sentences are cut at ., !, ? followed by whitespace and at line breaks, without changing a byte. These are candidates for a person to keep or discard, not an understanding of the meeting. Hosts can pass their own cue lists through NoteHeuristics(actionCues:decisionCues:version:); change the version string with them.

NotesRenderer.markdown writes Participants, the candidate sections that are nonempty, and a Transcript section that carries every cleaned turn verbatim (optionally with m:ss spans). Text is not escaped or reflowed. Hosts that want their own layout read the Notes value directly; everything the renderer prints is in it.

A model-written summary is a separate, explicit layer: CloudNotes (in PomvoxCleanupCloud) sends the cleaned transcript to an endpoint the host configured with credentials and returns ModelNotes, a summary and categorized notes in which every entry cites the turns it rests on. The client refuses a response in which any note has no citation, cites a turn that does not exist, or is not one bounded line. NotesRenderer.markdown(_:model:) renders the model's notes first, marked "(model)" with their citations, then the quoted candidates, marked "(quoted)", then the transcript. If the call fails for any reason the host still has the deterministic Notes; nothing depended on the model. See cloud.

Diarization

Who spoke when is a separate aspect with its own contract and an optional runtime, because it needs audio and a model. The root package carries only the contract (PomvoxDiarization, no third-party dependencies):

The local runtime

Runtime/Diarization is a separate Swift package, like Runtime/MLX, so contract users do not acquire Core ML model dependencies. It depends on FluidAudio at exactly 0.15.5 (inside the range the Pomvox app allows) and runs that release's offline pipeline: pyannote community-1 segmentation, WeSpeaker embeddings, VBx clustering, on the Neural Engine. The artifacts are Fluid Inference's Core ML conversions, CC-BY-4.0; see THIRD_PARTY_NOTICES.md for the attribution that travels with a distribution. The repository's older online pipeline and its bundles are not used: their license statement excludes them.

import PomvoxDiarizationFluid // re-exports PomvoxDiarization and the transcript types

let pack = try DiarizerPackLoader.validate(directory: installedDiarizerPack)
let diarizer = try await FluidDiarizer.open(pack: pack)      // never downloads
let who = try await diarizer.diarize(DiarizationRequest(samples: samples16k, speakers: 2...4))
let transcript = Transcript.attribute(words: words, segments: who.segments)
try await diarizer.closeAndWait() // returns once the models are released

Rules the runtime keeps:

Validated on macOS 15 (Apple M1, 16 GiB, Xcode 26.3). FluidAudio documents a Core ML BNNS crash on macOS 14 for this pipeline, fixed in macOS 15; the SDK's deployment floor is unchanged, but the diarizer is not validated below macOS 15.

On iOS the package builds for iOS 17 (FluidAudio 0.15.5's own floor), and an iPhone app links it with no deployment-target override. It has not run on a phone here: Neural Engine load time, memory, thermal state and background behaviour are unmeasured. The same applies to cleanup through Runtime/MLX, whose pinned pack is sized for a Mac (#30).

Testing it

The runtime's tests skip unless POMVOX_TEST_DIARIZER_PACK names an installed pack. The fixture is built at test time from macOS speech synthesis: two voices (Samantha, Daniel) alternate six utterances with 0.8 s gaps. The test asserts two speakers, one consistent label per voice, at least 60 % of each utterance covered by its voice's label, and that Transcript.attribute recovers the six scripted turns from the segments. Set POMVOX_DIARIZER_EVIDENCE to a path to record timings and coverage as JSON.

export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
python3 scripts/prepare-diarizer-pack.py /path/to/speaker-diarization-coreml-snapshot .local/pyannote-community1-coreml
swift build --package-path Runtime/Diarization --scratch-path /tmp/pomvox-diarizer --build-tests
POMVOX_TEST_DIARIZER_PACK=$PWD/.local/pyannote-community1-coreml \
POMVOX_DIARIZER_EVIDENCE=/tmp/diarizer-evidence.json \
  sandbox-exec -p '(version 1)(allow default)(deny network*)' \
  xcrun xctest /tmp/pomvox-diarizer/debug/PomvoxDiarizationFluidPackageTests.xctest

The build runs outside the sandbox (SwiftPM needs the network to resolve and cannot compile a manifest under deny network*); the test bundle runs inside it.

Measured on 2026-10-11 on an Apple M1 (MacBookPro17,1, 16 GiB, macOS 15.7.4, Xcode 26.3, Debug build), recorded in docs/evidence/diarizer-synthetic-2026-10-11.json (evidence/diarizer-synthetic-2026-10-11.json):

MeasureValue
Fixture36.7 s of synthetic speech, 6 utterances, 2 voices
Model load (preparationMS)54 ms (bundles already compiled by Core ML on an earlier run)
Request total752 ms, of which segmentation 381 ms, embedding 719 ms (overlapping stages), clustering 6 ms
Outcome2 speakers, 6 segments; every utterance ≥ 99 % covered by its voice's label

One run, one machine, synthetic voices: a plumbing and lifecycle result, not a quality measurement on real meetings. That needs a consented corpus and is a stated gap.

What more voices showed

ScenarioTests runs seven synthesized recordings through the real pipeline and records every outcome in docs/evidence/diarizer-scenarios-2026-10-11.json (evidence/diarizer-scenarios-2026-10-11.json) (same machine and build as above; voices Samantha, Daniel, Karen, Moira and Rishi, all from one speech-synthesis engine). The test asserts only what the pipeline is expected to hold on such voices; the rest is reported, not hidden.

ScenarioAudioSpeakers found / voicesConfusedMissedRequest
two voices, 6 utterances36.7 s2 / 200.06 s872 ms
two voices, 30 utterances183.7 s2 / 200.28 s2.58 s
two voices with added noise73.5 s2 / 201.69 s1.31 s
three voices (two female, one male)35.0 s2 / 300.10 s634 ms
four voices (two female, two male), short interjections37.4 s2 / 401.00 s684 ms
the same, minSegmentSeconds: 0.537.4 s2 / 400.19 s653 ms
the same, speakers: 4...437.4 s2 / 400.19 s670 ms

Three findings a host should know:

The defaults stay at the community-1 values the pack was validated with (threshold 0.6, minimum segment 1.0 s). Both are FluidDiarizerConfig knobs. A host that knows the attendee count passes speakers:; a host that cares about interjections lowers minSegmentSeconds. computeUnits: .all was fastest on the M1; .cpuAndNeuralEngine was 15 % slower on the long recording and .cpuOnly doubled the embedding stage. Overlapping speech is given to one speaker: a 1.5 s overlap counted as 1.5 s of confusion.

Host stand-in example

swift run --package-path Runtime/Diarization speaker-notes-example meeting.wav --diarizer-pack .local/pyannote-community1-coreml diarizes a file, runs Parakeet through FluidAudio in the example only to get word timings, aligns, and prints deterministic notes over the recognized (not cleaned) transcript. The cleanup step is left out because the MLX runtime needs an Xcode build to package Metal shaders; the cleaned path is covered by the root package tests and the Xcode consumer.

Limits and non-goals