Skip to content
in buildBuild phases 0–8 done · phase 9 (pipeline orchestration) underway, 5 of its 11 steps built · next: rate limiting (phase 10), then deployment

MeetMind: meeting videos to cited notes, with the video kept in the browser

Nuxt 4 app built to turn meeting videos into timestamped transcripts, cited summaries and Q&A; ffmpeg.wasm extracts the audio in the browser. Not deployed; the summary model has had one real run, on a synthetic meeting.

A one-hour 720p meeting video is about 1 GB, and uploading it hands over the whole recording. Notes drawn from a meeting are only worth trusting if each decision and answer points to words someone actually said.

stack:
Nuxt 4 + Nitro · ffmpeg.wasm (single-thread, Web Worker) · Groq whisper-large-v3-turbo · Groq gpt-oss-120b (structured outputs) · gemini-embedding-2 @ 768 dims · Supabase Postgres (pgvector) · Cloudflare R2 (presigned PUT) · Nuxt UI v4 · zod 4 · Vitest + playwright-core
when:
2026
published:
MeetMind: architecture Three columns: the browser, the Nitro server and the external services. In the browser, a video file goes to a Web Worker running single-thread ffmpeg.wasm, which writes 16 kHz mono Opus; the video stays in the browser. The browser registers the meeting with the server, which mints a presigned PUT URL and refuses with 507 past the storage ceiling; the browser then uploads only the audio straight to Cloudflare R2, which expires it after 30 days, and the server checks the stored object with a HEAD request. Meeting rows, transcripts, summaries and vectors live in Supabase Postgres with pgvector, which every route reads and writes and where row-level security has no policies. The transcribe route sends one request per meeting to Groq Whisper large-v3-turbo. The summarize and ask routes put the quote first and check every quote against the transcript with attestQuote; their model is Groq gpt-oss-120b, which has run for real once, on a synthetic meeting. The index, search and ask routes embed with Gemini embedding 2 at 768 dimensions: index embeds 45-second windows, search and ask embed the question, and search returns at most five passages per meeting. Whisper and Gemini are contract-tested against the real services. Cited items, answers and passages go to the meeting page, which shows the player, the transcript and the quote under every item. Cloudflare Workers is drawn dashed below the server: nothing is deployed, because the 30.7 MiB wasm file is over the 25 MiB asset cap. Browser · the video stays here Nitro server · runs locally External services video file about 1 GB an hour ffmpeg.wasm worker single-thread · WORKERFS 16 kHz mono Opus meeting page player · transcript quote under every item register · complete presigned PUT · HEAD check 507 past the ceiling transcribe one request per meeting summarize · ask quote first attestQuote check index · search · ask 45 s windows max 5 per meeting Supabase Postgres pgvector RLS, no policies Cloudflare R2 audio only 30-day expiry Groq Whisper large-v3-turbo contract-tested Groq gpt-oss-120b summaries · answers one real test run Gemini embedding 2 768 dims contract-tested audio only, presigned PUT Cloudflare Workers not deployed 30.7 MiB wasm, 25 MiB cap
The video stays in the browser, which uploads only the extracted audio, straight to R2. Nitro routes call the models and check every quote against the transcript. The summary model has run for real on one synthetic meeting, and nothing is deployed. · scroll sideways to see all of it

Problem & constraints

A one-hour 720p recording is about 1 GB (ADR-0001). Uploading it is slow, costs storage and hands the whole video to a server, when transcription needs only the speech. Groq’s Whisper free tier caps an upload at 25 MB, and Whisper downsamples to 16 kHz mono internally anyway, so everything above that is wasted. MeetMind’s one promise follows from that: the raw video never leaves the browser.

The second requirement is trust in the notes. A summary that names a decision, or an answer to “what did we decide about the budget?”, is only useful if the reader can check it against the moment it was said. So every decision, action item and answer has to carry a quote, and the quote has to be found in the transcript before anything is stored or shown.

The constraints were free tiers and no credentials. The target is 0 EUR/month. Groq’s per-minute token allowance, 8,000 tokens a minute for the summary model (ADR-0006), is the binding limit, not the daily one, so long transcripts are summarised in sequential, paced chunks. The development machine and CI had no cloud keys for most of the build, so every cloud service sits behind an interface with a local driver or a stub, and dev and CI run without accounts.

Architecture

MeetMind is a Nuxt 4 app with Nitro server routes; the model keys live only on the server. A video dropped on the home page goes to a Web Worker running single-thread ffmpeg.wasm, which mounts the file with WORKERFS instead of copying it into the WASM heap and writes the first audio track as 16 kHz mono Opus at 16 kbps. The video stays on the machine.

The upload has three steps. POST /api/meetings validates a small JSON body, checks the storage ceiling and mints a 15-minute presigned PUT URL for Cloudflare R2. The browser uploads the audio to R2 itself. A completion call then HEADs the object and accepts it only if its size equals the declared byte count and any media type it reports is audio/ogg; otherwise the object is deleted and the meeting is marked failed. Meeting metadata, transcripts, summaries and vectors live in Supabase Postgres with pgvector.

Each processing stage is a route started by a button on the meeting’s progress page. Transcribe sends the audio to Groq whisper-large-v3-turbo in one request per meeting and stores segments with integer millisecond offsets, under a compare-and-swap claim with a lease. Summarize splits the transcript on segment boundaries into chunks of at most 2,500 estimated tokens, sized so that a chunk and its reply fit one minute’s token allowance, sends them one after another to gpt-oss-120b, paced through a slot ledger in Postgres that the Q&A calls share, and asks for strict structured output whose schema puts the quote before the claim. reconcile() then looks for every quote in the transcript with attestQuote: text is normalised, matched as whole tokens so that “approved by everyone” does not match inside “disapproved by everyone”, and scripts written without spaces, such as Japanese or Thai, are matched character by character. An item whose quote is not found is dropped, and the timestamp comes from the segment where the quote was found.

Index embeds windows of about 45 seconds with gemini-embedding-2 at 768 dimensions. Ask embeds the question, retrieves the four nearest windows and has gpt-oss-120b answer with citations that must attest against those retrieved segments. Search across meetings returns passages, at most 5 per meeting, and no language model writes an answer. A native audio element plays the audio through a same-origin byte-range route, and a citation click seeks it to the quoted line. The decisions behind it are logged below, each with the alternative it beat.

What has run for real

The unit, end-to-end and browser tests run on stubs and local drivers. The stub summarizer quotes the transcript rather than inventing text, and says in its own output that it is a placeholder. A canary option makes it add one citation that is not in the transcript, so an end-to-end test proves over HTTP that the check drops it; without that, a disabled check would pass every credential-free test.

pnpm test:services exercises the real services. On 2026-10-23 it passed 17 of 17 against R2, Supabase, Groq Whisper and Gemini, the owner’s record in the repo’s pull request #14. The Whisper tests send real Ogg/Opus audio and parse the reply with the production parser. The Gemini tests check that 768 dimensions are honoured, that vectors come back unit-length, and that a question ranks nearer the passage that answers it. All nine database migrations are applied to the real Supabase project, and the R2 bucket has its CORS and lifecycle rules.

No automated test calls the chat model. On 2026-10-25 a synthetic one-hour meeting went through the real routes against the real Groq account, and at first it could not be summarised at all: the model spent its default output allowance reasoning, so every reply was cut off, and a full chunk with its reply came to more than the 8,000 tokens a minute allows. Smaller chunks, a set output cap and low reasoning effort fixed that. The check then dropped most of the correctly quoted items, because Whisper splits sentences across segments; a quote may now run across up to three consecutive segments, and 72 of 73 action items are kept. The meeting was synthetic, so this tests the check on verbatim quotes, not the quality of summaries of a real meeting.

GitHub Actions last passed on 2026-09-22. Since 2026-10-22 every run fails before a step starts, because of a billing block on the account, not a failing test. The gates run locally instead: pnpm ci:local runs the six jobs of the workflow, counts a job it cannot run as a failure, and a parity test holds it to the workflow file. Its last full green run was on 2026-10-23.

Outcomes

Build phases 0 to 8 of the plan’s 15 (0 to 14) are built: in-browser extraction, upload, transcription, the transcript player, cited summaries, cited Q&A over one meeting and search across meetings. Several of the plan’s instructions changed after checking: the summary model, the embedding model, a waveform player deferred in favour of the native one, and chunked transcription dropped because the app cannot produce a file over Groq’s cap. Each change has its reason in an ADR.

The extraction is the measured part. On 2026-07-08 a 1,045.8 MB one-hour 720p fixture became 8.11 MB of Opus in 16.0, 16.5 and 16.0 seconds over three runs in one page, with no out-of-memory error, in headless Chrome on a MacBook Air M5. The fixture is a test pattern with a sine tone, not a meeting: a sine encodes faster than speech, so the size class is the finding, and the re-run with a real recording is still open. Safari and Firefox are untested.

Nothing is deployed. A measurement spike before the orchestration phase (ADR-0009, 2026-10-22) built the server for Cloudflare Workers: the bundle is 1.69 MB, 495 kB gzipped, and loads under workerd with nodejs_compat. The blocker is ffmpeg-core.wasm, 30.7 MiB against the 25 MiB per-asset cap, which wrangler refuses locally and which is the same on every plan. Serving it from R2, shipping it gzipped at 9.7 MiB, building a smaller core or moving to WebCodecs are the options, and none is chosen. The spike also corrected an earlier audit: the local drivers fail with a 500 under workerd, so a deployment without credentials breaks loudly instead of serving stub transcripts.

Phase 9, orchestration, is underway (ADR-0012). On the Workers free plan a one-hour meeting takes about 8 minutes of paced Groq calls, so each request will do one bounded step and the browser will drive the loop. The Groq slot ledger in Postgres and the run ledger are built; until the later steps land, the stages are still started by hand, one button each. Phase 10, rate limiting, follows. An admin-only DELETE route closed the gap the repo had listed first (ADR-0011), and authentication has no phase yet.

Lessons

A check is only as good as its stated limits. attestQuote proves that words were said; it cannot see a dropped “not”. Writing that next to the check, and showing the quote under every item so the reader can judge, was worth more than another matcher rule.

A review needs a diff it can read. The file where model output becomes stored data held a literal NUL byte as its token separator, so Git treated it as binary and five review rounds passed over it without seeing a line. The separator is a space now, and a test fails on any control character in a tracked text file.

Passing tests can claim more than they prove. A manual review with four adversarial passes found six mutations the whole suite let through, among them dropping the trailing token boundary and moving the quote-length floor anywhere from 11 to 24. Each fix was proven by re-running the mutation that had survived.

Measure the runtime before designing against it. The audit’s worst operational hazard turned out not to exist on Workers, and the real blocker was a file size that wrangler dev reports locally. Subrequests per route were measured later, on 2026-10-25; CPU time still is not, so the orchestration design assumes the worst case and makes the size of each step a setting (ADR-0012).

Key decisions

  1. 01 Audio extracted in the browser with single-thread ffmpeg.wasm

    ffmpeg.wasm with the single-thread core runs in a Web Worker, reads the video through a WORKERFS mount, starts a fresh instance per job and encodes the first audio track to 16 kHz mono Opus at 16 kbps. Only that audio is uploaded.

    Server-side extraction means uploading the video, which breaks the product's one promise and its free-tier cost model. The multi-thread core needs cross-origin isolation, which breaks third-party embeds, and is flagged unstable upstream. An hour of FLAC is 55–75 MB, over Groq's 25 MB cap. mediabunny was kept as the pivot in case ffmpeg.wasm failed on 1 GB inputs; the spike showed it did not.

    ADR-0001 and ADR-0002. WORKERFS keeps the video out of the WASM heap, whose ceiling is 2 GB, and a fresh instance per job keeps memory flat because the heap never shrinks. The same core is the file that blocks the deployment, as described above.

    rejected: Server-side extraction from an uploaded video · The multi-thread core (core-mt) · Uploading lossless FLAC or WAV · mediabunny on WebCodecs

  2. 02 The browser uploads to R2 itself, and the server checks afterwards

    POST /api/meetings mints a 15-minute presigned PUT with aws4fetch, the browser uploads the audio straight to R2, and a completion call HEADs the object: its size must equal the declared byte count and any media type it reports must be audio/ogg, or the object is deleted and the meeting is marked failed.

    Supabase Storage allows 1 GB in total and 50 MB per file with metered egress, against R2's 10 GB and free egress. A proxy doubles bandwidth and puts the server in the data path. A signed length can only pin an exact value, never a maximum, and R2's check of it is undocumented. aws4fetch is a dependency-free 10 KB client that runs unchanged on Node and Workers.

    ADR-0003. The check does not inspect the bytes: a client that labels a video audio/ogg at exactly the declared length would pass. That would be the uploader leaking their own file, and the ADR names transcoding as the only real defence.

    rejected: Supabase Storage for the audio · Proxying uploads through the Nitro server · Signing content-length into the URL · @aws-sdk/client-s3

  3. 03 Quote first, and every quote checked against the transcript

    Summaries are built for Groq gpt-oss-120b with strict structured outputs. The schema asks for the quote before the claim, and reconcile() keeps an item only if attestQuote finds its quote in the transcript; the timestamp comes from the segment the quote was found in, never from the model.

    Llama 3.3 has no structured outputs on Groq, and the schema is the whole mechanism: under constrained decoding, keys come out in schema order, so the evidence is written before the claim. Self-reported confidence is uncalibrated, and a reader would over-trust it. A judge would spend the per-minute token allowance the design is built around, and it cannot run reproducibly in CI.

    ADR-0006. The check proves the quoted words were said, not that the claim drawn from them is right: a quote can splice across a sentence boundary or drop a leading negation and still pass, so the quote is shown under every item. It first ran against the real model on 2026-10-25, on one synthetic meeting (figures above).

    rejected: llama-3.3-70b-versatile, the plan's original model · A self-reported confidence score per item · An LLM judge over every summary

  4. 04 Q&A over 45-second windows, with citations checked against what was retrieved

    gemini-embedding-2 at 768 dimensions embeds windows of consecutive segments (about 45 seconds or 1,500 tokens, overlapping by one segment). The four nearest windows go to gpt-oss-120b, and each citation must attest against the retrieved segments only; if none survives, the answer says nothing retrieved from the meeting supports one.

    A segment is a few seconds of speech: "yes, let's do that" embedded alone retrieves nothing, because what it agrees to is in the segment before. Topic boundaries would spend quota for a gain nobody measured. The newer model takes 8,192 input tokens against 2,048 and normalizes shortened vectors itself. A quote the model was never shown cannot be what it read. One meeting's windows are few enough for an exact scan, while HNSW with a selective filter can return fewer rows than asked for.

    ADR-0007. The index records the model, the dimensions and a hash of each window's text, so a re-run resumes, and a question against a stale index is refused rather than answered.

    rejected: Embedding each segment on its own · Topic chunking with a model call · gemini-embedding-001, the plan's model · An HNSW index from the start

  5. 05 Search across meetings returns passages, not a written answer

    Search returns matching windows, at most 5 per meeting from at most 20 meetings, ranked by each meeting's best match through a LATERAL join, and each result seeks the player to its window's start. No model writes a cross-meeting answer.

    A cross-meeting answer needs a meeting on every citation and a staleness re-check per meeting, and every new piece is a new place to get citation integrity wrong. A flat top-K lets one long meeting crowd out the rest. The window function ranks the whole filtered set before it can discard anything. There is no labelled data to tune a threshold, so the floor is 0.

    ADR-0008. The synthesized answer is deferred, not rejected.

    rejected: One synthesized answer citing several meetings · A flat top-K across every window · ROW_NUMBER() OVER (PARTITION BY meeting_id) · A tuned similarity threshold

  6. 06 R2 storage cost bounded by a lifecycle rule and a ceiling

    A bucket rule expires meeting audio after 30 days, and POST /api/meetings answers 507 when the stored bytes plus the new upload would pass a ceiling, 8 GiB by default. The total is derived in SQL from the meetings table, and a pending registration reserves its bytes while its upload URL is live.

    R2 has no spending cap: past the free tier it bills instead of refusing. A counter drifts, because the lifecycle rule deletes objects without telling the database. Metering each request costs a database round trip, more than the $0.36 per million operations it guards. A lock blocks deletes, the lifecycle rule's included, and the one billing alert type needs a Professional plan and does not list R2.

    ADR-0010, 2026-10-23. The ceiling is global, not per caller: with no authentication there is no caller to meter, so one abuser can fill the archive for everyone. Since 2026-10-25 an admin-only DELETE route, behind a server-side token, can free space too (ADR-0011).

    rejected: A running byte counter · Metering R2 operations per request · A bucket lock · Relying on a billing alert

Evaluation results

Key figures, measured in the MeetMind repository
MetricValueStatus
1 h 720p video → audio, in browser16.0–16.5 smeasured

Single-thread ffmpeg.wasm in a Web Worker, no out-of-memory error in three runs in one page. The fixture is synthetic, a test pattern with a 440 Hz sine tone, not speech; a sine encodes faster than speech, and a re-run with a real recording is still owed.

Video in → audio out, same runs1,045.8 MB → 8.11 MBmeasured

16 kHz mono Opus, 18.9 kbps realized (ffprobe): about 8.5 MB per hour by the ADR's arithmetic, three times under Groq's 25 MB upload cap. Same synthetic fixture (test pattern and 440 Hz sine), not a recorded meeting.

Action items kept by the quote check, real model output72/73measured

The model quoted every item verbatim. Before a quote could run across consecutive segments, the check kept only 16 of 73, because Whisper splits sentences across segments. Decisions came to 62, on 46 distinct lines: every block of the synthetic meeting repeats one phrase. One synthetic meeting, not a recorded one, and no Q&A figure is recorded yet.

Summary and answer faithfulnessn/apending

No eval set exists; the repo records extraction quality as unmeasured (ADR-0006). It needs real meeting transcripts labelled with their decisions, action items and answers, scored over real meetings.

Measured: MeetMind docs/adr/0002-ffmpeg-spike-results.md (2026-07-08, three runs: 16.0 / 16.5 / 16.0 s; MacBook Air M5, headless Chrome 150, production build) · MeetMind docs/adr/0002-ffmpeg-spike-results.md (2026-07-08) · MeetMind docs/adr/0006-structured-summaries.md, addenda of 2026-10-25 (Groq gpt-oss-120b, one synthetic one-hour meeting through the real routes)

Cost & latency

Cost & latency
MetricValueStatus
Workers server bundle, reported / gzipped1.69 MB / 495 kBmeasured

About 3% of the 64 MiB Workers limit. A local build under workerd, not a deployment.

ffmpeg-core.wasm / the Workers asset cap30.7 MiB / 25 MiBmeasured

wrangler dev refuses the file locally, before any upload, and the cap is the same on Free and Paid. This blocks the deployment.

CPU time per route on Workersn/apending

Subrequests were measured on 2026-10-25 under workerd against the real services: at most 19 per route (summarize), under the free plan's cap of 50 (ADR-0012). CPU time against the free plan's 10 ms is still unmeasured; it is read from Workers Logs at the deployment.

Monthly running costn/apending

The target is 0 EUR/month on free tiers, and nothing is deployed. R2 has no spending cap, so storage is bounded by a 30-day lifecycle rule and an 8 GiB ceiling (ADR-0010). A month of a deployed instance produces this.

Measured: MeetMind docs/adr/0009-workers-measurement-spike.md §1 (2026-10-22, NITRO_PRESET=cloudflare_module build) · MeetMind docs/adr/0009-workers-measurement-spike.md §3 (2026-10-22, wrangler dev)

Safety & failure modes

Risks

  • No authentication: every route is anonymous, and the server reaches the database with the service-role key
  • No rate limiting: anyone who can reach the app can spend Groq, Gemini and R2 quota, and cross-meeting search needs no meeting id
  • A model can quote words that were really said and still misstate what they mean
  • A client can store any bytes under its upload URL, a video labelled as audio included
  • R2 bills past its free tier instead of refusing

Mitigations

  • Nothing is deployed, and the R2 bucket's CORS allows only http://localhost:3000. Row-level security is on with no policies, so the public keys see nothing. Authentication and per-user quotas have no phase in the plan yet
  • Rate limiting is the plan's phase 10 and not built. Until then each request is bounded: 25 MB of audio (checked in zod, a Postgres CHECK and the completion HEAD), a Groq slot ledger in Postgres that every instance shares, so the summary and Q&A calls stay inside the per-minute allowance, and at most 40 summary chunks
  • Every quote must attest against the transcript, the quote is shown under every item, and an answer with no surviving citation says nothing retrieved from the meeting supports one
  • The completion step deletes any object whose size differs from the declared byte count or whose reported type is not audio/ogg; it does not inspect the bytes, and ADR-0003 says so
  • A 30-day lifecycle rule and a 507 storage ceiling derived from the database (ADR-0010)