Description

whisper-cli produces no speaker labels on its own. Add a WhisperX/pyannote acoustic-diarization step to the zdots transcription job so its output carries real speaker turns with start/end timestamps. Define the diarized transcript schema (speaker segments + timestamps) and extend bin/validate_data + specs. These acoustic turns become the ground truth the audit skill’s heuristic speaker_map is fused onto in the next task.

Acceptance Criteria

Implementation Plan

Diarization approach — whisper.cpp (Metal) ASR + pyannote 3.1 diarization, merged by timestamp

Keep the existing whisper.cpp path (already Metal-accelerated at ~4x realtime per the transcription-stats retrospective, resumable, fingerprint-idempotent) as the ASR engine. Do NOT swap in WhisperX/faster-whisper as the transcriber: CTranslate2 has no real Metal/MPS backend on Apple Silicon and would run CPU int8, giving up the fast Metal path already in production.

Add a second, additive pass to the zdots transcription job:

Diarized transcript schema (additive, non-breaking)

Text-carrying turns stay byte-identical to today ({speaker, text} with M1/S1/S2). All timestamps live in a new top-level block, so the 196 existing structured files and the ArchiveState structured/content detection are untouched:

diarization:
  engine: pyannote-3.1
  model: pyannote/speaker-diarization-3.1
  asr: whisper.cpp
  generated_at: <iso8601>
  audio_duration: 865.6
  num_speakers_hint: 2
  segments:
    - speaker: SPEAKER_00   # acoustic label
      start: 0.0
      end: 12.4
      text: "..."           # merged whisper text (optional)

TASK-247 later adds acoustic_id: SPEAKER_00 onto each speaker_map entry to bind M1/S1/S2 → acoustic labels. Putting all timestamps here (not on turns) is the compat guarantee for 247: audit:ingest overwrites only speaker_map/turns/insights/youtube, so this block survives verbatim.

Files to touch

Specs

Verification

Run one interview end-to-end through the modified job (a 2-guest one, e.g. Ethan Gunderson/Ryan Briones, to exercise the num_speakers hint); confirm the .yml gains a diarization block with sane segment timestamps covering the audio duration, turns unchanged, bin/validate_data green, and the new specs pass. bin/pipeline ci.

Coordination

Depends on TASK-242 (enrichment routed onto the zdots queue). Produces the acoustic ground truth that TASK-247 fuses into the audit skill’s heuristic speaker_map; do NOT do any LLM speaker labeling here (that is 247). This task only adds the acoustic block + schema + validation.

Comments

author: claude created: 2026-07-04 14:48 — Ownership boundary (per target architecture): the acoustic-diarization capability (pyannote pass) is OWNED BY zdots as part of its transcription service job — that code lives in the zdots repo. This repo’s side of TASK-244 shrinks to: define the additive diarization schema block that receives zdots’ output, and the validator/spec for it. Same pattern as embedding (TASK-254): zdots computes, this repo consumes. Cross-references TASK-242 (capability boundary). —

author: claude created: 2026-07-05 02:14 — CORRECTION after validating against real zdots code (scaffold: scratchpad/diarization-scaffold.md): the plan’s premise ‘pyannote is not installed / add a pyannote pass’ is WRONG. Diarization is ~80% built already — bin/diarize is a working uv/PEP-723 pyannote/speaker-diarization-3.1 script (MPS→CUDA→CPU, segment-overlap dominant-speaker merge), yt-transcribe already has a wired --diarize flag, and HUGGINGFACE_TOKEN is keychain-loaded in env.sh. ASR stays whisper.cpp/Metal (matches plan).

The REAL remaining work is wiring + output shape, in two mechanisms (one root cause = wav lifetime):

  1. Long/chunked path: cannot just pass –diarize (window mode exits before the diarize block, per-chunk pyannote labels are incompatible, stitch drops the JSON). Add a whole-file diarized stage to ingest_media.rb’s PIPELINE (the code reserves this hook; gives AC#3 resumability via pipeline_runs).
  2. Short/interactive paths (transcription.rb, ingest_media#transcribe_raw): diarize inline via –diarize, because the recipe deletes the 16k wav right after whisper (line 206).

Output-shape gaps: bin/diarize emits mutated whisper-JSON to <id>.speaker.json; the tenant contract needs a self-contained diarization.json (engine/model/asr/audio_duration/num_speakers_hint/segments[]). Add a --num-speakers arg — the interviewees+1 hint is SITE-side data, so it must be caller-passed. Sidecar location differs by entry point (recipe → ~/Downloads/transcripts//; queue → ~/.local/state/zdots/ingest-sources//, which the site staging glob doesn't cover yet — coordination needed). Unverified risk: first `uv run` pulls multi-GB torch+pyannote and needs a torch wheel for uv's resolved Python (mise pins 3.14.5). This is a zdots-repo change (companion to Z-199 pattern).

author: claude created: 2026-07-05 17:00 — zdots-side wiring landed (STAGED, uncommitted in the zdots repo, awaiting review). Three files: (1) bin/diarize — whisper_json now optional; added --sidecar (emits a self-contained diarization.json: engine/model/asr/audio_duration/num_speakers_hint/segments[]) and --num-speakers (used as a min/max bracket, not exact); legacy merge mode preserved. (2) lib/zdots/jobs/ingest_media.rbdiarized PIPELINE stage (ordered LAST, after distilled), run_diarized is opt-in via ZDOTS_DIARIZE=1 and rescue-wrapped so an unverified pyannote/torch env can’t dead-letter the ingest; diarize_audio reuses prep_audio for the retained 16k wav and writes diarization.json beside it. (3) regenerated docs/generated/ingest-pipeline.mmd.

Producer contract for the SITE consumer: sidecar is diarization.json written to the retention root ~/.local/state/zdots/ingest-sources/<mid>/<...>/ (NOT ~/Downloads) — the site’s stage_completed_transcripts.rb glob must be extended to cover it (or the job copies to Downloads). num_speakers is caller-passed via job payload num_speakers (site: interviewees + 1).

VERIFIED: ruby -c + py_compile clean; the drift-test assertions (mmd matches PIPELINE; every stage bound) PASS under zdots-ruby. NOT verified: the pyannote path is not exercised — gated off by default; first uv run must resolve a torch wheel for the mise-pinned Python 3.14.5 (flagged risk). Do one live diarize run, then flip ZDOTS_DIARIZE=1. Short-path inline --diarize (recipe + transcription.rb) remains a separate follow-up.

Definition of Done