From 1f38a91cfce8b610516d8bb96c54b5268c254aa4 Mon Sep 17 00:00:00 2001 From: hannesreinsch Date: Wed, 9 Sep 2026 16:22:20 +0200 Subject: [PATCH] fix(tests): the suite typed on the real keyboard, and the second model is retired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE SUITE TYPED INTO HIS SCREEN, ALL DAY, AND THAT IS THE HEADLINE. Two tests drive `_stream_loop` with a fixed `Heard` and monkeypatch `dictate._inject`, believing the clipboard was the way out to the machine. It is not the only one: `dictate.place` tries `platforms.type_text` FIRST, and that is a real `CGEventKeyboardSetUnicodeString` with nothing in front of it. So every run of the suite typed both fixtures into whatever window had focus, back to back: hello there my friend and also yougokigen you desu ne totemo ii tenki Reported as "I keep getting this same random paste everywhere, even tho I'm not using murmurflow", which is exactly right — it was not MurmurFlow, it was MurmurFlow's tests. The giveaway was that it was byte-identical every time: a hallucination differs every time, a fixture does not. `tests/conftest.py` shuts that ONE door for every test, autouse, and a test asserts it stays shut. Only that door: the clipboard and the recorder have tests that drive them on purpose, and stubbing those would replace the thing under test with the stub. AND SILENCE IS CUT BEFORE ANYTHING TRANSCRIBES IT. Whisper invents words when handed audio with nothing in it — the same 12s of speech, three ways: speech alone "...but just in this text box," + 20s digital silence "...but just in this text box, Thank you." + 20s faint room noise "...but just in this text box.." A word list cannot catch that (this file already said so) and neither can whisper's own per-segment `no_speech_prob`: the invented " Thank you." came back at 0.000 among real speech at -0.05. Both were built and both were measured useless before `trim_trailing_quiet`, which was measured to work. THE SECOND MODEL AND ITS SERVER ARE GONE. `partial_model`, `PARTIAL_PREFERENCE`, `DEFAULT_PARTIAL_MODEL`, `partial_port`, `start_partial_server`, `partial_at`, `livePass` and the whole `at`/`model` parameter family are deleted; one model answers the live pass and writes the final, on one server, on one queue. `stop_server` still sweeps the port above ours, because a version that no longer starts a small server must still reap the one an older one left. THE LANGUAGE PIN IS GONE with it: whisper-server reports back whatever language it was TOLD to decode, so pinning made the "is that one of yours" gate blind for the whole clip after its first second — and a partial is pasted. AND `whole_clip_read` IS DELETED, four hours after it shipped. It never fired: a big-model pass costs 1.5-2.4s, so several seconds of speech are always unread when the key comes up, and the shortcut refused every time (measured on his own log: 4830ms, 8929ms, 5528ms, all after the change). It also reused a PINNED read as the final transcript, which is the guarantee the pin's own docstring promised never to break. STREAM_TIMEOUT 10s -> 30s: a pass on a two-minute clip costs more than ten. README and the website carry the measured numbers now: one model, 1.5s a pass under 30s of speech and 2.4s past it, and the microphone that closes itself. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P7oJz8M9QzsdJimoM318cj --- README.md | 94 ++++++++----- docs/field.js | 147 ++++++++++---------- docs/index.html | 20 +-- murmurflow/cli.py | 39 +----- murmurflow/dictate.py | 284 +++++++++++++++------------------------ murmurflow/whisper.py | 61 --------- tests/conftest.py | 45 +++++++ tests/test_murmurflow.py | 205 ++++++++++++++-------------- 8 files changed, 412 insertions(+), 483 deletions(-) create mode 100644 tests/conftest.py diff --git a/README.md b/README.md index 6b28024..5cd05ef 100644 --- a/README.md +++ b/README.md @@ -325,9 +325,9 @@ change: the best model **present** wins. murmurflow setup base # smaller and faster, noticeably worse ``` -`setup` also fetches `small`, which is not a downgrade of the transcript: it is the separate model -that answers the live pass while you talk. See -[how the words arrive](#how-the-words-arrive-while-you-talk). +ONE model does both jobs — the transcript you keep and the words that arrive while you talk. There +used to be a second, small one for the live pass; it was retired when the live pass began typing +punctuation. See [how the words arrive](#how-the-words-arrive-while-you-talk). Teach it your own words — the cheapest accuracy win there is: @@ -370,24 +370,25 @@ ends — so typing each pass's best guess would type words the next pass withdra un-type them. The first pass has nothing to agree with, so it holds back its last four words instead. -**Two models, because they do different jobs.** `large-v3-turbo` (~1.6 GB) writes the transcript -you keep. `small` (~488 MB) answers the live pass while you are still talking, on its own -whisper-server, on its own port. `murmurflow setup` fetches both. +**ONE model, and it types the punctuation you keep.** `large-v3-turbo` (~1.6 GB) answers the live +pass *and* writes the final transcript, on one warm server. -That is not only about speed. **whisper-server answers one request at a time**, so a live pass -still decoding when you stop talking is time the *final* transcription spends queued behind it — -measured at 1 to 2.3 seconds added to the end of every sentence, at exactly the moment somebody is -waiting for it. A separate process cannot queue against itself. +There used to be a second, small model on its own server, because it answered a pass in ~0.4s +against ~1.5s. It was retired the day the live pass started typing **marks** as well as words: a +full stop lands as soon as a later word confirms it, so the model answering the live pass is the +model that decides the punctuation you keep — and there the two are not close. Replayed through the +whole streaming loop on one real 38 second clip, against the big model's own whole-clip transcript: -`small` and not `base`, and that is a measurement rather than caution: on the same German clip -`base` typed *das* where the speaker said *dass* and dropped a plural, while `small` returned -character-for-character what `large-v3-turbo` did. A live word is pasted and **there is no -un-paste**, so a model that quietly rewords is not cheaper, it is wrong. +| live model | differs | what lands on screen | +|---|---|---| +| small | 13.9% | `...in the end like when I just stopped my control it just added a lot of gibberish` | +| **big** | **11.4%** | `...in the end, like when I just stopped my control, it just added a lot of gibberish, I'm not sure. And that came after a few seconds, after I already sent the message` | + +The percentages understate it. The sentences are the finding. **And a live word is TYPED, not pasted.** The clipboard round trip — save the pasteboard, write the text, send ⌘V, wait for the target to read it, put the old contents back — costs about 500ms, more -than decoding the audio did. It was half the cycle, so the words arrived in two- and three-word -lumps about once a second. A unicode key event carries the characters itself: no pasteboard, +than decoding the audio did. A unicode key event carries the characters itself: no pasteboard, nothing to settle, **2.6ms**. It is also safe on a German keyboard, which is the reason the clipboard was chosen in the first @@ -396,30 +397,46 @@ mangles every umlaut, where `CGEventKeyboardSetUnicodeString` sends the characte **final** transcript still goes through the clipboard, because it can be two thousand characters at once and because its paste reports back what the target actually received. -**Measured** on an M4 Pro, macOS 26, `language` on `auto`, one 10.5 second sentence: +**Measured** on an M4 Pro, macOS 26, `language` on `auto`: + +| clip length so far | one live pass | +|---|---| +| 2s | 1.47s | +| 10s | 1.50s | +| 20s | 1.49s | +| 30s | 2.35s | +| 45s | 2.38s | + +So the words arrive in **lumps of a second and a half**, and in bigger lumps the longer you talk — +past 30 seconds whisper.cpp's padding becomes a second 30s window. That is the price of the +punctuation, and it is a setting: -| | one big model, pasted | live model, pasted | live model, typed | -|---|---|---|---| -| one live pass | 2.2s | 0.4s | **0.4s** | -| the cycle: decode, then place the words | ~2.7s | ~0.9s | **~0.43s** | -| first words at the cursor | 3.7s | 1.4s | **1.5s** | -| lumps of text during the sentence | 5 | 12 | **21** | -| still left to paste when you stop | 62 of 195 | 21 of 195 | **9 of 195 chars** | +```sh +murmurflow config set livePass small # faster lumps, thinner punctuation +``` + +`murmurflow doctor` says which model is answering, and every clip's line in the daemon log ends +with what streaming actually did: `stream 21x → 20 typed`. -So it is **a word or two every 0.4 seconds**, not letter-by-letter captioning. Letters would be -cosmetic: no word is known any sooner than the pass that decodes it. Past 30 seconds of speech -whisper.cpp's padding becomes a second 30s window and the cycle doubles, so a long dictation lags -further behind than a short one. +**Nothing is pinned.** Detecting the language costs a whole extra encoder pass, and every pass pays +it. Pinning the language to what the first second heard saved ~0.75s a pass and cost the gate that +refuses invented speech: whisper-server reports back whatever language it was *told* to decode, so +every pass after the first reported the pinned language by construction, whatever it had actually +decoded. A partial is typed, so the gate that judges it has to be able to see. -Without the live model everything still works — the live pass goes to the big server, in ~2s lumps, -as it did before. `murmurflow doctor` says which one you are on, and every clip's line in the daemon -log ends with what streaming actually did: `stream 21x → 20 typed`. +**Silence is cut before anything transcribes it.** Whisper invents words when it is handed audio +with nothing in it. The same 12 seconds of speech, three ways: + +| clip | what came back | +|---|---| +| speech alone | `...but just in this text box,` | +| + 20s of digital silence | `...but just in this text box, Thank you.` | +| + 20s of faint room noise | `...but just in this text box..` | -**Detecting the language is a whole extra encoder pass** — 0.75s of every 2.2s, measured — and one -clip does not change language halfway through, so every pass after the first pins itself to what -the first one heard. It costs nothing: the pin is only taken from a pass that already cleared the -confidence gate, only to a language on your `languages` list when you have one, and the **final** -transcription is never pinned, so the language gate still judges the real clip. +So the invention is the silence, not the speech. A word list cannot catch it — whisper answers +silence in a different invented language each time — and neither can whisper's own per-segment +`no_speech_prob`: the invented "Thank you." came back at **0.000**, sitting among real speech. +The cure is not to hand the silence over. Two things to know: @@ -428,8 +445,11 @@ Two things to know: ⌥⌘V into whatever app you are in. With `doubleTap false` it stands down, and the daemon says so on the line it prints at start-up. - **It needs the warm whisper-server.** Partials never fall back to the cold `whisper-cli`: that - would spawn a model every 1.2s and make your final transcription slower, not faster. No warm + would spawn a model on every pass and make your final transcription slower, not faster. No warm server, and the words simply arrive at the end as they always did. +- **The microphone closes itself.** After 15 seconds with nothing said, or 120 seconds either way, + the clip is finished exactly as your second tap would have finished it — the words are typed, not + thrown away. `silenceStop` and `maxHold` move both; `0` switches either off. - **The last pass can still reword what is already typed.** Usually punctuation or a capital. There is no un-paste and deliberately no attempt at one: synthesising backspaces into an app whose cursor may have moved since would delete text that was never ours. A word left as first heard is diff --git a/docs/field.js b/docs/field.js index 7545ee1..0aa5a81 100644 --- a/docs/field.js +++ b/docs/field.js @@ -11,20 +11,27 @@ // * elastic collisions. Two ghosts knocking each other sideways is motion with INTENT in it, and // the eye follows intent. A sky moves because you are on a planet that turns; nothing in it // hits anything. -// * per-mark rotation. Brand law, and the loudest of the three: `brand/CLAUDE.md` fixes the -// geometry forever and says never rotated. Fifty tumbling logos is a screensaver. +// * per-mark rotation IN THE PLANE. Fifty logos spinning like a loading spinner is a +// screensaver, and `brand/CLAUDE.md` fixes the geometry forever. A TURN IN 3D IS NOT THAT AND +// IS NOW WHAT THIS FILE DOES - see AXES below: the mark is an xyz axis, so the object is fixed +// and only the angle you see it from moves. The geometry is never re-drawn. // -// SO: A JITTERED GRID, THREE SIZES, A SLOW DRIFT AND A TWINKLE. Placement is a grid because the +// SO: A JITTERED GRID, THREE SIZES, A SLOW DRIFT, A TWINKLE AND A SLOW TURN IN 3D. Placement is a grid because the // alternative is clusters and empty quadrants — the failure mode of every random field, and the // one thing a real star field never has. Jitter inside the cell is what stops it reading AS a // grid. Constant area per star, so a phone and a 5K display are the same sky at the same density // rather than the same COUNT at two densities. // -// AND ONE LIGHT SOURCE. A sun by day, a moon by night, drawn by the consumer (they are two CSS -// radial gradients, which is the one thing CSS does better than a canvas) and handed to `set()` as -// a position. This file only uses it to LIGHT the field: stars near the body gain a little -// brightness and a little glow, falling off with distance. That is the whole reason the night -// version reads as a sky with a moon in it rather than as dots beside a circle. +// A STAR IS A CRISP GLYPH AND NOTHING ELSE (SKY-QUIET-1, operator, 2026-08-27). +// +// It used to carry a radial wash behind it, and to gain brightness and glow from the body passing +// nearby. Both are deleted. Over a real screen the wash read as a smudge behind every mark and the +// body's light read as a lamp somebody had left on: "the light from the stars is a little too much +// for some reason... it looks a little too kitschy". A field of hairline glyphs at 5-14% is the +// same language as the eight marks that carry text, which is the point. +// +// So this file no longer knows where the sun or the moon is at all. `paint` still ACCEPTS a `body` +// so the two copied-out consumers do not break on an extra key, and does nothing with it. // // STILL ONE MODULE, STILL COPIED OUT. `zyx`'s dashboard, `zyxworks-site` and MurmurFlow's landing // page all run this file; `make brand-field` overwrites the other two from this one. A React repo @@ -52,12 +59,51 @@ export function mulberry32(seed) { // draw exactly this; a backdrop drawing a DIFFERENT three-stroke glyph was a second mark on one // screen, which is the thing brand law is for. const JOINT = [60, 60]; -const TIPS = [ - [60, 30], - [30, 82], - [94, 74], + +// AND THE MARK IS AN XYZ AXIS, SO IT TURNS ON ONE (operator, 2026-08-27: "I want this xyz thingy +// to rotate in the xyz axis, right? Cause Zyx is an xyz axis"). +// +// These are the brand's own three strokes lifted back into 3D. Drop the z and you get +// (60,30) (30,82) (94,74) EXACTLY - so nothing is re-drawn and nothing is a new glyph. The z each +// stroke carries is the one that makes the three mutually PERPENDICULAR, which is what makes the +// mark a real axis triad rather than three lines that happen to meet: solved once from +// `v1.v2 + z1*z2 = 0` and its two siblings (z1*z2 = 660, z1*z3 = 420, z2*z3 = 712), and pinned by +// `check-registry.mjs` so nobody has to re-derive it. +// +// The turn is a SWAY of about 11 degrees, never a spin. An axis rotated far enough to point at the +// viewer foreshortens to a dot, and a field of marks blinking out is the screensaver again. +const AXES = [ + [0, -30, 19.731], + [-30, 22, 33.449], + [34, 14, 21.286], ]; +/** How far a star's mark may turn, in radians: yaw about the vertical, pitch about the horizontal. */ +export const TURN = [0.3, 0.18]; + +/** The three tips of the mark as seen from `yaw`/`pitch`, projected by dropping z. At (0, 0) this + returns the brand geometry to the pixel. Pure, so a check can walk it without a canvas. */ +export function tipsAt(yaw, pitch) { + const cy = Math.cos(yaw); + const sy = Math.sin(yaw); + const cp = Math.cos(pitch); + const sp = Math.sin(pitch); + return AXES.map(([x, y, z]) => { + const rx = x * cy + z * sy; + const rz = z * cy - x * sy; + return [JOINT[0] + rx, JOINT[1] + y * cp - rz * sp]; + }); +} + +/** Where a star's mark is pointing right now. Same two-summed-sines shape as the drift, so the + angle never repeats visibly and never looks like a motor. */ +export function turnedAt(star, seconds) { + return [ + TURN[0] * Math.sin(seconds * star.fr * 6.283 + star.pr), + TURN[1] * Math.sin(seconds * star.fq * 6.283 + star.pq), + ]; +} + /** Three sizes, and the stroke steps with them — the brand's small-mark rule (9 -> 12 -> 14 at 120 units). At a flat 9 the 15px star is a smudge and the 29px one is a logo. */ export const STARS = [ @@ -105,9 +151,6 @@ export const INK = { dark: "246, 245, 241", light: "11, 12, 16" }; field vanish completely. */ const DAY_GAIN = 1.85; -/** How far the body's light reaches, as a fraction of the frame's diagonal. */ -const HALO_REACH = 0.52; - const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); /** @@ -150,6 +193,12 @@ export function createField(w, h, { seed = 20260827, keepOut = [] } = {}) { // brightness wobble is what makes it a light. Slow, and never below 0.72 of its own base. ft: 0.03 + rand() * 0.05, pt: rand() * Math.PI * 2, + // The turn. 25-90 second periods, deliberately slower than the twinkle and slower than the + // drift: the nearest thing to it in the real world is a planet turning, not a mobile. + fr: 0.011 + rand() * 0.029, + pr: rand() * Math.PI * 2, + fq: 0.011 + rand() * 0.022, + pq: rand() * Math.PI * 2, }); } } @@ -198,28 +247,17 @@ export function driftedAt(star, seconds, amp = 7) { ]; } -/** How much of the body's light reaches a point. 1 at the disc, 0 at `HALO_REACH` of the frame's - diagonal, linear in between — the handoff's own falloff. */ -function lightAt(x, y, body, w, h) { - if (!body) return 0; - const reach = Math.hypot(w, h) * HALO_REACH; - return clamp(1 - Math.hypot(x - body.x, y - body.y) / reach, 0, 1); -} - /** * DRAW ONE FRAME. * - * `theme` "dark" | "light" which ink, and whether there is glow and dust at all - * `body` {x, y} | null where the sun or moon is, in CSS pixels + * `theme` "dark" | "light" which ink, and whether there is dust at all * `dusk` 0…1 how far into civil twilight; fades the night field up * - * Day and night are not the same picture with a colour swapped. At night the moon LIGHTS the - * field — every star carries a glow, and the ones near the disc carry more. By day the sun DIMS - * it: no glow at all (a white wash around a dark mark on a warm-white page reads as a printing - * fault), a much higher base alpha, and stars inside the sun's wash lose a little rather than - * being erased. + * Day and night are the same picture in two inks and two alphas. Ink on warm-white needs far more + * of it to read at all (`DAY_GAIN`), and the far-field dust is night-only because on a light + * surface it only muddies it. There is no glow in either — see the head of this file. */ -export function paint(ctx, field, w, h, { seconds = 0, theme = "dark", body = null, dusk = 1 } = {}) { +export function paint(ctx, field, w, h, { seconds = 0, theme = "dark", dusk = 1 } = {}) { const night = theme !== "light"; const rgb = night ? INK.dark : INK.light; ctx.clearRect(0, 0, w, h); @@ -237,37 +275,10 @@ export function paint(ctx, field, w, h, { seconds = 0, theme = "dark", body = nu for (const s of field.stars) { const [x, y] = driftedAt(s, seconds); - const lit = lightAt(x, y, body, w, h); const twinkle = 0.86 + 0.14 * Math.sin(seconds * s.ft * 6.283 + s.pt); - let alpha; - if (night) { - alpha = (s.a + 0.035 * lit) * twinkle * dusk; - } else { - // Floor 0.09: the first draft subtracted a flat 0.05 off a 0.04 base and the whole day field - // disappeared. A mark must stay readable everywhere on a light surface. - alpha = Math.max(0.09, s.a * DAY_GAIN - 0.02 * lit) * twinkle; - } - - if (night) { - // The wash. `radial-gradient(circle, currentColor 0%, transparent 52%)` blurred 6px, drawn - // as a gradient with a soft shoulder instead — a blur filter on a canvas is a per-frame - // readback on some drivers, and a gradient is already the shape a blur was there to make. - // Never above 0.11: at the ring it stops being light and becomes an outline. - const g = Math.min(0.075, (0.018 + (s.a - 0.048) * 0.34 + 0.022 * lit) * dusk); - const rad = s.size * 0.95; - const grad = ctx.createRadialGradient(x, y, 0, x, y, rad); - grad.addColorStop(0, `rgba(${rgb}, ${g.toFixed(4)})`); - // The falloff is a SQUARE, not a shoulder. A stop partway out is a second edge, and the - // first version of this drew a visible 44px disc around every star - the exact "reads as a - // ring around each mark" the handoff warned about, arrived at from the other direction. - grad.addColorStop(0.45, `rgba(${rgb}, ${(g * 0.3).toFixed(4)})`); - grad.addColorStop(0.75, `rgba(${rgb}, ${(g * 0.07).toFixed(4)})`); - grad.addColorStop(1, `rgba(${rgb}, 0)`); - ctx.fillStyle = grad; - ctx.beginPath(); - ctx.arc(x, y, rad, 0, 6.2832); - ctx.fill(); - } + // Floor 0.09 by day: an early draft let a mark fall under it and the whole day field + // disappeared. A mark must stay readable everywhere on a light surface. + const alpha = night ? s.a * twinkle * dusk : Math.max(0.09, s.a * DAY_GAIN) * twinkle; const k = s.size / 120; ctx.save(); @@ -277,7 +288,7 @@ export function paint(ctx, field, w, h, { seconds = 0, theme = "dark", body = nu ctx.strokeStyle = `rgba(${rgb}, ${alpha.toFixed(4)})`; ctx.lineWidth = s.stroke; ctx.beginPath(); - for (const [tx, ty] of TIPS) { + for (const [tx, ty] of tipsAt(...turnedAt(s, seconds))) { ctx.moveTo(JOINT[0], JOINT[1]); ctx.lineTo(tx, ty); } @@ -289,9 +300,9 @@ export function paint(ctx, field, w, h, { seconds = 0, theme = "dark", body = nu /** * Mount the field on a canvas. Returns `{ stop, set }`. * - * `set({theme, body, dusk, keepOut})` is how the consumer moves the light: the sky's own state - * changes about once a minute, and a repaint on demand is cheaper and simpler than handing this - * module a clock and a location. Under reduced motion it repaints the one static frame. + * `set({theme, dusk, keepOut})` is how the consumer turns the sky over: its state changes about + * once a minute, and a repaint on demand is cheaper and simpler than handing this module a clock + * and a location. Under reduced motion it repaints the one static frame. * * THE BUDGET, because it runs forever behind everything. Count is capped by AREA and by * `MAX_STARS`; DPR is capped at 2; `visibilitychange` stops the loop outright so a background tab @@ -310,7 +321,7 @@ export function mount(canvas, { seed = 20260827, reducedMotion, observe = false, (typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches); - let state = { theme: "dark", body: null, dusk: 1, keepOut: [], ...initial }; + let state = { theme: "dark", dusk: 1, keepOut: [], ...initial }; let field = null; let w = 0; let h = 0; @@ -367,8 +378,8 @@ export function mount(canvas, { seed = 20260827, reducedMotion, observe = false, draw(); const api = { - /** Move the light, flip the theme, or hand over new keep-out rects. Only a keep-out change - re-places the field: everything else is a repaint, so the sky does not reshuffle at dawn. */ + /** Flip the theme, move through twilight, or hand over new keep-out rects. Only a keep-out + change re-places the field: everything else is a repaint, so it does not reshuffle at dawn. */ set(next = {}) { const rebuild = "keepOut" in next && next.keepOut !== state.keepOut; state = { ...state, ...next }; diff --git a/docs/index.html b/docs/index.html index 6b20f31..506b088 100644 --- a/docs/index.html +++ b/docs/index.html @@ -293,25 +293,25 @@

What you are not paying for

The words arrive while you talk

Tap twice and start speaking. The first words land at your cursor after about - a second and a half, and the rest keep up as you go — a word or two - every four tenths of a second. When you stop, there is almost nothing left to arrive.

+ a second and a half, and the rest keep up as you go — in lumps of about + a second and a half, punctuation and all.

- - + +
stepseconds
first words at your cursor1.5
a new word or two, thereafter0.43
still to arrive when you stop talking9 of 195 characters
a live pass, under 30s of speech1.5
a live pass, past 30s of speech2.4
first transcription after boot, cold13.3
microphone open, first ever9.9
speech lost at the start of a clip0.04
-

M4 Pro, macOS 26, one 10.5 second sentence. Two models do it: a small one - answers the live pass while you are still talking, and large-v3-turbo writes the - transcript you keep. The words are typed as key events rather than pasted, which is the - difference between a lump every second and a word every four tenths. Measure your own before +

M4 Pro, macOS 26. One model does both jobs — + large-v3-turbo answers the live pass and writes the transcript you keep — + because the model typing while you talk is the model that decides your punctuation. The words + are typed as key events rather than pasted: 2.6ms, and no clipboard. Measure your own before believing any of these, including ours.

@@ -319,7 +319,7 @@

The words arrive while you talk

What it does not do