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