MeetScribe / brief

MeetScribe brief v2.0

A Chrome extension that transcribes a Google Meet call you are in, on your own OpenRouter key, without Google's recording or transcription features, a bot in the call, or a server of ours.

Version2.0, 3 November 2026
Replacesv1.0 (ChatGPT draft); every change is reasoned on the review
Statusspecified Specified, nothing built. The plan starts with a Phase 0 spike
FamilyVoiceDebrief (voicedebrief.ai) for the product; the chrome-extensions.sgit.ai hub for how extensions are built
OwnerDinis Cruz, editor

Status marks used below: specified in this brief, to be built; spike a question Phase 0 must answer before the design is final; proposed an idea for the editor to accept or refuse, not in the MVP.

1. The one rule

MeetScribe listens only while you can see that it is listening, keeps every second it heard until that second is transcribed or you delete it, and never shows a guess as a fact.

Everything below follows from it: a visible side panel instead of a hidden recorder; audio persisted before it is sent; a cost of null rather than a made-up 0; a speaker label only where the channel proves it; a dimmed probable-echo line rather than a silent deletion.

2. Who it is for and what it does

The first user is one person on Google Workspace Business Starter, which has no Meet transcripts (Google's edition list stops at Business Standard), who already uses OpenRouter and wants transcripts and notes they hold themselves. Then freelancers, consultants and small teams on the same plan.

In the MVP specified

Later proposed

Remote speaker labels (section 13), search across meetings, a local Whisper engine as a "browser-local" privacy mode, Drive and Docs export, custom summary templates, a meeting assistant ("what did I agree to?").

Never

A bot in the call. Google's recording, transcription or the Meet Media API (deprecated 28 October 2026, endpoints off 15 January 2027). Starting without a click. Hiding Chrome's capture indicator. A server of ours holding audio or transcripts. Video.

3. Architecture

Who owns what

ContextLivesOwnsNever
Service workerWakes on eventsSession state machine, commands, tabs.onRemoved, alarms to drain the queue after a restartHolds a media stream or audio
Offscreen document (reason USER_MEDIA)Only while capturingBoth streams, playback to the speakers, the worklet, VAD, writing segments, draining the queue during captureShows UI
Side panelWhile openLive transcript, start, stop, pause, mic mute, consent dialog, warningsHolds audio
Setup page (an extension tab)First runAPI key, microphone grant, output device, test connection
Dashboard (an extension tab)On demandHistory, transcript editing, summary, export, retries
Options pageOn demandSettings, data deletion, build provenance

All contexts share the extension origin, so all read the same IndexedDB. Messages between them carry IDs and small state, never audio (a Blob sent through chrome.runtime.sendMessage arrives as {}).

Data flow

 Meet tab ──tabCapture.getMediaStreamId (user gesture)──┐
                                                        v
 Offscreen document                               getUserMedia(tab)      getUserMedia(mic, opt-in)
   tab stream ──► AudioContext ──► destination (setSinkId: your output device)
        │                                                              │
        └──► AudioWorklet: 16 kHz mono PCM, sample counter = clock ◄────┘   (one per channel)
                       │
                       ▼
               VAD: cut at pauses (8 s min, 15-25 s target, 30 s forced), drop silence
                       │
                       ▼
               WAV segment ──► IndexedDB  (status: pending)        ◄── persisted BEFORE any send
                                    │
                 drain (lease) ─────┤  offscreen during capture; service worker / dashboard after
                                    ▼
               POST openrouter.ai/api/v1/audio/transcriptions  { provider: { zdr: true } }
                                    │
                                    ▼
               transcript line (immutable model output) + usage.cost|null ──► side panel, dashboard
                                    │
                         after stop ▼
               (translate) ──► summary with segment citations ──► export

4. Capture

4.1 The meeting specified spike

The user's click on the extension is the gesture. The service worker calls chrome.tabCapture.getMediaStreamId({ targetTabId }) (Chrome 116 and later), creates the offscreen document if absent, and passes it the ID. The offscreen document redeems it with getUserMedia({ audio: { mandatory: { chromeMediaSource: 'tab', chromeMediaSourceId } } }), audio only.

Capturing silences the tab, so the offscreen document routes the stream to AudioContext.destination at once. The context's output goes to the device chosen in setup through AudioContext.setSinkId(), so the meeting does not jump from a headset to the laptop speakers.

Spike S1 decides the gesture route: (a) the action click opens the side panel and its Start button requests the stream ID, or (b) the action click itself starts capture and opens the panel. (a) is preferred because the consent dialog comes before any audio exists.

4.2 Your microphone specified spike

Opt-in per meeting, off by default. Granted once, for the extension's origin, from the setup page (an offscreen document cannot show a prompt, and a prompt from a popup closes it; spike S2 confirms the grant carries over). Requested with echoCancellation: true, noiseSuppression: true, autoGainControl: true and the device chosen in setup. Never routed to the speakers.

The side panel always shows the microphone's state, has its own mute, and says, in these words: MeetScribe's microphone is separate from Meet's. Muting yourself in Meet does not mute MeetScribe. A keyboard shortcut (chrome.commands, suggested Alt+Shift+M) toggles it from any tab. If the grant is refused or the device disappears, capture continues with the meeting channel and a visible warning.

4.3 Samples, not recorder blobs specified

MediaRecorder is not used for segments: with a timeslice only the first blob carries the container header, and the rest are not decodable files. Each channel goes through an AudioWorklet that downsamples to 16 kHz mono 16-bit PCM and posts frames to the offscreen document's main thread. The running sample count per channel is the clock: a segment's start and end are sample offsets from the session start, converted to milliseconds for display. Both channels start from the same AudioContext.currentTime, so they share one timeline.

Each segment is written as a WAV (RIFF header plus PCM): 32 KB per second, 640 KB for 20 seconds. Opus through WebCodecs is a later saving, not an MVP need.

4.4 Cutting at pauses specified

A voice activity detector per channel (RMS energy against an adaptive noise floor, with a 300 ms hangover) closes a segment at the first pause of at least 600 ms once the segment is 8 seconds long, and forces a cut at 30 seconds in unbroken speech. A forced cut is recorded on the segment (cut: "forced"). Segments with no speech are not stored as audio; the timeline records a silence span, so a gap is explained rather than missing. No overlap and no text de-duplication: a pause is a natural boundary.

4.5 Checked by content before sending specified

Before a segment is queued: the bytes start with RIFF....WAVE, the sample count matches the declared duration, and the energy is not zero. A segment that fails is marked invalid with the reason and kept for inspection. Never trust a label we wrote ourselves (VoiceDebrief issue 025).

5. Transcription

5.1 The request specified

POST https://openrouter.ai/api/v1/audio/transcriptions
Authorization: Bearer <key from chrome.storage.local, never logged>
{
  "model": "<chosen in setup>",
  "input_audio": { "data": "<base64 WAV>", "format": "wav" },
  "language": "<ISO-639-1, or omitted for auto-detect>",
  "response_format": "verbose_json",
  "timestamp_granularities": ["segment"],
  "temperature": 0,
  "provider": { "zdr": true }
}

If the model rejects verbose_json with a 400, the extension records that for the model and retries with json; lines from that model show segment-level time only. The extension never sets prompt at the top level (OpenRouter accepts and ignores it); continuity prompts for providers that support them go under provider.options and are proposed.

5.2 The response specified

Validate the status, then the shape. Store text, segments (offsets added to the audio segment's start), language, usage.seconds, usage.cost (or null), and the X-Generation-Id header. The model's output is immutable once stored.

Text that matches a known hallucination phrase ("thank you for watching", "subtitles by", and the like, a list kept in the repository with its sources) is flagged suspect, shown dimmed, never deleted.

5.3 The queue specified

Segments are pending, sending, done, failed or invalid. Before sending, a context takes a lease (claimedBy, claimedUntil = now + 90 s) in an IndexedDB transaction; a lease that expires returns the segment to pending. Two requests in flight per meeting at most. The offscreen document drains during capture; afterwards an alarms tick in the service worker and the open dashboard do. A browser restart resumes the drain.

5.4 Errors specified

ResponseMeaningWhat happens
200DoneStore, show, release the lease
400Bad requestverbose_json rejected: retry once as json. Otherwise failed with the message; no retry
401, 403Key rejectedStop sending for every meeting; the panel names the key, not the network
402Out of creditStop sending; keep capturing; say so; resume when the key works
413Too largeShould not happen at 30 s maximum; failed, reported as a bug
429, 529Rate limit, overloadedRespect Retry-After; back off with jitter
500, 502, 503, 504, 524Upstream or timeoutBack off with jitter, up to 5 attempts, then failed with a manual retry
Failed to fetchNetwork, or a key rejected without CORSAsk GET /api/v1/key. Rejected: treat as 401. Accepted: offline; keep the queue, retry on online

Backoff: 2, 4, 8, 16, 32 s, each with up to 50% jitter.

5.5 Choosing the model spike

No default is chosen from a price list. Phase 0 transcribes the same recordings (English, Portuguese, mixed, one with crosstalk) with four to six candidates from the snapshot, and the editor picks on quality, then cost from usage.cost, then latency, then verbose_json support. Starting candidates: openai/whisper-large-v3, openai/whisper-large-v3-turbo, mistralai/voxtral-mini-transcribe-3-0, nvidia/nemotron-3.5-transcribe, elevenlabs/scribe-v2, openai/gpt-transcribe. The setup page lists the live catalogue (/api/v1/models?output_modalities=transcription) so the user can pick another.

6. The transcript

6.1 Lines specified

One line per returned segment (or per request, where the model gives no segments). Ordered by the audio segment's start sample, so out-of-order responses settle into place. A gap where a segment is pending or failed is shown as such, with a retry button: the transcript is never silently shorter than the meeting.

6.2 Labels specified

You for the microphone channel, Meeting for the tab. A microphone line whose text closely matches a tab line overlapping it in time is marked probable-echo and shown dimmed (spike S3 measures how often). No other speaker labels in the MVP.

6.3 Edits specified

Edits are a revision layer: { lineId, text, editedAt }. The model's text stays; export offers edited (default) or original. Reprocessing never touches an edit.

6.4 Time specified

Every line has a start time to the millisecond on the session clock, honestly labelled: ≈ when it is the audio segment's start, exact when the model returned segment timestamps.

7. After the meeting

Declared as a small workflow file, as VoiceDebrief's pass is (issue 042): steps, model, ceiling per step, what to do on failure.

  1. Drain until no segment is pending or sending (or the user proceeds with gaps, which the summary is told about).
  2. Translate specified, optional, on when the detected language differs from the summary language. The transcript is never replaced; the translation is a separate artefact. On failure the summary runs on the original.
  3. Summarise specified with a chosen text model through /api/v1/chat/completions, in one request. Split into sections and merge only when the transcript is measured to exceed the model's context. The prompt requires: an executive summary; discussion points; decisions explicitly agreed; action items with owner and due date only where stated, otherwise null; open questions; suggestions kept apart from commitments; every item citing segment IDs, which the dashboard turns into links. Output is JSON, validated against a schema before display.

A failed summary leaves the transcript untouched.

8. Storage

IndexedDB through a small wrapper, no library. chrome.storage.local holds settings and the key only. Permission unlimitedStorage, and navigator.storage.persist() on first run.

// Types shown as TypeScript for reading; the code carries them as JSDoc.
type Meeting = {
  id: string; title: string; tabTitle: string | null;
  startedAt: string; endedAt: string | null;
  status: 'capturing' | 'paused' | 'draining' | 'complete' | 'interrupted';
  sampleRate: 16000; channels: ('tab' | 'mic')[];
  model: string; language: string | null;
  costUsd: number;            // the sum of reported costs only
  costUnknownRequests: number; // requests whose cost was not reported
};
type AudioSegment = {
  id: string; meetingId: string; channel: 'tab' | 'mic'; seq: number;
  startSample: number; endSample: number; cut: 'pause' | 'forced' | 'stop';
  wav: Blob | null;            // null once deleted under the retention policy
  status: 'pending' | 'sending' | 'done' | 'failed' | 'invalid';
  attempts: number; lastError: string | null;
  claimedBy: string | null; claimedUntil: number | null;
};
type Line = {
  id: string; meetingId: string; segmentId: string; channel: 'tab' | 'mic';
  startMs: number; endMs: number; timing: 'segment-start' | 'model';
  text: string;                // immutable model output
  flags: ('suspect' | 'probable-echo')[];
  model: string; generationId: string | null; costUsd: number | null;
};
type Edit = { lineId: string; text: string; editedAt: string };
type Summary = { id: string; meetingId: string; model: string; generatedAt: string;
  sourceLanguage: string | null; language: string; json: object; costUsd: number | null };

Retention defaults: transcripts and summaries until deleted; audio deleted 7 days after its segment is done (setting: immediately, 7 days, 30 days, keep); failed and invalid audio kept until retried or deleted. Delete meeting and Delete all data remove every row and blob, then report the count.

9. Permissions

PermissionWhyWarning at install
tabCaptureThe meeting's audioNo
offscreenA document to hold the streamsNo
activeTabConfirm the invoked tab is meet.google.comNo
sidePanelThe live viewNo
declarativeContentEnable the icon only on meet.google.com, without reading any pageNo
storage, unlimitedStorageSettings; audio queue that is not evictedNo
alarmsResume the queue after the service worker sleepsNo
host_permissions: https://openrouter.ai/*Transcription and summary requests, readable errorsYes: "Read and change your data on openrouter.ai"

Not requested: tabs, <all_urls>, meet.google.com host access, scripting, history, webRequest, debugger. The microphone is a web permission granted on the setup page, not a manifest permission. minimum_chrome_version: "116".

"content_security_policy": {
  "extension_pages": "script-src 'self'; object-src 'self'; connect-src https://openrouter.ai"
}

10. Interface

Pause stops capture (no audio exists for the paused span); it does not merely pause sending.

12. Engineering rules

  1. ES modules and native web components; no framework, no bundler, no runtime dependencies. Types as JSDoc, checked with tsc --noEmit --checkJs --strict in CI.
  2. extension/ is what ships; the release zip equals it byte for byte, with a SHA-256 beside it.
  3. Tests with no mocks: recorded real OpenRouter responses replayed by a local server; Chromium's fake media (--use-fake-device-for-media-stream, --use-file-for-fake-audio-capture) for the microphone; a local page playing known audio for the tab; a keyless demo that aborts any network request.
  4. The silent failures are tests: a non-first MediaRecorder chunk is rejected by the content check; a Blob never crosses a message; a cost that was not reported stays null; a segment is never sent twice under concurrent drains; silence produces no request.
  5. A validator gates every release: manifest permissions are exactly section 9's, no fetch outside openrouter.ai, no innerHTML with data, no key-shaped string (sk-or-v1-) anywhere.
  6. British English, no em-dashes, in the code's strings and the site.

13. Speaker labels, later proposed

14. Acceptance criteria

IDCriterionMeasured by
A1Starts on Business Starter with Meet's recording and transcripts unusedManual, on a Business Starter account
A2You hear the meeting, on your chosen device, with no echo and no added delay you can noticeManual, headset and speakers
A3Every second captured is transcribed, pending, failed or silence; nothing unaccountedTest: sum of spans equals session length
A4No segment is billed twiceTest: concurrent drains, killed service worker
A52 hours of capture: offscreen memory flat (within 50 MB of the 5-minute mark)Spike S4, then a test
A6Network off for 10 minutes: all segments sent after it returnsTest
A7A rejected key is reported as a key problem, never as a network errorTest with a revoked key
A8The microphone is never captured unless enabled for this meeting, and its mute works from any tabTest
A9Costs shown equal the sum of usage.cost; unreported costs are counted, not zeroTest
A10Summary items each cite at least one segment; no owner or date without one in the transcriptTest on recorded meetings
A11Meetings survive a browser restart; an interrupted capture is marked interrupted with its dataTest
A12Delete all leaves no MeetScribe data in IndexedDB or storageTest
A13Exports: TXT, Markdown, JSON, never containing the key or audioTest

15. Open questions for the editor

  1. Where the extension's site lives: this repository's GitHub Pages, meetscribe.voicedebrief.ai, or meetscribe.chrome-extension.sgit.ai beside Fix the Page.
  2. Whether MeetScribe is a VoiceDebrief product (its key guidance, its privacy wording, possibly its declared-workflow code) or an sgit.ai example extension.
  3. Chrome Web Store or load-unpacked only for the first users.
  4. Whether the per-meeting consent dialog may be skipped for meetings you host alone (a recording of yourself).
  5. The summary language default: the meeting's language or the browser's.