From 5417299d583360573565bd09ed0cf83fe19a98af Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Thu, 28 May 2026 14:19:34 +0000 Subject: [PATCH] meetings: phone call → transcript → whistlepop DSL → arxiv PDF Full pipeline from slab menubar "Start call" to LaTeX deliverable. Whistles emitted mid-conversation act as in-band directives — single whistle = section break, paired whistles bracket a spoken verb (highlight / decision / action / section / quote / skip / note), stripped from the transcript and applied as callouts to the surrounding context. - slab/bin/slab-call-record: ffmpeg avfoundation → ~/Documents/Shelf/meetings/ (prefers aggregate device for system audio, falls back to mic-only) - slab menubar: 📞 Start call / ◉ Recording call submenu with PID tracking - meetings/cli.mjs: ingest/transcribe/detect/parse/build/run, mtime-cached - meetings/detect-whistlepops.mjs: STFT + Hann + radix-2 FFT, peakStrength + harmonicRatio + bandConcentration features; k-NN over corpus when present, duration heuristic otherwise - meetings/parse-directives.mjs: pair-matching within 8s window; verb tokenization; anchor resolution excludes whistlepop-internal segments so "decision" pulls the real prev sentence, not the verb word - meetings/template/: Tufte layout, ywft-processing title, callout vocabulary for every directive type, exercised by sample-jeffrey-x-scott - wave-wizard/samples/whistlepops/: 42-prompt corpus spec for fitting the detector against jeffrey's actual whistle acoustics Co-Authored-By: Claude Opus 4.7 (1M context) --- meetings/.gitignore | 10 ++++++++++ meetings/README.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ meetings/cli.mjs | 520 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ meetings/detect-whistlepops.mjs | 397 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ meetings/dsl.md | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ meetings/parse-directives.mjs | 263 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ meetings/sample-jeffrey-x-scott/ac-meeting.sty | 1 + meetings/sample-jeffrey-x-scott/meeting.tex | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ meetings/template/ac-meeting.sty | 289 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ meetings/template/meeting.tex.tmpl | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/bin/slab-call-record | 169 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift | 34 ++++++++++++++++++++++++++++++++++ slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ slab/menubar-swift/Sources/SlabMenubar/Paths.swift | 9 +++++++++ slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift | 30 ++++++++++++++++++++++++++++++ wave-wizard/samples/whistlepops/README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ wave-wizard/samples/whistlepops/spec.json | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 17 file(s) changed, 2243 insertion(s)(+), 0 deletion(s)(-) diff --git a/meetings/.gitignore b/meetings/.gitignore new file mode 100644 --- /dev/null +++ b/meetings/.gitignore @@ -0,0 +1,10 @@ +# Live meeting dirs are timestamped YYYY-MM-DD-HHMM-* — local-only by default. +# To track a specific meeting (e.g. an archived reference), force-add it +# with `git add -f meetings/`. +/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[0-9][0-9][0-9][0-9]-*/ + +# LaTeX build artifacts under any sample / fixture dir. +*.aux +*.log +*.out +*.pdf diff --git a/meetings/README.md b/meetings/README.md new file mode 100644 --- /dev/null +++ b/meetings/README.md @@ -0,0 +1,57 @@ +# meetings + +Phone / Mac call recordings → WhisperX transcripts → whistlepop-detected +section breaks and directives → arxiv-style LaTeX PDF. + +## Pipeline + +``` +slab "Start Call" + → slab-recorder (mic + system audio aggregate → WAV in ~/Documents/Shelf/meetings/) +slab "Stop Call" + → meetings/cli.mjs ingest → meetings//audio.wav + → meetings/cli.mjs transcribe → transcript.json (WhisperX) + → meetings/cli.mjs detect → whistlepops.json (audio events) + → meetings/cli.mjs parse → directives.json (DSL applied) + → meetings/cli.mjs build → meeting.pdf +``` + +Each step is incrementally cached by mtime, matching `papers/cli.mjs`. + +## Whistlepop DSL + +See `dsl.md` for the grammar. TL;DR — a single whistle is punctuation +(section break); two whistles bracket a spoken directive ("highlight", +"decision", "section budget", "action alex", …) that the renderer applies +to the surrounding conversation context. + +## Status + +- [x] Skeleton CLI (`cli.mjs`) with `new/ingest/list/transcribe/detect/parse/build/run/open` +- [x] Detector stub (`detect-whistlepops.mjs`) +- [x] DSL spec (`dsl.md`) +- [x] Whistlepop training corpus spec at `wave-wizard/samples/whistlepops/` +- [x] LaTeX template (`template/meeting.tex.tmpl` + `template/ac-meeting.sty`) +- [x] Build pipeline end-to-end (transcript+directives JSON → PDF), verified with synthetic data +- [x] Recorder (`slab/bin/slab-call-record`) + slab "Start Call" menubar item +- [ ] Capture training takes (jeffrey runs `swift run WaveWizard samples/whistlepops/spec.json`) +- [ ] Real `detect-whistlepops.mjs` (k-NN against the corpus) +- [ ] `transcribe` step (shell out to WhisperX, populate `transcript.json`) +- [ ] `parse-directives.mjs` (whistlepops + transcript → directives) + +## Sample / reference output + +`sample-jeffrey-x-scott/` — hand-crafted reference meeting that exercises +every directive type in the .sty (title block, key ideas card, speaker +turns, highlight, decision, action, quote, freeform, skipped, margin +note, whistle break, colophon). Compile with `xelatex meeting.tex` to +regenerate the reference PDF. Named without a timestamp prefix so +`cli.mjs list` skips it. + +## Recording sources + +Mac-side calls (Zoom, Meet, FaceTime, Discord) work via a BlackHole + +mic aggregate device — `slab-recorder` will detect whether one is wired +and warn if not. iPhone phone calls don't expose audio to macOS apps; +the workaround is speakerphone, and the recorder tags the resulting +meeting with `source: "speakerphone"` for awareness. diff --git a/meetings/cli.mjs b/meetings/cli.mjs new file mode 100644 --- /dev/null +++ b/meetings/cli.mjs @@ -0,0 +1,520 @@ +#!/usr/bin/env node +// meetings cli — capture phone/Mac call audio, transcribe, detect +// whistlepops, build an arxiv-style PDF of the conversation. +// +// Usage: +// meetings/cli.mjs new [title] Create a new meeting slot +// meetings/cli.mjs ingest [--title] Adopt a WAV into a meeting dir +// meetings/cli.mjs list Show all meetings + state +// meetings/cli.mjs transcribe WhisperX → transcript.json +// meetings/cli.mjs detect Find whistlepops → whistlepops.json +// meetings/cli.mjs parse Whistlepops + transcript → directives.json +// meetings/cli.mjs build Compose LaTeX → meeting.pdf +// meetings/cli.mjs run transcribe + detect + parse + build +// meetings/cli.mjs open Open the meeting dir in Finder +// +// Mirrors papers/cli.mjs: each step is incrementally cached against the +// mtime of its inputs so reruns cost nothing. A meeting lives at +// meetings/-/ with: +// +// audio.wav source recording (or symlink to Shelf/) +// audio.meta.json sidecar from the recorder (devices, sample rate) +// transcript.json WhisperX output (segments + word timestamps) +// whistlepops.json detector output (events with kind + confidence) +// directives.json parsed DSL directives +// meeting.tex composed LaTeX +// meeting.pdf final deliverable + +import { execFileSync, spawnSync, execSync } from "node:child_process"; +import { + existsSync, mkdirSync, readFileSync, writeFileSync, statSync, + readdirSync, copyFileSync, symlinkSync, +} from "node:fs"; +import { dirname, basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const MEETINGS_DIR = HERE; + +// ─── helpers ───────────────────────────────────────────────────────── + +function log(...args) { console.log("[meetings]", ...args); } +function die(msg, code = 1) { console.error("[meetings] " + msg); process.exit(code); } + +function slugify(s) { + return String(s).toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40) || "untitled"; +} + +function timestamp() { + const d = new Date(); + const pad = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`; +} + +function meetingDirs() { + if (!existsSync(MEETINGS_DIR)) return []; + return readdirSync(MEETINGS_DIR) + .filter((n) => /^\d{4}-\d{2}-\d{2}-\d{4}/.test(n)) + .map((n) => join(MEETINGS_DIR, n)) + .filter((p) => statSync(p).isDirectory()) + .sort(); +} + +function resolveSlug(slug) { + if (!slug) die("missing "); + const direct = join(MEETINGS_DIR, slug); + if (existsSync(direct)) return direct; + const hit = meetingDirs().find((d) => basename(d).endsWith(slug) || basename(d).includes(slug)); + if (hit) return hit; + die(`no meeting matches "${slug}"`); +} + +function isFresh(out, ...inputs) { + if (!existsSync(out)) return false; + const outM = statSync(out).mtimeMs; + for (const i of inputs) { + if (!existsSync(i)) continue; + if (statSync(i).mtimeMs > outM) return false; + } + return true; +} + +// ─── commands ──────────────────────────────────────────────────────── + +function cmdNew(args) { + const title = args.join(" ").trim() || "untitled"; + const dir = join(MEETINGS_DIR, `${timestamp()}-${slugify(title)}`); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "meeting.json"), JSON.stringify({ + title, createdAt: new Date().toISOString(), state: "empty", + }, null, 2) + "\n"); + log("created", dir); + console.log(dir); +} + +function cmdIngest(args) { + const wavArg = args[0]; + if (!wavArg) die("usage: ingest [--title \"...\"]"); + const wav = resolve(wavArg); + if (!existsSync(wav)) die(`no such file: ${wav}`); + const titleIdx = args.indexOf("--title"); + const title = titleIdx >= 0 ? args[titleIdx + 1] : "untitled"; + const dir = join(MEETINGS_DIR, `${timestamp()}-${slugify(title)}`); + mkdirSync(dir, { recursive: true }); + const dest = join(dir, "audio.wav"); + // Symlink so we don't duplicate gigabytes — recorder writes once to Shelf. + try { symlinkSync(wav, dest); } catch { copyFileSync(wav, dest); } + const meta = { + title, ingestedAt: new Date().toISOString(), + sourceWav: wav, state: "ingested", + }; + writeFileSync(join(dir, "meeting.json"), JSON.stringify(meta, null, 2) + "\n"); + log("ingested", basename(dir)); + console.log(dir); +} + +function cmdList() { + const dirs = meetingDirs(); + if (dirs.length === 0) { log("no meetings yet"); return; } + for (const d of dirs) { + const name = basename(d); + const states = []; + if (existsSync(join(d, "audio.wav"))) states.push("audio"); + if (existsSync(join(d, "transcript.json"))) states.push("txt"); + if (existsSync(join(d, "whistlepops.json"))) states.push("pops"); + if (existsSync(join(d, "directives.json"))) states.push("dsl"); + if (existsSync(join(d, "meeting.pdf"))) states.push("pdf"); + console.log(` ${name} [${states.join(" ") || "empty"}]`); + } +} + +// Shell out to whisper-cli (whisper.cpp) and normalize its JSON into the +// shape our build step expects. WhisperX would also work here — same +// shape after normalization — but whisper.cpp is already on the box, +// Apple-Silicon-fast, and needs no Python env. +// +// Model lookup order: +// 1. $WHISPER_MODEL env var (absolute path) +// 2. ~/.whisper-models/ggml-base.en.bin +// 3. /recap/models/ggml-base.en.bin +// +// For 2-channel WAVs we pass `-di` (stereo channel diarization). With +// the slab-call-record setup the user's mic ends up on one channel and +// the call's system audio on the other, so the speaker label is +// effectively jeffrey vs other-party. We re-tag the labels by reading +// meeting.json.participants and mapping channels[0,1] → [0,1] in order. +function findWhisperModel() { + if (process.env.WHISPER_MODEL && existsSync(process.env.WHISPER_MODEL)) { + return process.env.WHISPER_MODEL; + } + const home = process.env.HOME || ""; + const candidates = [ + join(home, ".whisper-models/ggml-base.en.bin"), + join(HERE, "..", "recap/models/ggml-base.en.bin"), + ]; + return candidates.find((p) => existsSync(p)); +} + +function wavChannelCount(wav) { + try { + const buf = readFileSync(wav).subarray(0, 44); + // WAV header: "RIFF" + size + "WAVE" + "fmt " ... at byte 22: u16le channels + if (buf.toString("ascii", 0, 4) !== "RIFF") return 1; + return buf.readUInt16LE(22); + } catch { return 1; } +} + +function cmdTranscribe(slugArg) { + const dir = resolveSlug(slugArg); + const wav = join(dir, "audio.wav"); + const out = join(dir, "transcript.json"); + if (!existsSync(wav)) die(`no audio.wav in ${basename(dir)}`); + if (isFresh(out, wav)) { log("transcribe: cached"); return; } + + const model = findWhisperModel(); + if (!model) { + die("transcribe: no whisper model found. Set WHISPER_MODEL or drop ggml-base.en.bin into ~/.whisper-models/"); + } + + const channels = wavChannelCount(wav); + const diarize = channels >= 2; + + log(`transcribe: ${basename(wav)} (${channels}ch${diarize ? ", diarize" : ""})`); + + // whisper-cli writes .json next to its -of path. Output the JSON + // into the meeting dir then normalize. -np quiets the giant model banner. + const prefix = join(dir, "_whisper"); + const args = [ + "-m", model, + "-oj", + "-np", + "-of", prefix, + "-t", "4", + ]; + if (diarize) args.push("-di"); + args.push(wav); + + try { + execFileSync("whisper-cli", args, { stdio: "inherit", timeout: 600000 }); + } catch (e) { + die(`transcribe: whisper-cli failed (${e.message})`); + } + + const rawPath = `${prefix}.json`; + if (!existsSync(rawPath)) die(`transcribe: whisper-cli did not produce ${rawPath}`); + const raw = JSON.parse(readFileSync(rawPath, "utf8")); + + // whisper.cpp JSON shape: { systeminfo, model, params, result, transcription: [...] } + // Each transcription entry: { timestamps: {from, to}, offsets: {from, to}, text, ... } + // With -di each entry also has a speaker_turn_next or speaker field on some builds. + // We normalize to { segments: [{ start, end, speaker, text }, ...] }. + + const meta = existsSync(join(dir, "meeting.json")) + ? JSON.parse(readFileSync(join(dir, "meeting.json"), "utf8")) + : {}; + const participants = Array.isArray(meta.participants) ? meta.participants : []; + + const segs = (raw.transcription || []).map((t) => { + const start = (t.offsets?.from ?? 0) / 1000; + const end = (t.offsets?.to ?? 0) / 1000; + // Diarization labels: whisper.cpp uses speaker_turn_next + "(speaker N)" + // markers in text. Fall back to alternating speakers on long silence + // gaps if no per-segment speaker info is present. + let speaker = participants[0] || "speaker"; + if (typeof t.speaker === "string") { + // Map speaker index → participant name when available. + const idx = Number(String(t.speaker).replace(/\D/g, "")) || 0; + speaker = participants[idx] || `speaker ${idx}`; + } + return { start, end, speaker, text: (t.text || "").trim() }; + }).filter((s) => s.text.length > 0); + + // If no speaker info came through but we have ≥2 participants, alternate + // on silence gaps > 0.8s. Crude but better than every-line-is-jeffrey. + if (participants.length >= 2 && + segs.every((s) => s.speaker === participants[0])) { + let cur = 0; + for (let i = 0; i < segs.length; i++) { + if (i > 0 && (segs[i].start - segs[i - 1].end) > 0.8) { + cur = (cur + 1) % participants.length; + } + segs[i].speaker = participants[cur]; + } + } + + writeFileSync(out, JSON.stringify({ + source: basename(wav), + model: basename(model), + channels, + diarized: diarize, + segments: segs, + }, null, 2) + "\n"); + + // Clean up the raw whisper.cpp JSON; ours is canonical. + try { execSync(`rm -f "${rawPath}"`); } catch {} + + log(`transcribe: ${segs.length} segments`); +} + +function cmdDetect(slugArg) { + const dir = resolveSlug(slugArg); + const wav = join(dir, "audio.wav"); + const out = join(dir, "whistlepops.json"); + if (!existsSync(wav)) die(`no audio.wav in ${basename(dir)}`); + if (isFresh(out, wav, join(HERE, "detect-whistlepops.mjs"))) { + log("detect: cached"); return; + } + log("detect: scanning", basename(wav)); + const r = spawnSync(process.execPath, + [join(HERE, "detect-whistlepops.mjs"), wav, "--out", out], + { stdio: "inherit" }); + if (r.status !== 0) die("detector failed"); +} + +function cmdParse(slugArg) { + const dir = resolveSlug(slugArg); + const tx = join(dir, "transcript.json"); + const pops = join(dir, "whistlepops.json"); + const out = join(dir, "directives.json"); + const script = join(HERE, "parse-directives.mjs"); + if (!existsSync(tx) || !existsSync(pops)) { + die("parse needs transcript.json + whistlepops.json — run transcribe + detect first"); + } + if (isFresh(out, tx, pops, script)) { + log("parse: cached"); return; + } + const r = spawnSync(process.execPath, [script, dir, "--out", out], + { stdio: "inherit" }); + if (r.status !== 0) die("parse-directives failed"); +} + +// ─── LaTeX rendering ───────────────────────────────────────────────── + +function escapeTex(s) { + if (s == null) return ""; + return String(s) + .replace(/\\/g, "\\textbackslash{}") + .replace(/([&%$#_{}])/g, "\\$1") + .replace(/~/g, "\\textasciitilde{}") + .replace(/\^/g, "\\textasciicircum{}"); +} + +function hhmm(seconds) { + const s = Math.max(0, Math.floor(Number(seconds) || 0)); + const m = Math.floor(s / 60); + const r = s % 60; + return `${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`; +} + +// Compose KEY_IDEAS \item lines from directives. decisions/actions/highlights +// surface here; other directive types live inline in the body. +function renderKeyIdeas(directives) { + const items = []; + for (const d of directives) { + if (d.type === "decision" && d.text) { + items.push(` \\item Decision: ${escapeTex(d.text)}`); + } else if (d.type === "action" && d.text) { + const who = d.person ? `${escapeTex(d.person)} --- ` : ""; + items.push(` \\item Action --- ${who}${escapeTex(d.text)}`); + } else if (d.type === "highlight" && d.text) { + items.push(` \\item Highlighted: ${escapeTex(d.text)}`); + } + } + if (items.length === 0) { + items.push(" \\item \\textit{(no directives captured — transcript only)}"); + } + return items.join("\n"); +} + +// Walk transcript.segments + directives in time order. Each segment becomes +// a \turn; each directive emits the matching macro at its anchor. Anchors: +// - anchor "here": before the next segment (\section / \whistlebreak) +// - anchor "prev": after the previous segment (callout wraps the body) +// - anchor "margin": \mnote at the segment immediately before it +// - bracketed (skip/redact): \mskipped between segments +function renderBody(transcript, directives) { + const segments = (transcript.segments || []) + .map((s) => ({ + kind: "turn", + t: Number(s.start || 0), + speaker: s.speaker || "speaker", + text: s.text || "", + })) + .sort((a, b) => a.t - b.t); + + // Directives index by anchor time + type. + const items = [...segments]; + for (const d of directives) { + items.push({ kind: "directive", t: Number(d.t ?? d.t_open ?? 0), d }); + } + // Sort by time; at equal timestamps, structural directives (section / + // break) come BEFORE the turn so "Opening" precedes the first utterance + // when both anchor at t=0. Anchoring directives (highlight / note) come + // AFTER the turn so callouts attach to the speech they're commenting on. + const tieBreak = (it) => { + if (it.kind !== "directive") return 1; + const t = it.d.type; + if (t === "section" || t === "subsection" || t === "break" + || t === "lone-short") return 0; + return 2; + }; + items.sort((a, b) => (a.t - b.t) || (tieBreak(a) - tieBreak(b))); + + const out = []; + for (const it of items) { + if (it.kind === "turn") { + out.push(`\\turn{${escapeTex(it.speaker)}}{${hhmm(it.t)}}{${escapeTex(it.text)}}`); + } else { + const d = it.d; + switch (d.type) { + case "section": + out.push(`\\section{${escapeTex(d.name || "Section")}}`); + break; + case "subsection": + out.push(`\\subsection{${escapeTex(d.name || "Subsection")}}`); + break; + case "break": + case "lone-short": + out.push(`\\whistlebreak`); + break; + case "highlight": + out.push(`\\mhighlight{${escapeTex(d.text || "")}}`); + break; + case "decision": + out.push(`\\mdecision{${escapeTex(d.text || "")}}{${escapeTex(d.tag || "")}}`); + break; + case "action": + out.push(`\\maction{${escapeTex(d.person || "")}}{${escapeTex(d.text || "")}}`); + break; + case "quote": + out.push(`\\mquote{${escapeTex(d.text || "")}}`); + break; + case "note": + out.push(`\\mnote{${escapeTex(d.text || "")}}`); + break; + case "freeform": + out.push(`\\mfreeform{${escapeTex(d.text || "")}}`); + break; + case "skip": + case "redact": { + const dur = d.duration || "skipped span"; + out.push(`\\mskipped{${escapeTex(dur)}}`); + break; + } + default: + // Unknown directive types fall through as freeform margin notes + // so nothing gets silently dropped — the reader sees what the + // parser couldn't classify. + out.push(`\\mfreeform{${escapeTex(JSON.stringify(d))}}`); + } + } + } + + if (out.length === 0) { + out.push("\\textit{(empty transcript --- recording produced no segments)}"); + } + return out.join("\n\n"); +} + +function cmdBuild(slugArg) { + const dir = resolveSlug(slugArg); + const tx = join(dir, "transcript.json"); + const dsl = join(dir, "directives.json"); + if (!existsSync(tx) || !existsSync(dsl)) { + die("build needs transcript + directives — run parse first"); + } + const templatePath = join(HERE, "template/meeting.tex.tmpl"); + const styPath = join(HERE, "template/ac-meeting.sty"); + if (!existsSync(templatePath)) die(`missing template: ${templatePath}`); + + const out = join(dir, "meeting.pdf"); + if (isFresh(out, tx, dsl, templatePath, styPath, + join(dir, "meeting.json"))) { + log("build: cached"); return; + } + + const meta = existsSync(join(dir, "meeting.json")) + ? JSON.parse(readFileSync(join(dir, "meeting.json"), "utf8")) + : { title: "untitled" }; + const transcript = JSON.parse(readFileSync(tx, "utf8")); + const directivesFile = JSON.parse(readFileSync(dsl, "utf8")); + const directives = directivesFile.directives || []; + + // Symlink the stylesheet so xelatex finds it locally. Re-link each + // build in case the .sty has moved. + const styLink = join(dir, "ac-meeting.sty"); + try { execSync(`rm -f "${styLink}"`); } catch {} + try { symlinkSync(styPath, styLink); } catch { + // If symlink fails (rare — different fs), copy. + copyFileSync(styPath, styLink); + } + + const isoDate = basename(dir).match(/^(\d{4}-\d{2}-\d{2})/)?.[1] + ?? new Date().toISOString().slice(0, 10); + const participants = Array.isArray(meta.participants) + ? meta.participants.join(", ") + : (meta.participants || ""); + const sourceWav = basename(meta.sourceWav || meta.wav || "audio.wav"); + + const template = readFileSync(templatePath, "utf8"); + const tex = template + .replace(/{{\s*TITLE\s*}}/g, escapeTex(meta.title || "untitled")) + .replace(/{{\s*DATE\s*}}/g, isoDate) + .replace(/{{\s*DURATION\s*}}/g, escapeTex(meta.duration || "—")) + .replace(/{{\s*PARTICIPANTS\s*}}/g, escapeTex(participants)) + .replace(/{{\s*SOURCE_WAV\s*}}/g, escapeTex(sourceWav)) + .replace(/{{\s*KEY_IDEAS\s*}}/g, renderKeyIdeas(directives)) + .replace(/{{\s*BODY\s*}}/g, renderBody(transcript, directives)); + + writeFileSync(join(dir, "meeting.tex"), tex); + + // Two-pass xelatex (hyperref needs the second pass for outlines). + // Don't fail on non-zero exit; the .log will tell the truth. + try { + execSync( + `cd "${dir}" && xelatex -interaction=nonstopmode meeting.tex >/dev/null 2>&1; xelatex -interaction=nonstopmode meeting.tex >/dev/null 2>&1`, + { timeout: 120000 }); + } catch (_) {} + + if (!existsSync(out)) { + die("build: xelatex produced no PDF — see meeting.log"); + } + log("build:", basename(out)); +} + +function cmdRun(slugArg) { + cmdTranscribe(slugArg); + cmdDetect(slugArg); + cmdParse(slugArg); + cmdBuild(slugArg); +} + +function cmdOpen(slugArg) { + const dir = resolveSlug(slugArg); + execFileSync("/usr/bin/open", [dir]); +} + +// ─── dispatch ──────────────────────────────────────────────────────── + +const [cmd, ...rest] = process.argv.slice(2); +switch (cmd) { + case "new": cmdNew(rest); break; + case "ingest": cmdIngest(rest); break; + case "list": cmdList(); break; + case "transcribe": cmdTranscribe(rest[0]); break; + case "detect": cmdDetect(rest[0]); break; + case "parse": cmdParse(rest[0]); break; + case "build": cmdBuild(rest[0]); break; + case "run": cmdRun(rest[0]); break; + case "open": cmdOpen(rest[0]); break; + case undefined: + case "-h": + case "--help": + console.log(readFileSync(fileURLToPath(import.meta.url), "utf8") + .split("\n").slice(1, 26).join("\n").replace(/^\/\/ ?/gm, "")); + break; + default: die(`unknown command: ${cmd}`); +} diff --git a/meetings/detect-whistlepops.mjs b/meetings/detect-whistlepops.mjs new file mode 100644 --- /dev/null +++ b/meetings/detect-whistlepops.mjs @@ -0,0 +1,397 @@ +#!/usr/bin/env node +// detect-whistlepops.mjs — find whistle events in a meeting WAV. +// +// Pipeline: +// 1. Decode WAV (16-bit PCM, mono-downmix from stereo). +// 2. Sliding STFT (FFT_SIZE = 2048 samples, HOP = 512 samples). +// Hann window. +// 3. Per-frame features in the whistle band (800–4000 Hz): +// peakFreq, peakMag, peakStrength (peak / mean spectrum), +// harmonicEnergy (mag at 2f+3f+4f relative to f), +// bandConcentration (mag in ±50 Hz around peak / total band). +// 4. Threshold each frame; group contiguous whistle-like frames into +// events (min 80 ms, bridge <60 ms gaps). +// 5. Classify each event by duration into short/medium/long. When the +// wave-wizard/samples/whistlepops/takes/ corpus exists, additionally +// run k-NN over feature centroids to refine the label (handles +// rising/falling two-tone variants). +// 6. Emit JSON: [{ t_start, t_end, kind, confidence, meanFreq }]. +// +// No external deps — radix-2 FFT in this file. Built for offline meeting +// audio (≤60 min); processes in one pass without streaming. + +import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs"; +import { resolve, dirname, join, basename } from "node:path"; +import { fileURLToPath } from "node:url"; + +// ─── tuning ────────────────────────────────────────────────────────── + +const FFT_SIZE = 2048; +const HOP = 512; +const WHISTLE_LO_HZ = 800; +const WHISTLE_HI_HZ = 4000; +const PEAK_CONCENTRATION_HZ = 50; // ±50 Hz around peak for concentration + +// Frame-classification thresholds. Tuned conservatively — better to miss +// a soft whistle than to flag a vowel as one. Re-tune from corpus. +const T_STRENGTH = 5.0; // peakMag / meanMag of whistle band +const T_HARMONIC = 0.6; // (mag@2f + mag@3f + mag@4f) / mag@f +const T_CONCENTRATION = 0.35; // mag concentrated within ±50 Hz of peak + +// Event-grouping. +const MIN_EVENT_MS = 80; +const MAX_GAP_MS = 60; + +// ─── WAV decoder ───────────────────────────────────────────────────── + +function decodeWAV(path) { + const buf = readFileSync(path); + if (buf.toString("ascii", 0, 4) !== "RIFF" || + buf.toString("ascii", 8, 12) !== "WAVE") { + throw new Error(`not a WAV: ${path}`); + } + // Walk chunks to find 'fmt ' and 'data' (chunk order isn't fixed). + let off = 12; + let fmt = null; + let dataOff = 0, dataLen = 0; + while (off + 8 <= buf.length) { + const id = buf.toString("ascii", off, off + 4); + const size = buf.readUInt32LE(off + 4); + if (id === "fmt ") { + fmt = { + format: buf.readUInt16LE(off + 8), + channels: buf.readUInt16LE(off + 10), + sampleRate: buf.readUInt32LE(off + 12), + bitsPerSample: buf.readUInt16LE(off + 22), + }; + } else if (id === "data") { + dataOff = off + 8; + dataLen = size; + } + off += 8 + size + (size & 1); // RIFF chunks are even-padded + } + if (!fmt || !dataOff) throw new Error("WAV missing fmt or data chunk"); + if (fmt.format !== 1) throw new Error(`unsupported WAV format ${fmt.format} (only PCM)`); + if (fmt.bitsPerSample !== 16) throw new Error(`unsupported bits ${fmt.bitsPerSample} (only 16-bit)`); + + const samplesPerChannel = dataLen / 2 / fmt.channels; + const out = new Float32Array(samplesPerChannel); + if (fmt.channels === 1) { + for (let i = 0; i < samplesPerChannel; i++) { + out[i] = buf.readInt16LE(dataOff + i * 2) / 32768; + } + } else { + // Downmix all channels to mono by averaging. + for (let i = 0; i < samplesPerChannel; i++) { + let sum = 0; + for (let c = 0; c < fmt.channels; c++) { + sum += buf.readInt16LE(dataOff + (i * fmt.channels + c) * 2) / 32768; + } + out[i] = sum / fmt.channels; + } + } + return { samples: out, sampleRate: fmt.sampleRate, channels: fmt.channels }; +} + +// ─── radix-2 iterative FFT (in-place) ──────────────────────────────── +// Standard Cooley-Tukey. Operates on parallel real + imag arrays so we +// don't allocate per call. N must be a power of two. + +function fftInPlace(real, imag) { + const N = real.length; + // Bit reversal + for (let i = 1, j = 0; i < N; i++) { + let bit = N >> 1; + for (; j & bit; bit >>= 1) j ^= bit; + j ^= bit; + if (i < j) { + [real[i], real[j]] = [real[j], real[i]]; + [imag[i], imag[j]] = [imag[j], imag[i]]; + } + } + for (let size = 2; size <= N; size *= 2) { + const half = size / 2; + const tableStep = (-2 * Math.PI) / size; + for (let i = 0; i < N; i += size) { + for (let j = 0; j < half; j++) { + const angle = tableStep * j; + const wr = Math.cos(angle); + const wi = Math.sin(angle); + const k = i + j + half; + const tr = wr * real[k] - wi * imag[k]; + const ti = wr * imag[k] + wi * real[k]; + real[k] = real[i + j] - tr; + imag[k] = imag[i + j] - ti; + real[i + j] += tr; + imag[i + j] += ti; + } + } + } +} + +// Pre-compute a Hann window of length FFT_SIZE. +function hannWindow(N) { + const w = new Float32Array(N); + for (let i = 0; i < N; i++) w[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (N - 1))); + return w; +} + +// ─── feature extraction ────────────────────────────────────────────── + +function extractFeatures(samples, sampleRate) { + const win = hannWindow(FFT_SIZE); + const real = new Float32Array(FFT_SIZE); + const imag = new Float32Array(FFT_SIZE); + const mags = new Float32Array(FFT_SIZE / 2); + + const binHz = sampleRate / FFT_SIZE; + const loBin = Math.max(1, Math.floor(WHISTLE_LO_HZ / binHz)); + const hiBin = Math.min(FFT_SIZE / 2 - 1, Math.ceil(WHISTLE_HI_HZ / binHz)); + const concentrationBins = Math.max(1, Math.round(PEAK_CONCENTRATION_HZ / binHz)); + + const numFrames = Math.max(0, Math.floor((samples.length - FFT_SIZE) / HOP)); + const features = new Array(numFrames); + + for (let f = 0; f < numFrames; f++) { + const start = f * HOP; + for (let i = 0; i < FFT_SIZE; i++) { + real[i] = samples[start + i] * win[i]; + imag[i] = 0; + } + fftInPlace(real, imag); + for (let i = 0; i < mags.length; i++) { + mags[i] = Math.hypot(real[i], imag[i]); + } + + // Whistle band: find peak + mean. + let peakBin = loBin, peakMag = mags[loBin], bandSum = 0, bandCount = 0; + for (let i = loBin; i <= hiBin; i++) { + if (mags[i] > peakMag) { peakMag = mags[i]; peakBin = i; } + bandSum += mags[i]; + bandCount++; + } + const meanMag = bandSum / Math.max(1, bandCount); + const peakStrength = peakMag / Math.max(1e-9, meanMag); + + // Concentration: sum within ±50 Hz of peak / total band sum. + let concSum = 0; + const c0 = Math.max(loBin, peakBin - concentrationBins); + const c1 = Math.min(hiBin, peakBin + concentrationBins); + for (let i = c0; i <= c1; i++) concSum += mags[i]; + const concentration = concSum / Math.max(1e-9, bandSum); + + // Harmonic energy at 2f, 3f, 4f relative to f. + const f2 = peakBin * 2, f3 = peakBin * 3, f4 = peakBin * 4; + const h = ((mags[f2] || 0) + (mags[f3] || 0) + (mags[f4] || 0)) + / Math.max(1e-9, peakMag); + + features[f] = { + t: (start + FFT_SIZE / 2) / sampleRate, // frame center + peakFreq: peakBin * binHz, + peakMag, + peakStrength, + harmonic: h, + concentration, + }; + } + return features; +} + +// ─── thresholding + grouping ───────────────────────────────────────── + +function frameIsWhistle(f) { + return f.peakStrength > T_STRENGTH + && f.harmonic < T_HARMONIC + && f.concentration > T_CONCENTRATION; +} + +function groupEvents(features) { + const events = []; + let cur = null; + const maxGap = MAX_GAP_MS / 1000; + for (const f of features) { + if (frameIsWhistle(f)) { + if (!cur) { + cur = { t_start: f.t, t_end: f.t, frames: [f] }; + } else if (f.t - cur.t_end <= maxGap) { + cur.t_end = f.t; + cur.frames.push(f); + } else { + events.push(cur); + cur = { t_start: f.t, t_end: f.t, frames: [f] }; + } + } + } + if (cur) events.push(cur); + // Discard too-short events (probably transient noise). + return events.filter((e) => (e.t_end - e.t_start) * 1000 >= MIN_EVENT_MS); +} + +// ─── classification ────────────────────────────────────────────────── + +// Heuristic: duration alone, no corpus. Sufficient for v1 lone-whistle +// punctuation grammar. Rising/falling distinction requires corpus. +function heuristicKind(event) { + const ms = (event.t_end - event.t_start) * 1000; + if (ms < 500) return "short"; + if (ms < 1500) return "medium"; + return "long"; +} + +// Feature vector for k-NN. Median peakFreq + freq slope + duration + +// mean concentration + mean peakStrength. Median is robust against +// noisy edge frames. +function eventVector(event) { + const fs = event.frames.map((f) => f.peakFreq).sort((a, b) => a - b); + const median = fs[Math.floor(fs.length / 2)]; + const slope = (event.frames[event.frames.length - 1].peakFreq + - event.frames[0].peakFreq) / Math.max(0.01, event.t_end - event.t_start); + const dur = event.t_end - event.t_start; + const meanConc = event.frames.reduce((a, f) => a + f.concentration, 0) / event.frames.length; + const meanStr = event.frames.reduce((a, f) => a + f.peakStrength, 0) / event.frames.length; + return [median, slope, dur, meanConc, meanStr]; +} + +// Load the wave-wizard corpus if it's been recorded. Returns +// { label: vec[] } map, or {} if no takes are present yet. +function loadCorpus(repoRoot) { + const takesDir = join(repoRoot, "wave-wizard/samples/whistlepops/takes"); + if (!existsSync(takesDir)) return {}; + const wavs = readdirSync(takesDir).filter((f) => f.endsWith(".wav")); + if (wavs.length === 0) return {}; + const byLabel = {}; + for (const f of wavs) { + const m = f.match(/^(pos|neg)-([a-z]+)-/); + if (!m) continue; + const label = `${m[1]}-${m[2]}`; // e.g. "pos-short", "neg-hum" + const path = join(takesDir, f); + try { + const wav = decodeWAV(path); + const feats = extractFeatures(wav.samples, wav.sampleRate); + const events = groupEvents(feats); + // Each take should be one event after auto-trim. Skip if zero/multi. + if (events.length !== 1) continue; + const v = eventVector(events[0]); + (byLabel[label] ??= []).push(v); + } catch (e) { + console.error(`[detect] corpus load failed for ${f}: ${e.message}`); + } + } + return byLabel; +} + +// k-NN with k=3, Euclidean. Returns { label, confidence } where confidence +// is (votes for winner) / k. Throws empty corpus → fall back to heuristic. +function classifyKNN(corpus, vec) { + const flat = []; + for (const [label, vecs] of Object.entries(corpus)) { + for (const v of vecs) flat.push({ label, v }); + } + if (flat.length === 0) return null; + for (const entry of flat) { + let d = 0; + for (let i = 0; i < vec.length; i++) { + const x = (entry.v[i] - vec[i]); + d += x * x; + } + entry.d = Math.sqrt(d); + } + flat.sort((a, b) => a.d - b.d); + const k = Math.min(3, flat.length); + const top = flat.slice(0, k); + const counts = {}; + for (const e of top) counts[e.label] = (counts[e.label] || 0) + 1; + let winner = top[0].label, winCount = 0; + for (const [l, c] of Object.entries(counts)) { + if (c > winCount) { winner = l; winCount = c; } + } + return { label: winner, confidence: winCount / k }; +} + +// ─── entry ─────────────────────────────────────────────────────────── + +const args = process.argv.slice(2); +const wavPath = args[0]; +let outPath = null; +for (let i = 1; i < args.length; i++) { + if (args[i] === "--out") outPath = args[++i]; +} +if (!wavPath) { + console.error("usage: detect-whistlepops.mjs [--out ]"); + process.exit(2); +} +if (!existsSync(wavPath)) { + console.error(`no such file: ${wavPath}`); + process.exit(2); +} + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, ".."); + +const sizeMB = statSync(wavPath).size / (1024 * 1024); +console.error(`[detect] ${basename(wavPath)} (${sizeMB.toFixed(1)} MB)`); + +const wav = decodeWAV(wavPath); +console.error(`[detect] ${wav.sampleRate} Hz · ${wav.channels} ch · ${(wav.samples.length / wav.sampleRate).toFixed(1)} s`); + +const features = extractFeatures(wav.samples, wav.sampleRate); +const rawEvents = groupEvents(features); +console.error(`[detect] ${rawEvents.length} candidate events`); + +const corpus = loadCorpus(REPO_ROOT); +const corpusLabels = Object.keys(corpus); +if (corpusLabels.length > 0) { + console.error(`[detect] corpus: ${corpusLabels.length} classes (${corpusLabels.join(", ")})`); +} else { + console.error("[detect] no corpus — using duration heuristic for labels"); +} + +const events = rawEvents.map((e) => { + const vec = eventVector(e); + let kind, confidence; + const knn = corpusLabels.length > 0 ? classifyKNN(corpus, vec) : null; + if (knn && knn.label.startsWith("neg-")) { + // Negative-class winner — likely a false positive (laugh, vowel, + // kettle). Drop. + return null; + } + if (knn) { + // pos-short → "short"; preserve the acoustic variant for the DSL. + kind = knn.label.replace(/^pos-/, ""); + confidence = knn.confidence; + } else { + kind = heuristicKind(e); + confidence = Math.min(1, e.frames[0].peakStrength / 20); + } + const meanFreq = vec[0]; + return { + t_start: Number(e.t_start.toFixed(3)), + t_end: Number(e.t_end.toFixed(3)), + kind, + confidence: Number(confidence.toFixed(2)), + meanFreq: Number(meanFreq.toFixed(1)), + }; +}).filter(Boolean); + +console.error(`[detect] ${events.length} whistlepop events`); + +const payload = { + source: resolve(wavPath), + generatedAt: new Date().toISOString(), + detector: { + fftSize: FFT_SIZE, hop: HOP, + band: [WHISTLE_LO_HZ, WHISTLE_HI_HZ], + thresholds: { + strength: T_STRENGTH, harmonic: T_HARMONIC, concentration: T_CONCENTRATION, + }, + corpusLabels, + }, + events, +}; + +if (outPath) { + writeFileSync(outPath, JSON.stringify(payload, null, 2) + "\n"); + console.error(`[detect] wrote ${outPath}`); +} else { + process.stdout.write(JSON.stringify(payload, null, 2) + "\n"); +} diff --git a/meetings/dsl.md b/meetings/dsl.md new file mode 100644 --- /dev/null +++ b/meetings/dsl.md @@ -0,0 +1,118 @@ +# whistlepop DSL + +The grammar by which jeffrey's whistles, mid-conversation, instruct the +`meetings/` pipeline. The pipeline strips whistlepops from the conversation +transcript and applies them to the surrounding context when building the PDF. + +## Primitives + +### Lone whistle — punctuation + +A single whistle with no matching partner within `pairWindowSec` (default 8s) +acts as **structural punctuation**: + +| Form | Effect | +|----------|-----------------------------------------------| +| short | section break (`\section{}` with auto title) | +| medium | subsection break | +| long | "beat" — extra vertical space, no header | + +Auto-titles for section breaks come from the next 6–10 transcribed words +(WhisperX timestamps) trimmed at the first noun phrase. Override with a +paired whistlepop containing `section `. + +### Whistlepop — paired + +Two whistles within `pairWindowSec`, with speech in between, form a +**whistlepop**. The speech inside the brackets is a *directive*, not +conversation. It is parsed against the grammar below. + +``` +…regular talk… [WHISTLE] highlight [WHISTLE] …regular talk… +``` + +The renderer strips the bracketed directive from the body, and applies the +directive to the *surrounding* context (typically the sentence immediately +before or after the open whistle — see "anchor" below). + +## Directive grammar + +Directives are case-insensitive. The parser tokenizes the directive text and +matches the first token against the verb table; remaining tokens are +verb-specific arguments. + +| Verb | Anchor | Effect | +|-------------------|--------------|-----------------------------------------------------| +| `highlight` | prev sent. | wraps the previous sentence in a callout box | +| `decision` | prev sent. | pull-quote with "Decision" label + margin glyph | +| `action ` | prev sent. | "Action item — " callout | +| `quote` | prev sent. | render as block-quote with margin treatment | +| `section ` | here | `\section{}` starting at this point | +| `subsection ` | here | `\subsection{}` | +| `note ` | margin | margin note attached to the open-whistle anchor | +| `skip` | bracketed | mark surrounding ±10s as off-record (omit from PDF) | +| `redact ` | bracketed | as `skip`, but for a named span / topic | +| `start` | open | begin a highlight range | +| `end` | close | end the highlight range opened by a prior `start` | +| *(unmatched)* | margin | render the directive text as a freeform margin note | + +### Anchor semantics + +- **`prev sent.`** — verb applies to the sentence whose end timestamp is + closest to (but before) the open-whistle timestamp. Falls back to the + next sentence if no prior sentence is within `anchorWindowSec` (3s). +- **`here`** — applies at the open-whistle timestamp, splitting the + transcript at that point. +- **`margin`** — attached at the open-whistle anchor but rendered in the + margin without altering body flow. +- **`bracketed`** — applies to the time range between the two whistles + (and an optional `± padSec` slop). + +### `start` / `end` ranges + +A `start` directive opens a highlight range that continues until the next +matching `end` (or `end ` for named ranges). Useful for "highlight +this whole next paragraph" instead of one-sentence callouts: + +``` +[WHISTLE] start [WHISTLE] …a couple of minutes of important talk… [WHISTLE] end [WHISTLE] +``` + +Renderer shades the bracketed range with a soft tint and a margin bracket. + +## Detector → parser handoff + +`meetings/detect-whistlepops.mjs` emits: + +```json +[ + { "t_start": 12.34, "t_end": 12.49, "kind": "short", "confidence": 0.91 }, + { "t_start": 15.02, "t_end": 15.21, "kind": "short", "confidence": 0.88 } +] +``` + +`meetings/parse-directives.mjs` does two passes: + +1. **Pair pass** — group events within `pairWindowSec`. Each pair becomes a + whistlepop with `directiveText` taken from the WhisperX transcript span + between the two events. Unmatched events become `lone` punctuation. + +2. **Verb pass** — tokenize each directive's text against the verb table, + resolve anchors, emit a normalized `directives.json`: + + ```json + [ + { "type": "highlight", "anchor": "prev", "t_open": 12.34, "t_close": 12.49 }, + { "type": "section", "name": "Budget", "t": 84.10 }, + { "type": "freeform", "text": "ask alex about the timeline", "t": 142.7 } + ] + ``` + +## Open questions (v2) + +- Should `rising` / `falling` two-tone whistles get distinct verbs + (rising = `open`, falling = `close`) so pairing is explicit instead of + inferred by proximity? The corpus already includes them. +- Should `quiet` whistles be treated as private margin notes (different + visual style from regular whistlepops)? +- Should whistlepops nest? (`[WHISTLE] start [WHISTLE] talk [WHISTLE] decision [WHISTLE] more talk [WHISTLE] end [WHISTLE]`) diff --git a/meetings/parse-directives.mjs b/meetings/parse-directives.mjs new file mode 100644 --- /dev/null +++ b/meetings/parse-directives.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node +// parse-directives.mjs — whistlepops.json + transcript.json → directives.json +// +// Implements the two-pass grammar from meetings/dsl.md: +// 1. Pair pass — group whistlepop events within pairWindowSec; bracketed +// pairs become directives, unmatched events become lone punctuation. +// 2. Verb pass — tokenize the bracketed speech against the verb table +// and resolve anchors against the transcript segments. +// +// Usage: +// parse-directives.mjs [--out ] +// +// Reads: +// /whistlepops.json { events: [{ t_start, t_end, kind, ... }] } +// /transcript.json { segments: [{ start, end, speaker, text }] } +// +// Writes: +// /directives.json { directives: [{ type, t, text?, ... }] } + +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { join, resolve } from "node:path"; + +// ─── tuning constants ──────────────────────────────────────────────── + +const PAIR_WINDOW_SEC = 8; // max gap between open + close whistles +const ANCHOR_WINDOW_SEC = 3; // how far back to look for "prev sentence" + +// ─── verb table ────────────────────────────────────────────────────── +// +// Each verb knows: how many head tokens it consumes, where to anchor, +// and how to construct the directive record. `head` is matched +// case-insensitive against the first token(s) of the bracketed speech. + +const VERBS = [ + { head: ["highlight"], anchor: "prev", build: (_rest, ctx) => ({ type: "highlight", text: ctx.prevText }) }, + { head: ["decision"], anchor: "prev", build: (rest, ctx) => ({ type: "decision", text: ctx.prevText, tag: rest.join(" ").trim() || undefined }) }, + { head: ["action"], anchor: "prev", build: (rest, ctx) => ({ type: "action", person: rest[0] || "", text: ctx.prevText }) }, + { head: ["quote"], anchor: "prev", build: (_rest, ctx) => ({ type: "quote", text: ctx.prevText }) }, + { head: ["section"], anchor: "here", build: (rest) => ({ type: "section", name: rest.join(" ").trim() || "Section" }) }, + { head: ["subsection"],anchor: "here", build: (rest) => ({ type: "subsection", name: rest.join(" ").trim() || "Subsection" }) }, + { head: ["note"], anchor: "margin", build: (rest) => ({ type: "note", text: rest.join(" ").trim() }) }, + { head: ["skip"], anchor: "bracketed",build: (_rest, ctx) => ({ type: "skip", duration: ctx.bracketDuration }) }, + { head: ["redact"], anchor: "bracketed",build: (rest, ctx) => ({ type: "redact", text: rest.join(" ").trim(), duration: ctx.bracketDuration }) }, + { head: ["start"], anchor: "open", build: () => ({ type: "highlight-start" }) }, + { head: ["end"], anchor: "close", build: () => ({ type: "highlight-end" }) }, +]; + +// ─── helpers ───────────────────────────────────────────────────────── + +function tokenize(s) { + return String(s || "").toLowerCase().trim() + .split(/\s+/).filter(Boolean); +} + +// Pick the segment that the directive should anchor to as "prev sentence". +// Preference order: +// 1. A segment that contains t — the speaker was mid-utterance when they +// whistled, so the directive attaches to that in-progress sentence. +// 2. The most-recently-ended segment before t, if within ANCHOR_WINDOW_SEC. +// 3. Fallback: the first segment starting after t (whistle came in a gap). +function findPrevSegment(segments, t) { + for (const s of segments) { + if (s.start <= t && t <= s.end) return s; + } + let best = null; + for (const s of segments) { + if (s.end <= t) { + if (!best || s.end > best.end) best = s; + } + } + if (best && (t - best.end) <= ANCHOR_WINDOW_SEC) return best; + for (const s of segments) { + if (s.start >= t) return s; + } + return best; +} + +// Extract the transcript text covering [t0, t1]. Concatenates segments +// whose timestamps overlap the bracket window. +function bracketedText(segments, t0, t1) { + const parts = []; + for (const s of segments) { + if (s.end <= t0) continue; + if (s.start >= t1) break; + parts.push(s.text); + } + return parts.join(" ").trim(); +} + +function formatDuration(seconds) { + const s = Math.max(0, Math.round(Number(seconds) || 0)); + if (s < 60) return `${s} sec`; + const m = Math.floor(s / 60); + const r = s % 60; + return r === 0 ? `${m} min` : `${m} min ${r} sec`; +} + +// ─── main passes ───────────────────────────────────────────────────── + +function pairPass(events) { + // Sort by t_start. Greedy walk: when an event has no pair within window, + // it's lone; otherwise consume the next event and emit a pair record. + const sorted = [...events].sort((a, b) => a.t_start - b.t_start); + const out = []; + const used = new Set(); + for (let i = 0; i < sorted.length; i++) { + if (used.has(i)) continue; + const open = sorted[i]; + let pair = null; + for (let j = i + 1; j < sorted.length; j++) { + if (used.has(j)) continue; + if (sorted[j].t_start - open.t_end > PAIR_WINDOW_SEC) break; + pair = j; + break; + } + if (pair !== null) { + used.add(i); used.add(pair); + out.push({ kind: "pair", open, close: sorted[pair] }); + } else { + out.push({ kind: "lone", event: open }); + } + } + return out; +} + +// Segments are "conversational" if they don't fall inside any whistle bracket. +// Excluding bracketed segments from anchor lookups means "decision" doesn't +// pull "highlight" as its prev sentence — it pulls the previous real speech. +function filterConversational(segments, grouped) { + const brackets = grouped + .filter((g) => g.kind === "pair") + .map((g) => [g.open.t_start, g.close.t_end]); + return segments.filter((s) => { + const mid = (s.start + s.end) / 2; + return !brackets.some(([t0, t1]) => mid >= t0 && mid <= t1); + }); +} + +function verbPass(grouped, segments) { + const conversational = filterConversational(segments, grouped); + const directives = []; + for (const g of grouped) { + if (g.kind === "lone") { + const e = g.event; + // Map acoustic class → structural punctuation. + let type = "break"; + if (e.kind === "medium") type = "subsection-break"; + if (e.kind === "long") type = "break"; + directives.push({ + type, + t: e.t_start, + kind: e.kind, + }); + continue; + } + // Pair → directive. + const { open, close } = g; + const text = bracketedText(segments, open.t_end, close.t_start); + const tokens = tokenize(text); + const verb = VERBS.find((v) => + v.head.length <= tokens.length + && v.head.every((h, idx) => tokens[idx] === h)); + + const ctx = { + prevText: findPrevSegment(conversational, open.t_start)?.text || "", + bracketDuration: formatDuration(close.t_start - open.t_end), + }; + + if (!verb) { + // Unmatched directive — preserve as freeform margin note so the + // reader sees what the parser couldn't classify rather than + // silently dropping a deliberate whistle. + directives.push({ + type: "freeform", + t: open.t_start, + text: text || "(empty whistlepop)", + }); + continue; + } + const rest = tokens.slice(verb.head.length); + const built = verb.build(rest, ctx); + directives.push({ t: open.t_start, ...built }); + } + return directives.sort((a, b) => a.t - b.t); +} + +// ─── range pass: stitch start ... end pairs into a highlight range ─── +// +// `start` opens an unbounded highlight; the next matching `end` closes +// it. Renderer turns the [t_start, t_end] range into a shaded region. +function rangePass(directives) { + const out = []; + let open = null; + for (const d of directives) { + if (d.type === "highlight-start") { + if (open) { + // Unclosed earlier start — flush as a freeform note and reopen. + out.push({ type: "freeform", t: open.t, text: "unclosed highlight start" }); + } + open = d; + continue; + } + if (d.type === "highlight-end") { + if (open) { + out.push({ type: "highlight-range", t: open.t, t_end: d.t }); + open = null; + } else { + out.push({ type: "freeform", t: d.t, text: "unmatched highlight end" }); + } + continue; + } + out.push(d); + } + if (open) { + out.push({ type: "freeform", t: open.t, text: "unclosed highlight start" }); + } + return out; +} + +// ─── entry ─────────────────────────────────────────────────────────── + +const args = process.argv.slice(2); +const dirArg = args[0]; +let outPath = null; +for (let i = 1; i < args.length; i++) { + if (args[i] === "--out") outPath = args[++i]; +} +if (!dirArg) { + console.error("usage: parse-directives.mjs [--out ]"); + process.exit(2); +} +const dir = resolve(dirArg); +const popsPath = join(dir, "whistlepops.json"); +const txPath = join(dir, "transcript.json"); +if (!existsSync(popsPath) || !existsSync(txPath)) { + console.error(`[parse] needs whistlepops.json + transcript.json in ${dir}`); + process.exit(2); +} + +const pops = JSON.parse(readFileSync(popsPath, "utf8")); +const transcript = JSON.parse(readFileSync(txPath, "utf8")); +const segments = (transcript.segments || []).slice(); + +const events = pops.events || []; +const grouped = pairPass(events); +const verbed = verbPass(grouped, segments); +const ranged = rangePass(verbed); + +const result = { + source: { whistlepops: popsPath, transcript: txPath }, + generatedAt: new Date().toISOString(), + counts: { + events: events.length, + pairs: grouped.filter((g) => g.kind === "pair").length, + lone: grouped.filter((g) => g.kind === "lone").length, + directives: ranged.length, + }, + directives: ranged, +}; + +const outFile = outPath || join(dir, "directives.json"); +writeFileSync(outFile, JSON.stringify(result, null, 2) + "\n"); +console.error(`[parse] ${ranged.length} directives → ${outFile}`); diff --git a/meetings/sample-jeffrey-x-scott/ac-meeting.sty b/meetings/sample-jeffrey-x-scott/ac-meeting.sty new file mode 100644 --- /dev/null +++ b/meetings/sample-jeffrey-x-scott/ac-meeting.sty @@ -0,0 +1,1 @@ +../template/ac-meeting.sty \ No newline at end of file diff --git a/meetings/sample-jeffrey-x-scott/meeting.tex b/meetings/sample-jeffrey-x-scott/meeting.tex new file mode 100644 --- /dev/null +++ b/meetings/sample-jeffrey-x-scott/meeting.tex @@ -0,0 +1,102 @@ +% !TEX program = xelatex +% Sample meeting — exercises every macro in ac-meeting.sty so we can +% verify the template compiles before wiring the real build pipeline. + +\documentclass[11pt,letterpaper]{article} + +\usepackage[ + paperwidth=8.5in, paperheight=11in, + top=0.9in, bottom=0.9in, + inner=0.9in, outer=2.6in, + marginparwidth=1.8in, marginparsep=0.2in, + twoside=false, +]{geometry} + +\usepackage{fontspec} +\usepackage{unicode-math} +\setmainfont{Latin Modern Roman}[ + Extension=.otf, + UprightFont=lmroman10-regular, + BoldFont=lmroman10-bold, + ItalicFont=lmroman10-italic, + BoldItalicFont=lmroman10-bolditalic, +] + +\usepackage{xcolor} +\usepackage{hyperref} +\hypersetup{ + colorlinks=true, + linkcolor=acpink, + urlcolor=acpink, + pdftitle={jeffrey x scott — sample meeting}, +} + +\usepackage{ac-meeting} + +\begin{document} +\pagestyle{empty} + +\meetingtitle{jeffrey x scott} +\meetingmeta{2026-05-30}{53 min}{jeffrey, scott}{2026-05-30-1600-jeffrey-x-scott.wav} + +\begin{keyideas} +\begin{itemize} + \item Decision: ship trancepenta stems by Friday. + \item Highlighted: scott's bridge motif idea is the spine of the next section. + \item Action — jeffrey: send scott the bare project file with no mastering. +\end{itemize} +\end{keyideas} + +\vspace{1.5em} + +\section{Opening} + +\turn{jeffrey}{00:00}{Hey Scott — thanks for jumping on. I wanted to talk through where the trancepenta stems land before Friday.} + +\turn{scott}{00:18}{Yeah, perfect timing. I was just listening through last night and I have one structural thing I want to float.} + +\turn{jeffrey}{00:34}{Go ahead.} + +\mhighlight{Scott's bridge motif idea is the spine of the next section — we should orient everything else around it.} + +\turn{scott}{00:41}{The bridge — the part that comes in after the second drop — I think the motif you have there could carry the whole next section. Right now it's a transition. If we let it breathe, it becomes the spine.} + +\turn{jeffrey}{01:12}{That's a really good read. Let me sit with that.} + +\mnote{Sage made a similar point about letting motifs breathe in a chat last week.} + +\section{Decisions} + +\turn{scott}{02:30}{So timing-wise — when can you get me the bare stems?} + +\turn{jeffrey}{02:38}{I can have them Friday. No mastering, just the bare project export.} + +\mdecision{Ship trancepenta stems Friday, unmastered, bare project export.}{50/50 split, publishing his} + +\maction{jeffrey}{Send scott the bare project file with no mastering by Friday end-of-day.} + +\section{Tangents} + +\turn{scott}{04:15}{Random question — have you been keeping up with the whistlegraph stuff lately?} + +\turn{jeffrey}{04:22}{Yeah, big push this month. I'll show you the recap when we wrap.} + +\mquote{Big push this month. I'll show you the recap when we wrap.} + +\mfreeform{ask scott about that synth patch from 2023} + +\mskipped{1 min 12 sec} + +\turn{scott}{05:51}{OK, sending you my notes on the bridge after this.} + +\turn{jeffrey}{05:58}{Perfect — talk soon.} + +\whistlebreak + +\section{Notes for the future} + +\turn{jeffrey}{06:00}{For the followup we should plan out a second pass once the stems are in.} + +\meetingcolophon + +\end{document} diff --git a/meetings/template/ac-meeting.sty b/meetings/template/ac-meeting.sty new file mode 100644 --- /dev/null +++ b/meetings/template/ac-meeting.sty @@ -0,0 +1,289 @@ +% ac-meeting.sty — Aesthetic Computer meeting transcript layout +% Usage: \usepackage{ac-meeting} after fontspec, xcolor, geometry, titlesec. +% +% A meeting deliverable: title block, key ideas summary, speaker-attributed +% conversation transcript, and margin/callout treatments for whistlepop +% directives (highlight, decision, action, quote, section, freeform note). +% +% Layout grammar: +% - Single column, ~5.5in body width +% - 1.5in left margin reserved for whistlepop sidenotes / margin glyphs +% - YWFT Processing for chrome (title, callout labels, section heads) +% - Latin Modern Roman for body transcript +% - Berkeley Mono for the speaker labels + timestamps (fixed-width = scan) +% +% Whistlepop visual vocabulary: +% \mhighlight{prev sentence} → callout box around prev sentence +% \mdecision{prev sentence}{label} → decision pull-quote w/ ★ glyph +% \maction{person}{prev sentence} → action item, named glyph in margin +% \mquote{prev sentence} → block-quote treatment +% \mnote{margin note text} → tufte-style sidenote +% \mfreeform{verbatim directive} → tagged margin note (unparsed) +% \mskipped{N seconds} → "[skipped Ns]" inline placeholder + +\NeedsTeXFormat{LaTeX2e} +\ProvidesPackage{ac-meeting}[2026/05/27 Aesthetic Computer meeting layout] + +\RequirePackage{tikz} +\RequirePackage{tcolorbox} +\tcbuselibrary{skins,breakable} +\RequirePackage{titlesec} + +% \marginnote is provided by the marginnote package, which isn't in the +% basic TeX Live install. Alias it to the LaTeX kernel's \marginpar — +% same API for our use (single-arg margin note), slightly less +% placement control but no extra dependency. If marginnote is added +% later (richer footnote-style placement) this alias can be deleted. +\providecommand{\marginnote}[1]{\marginpar{\raggedright #1}} +\RequirePackage{enumitem} +\RequirePackage{xcolor} + +% === FONTS === +% Same convention as ac-paper-layout.sty so the meeting feels like a +% sibling of the arxiv working drafts. Berkeley Mono is used by the +% slides and the menubar — pulling it in here makes speaker labels and +% timestamps read as "system output", a different register from body +% prose without needing a different color. +\newfontfamily\acbold{ywft-processing-bold}[ + Path=../../system/public/type/webfonts/, + Extension=.ttf +] +\newfontfamily\aclight{ywft-processing-light}[ + Path=../../system/public/type/webfonts/, + Extension=.ttf +] + +% Berkeley Mono — system-installed (slides depend on it too). +% Falls back to a generic monospaced family if absent. +\IfFontExistsTF{Berkeley Mono}{% + \newfontfamily\acmono{Berkeley Mono} +}{% + \newfontfamily\acmono{Menlo} +} + +% === COLORS (AC palette + meeting-specific tints) === +\definecolor{acpink}{RGB}{180,72,135} +\definecolor{acpurple}{RGB}{120,80,180} +\definecolor{acdark}{RGB}{40,36,48} +\definecolor{acgray}{RGB}{119,119,119} +\definecolor{acsoft}{RGB}{248,242,250} + +% Whistlepop accents — each directive has a stable hue so the eye learns +% the vocabulary across many meetings. Decisions are warm-amber (weighty, +% commits us), actions cyan (forward-looking, who-owes-what), highlights +% pink (the house accent), quotes neutral gray (verbatim), notes muted. +\definecolor{popdecision}{RGB}{210,140,40} +\definecolor{popaction}{RGB}{60,140,170} +\definecolor{pophighlight}{RGB}{180,72,135} +\definecolor{popquote}{RGB}{90,90,100} +\definecolor{popnote}{RGB}{140,130,150} + +% === SECTION FORMATTING === +% Sections come from whistlepops (`section `) or from short +% lone-whistle punctuation. Plain Title Case bold, dark, no numbers — +% same restraint as the essay style. +\titleformat{\section}[block] + {\normalfont\bfseries\fontsize{14pt}{17pt}\selectfont\color{acdark}\raggedright} + {}{0pt}{} +\titlespacing{\section}{0pt}{2em}{0.6em} + +\titleformat{\subsection}[block] + {\normalfont\bfseries\fontsize{11pt}{13pt}\selectfont\color{acgray}\raggedright} + {}{0pt}{} +\titlespacing{\subsection}{0pt}{1.2em}{0.2em} + +\setcounter{secnumdepth}{-1} + +% === TITLE BLOCK === +% \meetingtitle{Title} — large pink title (matches essay style) +% \meetingdate{2026-05-30} — ISO date below the title +% \meetingparticipants{a, b} — comma-separated names +% \meetingduration{53 min} — human duration +% \meetingsource{path/to.wav} — italic gray sub-line +\newcommand{\meetingtitle}[1]{% + \begin{center}% + \begin{tikzpicture} + \node[acdark, opacity=0.22, + font=\aclight\fontsize{32pt}{36pt}\selectfont, + inner sep=0pt] at (2pt, -2pt) {#1}; + \node[acpink, + font=\aclight\fontsize{32pt}{36pt}\selectfont, + inner sep=0pt] at (0, 0) {#1}; + \end{tikzpicture}% + \end{center}% + \vspace{0.4em}% +} +\newcommand{\meetingmeta}[4]{% + \begin{center}% + {\fontsize{11pt}{14pt}\selectfont\itshape\color{acgray}% + #1 \enspace·\enspace #2 \enspace·\enspace #3}\\[0.3em] + {\acmono\fontsize{8pt}{10pt}\selectfont\color{acgray}#4}% + \end{center}% + \vspace{1.8em}% +} + +% === KEY IDEAS BLOCK === +% A summary block at the top of the meeting, populated by the build pass +% from highlight/decision/action whistlepops + (eventually) an LLM pass +% over the transcript. Renders as a soft-tinted card. +\newtcolorbox{keyideas}{% + enhanced, breakable, + colback=acsoft, colframe=acpink!50!white, + boxrule=0.5pt, arc=2pt, + left=1em, right=1em, top=0.6em, bottom=0.6em, + title={\acbold\color{acpink}KEY IDEAS}, + attach boxed title to top left={xshift=1em, yshift*=-0.4em}, + boxed title style={ + colback=white, colframe=acpink!50!white, + boxrule=0.5pt, arc=2pt, top=2pt, bottom=2pt, left=4pt, right=4pt, + }, +} + +% === SPEAKER TURNS === +% \turn{speaker}{HH:MM}{utterance text} +% Speaker label in Berkeley Mono small caps; timestamp in monospaced gray +% to the right; body in serif. Hanging indent puts the body flush-left +% with the speaker name's first letter for column alignment. +\newcommand{\turn}[3]{% + \par\vspace{0.45em}% + \noindent + {\acmono\fontsize{8.5pt}{11pt}\selectfont\bfseries\color{acdark}\MakeUppercase{#1}}% + \hspace{0.6em}% + {\acmono\fontsize{8pt}{10pt}\selectfont\color{acgray}#2}% + \par\noindent\hspace{0pt}\ignorespaces#3\par +} + +% === WHISTLEPOP RENDERINGS === + +% Highlight: previous sentence framed in a soft pink-tinted box, with a +% small "♦" glyph in the left margin so the eye finds it scanning down. +\newtcolorbox{whistlepophighlight}{% + enhanced, breakable, sharp corners, + colback=acsoft, colframe=pophighlight, + boxrule=0pt, leftrule=2pt, + left=0.6em, right=0.6em, top=0.4em, bottom=0.4em, + before skip=0.5em, after skip=0.5em, +} +\newcommand{\mhighlight}[1]{% + \marginnote{\color{pophighlight}\fontsize{10pt}{12pt}\selectfont\textbf{$\diamondsuit$}}% + \begin{whistlepophighlight}#1\end{whistlepophighlight}% +} + +% Decision: amber-bordered pull-quote with the literal word "DECISION" as +% a label, and an optional short tag underneath (the decision in 3-5 words). +\newtcolorbox{whistlepopdecision}{% + enhanced, breakable, sharp corners, + colback=white, colframe=popdecision, + boxrule=1pt, + left=0.8em, right=0.8em, top=0.5em, bottom=0.5em, + before skip=0.6em, after skip=0.6em, + title={\acbold\color{popdecision}\fontsize{8pt}{10pt}\selectfont DECISION}, + coltitle=popdecision, + attach boxed title to top left={xshift=0.5em, yshift*=-0.35em}, + boxed title style={colback=white, frame hidden}, +} +\newcommand{\mdecision}[2]{% + \marginnote{\color{popdecision}\fontsize{12pt}{14pt}\selectfont$\star$}% + \begin{whistlepopdecision}% + #1% + \ifx&% + \else + \par\vspace{0.2em}% + {\itshape\color{popdecision}\small — #2}% + \fi + \end{whistlepopdecision}% +} + +% Action item: cyan accent, "ACTION — " label, body underneath. +\newtcolorbox{whistlepopaction}[1]{% + enhanced, breakable, sharp corners, + colback=white, colframe=popaction, + boxrule=0pt, leftrule=2pt, + left=0.6em, right=0.6em, top=0.4em, bottom=0.4em, + before skip=0.5em, after skip=0.5em, + title={\acbold\color{popaction}\fontsize{8pt}{10pt}\selectfont ACTION{} \textperiodcentered{} \MakeUppercase{#1}}, + coltitle=popaction, + attach boxed title to top left={xshift=0.4em, yshift*=-0.35em}, + boxed title style={colback=white, frame hidden}, +} +\newcommand{\maction}[2]{% + \marginnote{\color{popaction}\fontsize{10pt}{12pt}\selectfont$\rightarrow$ \MakeUppercase{#1}}% + \begin{whistlepopaction}{#1}#2\end{whistlepopaction}% +} + +% Quote: block-quote, neutral gray rule on the left, no fill. +\newcommand{\mquote}[1]{% + \par\vspace{0.4em}% + \noindent + \begin{minipage}{\linewidth}% + \color{popquote}% + \hspace*{0pt}\hfill\begin{minipage}{0.92\linewidth}% + \itshape #1% + \end{minipage}\hfill\hspace*{0pt}% + \end{minipage}% + \par\vspace{0.4em}% +} + +% Margin note: a true sidenote, anchored at the call site. +\newcommand{\mnote}[1]{% + \marginnote{\fontsize{8pt}{10pt}\selectfont\color{popnote}\itshape #1}% +} + +% Freeform (unparsed directive): margin note tagged "[freeform]" so it's +% clear the parser couldn't match a verb — leaves the raw words for the +% human reader to interpret. +\newcommand{\mfreeform}[1]{% + \marginnote{\fontsize{8pt}{10pt}\selectfont\color{popnote}% + {\acmono\fontsize{6.5pt}{8pt}\selectfont [freeform]}\\#1}% +} + +% Skipped span: inline placeholder showing how long was removed from +% the body by a `skip` / `redact` whistlepop. +\newcommand{\mskipped}[1]{% + {\acmono\fontsize{8pt}{10pt}\selectfont\color{acgray}[\,skipped #1\,]}% +} + +% Start/end shaded range — wrap a span of turns in this environment. +\newenvironment{whistlepoprange}[1][highlight]{% + \par + \begin{tcolorbox}[enhanced, breakable, sharp corners, + colback=acsoft, colframe=pophighlight, + boxrule=0pt, leftrule=1.5pt, + left=0.4em, right=0.4em, top=0.2em, bottom=0.2em, + before skip=0.5em, after skip=0.5em]% +}{% + \end{tcolorbox}\par +} + +% === SECTION BREAK FROM LONE WHISTLE === +\newcommand{\whistlebreak}{% + \par\vspace{1.2em}% + \begin{center}% + {\color{acgray}\fontsize{12pt}{14pt}\selectfont$\sim$\hspace{0.4em}$\sim$\hspace{0.4em}$\sim$}% + \end{center}% + \vspace{0.6em}\par\noindent\ignorespaces% +} + +% === COLOPHON === +\newcommand{\meetingcolophon}{% + \par\vspace{2em}% + \noindent{\color{acgray}\rule{3em}{0.4pt}}\par + \vspace{0.4em}% + \noindent{\acmono\fontsize{7.5pt}{10pt}\selectfont\color{acgray}% + rendered by \texttt{meetings/cli.mjs build} \enspace·\enspace + transcript via WhisperX \enspace·\enspace + whistlepops via \texttt{detect-whistlepops.mjs}}\par +} + +% === LIST + PARAGRAPH === +\setlist[itemize]{leftmargin=1.4em, itemsep=0.2em, topsep=0.3em, parsep=0pt} +\setlist[enumerate]{leftmargin=1.4em, itemsep=0.2em, topsep=0.3em, parsep=0pt} +\setlength{\parindent}{0pt} +\setlength{\parskip}{0.3em} +\linespread{1.15} + +\tolerance=400 +\emergencystretch=2em +\hyphenpenalty=200 + +\endinput diff --git a/meetings/template/meeting.tex.tmpl b/meetings/template/meeting.tex.tmpl new file mode 100644 --- /dev/null +++ b/meetings/template/meeting.tex.tmpl @@ -0,0 +1,65 @@ +% !TEX program = xelatex +% Template substituted by meetings/cli.mjs build — placeholder names are +% wrapped in double curly braces (see cli.mjs cmdBuild for the list). +% Do not document the placeholders inline here; the substitution is +% literal string-replace and would corrupt the comments. + +\documentclass[11pt,letterpaper]{article} + +\usepackage[ + paperwidth=8.5in, paperheight=11in, + top=0.9in, bottom=0.9in, + inner=0.9in, outer=2.6in, + marginparwidth=1.8in, marginparsep=0.2in, + twoside=false, +]{geometry} + +\usepackage{fontspec} +\usepackage{unicode-math} +\setmainfont{Latin Modern Roman}[ + Extension=.otf, + UprightFont=lmroman10-regular, + BoldFont=lmroman10-bold, + ItalicFont=lmroman10-italic, + BoldItalicFont=lmroman10-bolditalic, +] +\setsansfont{Latin Modern Sans}[ + Extension=.otf, + UprightFont=lmsans10-regular, + BoldFont=lmsans10-bold, + ItalicFont=lmsans10-oblique, + BoldItalicFont=lmsans10-boldoblique, +] + +\usepackage{xcolor} +\usepackage{hyperref} +\hypersetup{ + colorlinks=true, + linkcolor=acpink, + urlcolor=acpink, + pdftitle={ {{TITLE}} }, + pdfsubject={meeting transcript}, + pdfauthor={Aesthetic Computer · meetings/cli.mjs}, +} + +\usepackage{ac-meeting} + +\begin{document} +\pagestyle{empty} + +\meetingtitle{ {{TITLE}} } +\meetingmeta{ {{DATE}} }{ {{DURATION}} }{ {{PARTICIPANTS}} }{ source: {{SOURCE_WAV}} } + +\begin{keyideas} +\begin{itemize} +{{KEY_IDEAS}} +\end{itemize} +\end{keyideas} + +\vspace{1.5em} + +{{BODY}} + +\meetingcolophon + +\end{document} diff --git a/slab/bin/slab-call-record b/slab/bin/slab-call-record new file mode 100644 --- /dev/null +++ b/slab/bin/slab-call-record @@ -0,0 +1,169 @@ +#!/bin/bash +# slab-call-record — start or stop a meeting recording from the slab menubar. +# +# Usage: +# slab-call-record start [title] begin recording → ~/Documents/Shelf/meetings/.wav +# slab-call-record stop stop active recording + hand off to meetings/cli.mjs +# slab-call-record status print "recording" or "idle" +# slab-call-record path print the active WAV path (when recording) +# +# State lives at ~/.ac-meeting-recording.json: +# { "pid": 12345, "wav": "/path/to.wav", "startedAt": "2026-05-27T19:00:00Z", +# "title": "untitled", "device": "MacBook Neo Microphone" } +# +# Capture device: prefers an aggregate device named "Meeting" (BlackHole + +# mic combo) if present, falls back to mic-only with a notification. The +# user is responsible for wiring the aggregate device — slab-call-record +# never installs system audio plumbing. + +set -uo pipefail + +STATE_FILE="$HOME/.ac-meeting-recording.json" +SHELF_DIR="$HOME/Documents/Shelf/meetings" +LOG_FILE="$HOME/Library/Logs/slab-call-record.log" +REPO="/Users/jas/aesthetic-computer" + +mkdir -p "$SHELF_DIR" "$(dirname "$LOG_FILE")" + +ts() { date -u +%Y-%m-%dT%H:%M:%SZ; } +log() { printf '[%s] %s\n' "$(ts)" "$*" >> "$LOG_FILE"; } +notify() { + /usr/bin/osascript -e "display notification \"$2\" with title \"$1\"" 2>/dev/null || true +} + +# Pick the best available capture device. Aggregate device beats mic-only +# because it can also pick up system audio (the other side of the call). +pick_device() { + local devices + devices="$(ffmpeg -f avfoundation -list_devices true -i "" 2>&1 | grep -E '^\[AVFoundation' | grep -i 'audio devices' -A 20 | grep -E '\[[0-9]+\]')" + # Prefer "Meeting" aggregate (user-named), then BlackHole, then default mic. + if echo "$devices" | grep -qi "Meeting"; then + echo "Meeting" + elif echo "$devices" | grep -qi "Aggregate"; then + echo "$devices" | grep -i "Aggregate" | head -1 | sed -E 's/.*\] //; s/ +$//' + elif echo "$devices" | grep -qi "BlackHole"; then + echo "$devices" | grep -i "BlackHole" | head -1 | sed -E 's/.*\] //; s/ +$//' + else + # Fall back to system default mic. ":0" addresses the first input device. + echo ":default" + fi +} + +cmd_status() { + if [[ -f "$STATE_FILE" ]]; then + local pid + pid="$(/usr/bin/awk -F'"pid":' '{print $2}' "$STATE_FILE" | /usr/bin/awk -F'[,}]' '{print $1+0}')" + if [[ -n "$pid" && "$pid" != "0" ]] && kill -0 "$pid" 2>/dev/null; then + echo recording + return 0 + fi + rm -f "$STATE_FILE" + fi + echo idle +} + +cmd_path() { + [[ -f "$STATE_FILE" ]] || { echo ""; return 0; } + /usr/bin/awk -F'"wav":' '{print $2}' "$STATE_FILE" \ + | /usr/bin/sed -E 's/^[[:space:]]*"//; s/".*$//' +} + +cmd_start() { + if [[ "$(cmd_status)" == "recording" ]]; then + notify "slab" "already recording — stop first" + log "start blocked: already recording" + exit 1 + fi + local title="${1:-untitled}" + local safe_title + safe_title="$(echo "$title" | tr -c 'A-Za-z0-9' '-' | tr -s '-' | sed -E 's/^-|-$//g')" + [[ -z "$safe_title" ]] && safe_title="untitled" + local stamp + stamp="$(date +%Y-%m-%d-%H%M%S)" + local wav="$SHELF_DIR/${stamp}-${safe_title}.wav" + local device + device="$(pick_device)" + + log "start: device='$device' wav='$wav' title='$title'" + + # Run ffmpeg detached, redirect all I/O so the menubar can't keep an + # implicit handle. nohup + setsid combo survives parent exit. + if [[ "$device" == ":default" ]]; then + nohup /opt/homebrew/bin/ffmpeg -nostdin -hide_banner -loglevel warning \ + -f avfoundation -i ":0" \ + -ar 48000 -ac 2 -c:a pcm_s16le "$wav" \ + >"$LOG_FILE" 2>&1 & + else + nohup /opt/homebrew/bin/ffmpeg -nostdin -hide_banner -loglevel warning \ + -f avfoundation -i ":$device" \ + -ar 48000 -ac 2 -c:a pcm_s16le "$wav" \ + >"$LOG_FILE" 2>&1 & + fi + local pid=$! + disown "$pid" 2>/dev/null || true + + /usr/bin/printf '{\n "pid": %d,\n "wav": "%s",\n "startedAt": "%s",\n "title": "%s",\n "device": "%s"\n}\n' \ + "$pid" "$wav" "$(ts)" "$title" "$device" > "$STATE_FILE" + + # Give ffmpeg a beat to fail fast on permission issues. + sleep 0.3 + if ! kill -0 "$pid" 2>/dev/null; then + rm -f "$STATE_FILE" + notify "slab" "recorder died — check log" + log "start failed: pid $pid gone" + exit 1 + fi + + if [[ "$device" == ":default" ]]; then + notify "slab" "recording — mic only (no aggregate device)" + else + notify "slab" "recording → $device" + fi + echo "$wav" +} + +cmd_stop() { + if [[ ! -f "$STATE_FILE" ]]; then + notify "slab" "no active recording" + log "stop: no state file" + exit 0 + fi + local pid wav + pid="$(/usr/bin/awk -F'"pid":' '{print $2}' "$STATE_FILE" | /usr/bin/awk -F'[,}]' '{print $1+0}')" + wav="$(cmd_path)" + + if [[ -n "$pid" && "$pid" != "0" ]]; then + # ffmpeg flushes the WAV header on SIGINT (q-key equivalent). Don't SIGKILL. + kill -INT "$pid" 2>/dev/null || true + for _ in 1 2 3 4 5 6 7 8 9 10; do + kill -0 "$pid" 2>/dev/null || break + sleep 0.2 + done + kill -0 "$pid" 2>/dev/null && kill -TERM "$pid" 2>/dev/null || true + fi + rm -f "$STATE_FILE" + log "stop: wav='$wav'" + if [[ -n "$wav" && -f "$wav" ]]; then + notify "slab" "saved $(basename "$wav")" + # Hand off to meetings/cli.mjs ingest in the background so the menubar + # returns immediately. Ingest is cheap (symlink + write JSON). + if [[ -x "$REPO/meetings/cli.mjs" ]]; then + (cd "$REPO" && /usr/bin/env node meetings/cli.mjs ingest "$wav" >>"$LOG_FILE" 2>&1) & + disown $! 2>/dev/null || true + fi + echo "$wav" + else + notify "slab" "stopped — no WAV" + fi +} + +case "${1:-status}" in + start) shift; cmd_start "$@" ;; + stop) cmd_stop ;; + status) cmd_status ;; + path) cmd_path ;; + *) + echo "usage: $(basename "$0") {start [title]|stop|status|path}" >&2 + exit 2 + ;; +esac diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -429,6 +429,40 @@ """ ShellRunner.runAsync("/usr/bin/osascript", args: ["-e", script]) } + /// Spawn the recorder. Refresh immediately + after a short delay so the + /// menu flips to "◉ Recording call…" without waiting for the slow tick + /// (the state file appears within ~300ms of the script starting). + @objc func startCall() { + ShellRunner.runAsync(Paths.slabCallRecord, args: ["start"]) { [weak self] in + DispatchQueue.main.async { self?.refresh() } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + self?.refresh() + } + } + + @objc func stopCall() { + ShellRunner.runAsync(Paths.slabCallRecord, args: ["stop"]) { [weak self] in + DispatchQueue.main.async { self?.refresh() } + } + } + + /// Reveal ~/Documents/Shelf/meetings — where slab-call-record writes + /// the raw WAVs before they get ingested into the meetings/ dir. + @objc func openMeetingsShelf() { + let shelf = "\(Paths.home)/Documents/Shelf/meetings" + let fm = FileManager.default + if !fm.fileExists(atPath: shelf) { + try? fm.createDirectory(atPath: shelf, + withIntermediateDirectories: true) + } + ShellRunner.run("/usr/bin/open", args: [shelf]) + } + + @objc func openMeetingsDir() { + ShellRunner.run("/usr/bin/open", args: [Paths.meetingsDir]) + } + @objc func reloadDaemon() { DispatchQueue.global(qos: .userInitiated).async { ShellRunner.run("/bin/launchctl", args: ["unload", Paths.daemonPlist]) diff --git a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift --- a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift @@ -55,6 +55,12 @@ selector: #selector(AppDelegate.requestForAudio(_:)), target: target) rfa.representedObject = "hum" rfa.toolTip = "Open the RFA wizard in iTerm2 — sing the 'hum' melody note by note, then hear it recompiled." menu.addItem(rfa) + + // Call recording — captures mic (+ system audio if an aggregate + // device is wired) to a WAV in ~/Documents/Shelf/meetings/. On stop + // the WAV is handed off to meetings/cli.mjs which transcribes, + // detects whistlepops, and builds an arxiv-style PDF. + menu.addItem(buildCall(state: state, target: target)) menu.addItem(.separator()) let stayAwake = item("Stay awake (lid closed)", selector: #selector(AppDelegate.toggleStayAwake), target: target) @@ -403,6 +409,50 @@ sub.addItem(item("Sync sotce-mail", selector: #selector(AppDelegate.syncSotceMail), target: target)) sub.addItem(item("Sync quiltnet-mail", selector: #selector(AppDelegate.syncQuiltnetMail), target: target)) sub.addItem(.separator()) sub.addItem(item("Open sync log", selector: #selector(AppDelegate.openSyncLog), target: target)) + parent.submenu = sub + return parent + } + + /// "Start Call" / "◉ Stop Call" item — toggles slab-call-record. While + /// recording, the title shows a recording dot + elapsed-style label and + /// the submenu offers Stop + a shortcut to open ~/Documents/Shelf/meetings. + private static func buildCall(state: StateSnapshot, target: AppDelegate) -> NSMenuItem { + if state.callRecording { + let parent = NSMenuItem(title: "◉ Recording call…", action: nil, keyEquivalent: "") + // Color the title red so it reads as a hot record indicator even + // at a glance — same vocabulary as the Claude awaiting dot. + let attr = NSMutableAttributedString(string: "◉ Recording call…") + attr.addAttribute(.foregroundColor, + value: NSColor(deviceHue: 0.99, saturation: 0.90, brightness: 0.95, alpha: 1.0), + range: NSRange(location: 0, length: 1)) + parent.attributedTitle = attr + let sub = NSMenu() + let stop = item("Stop call", selector: #selector(AppDelegate.stopCall), target: target) + stop.toolTip = "Stop ffmpeg, finalize the WAV, hand off to meetings/cli.mjs ingest." + sub.addItem(stop) + sub.addItem(.separator()) + let openShelf = item("Open meetings shelf", + selector: #selector(AppDelegate.openMeetingsShelf), target: target) + sub.addItem(openShelf) + let openMeetings = item("Open meetings dir", + selector: #selector(AppDelegate.openMeetingsDir), target: target) + sub.addItem(openMeetings) + parent.submenu = sub + return parent + } + let parent = NSMenuItem(title: "📞 Start call", action: nil, keyEquivalent: "") + let sub = NSMenu() + let start = item("Start call recording", + selector: #selector(AppDelegate.startCall), target: target) + start.toolTip = "Begin capturing mic (+ system audio if an aggregate device is wired) to a WAV. Stop from this menu when the call ends." + sub.addItem(start) + sub.addItem(.separator()) + let openShelf = item("Open meetings shelf", + selector: #selector(AppDelegate.openMeetingsShelf), target: target) + sub.addItem(openShelf) + let openMeetings = item("Open meetings dir", + selector: #selector(AppDelegate.openMeetingsDir), target: target) + sub.addItem(openMeetings) parent.submenu = sub return parent } diff --git a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift --- a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift @@ -47,6 +47,15 @@ static var daemonPlist: String { "\(home)/Library/LaunchAgents/computer.slab.daemon.plist" } static var menubarPlist: String { "\(home)/Library/LaunchAgents/computer.slab.menubar.plist" } static var claudeSleep: String { "\(slabBin)/claude-sleep" } + /// "Start Call" recorder + state file. The shell wrapper at + /// slab/bin/slab-call-record owns the ffmpeg subprocess and writes the + /// state file; the menubar only reads it. meetingsCli is the node + /// pipeline that ingests the WAV into meetings//. + static var slabCallRecord: String { "\(slabBin)/slab-call-record" } + static var meetingRecordingState: String { "\(home)/.ac-meeting-recording.json" } + static var meetingsCli: String { "\(acRepo)/meetings/cli.mjs" } + static var meetingsDir: String { "\(acRepo)/meetings" } + static var passphraseSocket: String { "\(home)/.ac-daemon.sock" } static var ambientFlag: String { "/tmp/slab-ambient-active" } diff --git a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift --- a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift @@ -35,6 +35,13 @@ /// is on. Set by AppDelegate (not gather()) from the imsg poll — the /// whole status surface (polygon icon + themed terminals) then carries a /// shared "she texted" accent until the thread is read. var messageWaiting: Bool = false + /// True when slab-call-record is actively capturing a meeting WAV. + /// Populated from ~/.ac-meeting-recording.json — the recorder script + /// owns the state file; the menubar only reads it. + var callRecording: Bool = false + /// Path to the active recording WAV when callRecording is true. + /// Empty otherwise. + var callRecordingPath: String = "" var totalActive: Int { activePrompts + activeSubagents } var hasWork: Bool { totalActive > 0 } @@ -67,7 +74,30 @@ s.forceBright = FileManager.default.fileExists(atPath: Paths.forceBrightFlag) s.tailnetPeers = TailnetPeer.query() s.claudeSessions = ClaudeSessionReader.active() s.popRenders = readPopRenders() + let (rec, recPath) = readCallRecordingState() + s.callRecording = rec + s.callRecordingPath = recPath return s + } + + /// Inspect ~/.ac-meeting-recording.json. The file exists only while + /// slab-call-record has a live ffmpeg subprocess; we additionally + /// verify the PID is alive (kill -0) so a stale file from a crashed + /// recorder doesn't keep the menu pinned to "recording" forever. + private static func readCallRecordingState() -> (Bool, String) { + let path = Paths.meetingRecordingState + guard FileManager.default.fileExists(atPath: path), + let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return (false, "") } + let pid = (obj["pid"] as? Int) ?? 0 + if pid > 0 && kill(pid_t(pid), 0) != 0 && errno == ESRCH { + // Recorder died — clean up so we don't lie to the user. + try? FileManager.default.removeItem(atPath: path) + return (false, "") + } + let wav = (obj["wav"] as? String) ?? "" + return (true, wav) } /// Read the /pop render progress heartbeats from ~/.ac-pop-renders/. diff --git a/wave-wizard/samples/whistlepops/README.md b/wave-wizard/samples/whistlepops/README.md new file mode 100644 --- /dev/null +++ b/wave-wizard/samples/whistlepops/README.md @@ -0,0 +1,55 @@ +# whistlepops corpus + +Training set for `meetings/detect-whistlepops.mjs` — the detector that finds +whistle events in meeting recordings and labels them so the LaTeX builder can +turn them into section breaks, decision callouts, highlight blocks, etc. + +## Grammar that this corpus is meant to train + +- **lone whistle** (`single`) → punctuation: section break / beat +- **whistle pair** (`pair-open` … speech … `pair-close`) → whistlepop: + the speech between two whistles is interpreted as a directive to the + pipeline, not part of the conversation transcript. See `meetings/dsl.md`. + +The acoustic classes the detector learns are the actual sounds, not the +grammatical roles — pairing is a post-pass over event timestamps. Classes: + +- `short` — quick tweet +- `medium` — ~1s single pitch +- `long` — 2–3s sustained +- `rising` — two-tone, pitch up +- `falling` — two-tone, pitch down +- `quiet` — soft, close-mic +- `far` — distant, room-mic + +Negatives guard against false positives the detector is likely to confuse +with whistles (vowels in the same band, hums, laughs, kettles, fan whine, +keyboards). + +## How to record + +```bash +cd wave-wizard +swift run WaveWizard samples/whistlepops/spec.json +``` + +The wizard walks the sample list, speaks the prompt, waits for an onset, +records, auto-trims, then offers Keep / Retry / Skip. Output WAVs land in +`takes/` named after the sample (`pos-short-01.wav`, …). + +Goal: 3 takes of every positive variant + 3 takes of every negative class. +~40 prompts × ~15s of effort each ≈ 15–20 minutes of focused recording. + +## How the detector uses this + +`meetings/detect-whistlepops.mjs` loads every WAV under `takes/`, infers the +class label from the filename prefix (`pos-*` vs `neg-*`, then the variant), +extracts a feature vector (peak freq, bandwidth, harmonic ratio, sustain +duration, attack time, peak band concentration), and fits a small classifier +(k-NN or logistic regression — TBD once we see how separable the features +are). At inference time it slides over a meeting WAV, scores each window, +emits `[{ kind, t_start, t_end, confidence }]` events. + +Acoustic variants (`short` / `medium` / `long` / `rising` / `falling`) are +preserved on output so a future DSL revision can use them as distinct +verbs without recapture (e.g. rising = "open", falling = "close"). diff --git a/wave-wizard/samples/whistlepops/spec.json b/wave-wizard/samples/whistlepops/spec.json new file mode 100644 --- /dev/null +++ b/wave-wizard/samples/whistlepops/spec.json @@ -0,0 +1,74 @@ +{ + "title": "whistlepops training corpus", + "outDir": "/Users/jas/aesthetic-computer/wave-wizard/samples/whistlepops/takes", + "trim": { + "thresholdPctOfPeak": 0.03, + "padHeadMs": 60, + "padTailMs": 120, + "fadeMs": 6, + "normalizeDb": -3.0 + }, + "autoStop": { + "onsetDb": -32, + "silenceDb": -44, + "silenceDurationMs": 700, + "maxRecordSec": 5 + }, + "samples": [ + { "name": "pos-short-01", "desc": "short whistle — a quick tweet, under half a second" }, + { "name": "pos-short-02", "desc": "again — short whistle, quick tweet" }, + { "name": "pos-short-03", "desc": "one more short whistle" }, + + { "name": "pos-medium-01", "desc": "medium whistle — about one second, single steady pitch" }, + { "name": "pos-medium-02", "desc": "another medium whistle, one second steady" }, + { "name": "pos-medium-03", "desc": "one more medium whistle" }, + + { "name": "pos-long-01", "desc": "long sustained whistle — hold for two to three seconds, single pitch" }, + { "name": "pos-long-02", "desc": "another long sustained whistle, two to three seconds" }, + { "name": "pos-long-03", "desc": "one more long sustained whistle" }, + + { "name": "pos-rising-01", "desc": "two-tone whistle going up in pitch — low, then high" }, + { "name": "pos-rising-02", "desc": "another rising two-tone whistle, low to high" }, + { "name": "pos-rising-03", "desc": "one more rising two-tone whistle" }, + + { "name": "pos-falling-01", "desc": "two-tone whistle going down — high, then low" }, + { "name": "pos-falling-02", "desc": "another falling two-tone whistle, high to low" }, + { "name": "pos-falling-03", "desc": "one more falling two-tone whistle" }, + + { "name": "pos-quiet-01", "desc": "soft, quiet whistle — close to the mic" }, + { "name": "pos-quiet-02", "desc": "another soft whistle, close to the mic" }, + { "name": "pos-quiet-03", "desc": "one more soft, quiet whistle" }, + + { "name": "pos-far-01", "desc": "whistle from a couple steps back — across the room" }, + { "name": "pos-far-02", "desc": "another whistle from across the room" }, + { "name": "pos-far-03", "desc": "one more whistle from across the room" }, + + { "name": "neg-whee-01", "desc": "say the word: wheeeee" }, + { "name": "neg-whee-02", "desc": "say it again: wheeeee" }, + { "name": "neg-whee-03", "desc": "one more: wheeeee" }, + + { "name": "neg-whoo-01", "desc": "say: whooo" }, + { "name": "neg-whoo-02", "desc": "again: whooo" }, + { "name": "neg-whoo-03", "desc": "one more: whooo" }, + + { "name": "neg-oo-01", "desc": "hold a wordless ooooo vowel for about a second" }, + { "name": "neg-oo-02", "desc": "another oooooo" }, + { "name": "neg-oo-03", "desc": "one more oooooo" }, + + { "name": "neg-hum-01", "desc": "hum a note with your mouth closed" }, + { "name": "neg-hum-02", "desc": "hum another note" }, + { "name": "neg-hum-03", "desc": "one more hum" }, + + { "name": "neg-laugh-01", "desc": "a short laugh — heh heh heh" }, + { "name": "neg-laugh-02", "desc": "another short laugh" }, + { "name": "neg-laugh-03", "desc": "one more short laugh" }, + + { "name": "neg-speech-01", "desc": "say a normal sentence: the kettle is on the stove" }, + { "name": "neg-speech-02", "desc": "say: i wonder what we should do about that" }, + { "name": "neg-speech-03", "desc": "say: this is just a regular voice take" }, + + { "name": "neg-typing-01", "desc": "type on your keyboard at a normal pace for about three seconds" }, + { "name": "neg-typing-02", "desc": "type again for about three seconds" }, + { "name": "neg-typing-03", "desc": "one more typing take" } + ] +} -- tangled.sh