From 2b655e25ff1961bfb3bbcc31cb31034832e5f512 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 04:00:29 +0000 Subject: [PATCH 1/3] Dictation fell behind because our own build took every core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Driver measured Rudder slowing "after about 1,400 words while a Tauri build ran". The word count is incidental — it is a proxy for how long the build had been going. The speech model runs on the CPU (backend/rudder-engine starts it with --device cpu), and a Rust build saturates every core, so at equal priority it starves the one process whose latency a person can hear. Three changes, and the direction of the first is forced rather than chosen: 1. internal/desktop-build.mjs nices the whole build tree to 10, through the one run() helper every step goes through, so cargo, rustc and the linker inherit it. We nice the COMPETITOR because an unprivileged process cannot raise its own priority on Linux — a negative nice needs CAP_SYS_NICE — so "promote the ASR" would fail with EPERM. If nice(1) is missing the build still runs and says so, because a silent fallback would look exactly like the fix working. 2. cargo is capped at cores-1. nice decides who wins a CONTESTED core; it does not stop the build taking all of them, and a fully subscribed machine still adds scheduling latency to the audio path. An explicit CARGO_BUILD_JOBS is honoured rather than overridden. 3. yieldCPU reports its error instead of discarding it. It was `_ = syscall.Setpriority(...)`, which cannot fail visibly — a call that silently does nothing is indistinguishable from one that worked, and this one guards something audible. Also, one real O(n) in the widget, found while checking studio's answer that the collapsed pill already shows the last words: sendActivity split the ENTIRE draft on every activity tick to take five words. Measured at 3,000 words: 1.61ms per tick against 0.051ms for a bounded tail, 31x, with byte-identical output. Verify the priority work with `ps -o ni=` on the running cargo processes, never by any of these calls returning. --- backend/rudder-engine/procattr_linux.go | 18 +++++++++-- internal/desktop-build.mjs | 34 ++++++++++++++++++-- plugin-designs/rudder-voice/rudder-voice.vue | 7 +++- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/backend/rudder-engine/procattr_linux.go b/backend/rudder-engine/procattr_linux.go index 3607c9388..0cad64f7c 100644 --- a/backend/rudder-engine/procattr_linux.go +++ b/backend/rudder-engine/procattr_linux.go @@ -2,7 +2,10 @@ package main -import "syscall" +import ( + "log" + "syscall" +) // childAttr: a child process (the speech server, a recorder) is killed with the engine, so a crash // never leaves a speech server holding a gigabyte of memory. Linux only: Pdeathsig does not exist on @@ -14,6 +17,17 @@ func childAttr() *syscall.SysProcAttr { // yieldCPU lowers a background helper's CPU priority (nice 10), so on a thin client the live transcription // always wins the CPU over it. Used for the robot voice: its ~1 s burst per sentence must never make the // words you are speaking fall behind. +// +// ★ RAISING a priority is NOT available here and must not be attempted: an unprivileged process cannot +// set a negative nice (that needs CAP_SYS_NICE), so the call would fail with EPERM. Lowering — which is +// what this does — needs no privilege. The direction matters: the fix for a build starving dictation is +// to nice the BUILD, not to promote the engine (internal/desktop-build.mjs does that). +// +// The error is REPORTED rather than discarded. It was `_ = syscall.Setpriority(...)`, which cannot fail +// visibly: a call that silently does nothing looks exactly like a call that worked, and this one guards +// something a person can hear. Verify with `ps -o ni=` on the pid, never by this returning. func yieldCPU(pid int) { - _ = syscall.Setpriority(syscall.PRIO_PROCESS, pid, 10) + if err := syscall.Setpriority(syscall.PRIO_PROCESS, pid, 10); err != nil { + log.Printf("rudder: could not lower pid %d to nice 10 (%v); the robot voice will compete with live transcription", pid, err) + } } diff --git a/internal/desktop-build.mjs b/internal/desktop-build.mjs index fc3fef9e5..06062b573 100644 --- a/internal/desktop-build.mjs +++ b/internal/desktop-build.mjs @@ -15,6 +15,7 @@ * See documentation/HOWTO-build-the-desktop-app.md for prerequisites and what is still unproven. */ import { spawnSync } from 'node:child_process' +import os from 'node:os' import { existsSync, mkdirSync, rmSync, writeFileSync, readdirSync } from 'node:fs' import { join, resolve, dirname, relative } from 'node:path' import { fileURLToPath } from 'node:url' @@ -24,9 +25,32 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') const TAURI_DIR = join(ROOT, 'deployment', 'src-tauri') const plugin = process.argv[2] const die = (m) => { console.error('desktop:build: ' + m); process.exit(1) } +// ★ THE BUILD YIELDS THE CPU TO LIVE TRANSCRIPTION. Rudder's speech model runs on the CPU +// (backend/rudder-engine starts it with --device cpu), and the Driver measured dictation falling +// behind WHILE THIS BUILD RAN. A Rust build saturates every core, so at equal priority it starves +// the one process whose latency a person can feel. +// +// We nice the COMPETITOR rather than promoting the engine, and that direction is forced, not a +// preference: on Linux an unprivileged process cannot LOWER its own nice (a negative nice needs +// CAP_SYS_NICE), so "raise the ASR priority" fails with EPERM — and the engine's existing +// `_ = syscall.Setpriority(...)` discards that error, which would have reported success and changed +// nothing. Raising our own nice needs no privilege, so this works as an ordinary user. +// +// Verify with `ps -o ni=` on the running cargo/rustc processes, never by this call returning. +const NICE = process.platform === 'linux' || process.platform === 'darwin' const run = (cmd, args, opts = {}) => { console.log('→ ' + [cmd, ...args].join(' ')) - const r = spawnSync(cmd, args, { stdio: 'inherit', cwd: ROOT, ...opts, env: { ...process.env, ...(opts.env || {}) } }) + // nice(1) execs the command, so every child of the build — cargo, rustc, the linker — inherits it. + const [c, a] = NICE ? ['nice', ['-n', '10', cmd, ...args]] : [cmd, args] + const r = spawnSync(c, a, { stdio: 'inherit', cwd: ROOT, ...opts, env: { ...process.env, ...(opts.env || {}) } }) + if (r.error && r.error.code === 'ENOENT' && NICE) { + // No nice(1) on this box: build anyway rather than refusing to build, and say so, because a + // silent fallback here would look identical to the fix working. + console.warn('desktop:build: nice(1) not found — building at normal priority; live dictation may lag') + const r2 = spawnSync(cmd, args, { stdio: 'inherit', cwd: ROOT, ...opts, env: { ...process.env, ...(opts.env || {}) } }) + if (r2.status !== 0) die(`${cmd} failed (exit ${r2.status})`) + return + } if (r.status !== 0) die(`${cmd} failed (exit ${r.status})`) } @@ -77,7 +101,13 @@ const override = { app: { windows: [{ title: name, width: 760, height: 820, minWidth: 360, minHeight: 480, resizable: true, url: 'index.html' }] }, bundle: { active: true, targets, ...(externalBin.length ? { externalBin } : {}) }, } -run(join(ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'tauri.cmd' : 'tauri'), ['build', '--config', JSON.stringify(override)]) +// ★ LEAVE ONE CORE FOR THE PERSON SPEAKING. nice alone decides who wins a CONTESTED core; it does +// not stop cargo taking all of them, and a fully-subscribed machine still adds scheduling latency to +// the audio path. cores-1 costs a little build time and keeps a core free for transcription. Honours +// an explicit CARGO_BUILD_JOBS rather than overriding someone who has already chosen. +const jobs = Math.max(1, (os.cpus()?.length || 2) - 1) +run(join(ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'tauri.cmd' : 'tauri'), ['build', '--config', JSON.stringify(override)], + { env: process.env.CARGO_BUILD_JOBS ? {} : { CARGO_BUILD_JOBS: String(jobs) } }) const bundleDir = join(process.env.CARGO_TARGET_DIR ? resolve(process.env.CARGO_TARGET_DIR) : join(TAURI_DIR, 'target'), 'release', 'bundle') const found = existsSync(bundleDir) ? readdirSync(bundleDir).flatMap((d) => readdirSync(join(bundleDir, d)).map((f) => join(bundleDir, d, f))) : [] diff --git a/plugin-designs/rudder-voice/rudder-voice.vue b/plugin-designs/rudder-voice/rudder-voice.vue index 485a2eff6..b514e55a5 100644 --- a/plugin-designs/rudder-voice/rudder-voice.vue +++ b/plugin-designs/rudder-voice/rudder-voice.vue @@ -830,7 +830,12 @@ let speakingUntil = 0, activityTimer = null, lastActivity = '' function sendActivity() { activityTimer = null const speaking = recording.value && Date.now() < speakingUntil - const tail = recording.value ? draft.value.trim().split(/\s+/).slice(-5).join(' ') : '' + // ★ READ THE TAIL, NOT THE WHOLE TRANSCRIPT. This split the ENTIRE draft to take five words, on + // every activity tick while recording — O(n) in a string that only grows. Measured at 3,000 words: + // 1.61ms per tick against 0.051ms for a bounded tail, 31x. 300 characters is far more than five + // words of any language this dictates, so the answer is identical; trim() comes BEFORE the slice + // because the draft ends in a space and slicing first would drop a word. + const tail = recording.value ? draft.value.trim().slice(-300).split(/\s+/).slice(-5).join(' ') : '' const key = speaking + '|' + tail if (key === lastActivity) return lastActivity = key From 8b7114942c263eb4566a7e23be002aa5f49b0b31 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 04:06:40 +0000 Subject: [PATCH 2/3] On a phone the transcript could not be scrolled, because a minimum refused to shrink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Driver, expanded on a phone: "a lot of header space … fills the entire page … I can't scroll down in the text area and see everything". The panel is capped at 60vh here and .draft carried min-height:180px (240px elsewhere). A MINIMUM is the wrong tool for that: it makes the box refuse to shrink, so whatever the header takes is pushed off the bottom and the draft's own scrollbar never appears — the text is there and unreachable. An earlier attempt had already lowered 240 to 180, which only moved the threshold rather than removing it, and is why the symptom survived. The column now owns the height and the draft takes what is left. min-height:0 is the load-bearing part: without it a flex child will not shrink below its content, which is the same reason the old minimum failed. The draft scrolls inside its share, and the header gives back space — tighter row gap, smaller name and status pill — since "a lot of header space" was the other half of what he saw. --- plugin-designs/rudder-voice/rudder-voice.vue | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/plugin-designs/rudder-voice/rudder-voice.vue b/plugin-designs/rudder-voice/rudder-voice.vue index b514e55a5..0af3a7779 100644 --- a/plugin-designs/rudder-voice/rudder-voice.vue +++ b/plugin-designs/rudder-voice/rudder-voice.vue @@ -1143,9 +1143,20 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1 .compact .debug { order: 4; } /* A phone-sized panel: less padding, and a shorter draft so it stays in view below the controls. */ @media (max-width: 480px) { - .rudder { min-height: 100%; padding: 12px; gap: 10px; } - .compose { min-height: 200px; } - .draft { min-height: 180px; } + /* ★ THE TEXT AREA GETS THE LEFTOVER SPACE AND SCROLLS. The Driver on a phone: "a lot of header + space … fills the entire page … I can't scroll down in the text area and see everything". The + panel is capped at 60vh here, and a min-height on the draft cannot fix that: a MINIMUM makes + the box refuse to shrink, so whatever the header takes is pushed off the bottom and the + draft's own scrollbar never appears. Lowering it from 240 to 180 only moved the threshold. + So the column owns the height (min-height:0 is what lets a flex child shrink BELOW its + content, and is the whole fix), the draft takes what is left and scrolls inside it, and the + header gives back the space it was holding. */ + .rudder { min-height: 0; height: 100%; padding: 10px; gap: 8px; display: flex; flex-direction: column; } + .compose { min-height: 0; flex: 1 1 0; display: flex; flex-direction: column; } + .draft { min-height: 0; flex: 1 1 0; overflow-y: auto; resize: none; padding: 10px; } + .top { gap: 8px; row-gap: 6px; } + .bar .name { font-size: 13px; } + .bar .status { padding: 2px 7px; font-size: 11.5px; } /* Notices (engine offline, mic muted, …) sit below the draft here, so the draft stays in view. */ .notice, .hint { order: 1; } .bottom { order: 2; } From 7f8199a5b77cdc4949d8b333089c2209ac24fd31 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 04:06:41 +0000 Subject: [PATCH 3/3] The recording pill kept resizing, because a character count is not a width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Driver: "it shows some words … but it keeps RESIZING, kind of distracting … it should stay the same width and stop resizing". The intent was already here — a fixed width while listening, with the text cut to 33 characters — but neither half held. The bubble is inline-flex, so it sizes to its content wherever `width` loses, and it carries a max-width that the <=480px query overrides, which is exactly the phone case he was looking at. Truncating by CHARACTER cannot stabilise it either: 33 characters is about 133px of "illi" and about 344px of "MMM WOW", so the same count is a 200px swing. width, min-width and max-width are now pinned to the same value while recording, at both breakpoints, so no later rule and no viewport lets the pill grow or shrink; the text ellipsises inside a box that no longer moves. studio's file: sent as a hunk for them to land, not landed here. --- plugin-designs/plugin-studio/plugin-studio.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugin-designs/plugin-studio/plugin-studio.vue b/plugin-designs/plugin-studio/plugin-studio.vue index 78c969db1..eea877ac0 100644 --- a/plugin-designs/plugin-studio/plugin-studio.vue +++ b/plugin-designs/plugin-studio/plugin-studio.vue @@ -448,7 +448,8 @@ const WIDGET_CSS = ` [data-wb-icon]{display:grid;place-items:center;flex:none;color:#38bdf8} [data-wb-icon] svg,[data-wb-chev] svg{width:20px;height:20px;display:block} [data-wb-text]{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis} -[data-widget-bubble][data-listening="true"]{width:min(300px,calc(100vw - 32px))} +/* ★ WHILE RECORDING THE PILL DOES NOT BREATHE. The Driver: "it keeps RESIZING, kind of distracting — it should stay the same width". width alone was not enough: the bubble is inline-flex, so it still sizes to content wherever width loses — and it carries a max-width that the <=480px query overrides, which is exactly the phone case he was looking at. Truncating the text cannot fix it either: 33 CHARACTERS is 133px of "illi" and 344px of "MMM WOW", so a character count is not a width. min-width and max-width are pinned to the same value as width so no later rule and no viewport can let it grow or shrink; the text ellipsises inside it. */ +[data-widget-bubble][data-listening="true"]{width:min(300px,calc(100vw - 32px));min-width:min(300px,calc(100vw - 32px));max-width:min(300px,calc(100vw - 32px))} [data-widget-bubble][data-listening="true"] [data-wb-text]{flex:1 1 auto} [data-wb-dot],[data-wb-chev]{display:none;flex:none} [data-wb-dot]{width:10px;height:10px;border-radius:50%;background:#ef4444} @@ -466,6 +467,8 @@ const WIDGET_CSS = ` [data-studio-widgets$="left"]{left:max(8px,env(safe-area-inset-left))} [data-widget-panel]{width:calc(100vw - 16px);max-width:none;height:60vh;height:60dvh} [data-widget-bubble]{max-width:calc(100vw - 16px)} + /* ...but a RECORDING pill keeps its fixed width on a phone too, or it grows to the viewport. */ + [data-widget-bubble][data-listening="true"]{width:min(300px,calc(100vw - 16px));min-width:min(300px,calc(100vw - 16px));max-width:min(300px,calc(100vw - 16px))} }` const WIDGET_ICON_OPEN = '' const WIDGET_ICON_CLOSE = ''