diff --git a/.env.example b/.env.example index a9c52a6..4bef5a2 100644 --- a/.env.example +++ b/.env.example @@ -127,12 +127,15 @@ GROQ_API_KEY= # its STT model or endpoint (OpenRouter's base64-JSON transcription API): # OPENROUTER_STT_MODEL=openai/whisper-large-v3 # OPENROUTER_STT_URL=https://openrouter.ai/api/v1/audio/transcriptions -# Diarizing STT primary (cloud lane). Leave blank to fall back to Groq (no diarization). +# Diarizing STT (cloud lane): the only cloud provider that returns speaker labels. +# Set this AND TRANSCRIPTION_PROVIDER=assemblyai for "who said what"; leave blank to +# fall back to Groq (transcription only, no speaker labels). ASSEMBLYAI_API_KEY= # Reserved for the deepgram provider (not yet wired): https://console.deepgram.com DEEPGRAM_API_KEY= -# Keyless self-host diarization: URL of the optional WhisperX+pyannote sidecar. -# When set and TRANSCRIPTION_PROVIDER=local, audio never leaves the instance. +# Diarizing STT (self-host lane): URL of the optional WhisperX+pyannote sidecar, the +# only keyless way to get speaker labels. Set this AND TRANSCRIPTION_PROVIDER=local; +# audio never leaves the instance. TRANSCRIPTION_LOCAL_URL= # --------------------------------------------------------------------------- diff --git a/README.md b/README.md index 4184052..fb92a6f 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,15 @@ Minutia works with zero AI, recording, or calendar; the data model is AI-ready a Self-hosters bring their own key: set `OPENROUTER_API_KEY` (or an OpenAI-compatible key) in your environment to enable AI, or leave it unset to run fully AI-free. +### Speaker diarization + +Speaker labels ("who said what") need a diarizing transcription provider. Groq and OpenAI-compatible Whisper transcribe accurately but return unlabeled text; only two providers diarize: + +- **AssemblyAI** - set `ASSEMBLYAI_API_KEY` and `TRANSCRIPTION_PROVIDER=assemblyai`. +- **Local WhisperX sidecar** - run the sidecar and point `TRANSCRIPTION_LOCAL_URL` at it, with `TRANSCRIPTION_PROVIDER=local`, to keep audio on your own infrastructure. + +With neither configured, transcripts are produced without speaker labels. Admin > Health shows the current transcription mode ("diarization on" or "transcription only"). + ## Capture the meeting, no bot in the room Some conversations you want captured word for word. [Minutia Desktop](https://github.com/shiprite-dev/minutia-desktop) is a native macOS menu bar app that records the meeting the moment it starts, your microphone and the room's system audio both, whether you're on Zoom, Teams, Meet, or sitting across a table. Nothing joins the call: no recording bot in the participant list, no extra service in the middle of your conversation. diff --git a/e2e/regression/audio-capture.spec.ts b/e2e/regression/audio-capture.spec.ts index 7ab1446..cf17c3c 100644 --- a/e2e/regression/audio-capture.spec.ts +++ b/e2e/regression/audio-capture.spec.ts @@ -83,6 +83,59 @@ async function createLiveMeetingFixture(request: APIRequestContext) { return { seriesId, meetingId }; } +async function setCompanionLastSeen( + request: APIRequestContext, + value: string | null +) { + await rest(request, `profiles?id=eq.${TEST_USER_ID}`, { + method: "PATCH", + headers: serviceHeaders("return=minimal"), + data: { companion_last_seen_at: value }, + }); +} + +const MAC_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"; + +test.describe("Companion record hand-off", () => { + test.use({ userAgent: MAC_UA }); + + test("offers a lowercase minutia://record deep link to a mac manager with a known companion", async ({ + page, + request, + }) => { + test.skip(!HAS_SERVICE_ROLE, "Requires service role for isolated fixtures"); + + const fixture = await createLiveMeetingFixture(request); + await setCompanionLastSeen(request, new Date().toISOString()); + // The primary platform check reads navigator.userAgentData; force macOS so the + // feature-detect resolves the same way it would on a real Mac browser. + await page.addInitScript(() => { + Object.defineProperty(navigator, "userAgentData", { + configurable: true, + get: () => ({ platform: "macOS" }), + }); + }); + + try { + await page.goto(`/series/${fixture.seriesId}/meetings/${fixture.meetingId}`); + await waitForApp(page); + await expect(page.getByText("Live").first()).toBeVisible(); + + const link = page.getByRole("link", { name: "Record with companion" }); + await expect(link).toBeVisible(); + await expect(link).toHaveAttribute( + "href", + `minutia://record?meeting_id=${fixture.meetingId.toLowerCase()}` + ); + } finally { + await setCompanionLastSeen(request, null); + await deleteSeries(request, fixture.seriesId); + } + }); +}); + test.describe("Meeting audio capture", () => { test("records during live capture and uploads audio on meeting end", async ({ page, diff --git a/package.json b/package.json index 15e1664..7b6c107 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "test:email-layout": "node --test scripts/verify-email-layout.test.mjs", "test:brief": "node --test scripts/verify-brief.test.mjs", "test:auth-links": "node --test scripts/verify-auth-links.test.mjs", + "test:companion-links": "node --test scripts/verify-companion-links.test.mjs", "test:cadence-contract": "node --test scripts/verify-cadence-contract.test.mjs", "test:seat-billing": "node --test scripts/verify-seat-billing.test.mjs", "test:admin-capabilities": "node --test scripts/verify-admin-capabilities.test.mjs", diff --git a/scripts/verify-admin-health.test.mjs b/scripts/verify-admin-health.test.mjs index 93a0dcf..3510531 100644 --- a/scripts/verify-admin-health.test.mjs +++ b/scripts/verify-admin-health.test.mjs @@ -19,7 +19,9 @@ await esbuild.build({ logLevel: "silent", absWorkingDir: root, }); -const { configStatus, overallHealth } = await import(pathToFileURL(bundled).href); +const { configStatus, overallHealth, transcriptionProbe } = await import( + pathToFileURL(bundled).href +); test("configStatus maps presence to ok/unconfigured", () => { assert.equal(configStatus("smtp.example.com"), "ok"); @@ -79,3 +81,38 @@ test("overallHealth is down when any probe reports down", () => { test("overallHealth handles empty input as ok", () => { assert.equal(overallHealth([]), "ok"); }); + +test("transcriptionProbe is unconfigured when no provider is set up", () => { + assert.deepEqual(transcriptionProbe(false, false), { + service: "transcription", + status: "unconfigured", + }); + // Diarizing can never be true without being configured, but guard the shape. + assert.equal(transcriptionProbe(false, true).status, "unconfigured"); +}); + +test("transcriptionProbe is ok with a diarization-on note when diarizing", () => { + assert.deepEqual(transcriptionProbe(true, true), { + service: "transcription", + status: "ok", + detail: "diarization on", + }); +}); + +test("transcriptionProbe is degraded (not down) when transcription cannot diarize", () => { + assert.deepEqual(transcriptionProbe(true, false), { + service: "transcription", + status: "degraded", + detail: "transcription only", + }); +}); + +test("a degraded transcription probe keeps overall health amber, not red", () => { + assert.equal( + overallHealth([ + { service: "database", status: "ok" }, + transcriptionProbe(true, false), + ]), + "degraded" + ); +}); diff --git a/scripts/verify-companion-links.test.mjs b/scripts/verify-companion-links.test.mjs new file mode 100644 index 0000000..9998ef1 --- /dev/null +++ b/scripts/verify-companion-links.test.mjs @@ -0,0 +1,77 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import * as esbuild from "esbuild"; + +// Bundle the pure companion-link helpers for node:test (repo verifier pattern). +const root = process.cwd(); +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "minutia-companion-links-")); +const bundled = path.join(tempDir, "companion-links.mjs"); +await esbuild.build({ + entryPoints: ["src/lib/companion-links.ts"], + outfile: bundled, + bundle: true, + platform: "node", + format: "esm", + logLevel: "silent", + absWorkingDir: root, +}); +const { buildCompanionAuthCallbackUrl, buildCompanionRecordUrl, isMacPlatform } = + await import(pathToFileURL(bundled).href); + +const LOWER = "0f9c2c9a-1a2b-4c3d-8e4f-5a6b7c8d9e0f"; + +test("buildCompanionAuthCallbackUrl encodes the token hash into the scheme", () => { + assert.equal( + buildCompanionAuthCallbackUrl("abc123"), + "minutia://auth-callback?token_hash=abc123" + ); + assert.equal( + buildCompanionAuthCallbackUrl("a b+c"), + "minutia://auth-callback?token_hash=a%20b%2Bc" + ); +}); + +test("buildCompanionAuthCallbackUrl rejects an empty token hash", () => { + assert.throws(() => buildCompanionAuthCallbackUrl("")); + assert.throws(() => buildCompanionAuthCallbackUrl(" ")); +}); + +test("buildCompanionRecordUrl builds the record scheme with the meeting id", () => { + assert.equal( + buildCompanionRecordUrl(LOWER), + `minutia://record?meeting_id=${LOWER}` + ); +}); + +test("buildCompanionRecordUrl lowercases an uppercase meeting id", () => { + assert.equal( + buildCompanionRecordUrl(LOWER.toUpperCase()), + `minutia://record?meeting_id=${LOWER}` + ); +}); + +test("buildCompanionRecordUrl rejects non-uuid meeting ids", () => { + assert.throws(() => buildCompanionRecordUrl("not-a-uuid")); + assert.throws(() => buildCompanionRecordUrl("")); + assert.throws(() => buildCompanionRecordUrl(`${LOWER} OR 1=1`)); + assert.throws(() => buildCompanionRecordUrl(`${LOWER}/extra`)); +}); + +test("isMacPlatform prefers userAgentData.platform when present", () => { + assert.equal(isMacPlatform({ platform: "macOS" }, "irrelevant"), true); + assert.equal(isMacPlatform({ platform: "Windows" }, "Mac irrelevant"), false); +}); + +test("isMacPlatform falls back to the userAgent string", () => { + assert.equal( + isMacPlatform(undefined, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"), + true + ); + assert.equal(isMacPlatform(undefined, "Mozilla/5.0 (Windows NT 10.0)"), false); + assert.equal(isMacPlatform(undefined, undefined), false); + assert.equal(isMacPlatform({}, "Macintosh"), true); +}); diff --git a/src/app/(app)/admin/(instance)/settings/page.tsx b/src/app/(app)/admin/(instance)/settings/page.tsx index ed9c9d3..b3d81a2 100644 --- a/src/app/(app)/admin/(instance)/settings/page.tsx +++ b/src/app/(app)/admin/(instance)/settings/page.tsx @@ -313,6 +313,7 @@ export default function AdminSettingsPage() { (form.ai_provider as "openai-compatible" | "anthropic" | null) ?? "openai-compatible"; const visibleAiFields = aiFormFields(activeProvider || "openai-compatible"); + const diarizationConfigured = form.diarization_configured === "true"; const showFeatureFlags = caps.retroToggle || caps.slackWebhook || caps.reminderWebhook || caps.promptLinks; @@ -520,6 +521,21 @@ export default function AdminSettingsPage() { )} + {aiKeyConfigured && !diarizationConfigured && ( +

+ Speaker labels are off. Transcripts will not identify who spoke. + Configure AssemblyAI or a local WhisperX sidecar to enable + diarization.{" "} + + Learn more + +

+ )}