diff options
Diffstat (limited to 'working')
27 files changed, 5875 insertions, 154 deletions
diff --git a/working/clock-display-references/2026-07-30-maeda-cosmos-notes.org b/working/clock-display-references/2026-07-30-maeda-cosmos-notes.org new file mode 100644 index 0000000..04d8629 --- /dev/null +++ b/working/clock-display-references/2026-07-30-maeda-cosmos-notes.org @@ -0,0 +1,11 @@ +#+TITLE: Companion to the Line applet sent a few minutes ago: a copy +#+SOURCE: from website +#+DATE: 2026-07-30 18:48:00 -0500 + +Companion to the Line applet sent a few minutes ago: a copy of Cosmos (C1, 1995), the orbits piece — 'numbers in orbit'. Delivered as 2026-07-30-maeda-cosmos-standalone.html in this inbox. + +Same shape as the Line copy: the <maeda-cosmos> web component plus its model/step/render modules esbuild-bundled inline as an IIFE, so it opens straight from file:// with no server and no network. Verified in Chrome from file:// — canvas 400x300, the month's days riding the rotated ellipse with today red on the sweep hand, seconds odometer live at the right edge, zero console errors. + +Controls: hover reshapes every orbit; press-drag pulls a new loop out of the system; the mark at bottom-left clears them and replays the fly-in credit; click the canvas then ESC toggles strip-move mode. Note this one has no readout event, unlike Line. + +Source of truth stays ~/code/maeda-tribute (src/pieces/cosmos/); regenerate rather than edit. Reference only — no action needed. diff --git a/working/clock-display-references/2026-07-30-maeda-cosmos-standalone.html b/working/clock-display-references/2026-07-30-maeda-cosmos-standalone.html new file mode 100644 index 0000000..bfa9fbb --- /dev/null +++ b/working/clock-display-references/2026-07-30-maeda-cosmos-standalone.html @@ -0,0 +1,422 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Cosmos (C1, 1995) — Maeda × Shiseido tribute</title> +<style> +:root{ + --ground:#151311; + --panel:#100f0f; + --well:#0a0c0d; + --raise:#1a1917; + --silver:#bfc4d0; + --cream:#f3e7c5; + --steel:#969385; + --dim:#7c838a; + --wash:#2c2f32; + --gold:#e2a038; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground);font-size:112%} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.wrap{max-width:820px;margin:0 auto} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{font-size:1.5rem;color:var(--cream);font-weight:600;margin:.35rem 0 .2rem} +.sub{color:var(--dim);font-size:.9rem;margin-bottom:1.6rem} +.card{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320; + border-radius:12px;padding:1.2rem;margin-bottom:1.4rem} +.stage{background:var(--well);border:1px solid #22201d;border-radius:8px;padding:1rem; + display:flex;justify-content:center} +.wnote{color:var(--dim);font-size:.85rem;margin-top:1rem} +.wnote b{color:var(--steel);font-weight:600} +.igrid{display:grid;grid-template-columns:11rem 1fr;gap:.35rem .9rem;margin-top:1rem; + font-size:.82rem} +.ik{color:var(--steel)} +.iv{color:var(--dim)} +</style> +</head> +<body> +<div class="wrap"> + <p class="eyebrow">Waste Time Beautifully · C1</p> + <h1>Cosmos — numbers in orbit</h1> + <p class="sub">John Maeda for Shiseido, 1995 (Cal0.class) — rebuilt as a web component. Standalone copy, no build step.</p> + + <div class="card"> + <div class="stage"><maeda-cosmos id="piece"></maeda-cosmos></div> + <p class="wnote"><b>Controls:</b> hover to reshape every orbit — the axes track 1.5× the + cursor's distance from each orbit's center; press and drag to pull a new loop out of the + system; the mark at bottom-left clears them and replays the fly-in credit. Click the + canvas first, then <b>ESC</b> toggles strip-move mode (the ground goes orange and the + clock strip comes free).</p> + <p class="wnote">The days of the month ride a rotated ellipse, today in red on the sweep + hand, with a seconds odometer scrolling at the right edge. The month as a gravitational + system, days as bodies in orbit, the user as a hand that perturbs the heavens. Time is + periodic, not linear.</p> + </div> + + <div class="card"> + <p class="eyebrow">Spec</p> + <div class="igrid"> + <span class="ik">original</span><span class="iv">Cal0.class + UniverseCal, orbit, eint, efloatsin (Java 1.1)</span> + <span class="ik">dimensions</span><span class="iv">400×300, black ground, Helvetica 10</span> + <span class="ik">collection</span><span class="iv">SFMOMA 99.550</span> + <span class="ik">revolution</span><span class="iv">7,500 ms per sweep, phase = wall clock mod cycle — every orbit in lockstep forever; orientation wobbles ±10° on a 15,000 ms sine</span> + <span class="ik">interaction</span><span class="iv">every mouse move (not just drag) restyles all orbits; press spawns a zero-axis orbit that inflates as the cursor pulls away</span> + <span class="ik">period quirks</span><span class="iv">a cursor-driven rotation angle is computed and stored but never drawn — a dead store in the 1995 bytecode; Mac Java Date bug detected and adjusted</span> + <span class="ik">credit</span><span class="iv">chars fly in at 800+80i ms from random scatter, hold 1.5 s, vanish; logo click replays</span> + <span class="ik">tests</span><span class="iv">38 (Vitest + fast-check)</span> + </div> + </div> +</div> + +<script> +(() => { + // src/pieces/cosmos/model.js + var TAU = Math.PI * 2; + var CREDIT = "designed by john maeda"; + var FAITHFUL_1995 = { + faceW: 400, + // applet tag WIDTH=400 HEIGHT=300 + faceH: 300, + cycleMs: 7500, + // one revolution, wall-clock modulo + wobbleAmp: 10 * Math.PI / 180, + // efloatsin(0, 10°, 15000) + wobblePeriodMs: 15e3, + stretch: 1.5, + // axes = 1.5× cursor distance from center + initialAxes: [300, 100], + // the startup orbit at panel center + fontSize: 10, + // applet param default; Helvetica plain + stripWidth: 20, + // stringWidth("59")+6 at 10 px, rounded + clockReach: 120, + // readout + weekday row extent left of the strip + logo: { dx: 8, dy: 8, w: 70, h: 16 } + // shiseido.gif box, bottom-left + }; + function sweepPhase(cfg, nowMs) { + return nowMs % cfg.cycleMs / cfg.cycleMs * TAU; + } + function wobbleAngle(cfg, elapsedMs) { + return cfg.wobbleAmp * Math.sin(elapsedMs * TAU / cfg.wobblePeriodMs); + } + function orbitPoint(o, theta, alpha) { + const ct = Math.cos(theta); + const st = Math.sin(theta); + const ca = Math.cos(alpha); + const sa = Math.sin(alpha); + return { + x: o.lmaj * ct * ca - o.lmin * st * sa + o.h, + y: o.lmaj * ct * sa + o.lmin * st * ca + o.k + }; + } + function dayTheta(day, today, ndays, phase) { + return phase + (day - today) * TAU / ndays; + } + function makeOrbit(x, y, lmaj = 0, lmin = 0) { + return { h: x + 0.5, k: y + 0.5, lmaj, lmin, wobbleT: 0 }; + } + function stretchOrbit(cfg, o, mx, my) { + return { ...o, lmaj: (mx - o.h) * cfg.stretch, lmin: (my - o.k) * cfg.stretch }; + } + function creditDurations(len) { + const chars = []; + for (let i = 0; i < len; i++) chars.push(800 + 80 * i); + return { chars, sentinel: 800 + 80 * len + 1500 }; + } + function easeLinear(t, a, b, dur) { + if (t <= 0) return a; + if (t >= dur) return b; + return a + (b - a) * t / dur; + } + function creditDone(len, t) { + return t >= creditDurations(len).sentinel; + } + function stripOffset(cfg, sec, msWithin) { + const ld = cfg.fontSize + 1; + const siddy = 3 + cfg.fontSize; + return -ld * sec - msWithin * ld / 1e3 + siddy - ld; + } + function logoHit(cfg, x, y) { + const { dx, dy, w, h } = cfg.logo; + return x < dx + w && y > cfg.faceH - h - dy; + } + function clockHit(cfg, strip, x, y) { + const ld = cfg.fontSize + 1; + const left = cfg.faceW - strip.sidex - cfg.clockReach; + const right = cfg.faceW - strip.sidex + cfg.stripWidth; + return x > left && x < right && y < strip.siddy && y > strip.siddy - ld; + } + + // src/pieces/cosmos/step.js + function scatterStarts(cfg, rng) { + const starts = []; + for (let i = 0; i < CREDIT.length; i++) { + starts.push({ + x: Math.floor(rng() * cfg.faceW) * (rng() < 0.5 ? 1 : -1) + cfg.faceW / 2, + y: Math.floor(rng() * cfg.faceH) * (rng() < 0.5 ? 1 : -1) + cfg.faceH / 2 + }); + } + return starts; + } + function initState(cfg, rng = Math.random) { + return { + orbits: [makeOrbit(cfg.faceW / 2, cfg.faceH / 2, cfg.initialAxes[0], cfg.initialAxes[1])], + credit: { t: 0, starts: scatterStarts(cfg, rng) }, + strip: { sidex: cfg.stripWidth, siddy: 3 + cfg.fontSize }, + killPending: false, + movable: false, + draggingStrip: false, + prevHeld: false, + prevCursor: null, + rng + }; + } + function toggleMovable(state) { + state.movable = !state.movable; + return state; + } + function step(cfg, state, dtMs, input) { + const s = { ...state, strip: { ...state.strip }, credit: { ...state.credit } }; + const { x, y, held } = input; + s.credit.t += dtMs; + s.orbits = s.orbits.map((o) => ({ ...o, wobbleT: o.wobbleT + dtMs })); + if (x == null) { + s.prevHeld = held; + return s; + } + if (held && !s.prevHeld) { + if (logoHit(cfg, x, y)) { + s.killPending = true; + s.credit = { t: 0, starts: scatterStarts(cfg, s.rng) }; + } else if (clockHit(cfg, s.strip, x, y) || s.movable) { + s.draggingStrip = true; + } else { + s.orbits = [...s.orbits, makeOrbit(x, y)]; + } + } + if (held && s.draggingStrip && s.prevCursor) { + s.strip.sidex -= x - s.prevCursor.x; + s.strip.siddy += y - s.prevCursor.y; + } + s.orbits = s.orbits.map((o) => stretchOrbit(cfg, o, x, y)); + if (!held && s.prevHeld) { + if (s.killPending) { + s.orbits = [s.orbits[0]]; + s.strip = { sidex: cfg.stripWidth, siddy: 3 + cfg.fontSize }; + } + s.killPending = false; + s.draggingStrip = false; + } + s.prevHeld = held; + s.prevCursor = { x, y }; + return s; + } + + // src/lib/calendar.js + function isLeapYear(y) { + return y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0); + } + var MDAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + function daysInMonth(y, m) { + return m === 2 && isLeapYear(y) ? 29 : MDAYS[m - 1]; + } + + // src/pieces/cosmos/render.js + var FACE = { w: 400, h: 300 }; + var MONTHS = ["jan ", "feb ", "mar ", "apr ", "may ", "jun ", "jul ", "aug ", "sep ", "oct ", "nov ", "dec "]; + var WEEKDAYS = ["S ", "M ", "T ", "W ", "T ", "F ", "S "]; + function font(cfg) { + return `${cfg.fontSize}px Helvetica, Arial, sans-serif`; + } + function dateParts(nowMs) { + const d = new Date(nowMs); + return { + year: d.getFullYear(), + month: d.getMonth() + 1, + // 1-12 + day: d.getDate(), + weekday: d.getDay(), + hours: d.getHours(), + minutes: d.getMinutes(), + seconds: d.getSeconds(), + msWithin: nowMs % 1e3 + }; + } + function drawOrbit(ctx, cfg, o, dp, phase, dim) { + const ndays = daysInMonth(dp.year, dp.month); + const alpha = wobbleAngle(cfg, o.wobbleT); + const daystr = `${dp.day} ${dp.year}`; + const monthstr = MONTHS[dp.month - 1]; + for (let d = 1; d <= ndays; d++) { + const p = orbitPoint(o, dayTheta(d, dp.day, ndays, phase), alpha); + if (d === dp.day) { + ctx.fillStyle = "#f00"; + ctx.fillText(daystr, p.x, p.y); + ctx.fillText(monthstr, p.x - ctx.measureText(monthstr).width, p.y); + } else { + ctx.fillStyle = dim ? "#808080" : "#fff"; + ctx.fillText(String(d), p.x, p.y); + } + } + } + function drawStrip(ctx, cfg, state, dp) { + const ld = cfg.fontSize + 1; + const { sidex, siddy } = state.strip; + const x = FACE.w - sidex; + const tileH = ld * 60; + const off = stripOffset(cfg, dp.seconds, dp.msWithin) + (siddy - (3 + cfg.fontSize)); + ctx.save(); + ctx.beginPath(); + ctx.rect(x, 0, cfg.stripWidth, FACE.h); + ctx.clip(); + ctx.fillStyle = "#404040"; + ctx.fillRect(x, 0, cfg.stripWidth, FACE.h); + ctx.fillStyle = "#808080"; + for (const tile of [-1, 0, 1]) { + for (let i = 0; i < 60; i++) { + const y = off + tile * tileH + i * ld + ld; + if (y > -ld && y < FACE.h + ld) { + ctx.fillText(String(i).padStart(2, "0"), x + 3, y); + } + } + } + ctx.restore(); + ctx.fillStyle = "#f00"; + ctx.fillText(String(dp.seconds).padStart(2, "0"), x + 3, siddy); + const h12 = dp.hours % 12 === 0 ? 12 : dp.hours % 12; + const clock = `${h12}${dp.minutes < 10 ? ":0" : ":"}${dp.minutes}:`; + const cx = x + 3 - ctx.measureText(clock).width; + ctx.fillText(clock, cx, siddy); + let wx = cx - ctx.measureText("S M T W T F S ").width; + for (let i = 0; i < 7; i++) { + ctx.fillStyle = i === dp.weekday ? "#f00" : "#808080"; + ctx.fillText(WEEKDAYS[i], wx, siddy); + wx += ctx.measureText(WEEKDAYS[i]).width; + } + } + function drawCredit(ctx, cfg, state) { + const { t, starts } = state.credit; + if (creditDone(CREDIT.length, t)) return; + const durs = creditDurations(CREDIT.length); + let fx = (FACE.w - ctx.measureText(CREDIT).width) / 2 + 1; + const fy = FACE.h / 2 + cfg.fontSize / 2; + ctx.fillStyle = "#fff"; + for (let i = 0; i < CREDIT.length; i++) { + const ch = CREDIT[i]; + const x = easeLinear(t, starts[i].x, fx, durs.chars[i]); + const y = easeLinear(t, starts[i].y, fy, durs.chars[i]); + ctx.fillText(ch, x, y); + fx += ctx.measureText(ch).width; + } + } + function drawLogo(ctx, cfg) { + const { dx, dy, h } = cfg.logo; + ctx.fillStyle = "#808080"; + ctx.fillText("shiseido", dx, FACE.h - dy - h / 2 + cfg.fontSize / 2); + } + function draw(ctx, cfg, state, nowMs) { + const dp = dateParts(nowMs); + ctx.fillStyle = state.movable ? "#e08000" : "#000"; + ctx.fillRect(0, 0, FACE.w, FACE.h); + ctx.font = font(cfg); + ctx.textBaseline = "alphabetic"; + drawStrip(ctx, cfg, state, dp); + const phase = sweepPhase(cfg, nowMs); + state.orbits.forEach((o, i) => { + drawOrbit(ctx, cfg, o, dp, phase, state.killPending && i > 0); + }); + drawLogo(ctx, cfg); + drawCredit(ctx, cfg, state); + ctx.strokeStyle = "#fff"; + ctx.lineWidth = 1; + ctx.strokeRect(0.5, 0.5, FACE.w - 1, FACE.h - 1); + } + + // src/lib/loop.js + var STEP_MS = 30; + function startLoop({ stepFn, renderFn, stepMs = STEP_MS }) { + let acc = 0; + let last = null; + let raf = null; + let running = true; + function frame(ts) { + if (!running) return; + if (last === null) last = ts; + acc += Math.min(ts - last, 250); + last = ts; + while (acc >= stepMs) { + stepFn(stepMs); + acc -= stepMs; + } + renderFn(); + raf = requestAnimationFrame(frame); + } + raf = requestAnimationFrame(frame); + return () => { + running = false; + if (raf) cancelAnimationFrame(raf); + }; + } + + // src/pieces/cosmos/index.js + var MaedaCosmos = class extends HTMLElement { + connectedCallback() { + const canvas = document.createElement("canvas"); + canvas.width = FACE.w; + canvas.height = FACE.h; + canvas.style.cssText = "display:block;width:100%;max-width:400px;cursor:crosshair;touch-action:none"; + canvas.tabIndex = 0; + this.appendChild(canvas); + const ctx = canvas.getContext("2d"); + const frozen = this.getAttribute("frozen-now"); + const nowMs = () => frozen ? Number(frozen) * 1e3 : Date.now(); + const cfg = FAITHFUL_1995; + let state = initState(cfg); + const input = { x: null, y: null, held: false }; + const toFace = (ev) => { + const r = canvas.getBoundingClientRect(); + return { + x: (ev.clientX - r.left) * (FACE.w / r.width), + y: (ev.clientY - r.top) * (FACE.h / r.height) + }; + }; + canvas.addEventListener("pointermove", (ev) => { + Object.assign(input, toFace(ev)); + }); + canvas.addEventListener("pointerdown", (ev) => { + canvas.setPointerCapture(ev.pointerId); + canvas.focus(); + Object.assign(input, toFace(ev)); + input.held = true; + }); + const release = () => { + input.held = false; + }; + canvas.addEventListener("pointerup", release); + canvas.addEventListener("pointercancel", release); + canvas.addEventListener("keydown", (ev) => { + if (ev.key === "Escape") toggleMovable(state); + }); + this._stop = startLoop({ + stepFn: (dtMs) => { + state = step(cfg, state, dtMs, input); + }, + renderFn: () => draw(ctx, cfg, state, nowMs()) + }); + } + disconnectedCallback() { + if (this._stop) this._stop(); + } + }; + customElements.define("maeda-cosmos", MaedaCosmos); +})(); + +</script> +</body> +</html> diff --git a/working/clock-display-references/2026-07-30-maeda-line-notes.org b/working/clock-display-references/2026-07-30-maeda-line-notes.org new file mode 100644 index 0000000..ba50a80 --- /dev/null +++ b/working/clock-display-references/2026-07-30-maeda-line-notes.org @@ -0,0 +1,11 @@ +#+TITLE: Copy of the Line applet (C2, 1997) from the Maeda x Shiseido +#+SOURCE: from website +#+DATE: 2026-07-30 18:38:41 -0500 + +Copy of the Line applet (C2, 1997) from the Maeda x Shiseido tribute — the diagonal timeline piece, 'zoom through time'. Delivered as 2026-07-30-1838-from-website-2026-07-30-maeda-line-standalone.html in this inbox. + +It is a single self-contained file: the <maeda-line> web component and its model/step/render modules are esbuild-bundled inline as an IIFE, so it opens straight from file:// with no server, no build step, and no network. Verified rendering in Chrome from file:// — canvas 460x400, zero console errors, live readout wired. + +Controls: move the pointer along the diagonal to aim, press and hold to zoom continuously into that moment, release to float back out. Full zoom is exactly 60 seconds and the release is symmetric. + +Source of truth stays ~/code/maeda-tribute (src/pieces/line/); this copy is a snapshot, so regenerate rather than edit it if the piece changes. Sent for reference — no action needed unless you want it wired into the panel-widget gallery. diff --git a/working/clock-display-references/2026-07-30-maeda-line-standalone.html b/working/clock-display-references/2026-07-30-maeda-line-standalone.html new file mode 100644 index 0000000..783939c --- /dev/null +++ b/working/clock-display-references/2026-07-30-maeda-line-standalone.html @@ -0,0 +1,457 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Line (C2, 1997) — Maeda × Shiseido tribute</title> +<style> +:root{ + --ground:#151311; + --panel:#100f0f; + --well:#0a0c0d; + --raise:#1a1917; + --silver:#bfc4d0; + --cream:#f3e7c5; + --steel:#969385; + --dim:#7c838a; + --wash:#2c2f32; + --gold:#e2a038; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground);font-size:112%} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.wrap{max-width:820px;margin:0 auto} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{font-size:1.5rem;color:var(--cream);font-weight:600;margin:.35rem 0 .2rem} +.sub{color:var(--dim);font-size:.9rem;margin-bottom:1.6rem} +.card{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320; + border-radius:12px;padding:1.2rem;margin-bottom:1.4rem} +.stage{background:var(--well);border:1px solid #22201d;border-radius:8px;padding:1rem; + display:flex;justify-content:center} +.readout{margin-top:.8rem;color:var(--gold);font-size:.82rem;letter-spacing:.06em} +.wnote{color:var(--dim);font-size:.85rem;margin-top:1rem} +.wnote b{color:var(--steel);font-weight:600} +.igrid{display:grid;grid-template-columns:11rem 1fr;gap:.35rem .9rem;margin-top:1rem; + font-size:.82rem} +.ik{color:var(--steel)} +.iv{color:var(--dim)} +</style> +</head> +<body> +<div class="wrap"> + <p class="eyebrow">Waste Time Beautifully · C2</p> + <h1>Line — zoom through time</h1> + <p class="sub">John Maeda for Shiseido, 1997 (cal1.class) — rebuilt as a web component. Standalone copy, no build step.</p> + + <div class="card"> + <div class="stage"><maeda-line id="piece"></maeda-line></div> + <div class="readout" id="readout">scale — · counter —</div> + <p class="wnote"><b>Controls:</b> move the pointer along the diagonal to aim; press and + hold to zoom continuously into that moment; release and you float back out. Full zoom + is exactly 60 seconds, and the release is symmetric.</p> + <p class="wnote">Time as a single line — <b>diagonal</b>, bottom-left to top-right, the + detail every written description of this applet gets wrong. Maeda's note: it reflects + the relativity and comparability of time intervals. The zoom is the content.</p> + </div> + + <div class="card"> + <p class="eyebrow">Spec</p> + <div class="igrid"> + <span class="ik">original</span><span class="iv">cal1.class, 12,339 bytes, single class, self-contained (Java 1.1)</span> + <span class="ik">dimensions</span><span class="iv">460×400, black ground, Helvetica throughout</span> + <span class="ik">collection</span><span class="iv">SFMOMA 99.554</span> + <span class="ik">zoom plateaus</span><span class="iv">72px per day / hour / minute / second — scn 1 → 72 → 1,728 → 103,680 → 6,220,800 px/day</span> + <span class="ik">easing</span><span class="iv">counter at 20 increments/sec over segments [150, 250, 350, 450]</span> + <span class="ik">labels</span><span class="iv">font = min(72, unit spacing); text under 5px degrades to a line; gray ramps 128→255 as a unit matures; the current unit is always red</span> + <span class="ik">period quirks</span><span class="iv">Mac JVM patch capped zoom at hour level; an unused HAPPY NEW YEAR string ships in the bytecode</span> + <span class="ik">tests</span><span class="iv">33 (Vitest + fast-check)</span> + </div> + </div> +</div> + +<script> +(() => { + // src/lib/calendar.js + function epochDayFromCivil(y, m, d) { + const yy = y - (m <= 2 ? 1 : 0); + const era = Math.floor(yy / 400); + const yoe = yy - era * 400; + const doy = Math.floor((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5) + d - 1; + const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy; + return era * 146097 + doe - 719468; + } + function civilFromEpochDay(ed) { + const z = ed + 719468; + const era = Math.floor(z / 146097); + const doe = z - era * 146097; + const yoe = Math.floor( + (doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365 + ); + const y = yoe + era * 400; + const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100)); + const mp = Math.floor((5 * doy + 2) / 153); + const d = doy - Math.floor((153 * mp + 2) / 5) + 1; + const m = mp + (mp < 10 ? 3 : -9); + return { y: y + (m <= 2 ? 1 : 0), m, d }; + } + + // src/pieces/line/model.js + var DAY = 86400; + var FAITHFUL_1997 = { + stops: [1, 72, 1728, 103680, 6220800], + segments: [150, 250, 350, 450], + incsPerSec: 20 + }; + function totalIncrements(cfg) { + return cfg.segments.reduce((a, b) => a + b, 0); + } + function scaleForCounter(cfg, counter) { + const total = totalIncrements(cfg); + const c = Math.max(0, Math.min(total, counter)); + let acc = 0; + for (let i = 0; i < cfg.segments.length; i++) { + const seg = cfg.segments[i]; + if (c <= acc + seg) { + const f = (c - acc) / seg; + return cfg.stops[i] + f * (cfg.stops[i + 1] - cfg.stops[i]); + } + acc += seg; + } + return cfg.stops[cfg.stops.length - 1]; + } + function advanceCounter(cfg, counter, dtMs, held) { + const d = cfg.incsPerSec * dtMs / 1e3; + const next = held ? counter + d : counter - d; + return Math.max(0, Math.min(totalIncrements(cfg), next)); + } + function pxOfTime(view, t) { + return (t - view.focus) * view.scale / DAY + view.anchorPx; + } + function timeOfPx(view, px) { + return (px - view.anchorPx) * DAY / view.scale + view.focus; + } + var MAX_FONT = 72; + var MIN_TEXT_PX = 5; + function fontSizeFor(spacingPx) { + return Math.min(MAX_FONT, spacingPx); + } + function textVisible(fontSize) { + return fontSize >= MIN_TEXT_PX; + } + function grayFor(fontSize) { + return Math.floor(Math.min(MAX_FONT, fontSize) * 127 / MAX_FONT) + 128; + } + var FIXED_UNITS = { second: 1, minute: 60, hour: 3600, day: DAY }; + function* calendarTicks(unit, t0, t1) { + let { y, m } = civilFromEpochDay(Math.floor(t0 / DAY)); + const stride = unit === "decade" ? 10 : unit === "century" ? 100 : 1; + if (unit === "month") { + for (; ; ) { + const t = epochDayFromCivil(y, m, 1) * DAY; + if (t >= t0) break; + m++; + if (m > 12) m = 1, y++; + } + for (; ; ) { + const t = epochDayFromCivil(y, m, 1) * DAY; + if (t > t1) return; + yield t; + m++; + if (m > 12) m = 1, y++; + } + } else { + let yy = Math.ceil(y / stride) * stride; + if (epochDayFromCivil(yy, 1, 1) * DAY < t0) yy += stride; + while (epochDayFromCivil(yy - stride, 1, 1) * DAY >= t0) yy -= stride; + for (; ; ) { + const t = epochDayFromCivil(yy, 1, 1) * DAY; + if (t > t1) return; + if (t >= t0) yield t; + yy += stride; + } + } + } + function ticksInRange(unit, t0, t1) { + if (unit in FIXED_UNITS) { + const w = FIXED_UNITS[unit]; + const out = []; + for (let t = Math.ceil(t0 / w) * w; t <= t1; t += w) out.push(t); + return out; + } + return [...calendarTicks(unit, t0, t1)]; + } + var MONTHS = [ + "JANUARY", + "FEBRUARY", + "MARCH", + "APRIL", + "MAY", + "JUNE", + "JULY", + "AUGUST", + "SEPTEMBER", + "OCTOBER", + "NOVEMBER", + "DECEMBER" + ]; + function formatMonth(m) { + return MONTHS[m - 1]; + } + function formatDay(m, d) { + return `${m}/${d}`; + } + function formatHour(h) { + const twelve = h % 12 === 0 ? 12 : h % 12; + return { text: ` ${twelve}`, suffix: h >= 12 ? "pm" : "am" }; + } + function formatMinute(mm) { + return `:${String(mm).padStart(2, "0")}`; + } + + // src/pieces/line/step.js + function initState(cfg, range, lineLenPx) { + return { + counter: 0, + scale: scaleForCounter(cfg, 0), + focus: 0, + // epoch seconds under the anchor pixel + anchorPx: 0, + // pixel along the line where focus projects + range, + // {tMin, tMax} or null for the unbounded twist + lineLenPx + }; + } + function step(cfg, s, dtMs, input) { + const cursorPx = Math.max(0, Math.min(s.lineLenPx, input.cursorPx)); + let { focus, anchorPx } = s; + if (input.held || s.counter > 0) { + focus = timeOfPx({ focus: s.focus, scale: s.scale, anchorPx: s.anchorPx }, cursorPx); + anchorPx = cursorPx; + } + const counter = advanceCounter(cfg, s.counter, dtMs, input.held); + const scale = scaleForCounter(cfg, counter); + if (!input.held && counter < cfg.segments[0]) { + const target = cursorPx * DAY; + focus += (target - focus) / (counter + 1); + anchorPx = cursorPx; + } + if (s.range) { + focus = Math.max(s.range.tMin, Math.min(s.range.tMax, focus)); + } + return { ...s, counter, scale, focus, anchorPx }; + } + + // src/pieces/line/render.js + var LEVELS = [ + { unit: "month", secs: 30 * DAY }, + { unit: "day", secs: DAY }, + { unit: "hour", secs: 3600 }, + { unit: "minute", secs: 60 }, + { unit: "second", secs: 1 } + ]; + var FACE = { w: 460, h: 400 }; + function lineOrigin(state) { + const margin = (FACE.h - state.lineLenPx) / 2; + return { mx: FACE.w - FACE.h + margin, my: margin }; + } + function labelFor(unit, t) { + const dayIdx = Math.floor(t / DAY); + const civ = civilFromEpochDay(dayIdx); + const rem = t - dayIdx * DAY; + const hh = Math.floor(rem / 3600); + const mm = Math.floor(rem % 3600 / 60); + const ss = Math.floor(rem % 60); + switch (unit) { + case "month": + return formatMonth(civ.m); + case "day": + return formatDay(civ.m, civ.d); + case "hour": { + const h = formatHour(hh); + return h.text + h.suffix; + } + case "minute": + return formatMinute(mm); + case "second": + return formatMinute(ss); + default: + return ""; + } + } + function sameTick(unit, t, now) { + const w = { second: 1, minute: 60, hour: 3600, day: DAY }[unit]; + if (w) return Math.floor(t / w) === Math.floor(now / w); + const a = civilFromEpochDay(Math.floor(t / DAY)); + const b = civilFromEpochDay(Math.floor(now / DAY)); + return a.y === b.y && a.m === b.m; + } + function draw(ctx, state, nowSec) { + const { mx, my } = lineOrigin(state); + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, FACE.w, FACE.h); + const view = { focus: state.focus, scale: state.scale, anchorPx: state.anchorPx }; + const tA = Math.max( + state.range ? state.range.tMin : -Infinity, + state.focus - state.anchorPx * DAY / state.scale + ); + const tB = Math.min( + state.range ? state.range.tMax : Infinity, + state.focus + (state.lineLenPx - state.anchorPx) * DAY / state.scale + ); + ctx.strokeStyle = "#666"; + ctx.beginPath(); + ctx.moveTo(mx, FACE.h - my); + ctx.lineTo(mx + state.lineLenPx, FACE.h - my - state.lineLenPx); + ctx.stroke(); + const colWidth = {}; + for (let i = LEVELS.length - 1; i >= 0; i--) { + const { unit, secs } = LEVELS[i]; + const spacing = secs * state.scale / DAY; + if (spacing < 2 && unit !== "month") { + colWidth[unit] = 0; + continue; + } + const size = Math.floor(fontSizeFor(spacing)); + colWidth[unit] = textVisible(size) ? size * 2.2 : 0; + } + for (let i = LEVELS.length - 1; i >= 0; i--) { + const { unit, secs } = LEVELS[i]; + const spacing = secs * state.scale / DAY; + if (spacing < 2 && unit !== "month") continue; + const size = unit === "month" ? Math.floor(Math.min(72, 11 + state.scale)) : Math.floor(fontSizeFor(spacing)); + const gray = unit === "month" ? 255 : grayFor(size); + let offset = 0; + for (let j = LEVELS.length - 1; j > i; j--) offset += colWidth[LEVELS[j].unit]; + ctx.font = `${Math.max(size, 1)}px Helvetica, Arial, sans-serif`; + for (const t of ticksInRange(unit, tA, tB)) { + const n = pxOfTime(view, t); + if (n < 0 || n > state.lineLenPx) continue; + const x = mx + n; + const y = FACE.h - my - n; + const isNow = sameTick(unit, t, nowSec); + ctx.strokeStyle = ctx.fillStyle = isNow ? "#f00" : `rgb(${gray},${gray},${gray})`; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + 3, y); + ctx.stroke(); + const text = labelFor(unit, t); + if (textVisible(size)) { + const w = ctx.measureText(text).width; + ctx.fillText(text, x - w - offset, y); + } else { + ctx.beginPath(); + ctx.moveTo(x - 4 - offset, y); + ctx.lineTo(x - offset, y); + ctx.stroke(); + } + } + } + ctx.fillStyle = "#ccc"; + ctx.font = "15px Helvetica, Arial, sans-serif"; + const label = `${Math.round(state.scale)}X${state.counter >= 1200 ? " (MAX)" : ""}`; + ctx.fillText(label, 3, 16); + } + + // src/lib/loop.js + var STEP_MS = 30; + function startLoop({ stepFn, renderFn, stepMs = STEP_MS }) { + let acc = 0; + let last = null; + let raf = null; + let running = true; + function frame(ts) { + if (!running) return; + if (last === null) last = ts; + acc += Math.min(ts - last, 250); + last = ts; + while (acc >= stepMs) { + stepFn(stepMs); + acc -= stepMs; + } + renderFn(); + raf = requestAnimationFrame(frame); + } + raf = requestAnimationFrame(frame); + return () => { + running = false; + if (raf) cancelAnimationFrame(raf); + }; + } + + // src/pieces/line/index.js + var MaedaLine = class extends HTMLElement { + connectedCallback() { + const canvas = document.createElement("canvas"); + canvas.width = FACE.w; + canvas.height = FACE.h; + canvas.style.cssText = "display:block;width:100%;max-width:460px;cursor:crosshair;touch-action:none"; + this.appendChild(canvas); + const ctx = canvas.getContext("2d"); + const frozen = this.getAttribute("frozen-now"); + const nowSec = () => frozen ? Number(frozen) : Date.now() / 1e3; + const { y } = (() => { + const d = new Date(nowSec() * 1e3); + return { y: d.getUTCFullYear() }; + })(); + const t0 = epochDayFromCivil(y, 1, 1) * DAY; + const t1 = epochDayFromCivil(y + 1, 1, 1) * DAY; + const lineLen = Math.round((t1 - t0) / DAY); + const cfg = FAITHFUL_1997; + let state = initState(cfg, { tMin: 0, tMax: t1 - t0 }, lineLen); + state.focus = nowSec() - t0; + state.anchorPx = state.focus / DAY; + const input = { cursorPx: state.anchorPx, held: false }; + const toLinePx = (ev) => { + const r = canvas.getBoundingClientRect(); + const sx = FACE.w / r.width; + const x = (ev.clientX - r.left) * sx; + const yy = (ev.clientY - r.top) * (FACE.h / r.height); + const margin = (FACE.h - lineLen) / 2; + const mx = FACE.w - FACE.h + margin; + return (x - mx + (FACE.h - margin - yy)) / 2; + }; + canvas.addEventListener("pointermove", (ev) => { + input.cursorPx = toLinePx(ev); + }); + canvas.addEventListener("pointerdown", (ev) => { + canvas.setPointerCapture(ev.pointerId); + input.cursorPx = toLinePx(ev); + input.held = true; + }); + const release = () => { + input.held = false; + }; + canvas.addEventListener("pointerup", release); + canvas.addEventListener("pointercancel", release); + this._stop = startLoop({ + stepFn: (dtMs) => { + state = step(cfg, state, dtMs, input); + }, + renderFn: () => { + draw(ctx, state, nowSec() - t0); + this.dispatchEvent(new CustomEvent("readout", { + detail: { scale: state.scale, counter: state.counter } + })); + } + }); + } + disconnectedCallback() { + if (this._stop) this._stop(); + } + }; + customElements.define("maeda-line", MaedaLine); +})(); + +</script> +<script> +document.getElementById("piece").addEventListener("readout", (e) => { + const { scale, counter } = e.detail; + document.getElementById("readout").textContent = + "scale " + Number(scale).toFixed(3) + " · counter " + Math.round(counter); +}); +</script> +</body> +</html> diff --git a/working/hyprland-lua-port/README.org b/working/hyprland-lua-port/README.org new file mode 100644 index 0000000..42779e5 --- /dev/null +++ b/working/hyprland-lua-port/README.org @@ -0,0 +1,131 @@ +#+TITLE: Hyprland .conf → Lua port — staged, not deployed +#+AUTHOR: Craig Jennings + +* Status + +Built and verified in a nested compositor. *Not deployed.* I deployed it to the +dotfiles tree on 2026-08-24 and then rolled it back the same afternoon, because +the switch had not been checked on real hardware and the machine needs to stay +usable. The dotfiles repo is untouched at =8f692f5=; the live config is the +original =hyprland.conf=. + +The port goes live only after the hardware check in =todo.org= under "Manual +testing and validation" passes. + +* What is here + +| File | What it is | +|----------------------------+---------------------------------------------------------------| +| =hyprland.lua= | The deliverable, 788 lines. Shared config. | +| =velox-local.lua= | velox host overrides, for =velox/.config/hypr/conf.d/=. | +| =ratio-local.lua= | ratio host overrides, for =ratio/.config/hypr/conf.d/=. | +| =reader-changes-for-lua.patch= | dotfiles-side: the four readers, ported and mutation-tested. | +| =test-desktop-for-lua.patch= | archsetup-side: the post-install desktop checks. | +| =hyprland.lua.generated= | Raw =hyprlang2lua= output, merging mode. Derivation evidence. | +| =nomerge.lua= | Same converter with =--no-merge=. Derivation evidence. | + +The two patches are the part that is easy to lose and expensive to redo. Both +were mutation-tested — every ported assertion was confirmed to go red when the +property it guards was removed — so replay them rather than rewriting the +assertions from scratch. + +* Redeploy, when the port is ready + +1. =cp hyprland.lua ~/.dotfiles/hyprland/.config/hypr/hyprland.lua= +2. =cp velox-local.lua ~/.dotfiles/velox/.config/hypr/conf.d/local.lua= +3. =cp ratio-local.lua ~/.dotfiles/ratio/.config/hypr/conf.d/local.lua= +4. Move the three =.conf= files out of their stow packages. Do NOT merely leave + them beside the =.lua=: with both present Hyprland 0.56.2 loads the =.lua= + (proven — see below), so leaving the =.conf= in place buys no rollback and + only creates ambiguity about which file is live. +5. =cd ~/.dotfiles && git apply <path>/reader-changes-for-lua.patch= +6. =cd ~/code/archsetup && git apply <path>/test-desktop-for-lua.patch= +7. Restow. Expect two traps, both hit on 2026-08-24 and both documented in + =todo.org=: =make restow hyprland= aborts on the pre-existing + =obsbot-wb-guard.service= conflict in =common= (restow =hyprland= and the host + package individually instead), and the running Hyprland rewrites a stub + =hyprland.conf= within a second of the symlink vanishing. Silence the stub with + =hyprctl keyword misc:disable_autoreload 1=, do the stow, then set it back to 0. +8. *Push the dotfiles change before committing archsetup.* The installer clones + the dotfiles *remote* (=archsetup:1481=), so until the push lands a fresh VM + stows a tree with only =hyprland.conf= and the post-install checks fail. + +* Two things already proven, so nobody re-derives them + +*With both files present, the =.lua= wins.* Tested in a nested Hyprland 0.56.2 +with a fixture whose =.conf= set =gaps_in=11= and whose =.lua= set =77=. Result +was 77, and the log read "[cfg] Using lua config found at ...hyprland.lua". This +is why step 4 moves the =.conf= out rather than leaving it as a fallback. + +*The converter is a draft, not an answer.* =hyprlang2lua= +(github.com/EIonTusk/hyprlang2lua) reported 100% coverage and still produced +three functional defects, two of which would have broken the desktop. The two +generated files are kept as evidence: both still carry the unfixed bind defect +(="CTRL" .. mod .. " + S"=, which collapses to an unparseable =CTRLSUPER + S=), +and the merging-mode file shows the source glob emitted mid-file where it silently +reverses every per-host override. =--no-merge= fixed the ordering structurally; +the rest were hand-fixed. + +* Review findings folded in (2026-08-24) + +An isolated review of the deployed diff, before the rollback. Four were fixed in +the files here; the rest are gates on redeploying, not on the port's correctness. + +** Fixed here + +- =hl_source_glob= now surfaces all three failure modes and survives them. A + host override that fails to parse was warned about and skipped, which on velox + means coming up with no =force_zero_scaling= and no monitor scale — looking + like the whole port failed rather than one file. A runtime error inside the + chunk was unprotected and would have taken the entire config down over a single + host file. Now: parse failure says SKIPPED, runtime failure says PARTIAL and is + caught with =pcall=, and a glob matching nothing says NO MATCH. hyprlang did + none of this. +- The =col.nogroup_border*= rationale sat *below* the =col= table, reading as a + preamble to =layout=. Moved above the two keys it explains — the same defect + the file header says was fixed for autostart. +- =Generated by hyprlang2lua. Review TODOs before reloading Hyprland= removed + from all three files. No TODOs exist, and in =ratio-local.lua= it had landed + mid-paragraph, splitting the DP-4 rationale from the =hl.monitor= call it + explains. +- =ratio-local.lua='s usage examples were still hyprlang syntax + (=monitor=DP-1,...=, =bind = $mod, L, ...=), which are syntax errors in a Lua + file, and the second named =$mod= — a variable a sourced chunk cannot see. + Rewritten in Lua, with a note on the scoping. Verified rather than assumed: + =loadfile= gives the chunk globals only, and both =mod= and =at_start= are + locals, so both read =nil= inside a sourced file. + +** Refuted by measurement + +- *Duplicate chords append; they do not replace.* The config binds Super+Z twice + on purpose (=exec pypr zoom= plus =submap zoom=), and the same for Escape + inside the submap — if the Lua API replaced rather than appended, Super+Z would + enter the submap without zooming and the pairing the config's own comment + relies on would be broken. Tested in a nested Hyprland 0.56.2 with exactly that + shape: both binds register. The live =.conf= session registers the same pair, + so behaviour matches. No action needed; recorded so nobody re-derives it. + +** Gates on redeploy — do these as part of the switch + +1. *Do not put the =.conf= files in a =retired/= directory inside the repo + without also excluding them from =dotfiles-validate=.* Its find uses + =-path '*/.config/hypr/*.conf'=, which globs across slashes and would match + the retired copies — 81 of 228 checked references came from the dead config + when this was tried. The validator would then fail pointing at a file kept + precisely because nothing loads it. Add =-not -path "$root/retired/*"= to both + finds, or park the =.conf= files outside the repo entirely. +2. *Add tests for the new =dotfiles-validate= Lua branch.* The 25 new lines ship + with none. Proven vacuous: replacing both new awk regexes with =NEVERMATCHES= + still leaves =tests/dotfiles-validate/= reporting 15 tests OK. An extractor + that matches nothing prints nothing and exits 0 — the same false-pass shape + the three ported test suites got vacuity guards for. This one has no guard. +3. *Guard the override ordering.* Nothing asserts =hl_source_glob= is the last + statement in =hyprland.lua=, and it is the single invariant the whole per-host + layer rests on. An edit that moves it above the =hl.config= blocks silently + reverses every host override — converter defect 1, the one that would have + reverted the Qt scaling fix. archsetup's VM suite structurally cannot catch + it, because the VM stows no host tier. The guard belongs in the dotfiles repo. +4. *Sweep the prose comments that still name =hyprland.conf=.* About fifteen + across live scripts. =hyprland/.local/bin/waybar-reserve:12= is the one that + matters: it documents "Wired as =exec = waybar-reserve= in hyprland.conf", + which is exactly the mechanism the port replaces with =at_reload=. diff --git a/working/hyprland-lua-port/hyprland.lua b/working/hyprland-lua-port/hyprland.lua new file mode 100644 index 0000000..c005193 --- /dev/null +++ b/working/hyprland-lua-port/hyprland.lua @@ -0,0 +1,812 @@ +-- Hyprland Configuration +-- Translated from DWM config.def.h and sxhkdrc +-- Craig Jennings <c@cjennings.net> + +-- ============================================================================ +-- Monitor Configuration +-- ============================================================================ + +-- hyprlang2lua polyfills — runtime helpers reproducing +-- hyprlang behaviour the typed Lua API doesn't expose directly. + +local function hl_source_glob(pattern) + -- 'source = path/*.conf' had hyprlang glob and inline-expand the + -- matches. require() can't glob, so we shell out to ls (matching + -- the user's brace-expansion behaviour) and dofile each result. + -- Paths with spaces or shell metacharacters in the directory + -- portion will misparse; typical ~/.config/hypr/ layouts don't + -- hit this. Swap to lfs.dir() or find -name if you need fancier. + -- + -- Both failure paths are surfaced loudly and neither is fatal. hyprlang did + -- neither, and the asymmetry matters in opposite directions: a syntax error + -- that only warns means velox comes up with no force_zero_scaling and no + -- monitor scale, which looks like the port failed rather than like one file + -- failed; and an uncaught runtime error inside the chunk would take the + -- entire config down over a single host override. So: report both, continue + -- past both. + local p = io.popen("ls " .. pattern .. " 2>/dev/null") + if not p then return end + local matched = 0 + for f in p:lines() do + matched = matched + 1 + local chunk, err = loadfile(f) + if not chunk then + io.stderr:write("hl_source_glob: SKIPPED " .. f .. + " -- it did not parse, so nothing in it applied: " .. + tostring(err) .. "\n") + else + local ok, rerr = pcall(chunk) + if not ok then + io.stderr:write("hl_source_glob: PARTIAL " .. f .. + " -- it errored partway, so some of it applied " .. + "and the rest did not: " .. tostring(rerr) .. "\n") + end + end + end + p:close() + if matched == 0 then + io.stderr:write("hl_source_glob: NO MATCH for " .. pattern .. + " -- every host override is missing. On a machine that " .. + "has a conf.d file this means the glob is wrong.\n") + end +end + +-- Autostart collectors. Each command stays under the comment that explains it, +-- in the order hyprlang ran them; the hl.on() handlers at the bottom replay the +-- lists. Written this way because the generated form hoisted every command into +-- one block at the end of the file and left the comments stranded where the +-- commands had been -- in this config that means paragraphs of rationale with +-- no code under them, and one cross-reference ("the two exec-once lines above") +-- that had become false. +local autostart, atshutdown, atreload = {}, {}, {} +local function at_start(cmd) autostart[#autostart + 1] = cmd end +local function at_shutdown(cmd) atshutdown[#atshutdown + 1] = cmd end +local function at_reload(cmd) atreload[#atreload + 1] = cmd end + +hl.monitor({ + output = "", + mode = "preferred", + position = "auto", + scale = "auto", +}) + +-- Waybar's strip (6px top margin + 54px bar) is reserved statically by +-- waybar-reserve, and waybar runs with "exclusive": false. The bar's own +-- exclusive zone would vanish and reappear on every SIGUSR2 reload (the +-- collapse mechanism) and on hide/crash/relaunch, snapping every tiled window +-- up and back down. The static reservation holds the clients in place; only +-- the bar itself changes. `exec` (not exec-once) reruns it on every config +-- reload, which is exactly when Hyprland resets dynamic reservations. The +-- script is idempotent, and a catch-all `monitor=,addreserved,...` rule can't +-- replace it (empty-name addreserved silently no-ops). +-- +-- Run three times over ~0.6s, not once: on reload Hyprland clears the +-- reservation AND re-fires this exec, and the two race. A single run that +-- fires before the clear no-ops (reserved still looks correct), the clear then +-- wins, and the non-exclusive bar drops off-screen. Re-applying past the clear +-- window makes the restore reliable; the script is idempotent so extra runs are +-- free. Applying a monitor rule (e.g. the DP-4 pin) also clears the reservation, +-- so this covers a reload that re-asserts monitors too. +at_reload("for i in 1 2 3; do sleep 0.2; waybar-reserve; done") + +-- ============================================================================ +-- Startup Applications +-- ============================================================================ +-- Portal and D-Bus setup FIRST, then waybar (needs portal for appearance query) +at_start("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE") +-- Start hyprland-session.target FIRST: it pulls up graphical-session.target, +-- which xdg-desktop-portal 1.22+ hard-requires (Requisite=). A bare-exec Hyprland +-- session has no session manager to raise that target, so without this the portal +-- fails its dependency at every login (screen-share + file pickers dead). +-- 'systemctl start' blocks until active, so the ';' sequence guarantees the target +-- is up before the portal restart runs. +-- +-- Portal restart (not start) reconnects stale portals on Hyprland restart. +-- Backend portals (GTK, Hyprland) restart BEFORE the main portal to avoid a 50s +-- GTK settings proxy timeout; the sequence keeps that ordering. Separated by ';' +-- not '&&' so a failing portal restart can't stop waybar from launching — waybar +-- degrades gracefully without the portal (only the appearance query is missed), +-- and gating the bar behind the portal left the desktop bar-less whenever +-- xdg-desktop-portal failed its dependency at login. Waybar stays gated on its +-- own config generation (waybar-active-config && waybar). +at_start("systemctl --user start hyprland-session.target; systemctl --user restart xdg-desktop-portal-hyprland xdg-desktop-portal-gtk; systemctl --user restart xdg-desktop-portal; waybar-active-config && waybar -c \"$XDG_RUNTIME_DIR/waybar/config\" -s ~/.config/waybar/style.css 2>&1 | grep -v \"LIBDBUSMENU-GLIB-WARNING\" > ~/.local/var/log/waybar-$(date +%Y-%m-%d-%H%M%S).log") + +-- Core services +at_start("/usr/lib/polkit-kde-authentication-agent-1") +at_start("/usr/bin/gnome-keyring-daemon --start --components=pkcs11,secrets,ssh") +at_start("dunst > ~/.local/var/log/dunst-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + +-- Desktop appearance +-- `settings restore` replays the remembered toggles and reapplies the stored +-- wallpaper. It replaced `waypaper --restore` on 2026-08-14: waypaper keeps +-- its own config.ini and the settings store keeps another, neither knew about +-- the other, and the login replay always won — so a wallpaper chosen in the +-- panel came back as whatever the shell had last set. The store is the only +-- one of the two that can hold a sun pair, a video or a projected face, so it +-- owns the restore. set-wallpaper records into it for choices made outside +-- the panel. +-- +-- waypaper --restore stays as the fallback, not the owner. If the stored +-- wallpaper cannot be applied (an image deleted, a drive not mounted yet), +-- `settings restore` exits 3 and waypaper's independent copy still puts +-- something on the screen. Dropping it outright would trade this bug for a +-- bare desktop. +-- +-- The wallpaper half only. The toggle half runs from its own exec-once further +-- down, after hypridle and dunst — caffeine *is* "hypridle isn't running" and +-- DND *is* dunst's pause level, so replaying them here would spend the whole +-- re-assert budget correcting backings that have not launched yet, and would +-- replay them a second time besides. This slot exists for awww's timing, not +-- theirs. +at_start("awww-daemon & sleep 1 && { settings restore-wallpaper || waypaper --restore; }") + +-- Background services +at_start("touchpad-auto") +-- hypridle is reaped on both exit paths, because it outlives its compositor +-- otherwise. An orphaned daemon keeps firing idle actions at whatever session +-- is live next, and it holds its old logind session scope open (the scope can't +-- close while a process sits in it), so orphans accumulate one per abnormal +-- session death. On 2026-07-22 velox reached five concurrent hypridle daemons; +-- two of them racing to lock produced "Cannot re-lock" and a session wedged +-- locked with no client able to draw a password prompt — recoverable only from +-- another console. exec-shutdown covers a clean compositor exit; the pkill in +-- exec-once covers the paths where it never runs (crash, SIGKILL, TTY logout). +at_shutdown("pkill -x hypridle") +-- hypridle.conf is rendered here rather than tracked, because its contents +-- are this machine's stage times and hibernate setting. Tracking the render +-- meant every panel change dirtied the repo, and whichever machine +-- committed last imposed its policy on the others: a desktop ended up +-- carrying a laptop's suspend-then-hibernate line that it cannot run. +-- Rendering at session start makes the store the only source of truth and +-- the file a build artifact. +-- +-- hypridle-start owns the render, the fallback, and the ordering between +-- them, because that ordering is subtle enough to get wrong in a config +-- line nothing can test: a render can fail on purpose (a damaged store, to +-- avoid overwriting a real policy with defaults), and a fallback that +-- fired there would perform exactly the overwrite the render refused. +at_start("pkill -x hypridle; hypridle-start > ~/.local/var/log/hypridle-$(date +%Y-%m-%d-%H%M%S).log 2>&1") +at_start("/usr/lib/geoclue-2.0/demos/agent") +at_start("gammastep > ~/.local/var/log/gammastep-$(date +%Y-%m-%d-%H%M%S).log 2>&1") +at_start("mpd") +-- Replay the toggles that have no durable state of their own. Caffeine *is* +-- "hypridle isn't running" and DND *is* dunst's pause level, so the two +-- exec-once lines above (and dunst's) recreate both at a fixed default every +-- start — a deliberately-set caffeine was silently discarded on every login. +-- Ordered after those launches so it corrects a backing that exists; it also +-- re-asserts for a few seconds, which covers a backing that comes up late. +-- Logged like its neighbours: a silent exec-once failure here would look +-- exactly like the bug it fixes, and gammastep is the standing proof that a +-- launch dying quietly at session start can go unnoticed for a long time. +at_start("settings restore > ~/.local/var/log/settings-restore-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + +-- Pyprland (scratchpads, magnify, etc.) +at_start("pypr > ~/.local/var/log/pypr-$(date +%Y-%m-%d-%H%M%S).log 2>&1") +at_start("hypr-refocus-scratchpad") + +-- Tray apps. wait-for-tray blocks until waybar's systray host is up (a fixed +-- sleep can't cover a slow cold-start waybar), so these register their icons +-- instead of opening as windows. Caps at ~30s, then launches anyway. +at_start("wait-for-tray && signal-desktop --start-in-tray --ozone-platform=wayland") +-- QT_FONT_DPI bumps the bridge's QML UI font (qt6ct General font is ignored by Qt Quick) +at_start("env QT_FONT_DPI=108 protonmail-bridge --no-window") + +-- ============================================================================ +-- Environment Variables +-- ============================================================================ +hl.env("XCURSOR_SIZE", "24") +hl.env("XCURSOR_THEME", "Bibata-Modern-Ice") +hl.env("XDG_CURRENT_DESKTOP", "Hyprland") +hl.env("XDG_SESSION_TYPE", "wayland") +hl.env("XDG_SESSION_DESKTOP", "Hyprland") +hl.env("_JAVA_AWT_WM_NONREPARENTING", "1") + +-- ============================================================================ +-- Appearance (matching DWM colors) +-- ============================================================================ +-- DWM colors: gray1=#222222, gray2=#444444, gray3=#bbbbbb, gray4=#eeeeee, cyan=#daa520 + +hl.config({ + general = { + gaps_in = 25, + gaps_out = 30, + border_size = 2, + col = { + active_border = "rgba(daa520ff)", + inactive_border = "rgba(444444ff)", + -- Pyprland 3.4+ applies `group deny` to scratchpads, which routes + -- their border through col.nogroup_border* instead of col.*_border. + -- Without these overrides Hyprland's defaults paint scratchpads + -- bright magenta. + nogroup_border_active = "rgba(daa520ff)", + nogroup_border = "rgba(444444ff)", + }, + layout = "master", + resize_on_border = true, + }, +}) + +hl.config({ + decoration = { + rounding = 10, + dim_inactive = true, + dim_strength = 0.4, + dim_special = 0.2, + blur = { + enabled = false, + }, + shadow = { + enabled = false, + }, + }, +}) + +hl.config({ + animations = { + enabled = true, + }, +}) + +hl.curve("myBezier", { type = "bezier", points = { { 0.05, 0.9 }, { 0.1, 1.05 } } }) +hl.animation({ + leaf = "windows", + enabled = true, + speed = 2, + bezier = "myBezier", +}) +hl.animation({ + leaf = "windowsOut", + enabled = true, + speed = 2, + bezier = "default", + style = "popin 80%", +}) +hl.animation({ + leaf = "fade", + enabled = true, + speed = 2, + bezier = "default", +}) +hl.animation({ + leaf = "workspaces", + enabled = true, + speed = 2, + bezier = "default", +}) +hl.animation({ + leaf = "specialWorkspace", + enabled = true, + speed = 2, + bezier = "default", + style = "slidevert", +}) + +-- ============================================================================ +-- Layout (master-stack like DWM tile) +-- ============================================================================ + +hl.config({ + master = { + new_status = "master", + new_on_top = true, + mfact = 0.55, + }, +}) + +hl.config({ + dwindle = { + preserve_split = true, + }, +}) + +-- ============================================================================ +-- Input +-- ============================================================================ + +hl.config({ + cursor = { + no_warps = true, + inactive_timeout = 2.0, + }, +}) + +hl.config({ + input = { + kb_layout = "us", + kb_options = "ctrl:nocaps", + numlock_by_default = true, + follow_mouse = 0, + -- 0, not the default 1: with follow_mouse off we never want focus to follow + -- the cursor. At 1, focus still jumps to the window under the pointer when it + -- crosses a floating<->tiled boundary, so launching a floating scratchpad (or + -- the org-capture popup) re-enabled focus-follows-mouse onto tiled windows. + float_switch_override_focus = 0, + mouse_refocus = false, + natural_scroll = true, + touchpad = { + natural_scroll = false, + }, + }, +}) + +-- ============================================================================ +-- Misc +-- ============================================================================ + +hl.config({ + misc = { + force_default_wallpaper = 0, + disable_hyprland_logo = true, + -- false so apps can't pull focus via activation requests. New windows still + -- focus on open (separate path); this stops e.g. a browser yanking focus + -- back off a freshly opened emacs frame. + focus_on_activate = false, + -- Let a fresh lock client adopt a session whose previous one died. The + -- default (off) is the strict reading of ext-session-lock: a dead lock + -- client leaves the session locked forever and refuses every replacement + -- ("Cannot re-lock"), so the screen stays up with nothing able to draw a + -- password prompt and the only way back in is another console. That is a + -- hard lockout, and it cost a session on velox 2026-07-22. On means a + -- replacement hyprlock re-attaches and prompts normally. The screen stays + -- locked either way — this decides whether the lock is recoverable, never + -- whether it holds. + allow_session_lock_restore = true, + }, +}) + +-- ============================================================================ +-- Debug (temporary - disable when stable) +-- ============================================================================ + +hl.config({ + debug = { + disable_logs = false, + }, +}) + +-- ============================================================================ +-- XWayland +-- ============================================================================ + +hl.config({ + xwayland = { + force_zero_scaling = true, + }, +}) + +-- ============================================================================ +-- Window Rules (Hyprland 0.53+ syntax: match:CONDITION, RULE) +-- ============================================================================ +-- Floating windows (from DWM rules) +hl.window_rule({ + match = { + class = "^(xdg-desktop-portal-gtk)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(Gimp)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(caffeine)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(qalculate-gtk)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + title = "^(Event Tester)$", + }, + float = true, +}) + +-- net / bluetooth instrument-console panels. Normal floating windows (formerly +-- gtk4-layer-shell overlays) so they drag to move and corner-drag to resize. +-- Opened top-right to match their old anchored spot: the panel is right-aligned +-- with a 44px gap, so x = 100% - (window width + 44). net is 420 wide, bt 380. +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.netpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.netpanel)$", + }, + move = "100%-464 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.btpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.btpanel)$", + }, + move = "100%-424 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.audiopanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.audiopanel)$", + }, + move = "100%-444 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.timerpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.timerpanel)$", + }, + move = "100%-444 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.settingspanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.settingspanel)$", + }, + move = "100%-584 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.weatherpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.weatherpanel)$", + }, + move = "100%-464 50", +}) + +-- maintenance console: the wide board (960), same right-aligned convention. +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.maintpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.maintpanel)$", + }, + move = "100%-1004 50", +}) + +-- org-capture popup frame (quick-capture script names the frame) +-- Size is per-host in <host>/conf.d/local.lua: native window rules ignore +-- percentages (only pyprland honors them), so the popup is sized in absolute +-- pixels matching that host's terminal scratchpad. No size rule here means a +-- host without an override falls back to the script's char-cell geometry. +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + center = true, +}) + +-- dirvish popup frame (dirvish-popup script names the frame). No stay_focused — +-- it's a file manager that launches files into other apps, so focus must be free +-- to follow; q (cj/dirvish-popup-quit) closes the frame. +hl.window_rule({ + match = { + title = "^(dirvish)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + title = "^(dirvish)$", + }, + size = "1100 700", +}) + +hl.window_rule({ + match = { + title = "^(dirvish)$", + }, + center = true, +}) + +-- NOTE: center windowrules removed 2026-03-04 per pyprland maintainer suggestion +-- Testing whether pyprland handles scratchpad re-centering natively (issue #211) + +-- Gaming +hl.window_rule({ + match = { + class = "^(Civ5XP)$", + }, + fullscreen = true, +}) + +-- ============================================================================ +-- Key Bindings +-- ============================================================================ +local mod = "SUPER" + +-- Terminal and core apps (from DWM) +hl.bind(mod .. " + T", hl.dsp.exec_cmd("foot")) +hl.bind(mod .. " + E", hl.dsp.exec_cmd("emacsclient -c -a \"\" || emacs")) +-- Standalone emacs: its own process, not a frame on the daemon, so killing it +-- takes no other frame with it. init.el guards server-start on server-running-p, +-- so while the daemon holds the socket this process leaves it alone. With no +-- daemon up it finds no server and becomes one -- the guard lives in init.el, and +-- a keybind can't override it, since --eval runs after init. +hl.bind(mod .. " + SHIFT + E", hl.dsp.exec_cmd("emacs")) +hl.bind(mod .. " + N", hl.dsp.exec_cmd("quick-capture")) +hl.bind(mod .. " + W", hl.dsp.exec_cmd("$BROWSER")) +hl.bind(mod .. " + F", hl.dsp.exec_cmd("dirvish-popup")) +hl.bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd("layout-cycle float-toggle")) + +-- From sxhkdrc +hl.bind(mod .. " + SPACE", hl.dsp.exec_cmd("fuzzel-toggle")) +hl.bind(mod .. " + SHIFT + W", hl.dsp.exec_cmd("$ALTBROWSER")) +hl.bind(mod .. " + P", hl.dsp.exec_cmd("media-toggle-all")) +hl.bind(mod .. " + SHIFT + L", hl.dsp.exec_cmd("calibre")) +hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("toggle-touchpad")) + +-- Window management (from DWM) +-- Layout-aware navigation (works across master, scrolling) +hl.bind(mod .. " + J", hl.dsp.exec_cmd("layout-navigate next")) +hl.bind(mod .. " + K", hl.dsp.exec_cmd("layout-navigate prev")) +hl.bind(mod .. " + SHIFT + J", hl.dsp.exec_cmd("layout-navigate next move")) +hl.bind(mod .. " + SHIFT + K", hl.dsp.exec_cmd("layout-navigate prev move")) +hl.bind(mod .. " + H", hl.dsp.exec_cmd("layout-resize shrink")) +hl.bind(mod .. " + L", hl.dsp.exec_cmd("layout-resize grow")) +-- Swap focused window with master, then force focus onto the master slot. +-- swapwithmaster's own `master` focus param doesn't stick when invoked from +-- the master, so focusmaster master pins focus afterward. The 50ms sleep is +-- load-bearing: swapwithmaster fires an async focus event ~1-2ms after it +-- returns; without the delay that event lands AFTER focusmaster and flips +-- focus back to the detail. The sleep lets the swap's focus settle so +-- focusmaster runs last and wins. Proven via instrumented capture (19/19). +hl.bind(mod .. " + RETURN", hl.dsp.exec_cmd("hyprctl dispatch layoutmsg swapwithmaster && sleep 0.05 && hyprctl dispatch layoutmsg focusmaster master")) +hl.bind(mod .. " + G", hl.dsp.window.center()) +hl.bind(mod .. " + TAB", hl.dsp.focus({ workspace = "previous" })) +hl.bind(mod .. " + SHIFT + C", hl.dsp.window.close()) + +-- Layouts: master -> monocle +-- Cycle with Shift+arrows, or jump directly with Shift+T/M +-- (scrolling layout disabled until frame-fit + wrap-around work lands) +hl.bind(mod .. " + SHIFT + RIGHT", hl.dsp.exec_cmd("layout-cycle next")) +hl.bind(mod .. " + SHIFT + LEFT", hl.dsp.exec_cmd("layout-cycle prev")) +hl.bind(mod .. " + SHIFT + T", hl.dsp.exec_cmd("hyprctl keyword general:layout master && hyprctl keyword master:orientation left")) +hl.bind(mod .. " + SHIFT + M", hl.dsp.exec_cmd("hyprctl keyword general:layout monocle")) +hl.bind(mod .. " + SHIFT + SPACE", hl.dsp.window.float({ action = "toggle" })) + +-- Master layout adjustments +hl.bind(mod .. " + U", hl.dsp.layout("addmaster")) +hl.bind(mod .. " + D", hl.dsp.layout("removemaster")) + +-- Stash windows (hide to special workspace) +-- O = stash focused / Alt+O = stash others / Shift+O = restore all +hl.bind(mod .. " + O", hl.dsp.exec_cmd("stash-window")) +hl.bind(mod .. " + ALT + O", hl.dsp.exec_cmd("stash-others")) +hl.bind(mod .. " + SHIFT + O", hl.dsp.exec_cmd("stash-restore")) + +-- Gaps between windows only; window-gaps leaves the monitor-edge gap +-- (general:gaps_out) fixed, so widening/narrowing moves the space between +-- windows, not the screen-edge margin. +hl.bind(mod .. " + MINUS", hl.dsp.exec_cmd("window-gaps narrow")) +hl.bind(mod .. " + EQUAL", hl.dsp.exec_cmd("window-gaps widen")) +hl.bind(mod .. " + SHIFT + EQUAL", hl.dsp.exec_cmd("window-gaps reset")) +hl.bind(mod .. " + SHIFT + MINUS", hl.dsp.exec_cmd("window-gaps zero")) + +-- Auto-dim toggle (D = dim). Same action as clicking the waybar custom/dim icon. +hl.bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd("dim-toggle")) +hl.bind(mod .. " + SHIFT + G", hl.dsp.exec_cmd("settings-panel")) + +-- Caffeine (keep-awake) toggle. Same action as clicking the waybar +-- custom/caffeine icon — flips the hypridle daemon so the screen will / won't +-- lock. Stays on $mod+I ($mod+C is taken by hyprpicker; no free caffeine key). +hl.bind(mod .. " + I", hl.dsp.exec_cmd("caffeine-toggle")) + +-- Airplane mode (low-power: wifi off + CPU/brightness/services). A deliberate +-- keybind, not a bar click — engaging it disconnects you, so it shouldn't be a +-- misclick away. The custom/net module shows the state; this toggles it. +-- On Super+Shift+X ("X" = everything off); Super+Shift+A toggles push-to-talk. +hl.bind(mod .. " + SHIFT + X", hl.dsp.exec_cmd("airplane-mode")) + +-- Toggle bar visibility, or relaunch waybar if it crashed (no exec-once respawn). +hl.bind(mod .. " + B", hl.dsp.exec_cmd("waybar-toggle")) + +-- Collapse / expand the left or right side of the bar to its base set +-- (same action as clicking the side's arrowhead). [ = left, ] = right. +hl.bind(mod .. " + bracketleft", hl.dsp.exec_cmd("waybar-collapse left")) +hl.bind(mod .. " + bracketright", hl.dsp.exec_cmd("waybar-collapse right")) + +-- Fullscreen +hl.bind(mod .. " + F11", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })) + +-- Workspaces 1-9 (from DWM TAGKEYS) +hl.bind(mod .. " + 1", hl.dsp.focus({ workspace = 1 })) +hl.bind(mod .. " + 2", hl.dsp.focus({ workspace = 2 })) +hl.bind(mod .. " + 3", hl.dsp.focus({ workspace = 3 })) +hl.bind(mod .. " + 4", hl.dsp.focus({ workspace = 4 })) +hl.bind(mod .. " + 5", hl.dsp.focus({ workspace = 5 })) +hl.bind(mod .. " + 6", hl.dsp.focus({ workspace = 6 })) +hl.bind(mod .. " + 7", hl.dsp.focus({ workspace = 7 })) +hl.bind(mod .. " + 8", hl.dsp.focus({ workspace = 8 })) +hl.bind(mod .. " + 9", hl.dsp.focus({ workspace = 9 })) + +-- Move window to workspace (from DWM tag) +hl.bind(mod .. " + SHIFT + 1", hl.dsp.window.move({ workspace = 1, follow = false })) +hl.bind(mod .. " + SHIFT + 2", hl.dsp.window.move({ workspace = 2, follow = false })) +hl.bind(mod .. " + SHIFT + 3", hl.dsp.window.move({ workspace = 3, follow = false })) +hl.bind(mod .. " + SHIFT + 4", hl.dsp.window.move({ workspace = 4, follow = false })) +hl.bind(mod .. " + SHIFT + 5", hl.dsp.window.move({ workspace = 5, follow = false })) +hl.bind(mod .. " + SHIFT + 6", hl.dsp.window.move({ workspace = 6, follow = false })) +hl.bind(mod .. " + SHIFT + 7", hl.dsp.window.move({ workspace = 7, follow = false })) +hl.bind(mod .. " + SHIFT + 8", hl.dsp.window.move({ workspace = 8, follow = false })) +hl.bind(mod .. " + SHIFT + 9", hl.dsp.window.move({ workspace = 9, follow = false })) + +-- Monitor focus (from DWM focusmon) +hl.bind(mod .. " + COMMA", hl.dsp.focus({ monitor = "-1" })) +hl.bind(mod .. " + PERIOD", hl.dsp.focus({ monitor = "+1" })) +hl.bind(mod .. " + SHIFT + COMMA", hl.dsp.window.move({ monitor = "-1" })) +hl.bind(mod .. " + SHIFT + PERIOD", hl.dsp.window.move({ monitor = "+1" })) + +-- ============================================================================ +-- Scratchpads (via pyprland) +-- ============================================================================ +-- Configured in ~/.config/hypr/pyprland.toml +-- Uses normal workspaces (not special), so new windows won't be captured +hl.bind(mod .. " + SHIFT + RETURN", hl.dsp.exec_cmd("pypr toggle term")) +hl.bind(mod .. " + A", hl.dsp.exec_cmd("audio-panel")) +hl.bind(mod .. " + R", hl.dsp.exec_cmd("pypr toggle monitor")) +hl.bind(mod .. " + SHIFT + N", hl.dsp.exec_cmd("net panel")) +hl.bind(mod .. " + SLASH", hl.dsp.exec_cmd("pypr toggle music")) + +-- Magnify (zoom) +-- mod+Z zooms and enters the "zoom" submap; inside it, Escape or mod+Z +-- unzooms and returns to the normal keymap. Exit forces `pypr zoom 1` +-- (factor 1) so submap state and zoom state can't desync. Note: while +-- zoomed, other Hyprland binds pause until you exit the submap. +hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom")) +hl.bind(mod .. " + Z", hl.dsp.submap("zoom")) + +hl.define_submap("zoom", function() + hl.bind("ESCAPE", hl.dsp.exec_cmd("pypr zoom 1")) + hl.bind("ESCAPE", hl.dsp.submap("reset")) + hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom 1")) + hl.bind(mod .. " + Z", hl.dsp.submap("reset")) +end) + +-- Calculator (not a scratchpad, just launches app) +hl.bind(mod .. " + X", hl.dsp.exec_cmd("calc-toggle")) +hl.bind(mod .. " + C", hl.dsp.exec_cmd("hyprpicker -a")) +hl.bind(mod .. " + CONTROL + C", hl.dsp.exec_cmd("clock-panel toggle")) + +-- Media/hardware keys +hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ +5%"), { locked = true, repeating = true }) +hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ -5%"), { locked = true, repeating = true }) +hl.bind("XF86AudioMute", hl.dsp.exec_cmd("audio quick-mute"), { locked = true }) +hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl s +10%"), { locked = true, repeating = true }) +hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl s 10%-"), { locked = true, repeating = true }) + +-- Microphone mute toggle (waybar pulseaudio#mic indicator follows via PipeWire events). +-- On the hardware mic-mute key. Super+Shift+A used to duplicate this; it now +-- toggles push-to-talk mode instead (mic-toggle stays reachable on the hw key). +hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("mic-toggle"), { locked = true }) + +-- Push-to-talk toggle: enter PTT (mic muted, hold key armed) / exit (restore). +-- The hold key is configurable (audio config ptt_key = Control_R or mouse:NNN). +hl.bind(mod .. " + SHIFT + A", hl.dsp.exec_cmd("audio ptt-toggle")) + +-- Bluetooth panel (blueman retired in favor of the bt panel) +hl.bind(mod .. " + SHIFT + B", hl.dsp.exec_cmd("bt-panel")) + +-- Screenshots (grim + slurp + fuzzel menu) +-- Shift+S captures the whole desktop with no pointer interaction, so it +-- works on scratchpads and popups that region-select would dismiss +hl.bind(mod .. " + S", hl.dsp.exec_cmd("screenshot region")) +hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("screenshot fullscreen")) +hl.bind("CTRL + " .. mod .. " + S", hl.dsp.exec_cmd("screenshot fullscreen")) + +-- Lock screen +hl.bind(mod .. " + ESCAPE", hl.dsp.exec_cmd("hyprlock")) + +-- Audio mute cycle (M for Mute): one key walks volume/mic through all four +-- on/off combinations. The waybar pulseaudio modules follow via PipeWire. +hl.bind(mod .. " + M", hl.dsp.exec_cmd("audio-cycle")) + +-- Exit/session +hl.bind(mod .. " + SHIFT + Q", hl.dsp.exec_cmd("pgrep -x wlogout || wlogout-menu")) +-- mod+Shift+Backspace no longer exits outright — it enters the "exitconfirm" +-- submap, where a second Backspace confirms and anything else backs out. +-- The bare bind killed two live sessions on 2026-07-22: it sits one key from +-- the mod+Shift cluster (Q wlogout, C killactive, Return terminal, G settings) +-- and took the whole session down with no prompt. wlogout owns the normal +-- exit path; this stays as the keyboard escape hatch for when wlogout won't +-- come up, so it's gated rather than removed. +hl.bind(mod .. " + SHIFT + BACKSPACE", hl.dsp.submap("exitconfirm")) + +hl.define_submap("exitconfirm", function() + hl.bind("BACKSPACE", hl.dsp.exit()) + hl.bind("ESCAPE", hl.dsp.submap("reset")) + hl.bind("catchall", hl.dsp.submap("reset")) +end) + +hl.bind(mod .. " + SHIFT + ESCAPE", hl.dsp.exec_cmd("hyprctl reload")) +hl.bind("CTRL + ALT + " .. mod .. " + K", hl.dsp.exec_cmd("hyprctl kill")) + +-- Mouse bindings (from DWM buttons) +hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true }) +hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true }) +hl.bind(mod .. " + SHIFT + mouse:272", hl.dsp.window.resize(), { mouse = true }) + +-- ============================================================================ +-- Machine-local overrides +-- ============================================================================ +-- Sourced last so machine-specific settings (monitor scale, gaps, keybinds) +-- override the defaults above. See conf.d/local.lua. + +-- Replay the collected autostart commands. +hl.on("hyprland.start", function() + for _, cmd in ipairs(autostart) do hl.exec_cmd(cmd) end +end) + +hl.on("hyprland.shutdown", function() + for _, cmd in ipairs(atshutdown) do hl.exec_cmd(cmd) end +end) + +-- hyprlang's `exec` ran at startup AND on every reload. config.reloaded fires on +-- the initial load too (verified in a nested Hyprland 0.56.2 on 2026-08-24: a +-- marker written from this handler appears at session start), so this single +-- handler covers both cases, which is what waybar-reserve needs. +hl.on("config.reloaded", function() + for _, cmd in ipairs(atreload) do hl.exec_cmd(cmd) end +end) + +hl_source_glob("$HOME/.config/hypr/conf.d/*.lua") diff --git a/working/hyprland-lua-port/hyprland.lua.generated b/working/hyprland-lua-port/hyprland.lua.generated new file mode 100644 index 0000000..04b30b9 --- /dev/null +++ b/working/hyprland-lua-port/hyprland.lua.generated @@ -0,0 +1,674 @@ +-- Hyprland Configuration +-- Translated from DWM config.def.h and sxhkdrc +-- Craig Jennings <c@cjennings.net> + +-- ============================================================================ +-- Monitor Configuration +-- ============================================================================ +-- Generated by hyprlang2lua. Review TODOs before reloading Hyprland. + +-- hyprlang2lua polyfills — runtime helpers reproducing +-- hyprlang behaviour the typed Lua API doesn't expose directly. + +local function hl_source_glob(pattern) + -- 'source = path/*.conf' had hyprlang glob and inline-expand the + -- matches. require() can't glob, so we shell out to ls (matching + -- the user's brace-expansion behaviour) and dofile each result. + -- Paths with spaces or shell metacharacters in the directory + -- portion will misparse; typical ~/.config/hypr/ layouts don't + -- hit this. Swap to lfs.dir() or find -name if you need fancier. + local p = io.popen("ls " .. pattern .. " 2>/dev/null") + if not p then return end + for f in p:lines() do + local chunk, err = loadfile(f) + if chunk then chunk() + else io.stderr:write("hl_source_glob: " .. tostring(err) .. "\n") end + end + p:close() +end + +hl.monitor({ + output = "", + mode = "preferred", + position = "auto", + scale = "auto", +}) + +-- Waybar's strip (6px top margin + 54px bar) is reserved statically by +-- waybar-reserve, and waybar runs with "exclusive": false. The bar's own +-- exclusive zone would vanish and reappear on every SIGUSR2 reload (the +-- collapse mechanism) and on hide/crash/relaunch, snapping every tiled window +-- up and back down. The static reservation holds the clients in place; only +-- the bar itself changes. `exec` (not exec-once) reruns it on every config +-- reload, which is exactly when Hyprland resets dynamic reservations. The +-- script is idempotent, and a catch-all `monitor=,addreserved,...` rule can't +-- replace it (empty-name addreserved silently no-ops). +-- +-- Run three times over ~0.6s, not once: on reload Hyprland clears the +-- reservation AND re-fires this exec, and the two race. A single run that +-- fires before the clear no-ops (reserved still looks correct), the clear then +-- wins, and the non-exclusive bar drops off-screen. Re-applying past the clear +-- window makes the restore reliable; the script is idempotent so extra runs are +-- free. Applying a monitor rule (e.g. the DP-4 pin) also clears the reservation, +-- so this covers a reload that re-asserts monitors too. + +-- ============================================================================ +-- Startup Applications +-- ============================================================================ +-- Portal and D-Bus setup FIRST, then waybar (needs portal for appearance query) +-- Start hyprland-session.target FIRST: it pulls up graphical-session.target, +-- which xdg-desktop-portal 1.22+ hard-requires (Requisite=). A bare-exec Hyprland +-- session has no session manager to raise that target, so without this the portal +-- fails its dependency at every login (screen-share + file pickers dead). +-- 'systemctl start' blocks until active, so the ';' sequence guarantees the target +-- is up before the portal restart runs. +-- +-- Portal restart (not start) reconnects stale portals on Hyprland restart. +-- Backend portals (GTK, Hyprland) restart BEFORE the main portal to avoid a 50s +-- GTK settings proxy timeout; the sequence keeps that ordering. Separated by ';' +-- not '&&' so a failing portal restart can't stop waybar from launching — waybar +-- degrades gracefully without the portal (only the appearance query is missed), +-- and gating the bar behind the portal left the desktop bar-less whenever +-- xdg-desktop-portal failed its dependency at login. Waybar stays gated on its +-- own config generation (waybar-active-config && waybar). + +-- Core services + +-- Desktop appearance +-- `settings restore` replays the remembered toggles and reapplies the stored +-- wallpaper. It replaced `waypaper --restore` on 2026-08-14: waypaper keeps +-- its own config.ini and the settings store keeps another, neither knew about +-- the other, and the login replay always won — so a wallpaper chosen in the +-- panel came back as whatever the shell had last set. The store is the only +-- one of the two that can hold a sun pair, a video or a projected face, so it +-- owns the restore. set-wallpaper records into it for choices made outside +-- the panel. +-- +-- waypaper --restore stays as the fallback, not the owner. If the stored +-- wallpaper cannot be applied (an image deleted, a drive not mounted yet), +-- `settings restore` exits 3 and waypaper's independent copy still puts +-- something on the screen. Dropping it outright would trade this bug for a +-- bare desktop. +-- +-- The wallpaper half only. The toggle half runs from its own exec-once further +-- down, after hypridle and dunst — caffeine *is* "hypridle isn't running" and +-- DND *is* dunst's pause level, so replaying them here would spend the whole +-- re-assert budget correcting backings that have not launched yet, and would +-- replay them a second time besides. This slot exists for awww's timing, not +-- theirs. + +-- Background services +-- hypridle is reaped on both exit paths, because it outlives its compositor +-- otherwise. An orphaned daemon keeps firing idle actions at whatever session +-- is live next, and it holds its old logind session scope open (the scope can't +-- close while a process sits in it), so orphans accumulate one per abnormal +-- session death. On 2026-07-22 velox reached five concurrent hypridle daemons; +-- two of them racing to lock produced "Cannot re-lock" and a session wedged +-- locked with no client able to draw a password prompt — recoverable only from +-- another console. exec-shutdown covers a clean compositor exit; the pkill in +-- exec-once covers the paths where it never runs (crash, SIGKILL, TTY logout). +hl.on("hyprland.shutdown", function() + hl.exec_cmd("pkill -x hypridle") +end) + +-- hypridle.conf is rendered here rather than tracked, because its contents +-- are this machine's stage times and hibernate setting. Tracking the render +-- meant every panel change dirtied the repo, and whichever machine +-- committed last imposed its policy on the others: a desktop ended up +-- carrying a laptop's suspend-then-hibernate line that it cannot run. +-- Rendering at session start makes the store the only source of truth and +-- the file a build artifact. +-- +-- hypridle-start owns the render, the fallback, and the ordering between +-- them, because that ordering is subtle enough to get wrong in a config +-- line nothing can test: a render can fail on purpose (a damaged store, to +-- avoid overwriting a real policy with defaults), and a fallback that +-- fired there would perform exactly the overwrite the render refused. +-- Replay the toggles that have no durable state of their own. Caffeine *is* +-- "hypridle isn't running" and DND *is* dunst's pause level, so the two +-- exec-once lines above (and dunst's) recreate both at a fixed default every +-- start — a deliberately-set caffeine was silently discarded on every login. +-- Ordered after those launches so it corrects a backing that exists; it also +-- re-asserts for a few seconds, which covers a backing that comes up late. +-- Logged like its neighbours: a silent exec-once failure here would look +-- exactly like the bug it fixes, and gammastep is the standing proof that a +-- launch dying quietly at session start can go unnoticed for a long time. + +-- Pyprland (scratchpads, magnify, etc.) + +-- Tray apps. wait-for-tray blocks until waybar's systray host is up (a fixed +-- sleep can't cover a slow cold-start waybar), so these register their icons +-- instead of opening as windows. Caps at ~30s, then launches anyway. +-- QT_FONT_DPI bumps the bridge's QML UI font (qt6ct General font is ignored by Qt Quick) + +-- ============================================================================ +-- Environment Variables +-- ============================================================================ +hl.env("XCURSOR_SIZE", "24") +hl.env("XCURSOR_THEME", "Bibata-Modern-Ice") +hl.env("XDG_CURRENT_DESKTOP", "Hyprland") +hl.env("XDG_SESSION_TYPE", "wayland") +hl.env("XDG_SESSION_DESKTOP", "Hyprland") +hl.env("_JAVA_AWT_WM_NONREPARENTING", "1") + +-- ============================================================================ +-- Appearance (matching DWM colors) +-- ============================================================================ +-- DWM colors: gray1=#222222, gray2=#444444, gray3=#bbbbbb, gray4=#eeeeee, cyan=#daa520 + +hl.curve("myBezier", { type = "bezier", points = { { 0.05, 0.9 }, { 0.1, 1.05 } } }) +hl.animation({ + leaf = "windows", + enabled = true, + speed = 2, + bezier = "myBezier", +}) +hl.animation({ + leaf = "windowsOut", + enabled = true, + speed = 2, + bezier = "default", + style = "popin 80%", +}) +hl.animation({ + leaf = "fade", + enabled = true, + speed = 2, + bezier = "default", +}) +hl.animation({ + leaf = "workspaces", + enabled = true, + speed = 2, + bezier = "default", +}) +hl.animation({ + leaf = "specialWorkspace", + enabled = true, + speed = 2, + bezier = "default", + style = "slidevert", +}) + +hl.window_rule({ + match = { + class = "^(xdg-desktop-portal-gtk)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(Gimp)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(caffeine)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(qalculate-gtk)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + title = "^(Event Tester)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.netpanel)$", + }, + float = true, + move = "100%-464 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.btpanel)$", + }, + float = true, + move = "100%-424 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.audiopanel)$", + }, + float = true, + move = "100%-444 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.timerpanel)$", + }, + float = true, + move = "100%-444 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.settingspanel)$", + }, + float = true, + move = "100%-584 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.weatherpanel)$", + }, + float = true, + move = "100%-464 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.maintpanel)$", + }, + float = true, + move = "100%-1004 50", +}) + +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + float = true, + center = true, +}) + +hl.window_rule({ + match = { + title = "^(dirvish)$", + }, + float = true, + size = "1100 700", + center = true, +}) + +hl.window_rule({ + match = { + class = "^(Civ5XP)$", + }, + fullscreen = true, +}) + +local mod = "SUPER" + +hl.bind(mod .. " + T", hl.dsp.exec_cmd("foot")) +hl.bind(mod .. " + E", hl.dsp.exec_cmd("emacsclient -c -a \"\" || emacs")) +hl.bind(mod .. " + SHIFT + E", hl.dsp.exec_cmd("emacs")) +hl.bind(mod .. " + N", hl.dsp.exec_cmd("quick-capture")) +hl.bind(mod .. " + W", hl.dsp.exec_cmd("$BROWSER")) +hl.bind(mod .. " + F", hl.dsp.exec_cmd("dirvish-popup")) +hl.bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd("layout-cycle float-toggle")) + +hl.bind(mod .. " + SPACE", hl.dsp.exec_cmd("fuzzel-toggle")) +hl.bind(mod .. " + SHIFT + W", hl.dsp.exec_cmd("$ALTBROWSER")) +hl.bind(mod .. " + P", hl.dsp.exec_cmd("media-toggle-all")) +hl.bind(mod .. " + SHIFT + L", hl.dsp.exec_cmd("calibre")) +hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("toggle-touchpad")) + +hl.bind(mod .. " + J", hl.dsp.exec_cmd("layout-navigate next")) +hl.bind(mod .. " + K", hl.dsp.exec_cmd("layout-navigate prev")) +hl.bind(mod .. " + SHIFT + J", hl.dsp.exec_cmd("layout-navigate next move")) +hl.bind(mod .. " + SHIFT + K", hl.dsp.exec_cmd("layout-navigate prev move")) +hl.bind(mod .. " + H", hl.dsp.exec_cmd("layout-resize shrink")) +hl.bind(mod .. " + L", hl.dsp.exec_cmd("layout-resize grow")) +hl.bind(mod .. " + RETURN", hl.dsp.layout("swapwithmaster && sleep 0.05 && hyprctl dispatch layoutmsg focusmaster master")) +hl.bind(mod .. " + G", hl.dsp.window.center()) +hl.bind(mod .. " + TAB", hl.dsp.focus({ workspace = "previous" })) +hl.bind(mod .. " + SHIFT + C", hl.dsp.window.close()) + +hl.bind(mod .. " + SHIFT + RIGHT", hl.dsp.exec_cmd("layout-cycle next")) +hl.bind(mod .. " + SHIFT + LEFT", hl.dsp.exec_cmd("layout-cycle prev")) +hl.bind(mod .. " + SHIFT + T", hl.dsp.exec_cmd("hyprctl keyword general:layout master && hyprctl keyword master:orientation left")) +hl.bind(mod .. " + SHIFT + M", hl.dsp.exec_cmd("hyprctl keyword general:layout monocle")) +hl.bind(mod .. " + SHIFT + SPACE", hl.dsp.window.float({ action = "toggle" })) + +hl.bind(mod .. " + U", hl.dsp.layout("addmaster")) +hl.bind(mod .. " + D", hl.dsp.layout("removemaster")) + +hl.bind(mod .. " + O", hl.dsp.exec_cmd("stash-window")) +hl.bind(mod .. " + ALT + O", hl.dsp.exec_cmd("stash-others")) +hl.bind(mod .. " + SHIFT + O", hl.dsp.exec_cmd("stash-restore")) + +hl.bind(mod .. " + MINUS", hl.dsp.exec_cmd("window-gaps narrow")) +hl.bind(mod .. " + EQUAL", hl.dsp.exec_cmd("window-gaps widen")) +hl.bind(mod .. " + SHIFT + EQUAL", hl.dsp.exec_cmd("window-gaps reset")) +hl.bind(mod .. " + SHIFT + MINUS", hl.dsp.exec_cmd("window-gaps zero")) + +hl.bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd("dim-toggle")) +hl.bind(mod .. " + SHIFT + G", hl.dsp.exec_cmd("settings-panel")) + +hl.bind(mod .. " + I", hl.dsp.exec_cmd("caffeine-toggle")) + +hl.bind(mod .. " + SHIFT + X", hl.dsp.exec_cmd("airplane-mode")) + +hl.bind(mod .. " + B", hl.dsp.exec_cmd("waybar-toggle")) + +hl.bind(mod .. " + bracketleft", hl.dsp.exec_cmd("waybar-collapse left")) +hl.bind(mod .. " + bracketright", hl.dsp.exec_cmd("waybar-collapse right")) + +hl.bind(mod .. " + F11", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })) + +hl.bind(mod .. " + 1", hl.dsp.focus({ workspace = 1 })) +hl.bind(mod .. " + 2", hl.dsp.focus({ workspace = 2 })) +hl.bind(mod .. " + 3", hl.dsp.focus({ workspace = 3 })) +hl.bind(mod .. " + 4", hl.dsp.focus({ workspace = 4 })) +hl.bind(mod .. " + 5", hl.dsp.focus({ workspace = 5 })) +hl.bind(mod .. " + 6", hl.dsp.focus({ workspace = 6 })) +hl.bind(mod .. " + 7", hl.dsp.focus({ workspace = 7 })) +hl.bind(mod .. " + 8", hl.dsp.focus({ workspace = 8 })) +hl.bind(mod .. " + 9", hl.dsp.focus({ workspace = 9 })) + +hl.bind(mod .. " + SHIFT + 1", hl.dsp.window.move({ workspace = 1, follow = false })) +hl.bind(mod .. " + SHIFT + 2", hl.dsp.window.move({ workspace = 2, follow = false })) +hl.bind(mod .. " + SHIFT + 3", hl.dsp.window.move({ workspace = 3, follow = false })) +hl.bind(mod .. " + SHIFT + 4", hl.dsp.window.move({ workspace = 4, follow = false })) +hl.bind(mod .. " + SHIFT + 5", hl.dsp.window.move({ workspace = 5, follow = false })) +hl.bind(mod .. " + SHIFT + 6", hl.dsp.window.move({ workspace = 6, follow = false })) +hl.bind(mod .. " + SHIFT + 7", hl.dsp.window.move({ workspace = 7, follow = false })) +hl.bind(mod .. " + SHIFT + 8", hl.dsp.window.move({ workspace = 8, follow = false })) +hl.bind(mod .. " + SHIFT + 9", hl.dsp.window.move({ workspace = 9, follow = false })) + +hl.bind(mod .. " + COMMA", hl.dsp.focus({ monitor = -1 })) +hl.bind(mod .. " + PERIOD", hl.dsp.focus({ monitor = "+1" })) +hl.bind(mod .. " + SHIFT + COMMA", hl.dsp.window.move({ monitor = "-1" })) +hl.bind(mod .. " + SHIFT + PERIOD", hl.dsp.window.move({ monitor = "+1" })) + +hl.bind(mod .. " + SHIFT + RETURN", hl.dsp.exec_cmd("pypr toggle term")) +hl.bind(mod .. " + A", hl.dsp.exec_cmd("audio-panel")) +hl.bind(mod .. " + R", hl.dsp.exec_cmd("pypr toggle monitor")) +hl.bind(mod .. " + SHIFT + N", hl.dsp.exec_cmd("net panel")) +hl.bind(mod .. " + SLASH", hl.dsp.exec_cmd("pypr toggle music")) + +hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom")) +hl.bind(mod .. " + Z", hl.dsp.submap("zoom")) + +hl.define_submap("zoom", function() + hl.bind("ESCAPE", hl.dsp.exec_cmd("pypr zoom 1")) + hl.bind("ESCAPE", hl.dsp.submap("reset")) + hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom 1")) + hl.bind(mod .. " + Z", hl.dsp.submap("reset")) +end) + +hl.bind(mod .. " + X", hl.dsp.exec_cmd("calc-toggle")) +hl.bind(mod .. " + C", hl.dsp.exec_cmd("hyprpicker -a")) +hl.bind(mod .. " + CONTROL + C", hl.dsp.exec_cmd("clock-panel toggle")) + +hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ +5%"), { locked = true, repeating = true }) +hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ -5%"), { locked = true, repeating = true }) +hl.bind("XF86AudioMute", hl.dsp.exec_cmd("audio quick-mute"), { locked = true }) +hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl s +10%"), { locked = true, repeating = true }) +hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl s 10%-"), { locked = true, repeating = true }) + +hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("mic-toggle"), { locked = true }) + +hl.bind(mod .. " + SHIFT + A", hl.dsp.exec_cmd("audio ptt-toggle")) + +hl.bind(mod .. " + SHIFT + B", hl.dsp.exec_cmd("bt-panel")) + +hl.bind(mod .. " + S", hl.dsp.exec_cmd("screenshot region")) +hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("screenshot fullscreen")) +hl.bind("CTRL" .. mod .. " + S", hl.dsp.exec_cmd("screenshot fullscreen")) + +hl.bind(mod .. " + ESCAPE", hl.dsp.exec_cmd("hyprlock")) + +hl.bind(mod .. " + M", hl.dsp.exec_cmd("audio-cycle")) + +hl.bind(mod .. " + SHIFT + Q", hl.dsp.exec_cmd("pgrep -x wlogout || wlogout-menu")) +hl.bind(mod .. " + SHIFT + BACKSPACE", hl.dsp.submap("exitconfirm")) + +hl.define_submap("exitconfirm", function() + hl.bind("BACKSPACE", hl.dsp.exit()) + hl.bind("ESCAPE", hl.dsp.submap("reset")) + hl.bind("catchall", hl.dsp.submap("reset")) +end) + +hl.bind(mod .. " + SHIFT + ESCAPE", hl.dsp.exec_cmd("hyprctl reload")) +hl.bind("CTRL + ALT" .. mod .. " + K", hl.dsp.exec_cmd("hyprctl kill")) + +hl.bind(mod .. " + mouse:272", hl.dsp.window.drag()) +hl.bind(mod .. " + mouse:273", hl.dsp.window.resize()) +hl.bind(mod .. " + SHIFT + mouse:272", hl.dsp.window.resize()) + +-- Source: $HOME/.config/hypr/conf.d/*.conf (glob; resolved at runtime). Each matched .conf must be converted to .lua. +hl_source_glob("$HOME/.config/hypr/conf.d/*.lua") +hl.config({ + general = { + gaps_in = 25, + gaps_out = 30, + border_size = 2, + col = { + active_border = "rgba(daa520ff)", + inactive_border = "rgba(444444ff)", + nogroup_border_active = "rgba(daa520ff)", + nogroup_border = "rgba(444444ff)", + }, + -- Pyprland 3.4+ applies `group deny` to scratchpads, which routes their + -- border through col.nogroup_border* instead of col.*_border. Without + -- these overrides Hyprland's defaults paint scratchpads bright magenta. + layout = "master", + resize_on_border = true, + }, + decoration = { + rounding = 10, + dim_inactive = true, + dim_strength = 0.4, + dim_special = 0.2, + blur = { + enabled = false, + }, + shadow = { + enabled = false, + }, + }, + animations = { + enabled = true, + }, + -- ============================================================================ + -- Layout (master-stack like DWM tile) + -- ============================================================================ + master = { + new_status = "master", + new_on_top = true, + mfact = 0.55, + }, + dwindle = { + preserve_split = true, + }, + -- ============================================================================ + -- Input + -- ============================================================================ + cursor = { + no_warps = true, + inactive_timeout = 2.0, + }, + input = { + kb_layout = "us", + kb_options = "ctrl:nocaps", + numlock_by_default = true, + follow_mouse = 0, + -- 0, not the default 1: with follow_mouse off we never want focus to follow + -- the cursor. At 1, focus still jumps to the window under the pointer when it + -- crosses a floating<->tiled boundary, so launching a floating scratchpad (or + -- the org-capture popup) re-enabled focus-follows-mouse onto tiled windows. + float_switch_override_focus = 0, + mouse_refocus = false, + natural_scroll = true, + touchpad = { + natural_scroll = false, + }, + }, + -- ============================================================================ + -- Misc + -- ============================================================================ + misc = { + force_default_wallpaper = 0, + disable_hyprland_logo = true, + -- false so apps can't pull focus via activation requests. New windows still + -- focus on open (separate path); this stops e.g. a browser yanking focus + -- back off a freshly opened emacs frame. + focus_on_activate = false, + -- Let a fresh lock client adopt a session whose previous one died. The + -- default (off) is the strict reading of ext-session-lock: a dead lock + -- client leaves the session locked forever and refuses every replacement + -- ("Cannot re-lock"), so the screen stays up with nothing able to draw a + -- password prompt and the only way back in is another console. That is a + -- hard lockout, and it cost a session on velox 2026-07-22. On means a + -- replacement hyprlock re-attaches and prompts normally. The screen stays + -- locked either way — this decides whether the lock is recoverable, never + -- whether it holds. + allow_session_lock_restore = true, + }, + -- ============================================================================ + -- Debug (temporary - disable when stable) + -- ============================================================================ + debug = { + disable_logs = false, + }, + -- ============================================================================ + -- XWayland + -- ============================================================================ + xwayland = { + force_zero_scaling = true, + }, + -- ============================================================================ + -- Window Rules (Hyprland 0.53+ syntax: match:CONDITION, RULE) + -- ============================================================================ + -- Floating windows (from DWM rules) + -- net / bluetooth instrument-console panels. Normal floating windows (formerly + -- gtk4-layer-shell overlays) so they drag to move and corner-drag to resize. + -- Opened top-right to match their old anchored spot: the panel is right-aligned + -- with a 44px gap, so x = 100% - (window width + 44). net is 420 wide, bt 380. + -- maintenance console: the wide board (960), same right-aligned convention. + -- org-capture popup frame (quick-capture script names the frame) + -- Size is per-host in <host>/conf.d/local.conf: native window rules ignore + -- percentages (only pyprland honors them), so the popup is sized in absolute + -- pixels matching that host's terminal scratchpad. No size rule here means a + -- host without an override falls back to the script's char-cell geometry. + -- dirvish popup frame (dirvish-popup script names the frame). No stay_focused — + -- it's a file manager that launches files into other apps, so focus must be free + -- to follow; q (cj/dirvish-popup-quit) closes the frame. + -- NOTE: center windowrules removed 2026-03-04 per pyprland maintainer suggestion + -- Testing whether pyprland handles scratchpad re-centering natively (issue #211) + -- Gaming + -- ============================================================================ + -- Key Bindings + -- ============================================================================ + -- Terminal and core apps (from DWM) + -- Standalone emacs: its own process, not a frame on the daemon, so killing it + -- takes no other frame with it. init.el guards server-start on server-running-p, + -- so while the daemon holds the socket this process leaves it alone. With no + -- daemon up it finds no server and becomes one -- the guard lives in init.el, and + -- a keybind can't override it, since --eval runs after init. + -- From sxhkdrc + -- Window management (from DWM) + -- Layout-aware navigation (works across master, scrolling) + -- Swap focused window with master, then force focus onto the master slot. + -- swapwithmaster's own `master` focus param doesn't stick when invoked from + -- the master, so focusmaster master pins focus afterward. The 50ms sleep is + -- load-bearing: swapwithmaster fires an async focus event ~1-2ms after it + -- returns; without the delay that event lands AFTER focusmaster and flips + -- focus back to the detail. The sleep lets the swap's focus settle so + -- focusmaster runs last and wins. Proven via instrumented capture (19/19). + -- Layouts: master -> monocle + -- Cycle with Shift+arrows, or jump directly with Shift+T/M + -- (scrolling layout disabled until frame-fit + wrap-around work lands) + -- Master layout adjustments + -- Stash windows (hide to special workspace) + -- O = stash focused / Alt+O = stash others / Shift+O = restore all + -- Gaps between windows only; window-gaps leaves the monitor-edge gap + -- (general:gaps_out) fixed, so widening/narrowing moves the space between + -- windows, not the screen-edge margin. + -- Auto-dim toggle (D = dim). Same action as clicking the waybar custom/dim icon. + -- Caffeine (keep-awake) toggle. Same action as clicking the waybar + -- custom/caffeine icon — flips the hypridle daemon so the screen will / won't + -- lock. Stays on $mod+I ($mod+C is taken by hyprpicker; no free caffeine key). + -- Airplane mode (low-power: wifi off + CPU/brightness/services). A deliberate + -- keybind, not a bar click — engaging it disconnects you, so it shouldn't be a + -- misclick away. The custom/net module shows the state; this toggles it. + -- On Super+Shift+X ("X" = everything off); Super+Shift+A toggles push-to-talk. + -- Toggle bar visibility, or relaunch waybar if it crashed (no exec-once respawn). + -- Collapse / expand the left or right side of the bar to its base set + -- (same action as clicking the side's arrowhead). [ = left, ] = right. + -- Fullscreen + -- Workspaces 1-9 (from DWM TAGKEYS) + -- Move window to workspace (from DWM tag) + -- Monitor focus (from DWM focusmon) + -- ============================================================================ + -- Scratchpads (via pyprland) + -- ============================================================================ + -- Configured in ~/.config/hypr/pyprland.toml + -- Uses normal workspaces (not special), so new windows won't be captured + -- Magnify (zoom) + -- mod+Z zooms and enters the "zoom" submap; inside it, Escape or mod+Z + -- unzooms and returns to the normal keymap. Exit forces `pypr zoom 1` + -- (factor 1) so submap state and zoom state can't desync. Note: while + -- zoomed, other Hyprland binds pause until you exit the submap. + -- Calculator (not a scratchpad, just launches app) + -- Media/hardware keys + -- Microphone mute toggle (waybar pulseaudio#mic indicator follows via PipeWire events). + -- On the hardware mic-mute key. Super+Shift+A used to duplicate this; it now + -- toggles push-to-talk mode instead (mic-toggle stays reachable on the hw key). + -- Push-to-talk toggle: enter PTT (mic muted, hold key armed) / exit (restore). + -- The hold key is configurable (audio config ptt_key = Control_R or mouse:NNN). + -- Bluetooth panel (blueman retired in favor of the bt panel) + -- Screenshots (grim + slurp + fuzzel menu) + -- Shift+S captures the whole desktop with no pointer interaction, so it + -- works on scratchpads and popups that region-select would dismiss + -- Lock screen + -- Audio mute cycle (M for Mute): one key walks volume/mic through all four + -- on/off combinations. The waybar pulseaudio modules follow via PipeWire. + -- Exit/session + -- mod+Shift+Backspace no longer exits outright — it enters the "exitconfirm" + -- submap, where a second Backspace confirms and anything else backs out. + -- The bare bind killed two live sessions on 2026-07-22: it sits one key from + -- the mod+Shift cluster (Q wlogout, C killactive, Return terminal, G settings) + -- and took the whole session down with no prompt. wlogout owns the normal + -- exit path; this stays as the keyboard escape hatch for when wlogout won't + -- come up, so it's gated rather than removed. + -- Mouse bindings (from DWM buttons) + -- ============================================================================ + -- Machine-local overrides + -- ============================================================================ + -- Sourced last so machine-specific settings (monitor scale, gaps, keybinds) + -- override the defaults above. See conf.d/local.conf. +}) + +hl.on("hyprland.start", function() + hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE") + hl.exec_cmd("systemctl --user start hyprland-session.target; systemctl --user restart xdg-desktop-portal-hyprland xdg-desktop-portal-gtk; systemctl --user restart xdg-desktop-portal; waybar-active-config && waybar -c \"$XDG_RUNTIME_DIR/waybar/config\" -s ~/.config/waybar/style.css 2>&1 | grep -v \"LIBDBUSMENU-GLIB-WARNING\" > ~/.local/var/log/waybar-$(date +%Y-%m-%d-%H%M%S).log") + hl.exec_cmd("/usr/lib/polkit-kde-authentication-agent-1") + hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=pkcs11,secrets,ssh") + hl.exec_cmd("dunst > ~/.local/var/log/dunst-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("awww-daemon & sleep 1 && { settings restore-wallpaper || waypaper --restore; }") + hl.exec_cmd("touchpad-auto") + hl.exec_cmd("pkill -x hypridle; hypridle-start > ~/.local/var/log/hypridle-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("/usr/lib/geoclue-2.0/demos/agent") + hl.exec_cmd("gammastep > ~/.local/var/log/gammastep-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("mpd") + hl.exec_cmd("settings restore > ~/.local/var/log/settings-restore-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("pypr > ~/.local/var/log/pypr-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("hypr-refocus-scratchpad") + hl.exec_cmd("wait-for-tray && signal-desktop --start-in-tray --ozone-platform=wayland") + hl.exec_cmd("env QT_FONT_DPI=108 protonmail-bridge --no-window") +end) + +hl.on("config.reloaded", function() + hl.exec_cmd("for i in 1 2 3; do sleep 0.2; waybar-reserve; done") +end) + diff --git a/working/hyprland-lua-port/nomerge.lua b/working/hyprland-lua-port/nomerge.lua new file mode 100644 index 0000000..c85fd24 --- /dev/null +++ b/working/hyprland-lua-port/nomerge.lua @@ -0,0 +1,768 @@ +-- Hyprland Configuration +-- Translated from DWM config.def.h and sxhkdrc +-- Craig Jennings <c@cjennings.net> + +-- ============================================================================ +-- Monitor Configuration +-- ============================================================================ +-- Generated by hyprlang2lua. Review TODOs before reloading Hyprland. + +-- hyprlang2lua polyfills — runtime helpers reproducing +-- hyprlang behaviour the typed Lua API doesn't expose directly. + +local function hl_source_glob(pattern) + -- 'source = path/*.conf' had hyprlang glob and inline-expand the + -- matches. require() can't glob, so we shell out to ls (matching + -- the user's brace-expansion behaviour) and dofile each result. + -- Paths with spaces or shell metacharacters in the directory + -- portion will misparse; typical ~/.config/hypr/ layouts don't + -- hit this. Swap to lfs.dir() or find -name if you need fancier. + local p = io.popen("ls " .. pattern .. " 2>/dev/null") + if not p then return end + for f in p:lines() do + local chunk, err = loadfile(f) + if chunk then chunk() + else io.stderr:write("hl_source_glob: " .. tostring(err) .. "\n") end + end + p:close() +end + +hl.monitor({ + output = "", + mode = "preferred", + position = "auto", + scale = "auto", +}) + +-- Waybar's strip (6px top margin + 54px bar) is reserved statically by +-- waybar-reserve, and waybar runs with "exclusive": false. The bar's own +-- exclusive zone would vanish and reappear on every SIGUSR2 reload (the +-- collapse mechanism) and on hide/crash/relaunch, snapping every tiled window +-- up and back down. The static reservation holds the clients in place; only +-- the bar itself changes. `exec` (not exec-once) reruns it on every config +-- reload, which is exactly when Hyprland resets dynamic reservations. The +-- script is idempotent, and a catch-all `monitor=,addreserved,...` rule can't +-- replace it (empty-name addreserved silently no-ops). +-- +-- Run three times over ~0.6s, not once: on reload Hyprland clears the +-- reservation AND re-fires this exec, and the two race. A single run that +-- fires before the clear no-ops (reserved still looks correct), the clear then +-- wins, and the non-exclusive bar drops off-screen. Re-applying past the clear +-- window makes the restore reliable; the script is idempotent so extra runs are +-- free. Applying a monitor rule (e.g. the DP-4 pin) also clears the reservation, +-- so this covers a reload that re-asserts monitors too. + +-- ============================================================================ +-- Startup Applications +-- ============================================================================ +-- Portal and D-Bus setup FIRST, then waybar (needs portal for appearance query) +-- Start hyprland-session.target FIRST: it pulls up graphical-session.target, +-- which xdg-desktop-portal 1.22+ hard-requires (Requisite=). A bare-exec Hyprland +-- session has no session manager to raise that target, so without this the portal +-- fails its dependency at every login (screen-share + file pickers dead). +-- 'systemctl start' blocks until active, so the ';' sequence guarantees the target +-- is up before the portal restart runs. +-- +-- Portal restart (not start) reconnects stale portals on Hyprland restart. +-- Backend portals (GTK, Hyprland) restart BEFORE the main portal to avoid a 50s +-- GTK settings proxy timeout; the sequence keeps that ordering. Separated by ';' +-- not '&&' so a failing portal restart can't stop waybar from launching — waybar +-- degrades gracefully without the portal (only the appearance query is missed), +-- and gating the bar behind the portal left the desktop bar-less whenever +-- xdg-desktop-portal failed its dependency at login. Waybar stays gated on its +-- own config generation (waybar-active-config && waybar). + +-- Core services + +-- Desktop appearance +-- `settings restore` replays the remembered toggles and reapplies the stored +-- wallpaper. It replaced `waypaper --restore` on 2026-08-14: waypaper keeps +-- its own config.ini and the settings store keeps another, neither knew about +-- the other, and the login replay always won — so a wallpaper chosen in the +-- panel came back as whatever the shell had last set. The store is the only +-- one of the two that can hold a sun pair, a video or a projected face, so it +-- owns the restore. set-wallpaper records into it for choices made outside +-- the panel. +-- +-- waypaper --restore stays as the fallback, not the owner. If the stored +-- wallpaper cannot be applied (an image deleted, a drive not mounted yet), +-- `settings restore` exits 3 and waypaper's independent copy still puts +-- something on the screen. Dropping it outright would trade this bug for a +-- bare desktop. +-- +-- The wallpaper half only. The toggle half runs from its own exec-once further +-- down, after hypridle and dunst — caffeine *is* "hypridle isn't running" and +-- DND *is* dunst's pause level, so replaying them here would spend the whole +-- re-assert budget correcting backings that have not launched yet, and would +-- replay them a second time besides. This slot exists for awww's timing, not +-- theirs. + +-- Background services +-- hypridle is reaped on both exit paths, because it outlives its compositor +-- otherwise. An orphaned daemon keeps firing idle actions at whatever session +-- is live next, and it holds its old logind session scope open (the scope can't +-- close while a process sits in it), so orphans accumulate one per abnormal +-- session death. On 2026-07-22 velox reached five concurrent hypridle daemons; +-- two of them racing to lock produced "Cannot re-lock" and a session wedged +-- locked with no client able to draw a password prompt — recoverable only from +-- another console. exec-shutdown covers a clean compositor exit; the pkill in +-- exec-once covers the paths where it never runs (crash, SIGKILL, TTY logout). +hl.on("hyprland.shutdown", function() + hl.exec_cmd("pkill -x hypridle") +end) + +-- hypridle.conf is rendered here rather than tracked, because its contents +-- are this machine's stage times and hibernate setting. Tracking the render +-- meant every panel change dirtied the repo, and whichever machine +-- committed last imposed its policy on the others: a desktop ended up +-- carrying a laptop's suspend-then-hibernate line that it cannot run. +-- Rendering at session start makes the store the only source of truth and +-- the file a build artifact. +-- +-- hypridle-start owns the render, the fallback, and the ordering between +-- them, because that ordering is subtle enough to get wrong in a config +-- line nothing can test: a render can fail on purpose (a damaged store, to +-- avoid overwriting a real policy with defaults), and a fallback that +-- fired there would perform exactly the overwrite the render refused. +-- Replay the toggles that have no durable state of their own. Caffeine *is* +-- "hypridle isn't running" and DND *is* dunst's pause level, so the two +-- exec-once lines above (and dunst's) recreate both at a fixed default every +-- start — a deliberately-set caffeine was silently discarded on every login. +-- Ordered after those launches so it corrects a backing that exists; it also +-- re-asserts for a few seconds, which covers a backing that comes up late. +-- Logged like its neighbours: a silent exec-once failure here would look +-- exactly like the bug it fixes, and gammastep is the standing proof that a +-- launch dying quietly at session start can go unnoticed for a long time. + +-- Pyprland (scratchpads, magnify, etc.) + +-- Tray apps. wait-for-tray blocks until waybar's systray host is up (a fixed +-- sleep can't cover a slow cold-start waybar), so these register their icons +-- instead of opening as windows. Caps at ~30s, then launches anyway. +-- QT_FONT_DPI bumps the bridge's QML UI font (qt6ct General font is ignored by Qt Quick) + +-- ============================================================================ +-- Environment Variables +-- ============================================================================ +hl.env("XCURSOR_SIZE", "24") +hl.env("XCURSOR_THEME", "Bibata-Modern-Ice") +hl.env("XDG_CURRENT_DESKTOP", "Hyprland") +hl.env("XDG_SESSION_TYPE", "wayland") +hl.env("XDG_SESSION_DESKTOP", "Hyprland") +hl.env("_JAVA_AWT_WM_NONREPARENTING", "1") + +-- ============================================================================ +-- Appearance (matching DWM colors) +-- ============================================================================ +-- DWM colors: gray1=#222222, gray2=#444444, gray3=#bbbbbb, gray4=#eeeeee, cyan=#daa520 + +hl.config({ + general = { + gaps_in = 25, + gaps_out = 30, + border_size = 2, + col = { + active_border = "rgba(daa520ff)", + inactive_border = "rgba(444444ff)", + nogroup_border_active = "rgba(daa520ff)", + nogroup_border = "rgba(444444ff)", + }, + -- Pyprland 3.4+ applies `group deny` to scratchpads, which routes their + -- border through col.nogroup_border* instead of col.*_border. Without + -- these overrides Hyprland's defaults paint scratchpads bright magenta. + layout = "master", + resize_on_border = true, + }, +}) + +hl.config({ + decoration = { + rounding = 10, + dim_inactive = true, + dim_strength = 0.4, + dim_special = 0.2, + blur = { + enabled = false, + }, + shadow = { + enabled = false, + }, + }, +}) + +hl.config({ + animations = { + enabled = true, + }, +}) + +hl.curve("myBezier", { type = "bezier", points = { { 0.05, 0.9 }, { 0.1, 1.05 } } }) +hl.animation({ + leaf = "windows", + enabled = true, + speed = 2, + bezier = "myBezier", +}) +hl.animation({ + leaf = "windowsOut", + enabled = true, + speed = 2, + bezier = "default", + style = "popin 80%", +}) +hl.animation({ + leaf = "fade", + enabled = true, + speed = 2, + bezier = "default", +}) +hl.animation({ + leaf = "workspaces", + enabled = true, + speed = 2, + bezier = "default", +}) +hl.animation({ + leaf = "specialWorkspace", + enabled = true, + speed = 2, + bezier = "default", + style = "slidevert", +}) + +-- ============================================================================ +-- Layout (master-stack like DWM tile) +-- ============================================================================ + +hl.config({ + master = { + new_status = "master", + new_on_top = true, + mfact = 0.55, + }, +}) + +hl.config({ + dwindle = { + preserve_split = true, + }, +}) + +-- ============================================================================ +-- Input +-- ============================================================================ + +hl.config({ + cursor = { + no_warps = true, + inactive_timeout = 2.0, + }, +}) + +hl.config({ + input = { + kb_layout = "us", + kb_options = "ctrl:nocaps", + numlock_by_default = true, + follow_mouse = 0, + -- 0, not the default 1: with follow_mouse off we never want focus to follow + -- the cursor. At 1, focus still jumps to the window under the pointer when it + -- crosses a floating<->tiled boundary, so launching a floating scratchpad (or + -- the org-capture popup) re-enabled focus-follows-mouse onto tiled windows. + float_switch_override_focus = 0, + mouse_refocus = false, + natural_scroll = true, + touchpad = { + natural_scroll = false, + }, + }, +}) + +-- ============================================================================ +-- Misc +-- ============================================================================ + +hl.config({ + misc = { + force_default_wallpaper = 0, + disable_hyprland_logo = true, + -- false so apps can't pull focus via activation requests. New windows still + -- focus on open (separate path); this stops e.g. a browser yanking focus + -- back off a freshly opened emacs frame. + focus_on_activate = false, + -- Let a fresh lock client adopt a session whose previous one died. The + -- default (off) is the strict reading of ext-session-lock: a dead lock + -- client leaves the session locked forever and refuses every replacement + -- ("Cannot re-lock"), so the screen stays up with nothing able to draw a + -- password prompt and the only way back in is another console. That is a + -- hard lockout, and it cost a session on velox 2026-07-22. On means a + -- replacement hyprlock re-attaches and prompts normally. The screen stays + -- locked either way — this decides whether the lock is recoverable, never + -- whether it holds. + allow_session_lock_restore = true, + }, +}) + +-- ============================================================================ +-- Debug (temporary - disable when stable) +-- ============================================================================ + +hl.config({ + debug = { + disable_logs = false, + }, +}) + +-- ============================================================================ +-- XWayland +-- ============================================================================ + +hl.config({ + xwayland = { + force_zero_scaling = true, + }, +}) + +-- ============================================================================ +-- Window Rules (Hyprland 0.53+ syntax: match:CONDITION, RULE) +-- ============================================================================ +-- Floating windows (from DWM rules) +hl.window_rule({ + match = { + class = "^(xdg-desktop-portal-gtk)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(Gimp)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(caffeine)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(qalculate-gtk)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + title = "^(Event Tester)$", + }, + float = true, +}) + +-- net / bluetooth instrument-console panels. Normal floating windows (formerly +-- gtk4-layer-shell overlays) so they drag to move and corner-drag to resize. +-- Opened top-right to match their old anchored spot: the panel is right-aligned +-- with a 44px gap, so x = 100% - (window width + 44). net is 420 wide, bt 380. +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.netpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.netpanel)$", + }, + move = "100%-464 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.btpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.btpanel)$", + }, + move = "100%-424 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.audiopanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.audiopanel)$", + }, + move = "100%-444 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.timerpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.timerpanel)$", + }, + move = "100%-444 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.settingspanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.settingspanel)$", + }, + move = "100%-584 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.weatherpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.weatherpanel)$", + }, + move = "100%-464 50", +}) + +-- maintenance console: the wide board (960), same right-aligned convention. +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.maintpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.maintpanel)$", + }, + move = "100%-1004 50", +}) + +-- org-capture popup frame (quick-capture script names the frame) +-- Size is per-host in <host>/conf.d/local.conf: native window rules ignore +-- percentages (only pyprland honors them), so the popup is sized in absolute +-- pixels matching that host's terminal scratchpad. No size rule here means a +-- host without an override falls back to the script's char-cell geometry. +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + center = true, +}) + +-- dirvish popup frame (dirvish-popup script names the frame). No stay_focused — +-- it's a file manager that launches files into other apps, so focus must be free +-- to follow; q (cj/dirvish-popup-quit) closes the frame. +hl.window_rule({ + match = { + title = "^(dirvish)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + title = "^(dirvish)$", + }, + size = "1100 700", +}) + +hl.window_rule({ + match = { + title = "^(dirvish)$", + }, + center = true, +}) + +-- NOTE: center windowrules removed 2026-03-04 per pyprland maintainer suggestion +-- Testing whether pyprland handles scratchpad re-centering natively (issue #211) + +-- Gaming +hl.window_rule({ + match = { + class = "^(Civ5XP)$", + }, + fullscreen = true, +}) + +-- ============================================================================ +-- Key Bindings +-- ============================================================================ +local mod = "SUPER" + +-- Terminal and core apps (from DWM) +hl.bind(mod .. " + T", hl.dsp.exec_cmd("foot")) +hl.bind(mod .. " + E", hl.dsp.exec_cmd("emacsclient -c -a \"\" || emacs")) +-- Standalone emacs: its own process, not a frame on the daemon, so killing it +-- takes no other frame with it. init.el guards server-start on server-running-p, +-- so while the daemon holds the socket this process leaves it alone. With no +-- daemon up it finds no server and becomes one -- the guard lives in init.el, and +-- a keybind can't override it, since --eval runs after init. +hl.bind(mod .. " + SHIFT + E", hl.dsp.exec_cmd("emacs")) +hl.bind(mod .. " + N", hl.dsp.exec_cmd("quick-capture")) +hl.bind(mod .. " + W", hl.dsp.exec_cmd("$BROWSER")) +hl.bind(mod .. " + F", hl.dsp.exec_cmd("dirvish-popup")) +hl.bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd("layout-cycle float-toggle")) + +-- From sxhkdrc +hl.bind(mod .. " + SPACE", hl.dsp.exec_cmd("fuzzel-toggle")) +hl.bind(mod .. " + SHIFT + W", hl.dsp.exec_cmd("$ALTBROWSER")) +hl.bind(mod .. " + P", hl.dsp.exec_cmd("media-toggle-all")) +hl.bind(mod .. " + SHIFT + L", hl.dsp.exec_cmd("calibre")) +hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("toggle-touchpad")) + +-- Window management (from DWM) +-- Layout-aware navigation (works across master, scrolling) +hl.bind(mod .. " + J", hl.dsp.exec_cmd("layout-navigate next")) +hl.bind(mod .. " + K", hl.dsp.exec_cmd("layout-navigate prev")) +hl.bind(mod .. " + SHIFT + J", hl.dsp.exec_cmd("layout-navigate next move")) +hl.bind(mod .. " + SHIFT + K", hl.dsp.exec_cmd("layout-navigate prev move")) +hl.bind(mod .. " + H", hl.dsp.exec_cmd("layout-resize shrink")) +hl.bind(mod .. " + L", hl.dsp.exec_cmd("layout-resize grow")) +-- Swap focused window with master, then force focus onto the master slot. +-- swapwithmaster's own `master` focus param doesn't stick when invoked from +-- the master, so focusmaster master pins focus afterward. The 50ms sleep is +-- load-bearing: swapwithmaster fires an async focus event ~1-2ms after it +-- returns; without the delay that event lands AFTER focusmaster and flips +-- focus back to the detail. The sleep lets the swap's focus settle so +-- focusmaster runs last and wins. Proven via instrumented capture (19/19). +hl.bind(mod .. " + RETURN", hl.dsp.layout("swapwithmaster && sleep 0.05 && hyprctl dispatch layoutmsg focusmaster master")) +hl.bind(mod .. " + G", hl.dsp.window.center()) +hl.bind(mod .. " + TAB", hl.dsp.focus({ workspace = "previous" })) +hl.bind(mod .. " + SHIFT + C", hl.dsp.window.close()) + +-- Layouts: master -> monocle +-- Cycle with Shift+arrows, or jump directly with Shift+T/M +-- (scrolling layout disabled until frame-fit + wrap-around work lands) +hl.bind(mod .. " + SHIFT + RIGHT", hl.dsp.exec_cmd("layout-cycle next")) +hl.bind(mod .. " + SHIFT + LEFT", hl.dsp.exec_cmd("layout-cycle prev")) +hl.bind(mod .. " + SHIFT + T", hl.dsp.exec_cmd("hyprctl keyword general:layout master && hyprctl keyword master:orientation left")) +hl.bind(mod .. " + SHIFT + M", hl.dsp.exec_cmd("hyprctl keyword general:layout monocle")) +hl.bind(mod .. " + SHIFT + SPACE", hl.dsp.window.float({ action = "toggle" })) + +-- Master layout adjustments +hl.bind(mod .. " + U", hl.dsp.layout("addmaster")) +hl.bind(mod .. " + D", hl.dsp.layout("removemaster")) + +-- Stash windows (hide to special workspace) +-- O = stash focused / Alt+O = stash others / Shift+O = restore all +hl.bind(mod .. " + O", hl.dsp.exec_cmd("stash-window")) +hl.bind(mod .. " + ALT + O", hl.dsp.exec_cmd("stash-others")) +hl.bind(mod .. " + SHIFT + O", hl.dsp.exec_cmd("stash-restore")) + +-- Gaps between windows only; window-gaps leaves the monitor-edge gap +-- (general:gaps_out) fixed, so widening/narrowing moves the space between +-- windows, not the screen-edge margin. +hl.bind(mod .. " + MINUS", hl.dsp.exec_cmd("window-gaps narrow")) +hl.bind(mod .. " + EQUAL", hl.dsp.exec_cmd("window-gaps widen")) +hl.bind(mod .. " + SHIFT + EQUAL", hl.dsp.exec_cmd("window-gaps reset")) +hl.bind(mod .. " + SHIFT + MINUS", hl.dsp.exec_cmd("window-gaps zero")) + +-- Auto-dim toggle (D = dim). Same action as clicking the waybar custom/dim icon. +hl.bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd("dim-toggle")) +hl.bind(mod .. " + SHIFT + G", hl.dsp.exec_cmd("settings-panel")) + +-- Caffeine (keep-awake) toggle. Same action as clicking the waybar +-- custom/caffeine icon — flips the hypridle daemon so the screen will / won't +-- lock. Stays on $mod+I ($mod+C is taken by hyprpicker; no free caffeine key). +hl.bind(mod .. " + I", hl.dsp.exec_cmd("caffeine-toggle")) + +-- Airplane mode (low-power: wifi off + CPU/brightness/services). A deliberate +-- keybind, not a bar click — engaging it disconnects you, so it shouldn't be a +-- misclick away. The custom/net module shows the state; this toggles it. +-- On Super+Shift+X ("X" = everything off); Super+Shift+A toggles push-to-talk. +hl.bind(mod .. " + SHIFT + X", hl.dsp.exec_cmd("airplane-mode")) + +-- Toggle bar visibility, or relaunch waybar if it crashed (no exec-once respawn). +hl.bind(mod .. " + B", hl.dsp.exec_cmd("waybar-toggle")) + +-- Collapse / expand the left or right side of the bar to its base set +-- (same action as clicking the side's arrowhead). [ = left, ] = right. +hl.bind(mod .. " + bracketleft", hl.dsp.exec_cmd("waybar-collapse left")) +hl.bind(mod .. " + bracketright", hl.dsp.exec_cmd("waybar-collapse right")) + +-- Fullscreen +hl.bind(mod .. " + F11", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })) + +-- Workspaces 1-9 (from DWM TAGKEYS) +hl.bind(mod .. " + 1", hl.dsp.focus({ workspace = 1 })) +hl.bind(mod .. " + 2", hl.dsp.focus({ workspace = 2 })) +hl.bind(mod .. " + 3", hl.dsp.focus({ workspace = 3 })) +hl.bind(mod .. " + 4", hl.dsp.focus({ workspace = 4 })) +hl.bind(mod .. " + 5", hl.dsp.focus({ workspace = 5 })) +hl.bind(mod .. " + 6", hl.dsp.focus({ workspace = 6 })) +hl.bind(mod .. " + 7", hl.dsp.focus({ workspace = 7 })) +hl.bind(mod .. " + 8", hl.dsp.focus({ workspace = 8 })) +hl.bind(mod .. " + 9", hl.dsp.focus({ workspace = 9 })) + +-- Move window to workspace (from DWM tag) +hl.bind(mod .. " + SHIFT + 1", hl.dsp.window.move({ workspace = 1, follow = false })) +hl.bind(mod .. " + SHIFT + 2", hl.dsp.window.move({ workspace = 2, follow = false })) +hl.bind(mod .. " + SHIFT + 3", hl.dsp.window.move({ workspace = 3, follow = false })) +hl.bind(mod .. " + SHIFT + 4", hl.dsp.window.move({ workspace = 4, follow = false })) +hl.bind(mod .. " + SHIFT + 5", hl.dsp.window.move({ workspace = 5, follow = false })) +hl.bind(mod .. " + SHIFT + 6", hl.dsp.window.move({ workspace = 6, follow = false })) +hl.bind(mod .. " + SHIFT + 7", hl.dsp.window.move({ workspace = 7, follow = false })) +hl.bind(mod .. " + SHIFT + 8", hl.dsp.window.move({ workspace = 8, follow = false })) +hl.bind(mod .. " + SHIFT + 9", hl.dsp.window.move({ workspace = 9, follow = false })) + +-- Monitor focus (from DWM focusmon) +hl.bind(mod .. " + COMMA", hl.dsp.focus({ monitor = -1 })) +hl.bind(mod .. " + PERIOD", hl.dsp.focus({ monitor = "+1" })) +hl.bind(mod .. " + SHIFT + COMMA", hl.dsp.window.move({ monitor = "-1" })) +hl.bind(mod .. " + SHIFT + PERIOD", hl.dsp.window.move({ monitor = "+1" })) + +-- ============================================================================ +-- Scratchpads (via pyprland) +-- ============================================================================ +-- Configured in ~/.config/hypr/pyprland.toml +-- Uses normal workspaces (not special), so new windows won't be captured +hl.bind(mod .. " + SHIFT + RETURN", hl.dsp.exec_cmd("pypr toggle term")) +hl.bind(mod .. " + A", hl.dsp.exec_cmd("audio-panel")) +hl.bind(mod .. " + R", hl.dsp.exec_cmd("pypr toggle monitor")) +hl.bind(mod .. " + SHIFT + N", hl.dsp.exec_cmd("net panel")) +hl.bind(mod .. " + SLASH", hl.dsp.exec_cmd("pypr toggle music")) + +-- Magnify (zoom) +-- mod+Z zooms and enters the "zoom" submap; inside it, Escape or mod+Z +-- unzooms and returns to the normal keymap. Exit forces `pypr zoom 1` +-- (factor 1) so submap state and zoom state can't desync. Note: while +-- zoomed, other Hyprland binds pause until you exit the submap. +hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom")) +hl.bind(mod .. " + Z", hl.dsp.submap("zoom")) + +hl.define_submap("zoom", function() + hl.bind("ESCAPE", hl.dsp.exec_cmd("pypr zoom 1")) + hl.bind("ESCAPE", hl.dsp.submap("reset")) + hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom 1")) + hl.bind(mod .. " + Z", hl.dsp.submap("reset")) +end) + +-- Calculator (not a scratchpad, just launches app) +hl.bind(mod .. " + X", hl.dsp.exec_cmd("calc-toggle")) +hl.bind(mod .. " + C", hl.dsp.exec_cmd("hyprpicker -a")) +hl.bind(mod .. " + CONTROL + C", hl.dsp.exec_cmd("clock-panel toggle")) + +-- Media/hardware keys +hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ +5%"), { locked = true, repeating = true }) +hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ -5%"), { locked = true, repeating = true }) +hl.bind("XF86AudioMute", hl.dsp.exec_cmd("audio quick-mute"), { locked = true }) +hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl s +10%"), { locked = true, repeating = true }) +hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl s 10%-"), { locked = true, repeating = true }) + +-- Microphone mute toggle (waybar pulseaudio#mic indicator follows via PipeWire events). +-- On the hardware mic-mute key. Super+Shift+A used to duplicate this; it now +-- toggles push-to-talk mode instead (mic-toggle stays reachable on the hw key). +hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("mic-toggle"), { locked = true }) + +-- Push-to-talk toggle: enter PTT (mic muted, hold key armed) / exit (restore). +-- The hold key is configurable (audio config ptt_key = Control_R or mouse:NNN). +hl.bind(mod .. " + SHIFT + A", hl.dsp.exec_cmd("audio ptt-toggle")) + +-- Bluetooth panel (blueman retired in favor of the bt panel) +hl.bind(mod .. " + SHIFT + B", hl.dsp.exec_cmd("bt-panel")) + +-- Screenshots (grim + slurp + fuzzel menu) +-- Shift+S captures the whole desktop with no pointer interaction, so it +-- works on scratchpads and popups that region-select would dismiss +hl.bind(mod .. " + S", hl.dsp.exec_cmd("screenshot region")) +hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("screenshot fullscreen")) +hl.bind("CTRL" .. mod .. " + S", hl.dsp.exec_cmd("screenshot fullscreen")) + +-- Lock screen +hl.bind(mod .. " + ESCAPE", hl.dsp.exec_cmd("hyprlock")) + +-- Audio mute cycle (M for Mute): one key walks volume/mic through all four +-- on/off combinations. The waybar pulseaudio modules follow via PipeWire. +hl.bind(mod .. " + M", hl.dsp.exec_cmd("audio-cycle")) + +-- Exit/session +hl.bind(mod .. " + SHIFT + Q", hl.dsp.exec_cmd("pgrep -x wlogout || wlogout-menu")) +-- mod+Shift+Backspace no longer exits outright — it enters the "exitconfirm" +-- submap, where a second Backspace confirms and anything else backs out. +-- The bare bind killed two live sessions on 2026-07-22: it sits one key from +-- the mod+Shift cluster (Q wlogout, C killactive, Return terminal, G settings) +-- and took the whole session down with no prompt. wlogout owns the normal +-- exit path; this stays as the keyboard escape hatch for when wlogout won't +-- come up, so it's gated rather than removed. +hl.bind(mod .. " + SHIFT + BACKSPACE", hl.dsp.submap("exitconfirm")) + +hl.define_submap("exitconfirm", function() + hl.bind("BACKSPACE", hl.dsp.exit()) + hl.bind("ESCAPE", hl.dsp.submap("reset")) + hl.bind("catchall", hl.dsp.submap("reset")) +end) + +hl.bind(mod .. " + SHIFT + ESCAPE", hl.dsp.exec_cmd("hyprctl reload")) +hl.bind("CTRL + ALT" .. mod .. " + K", hl.dsp.exec_cmd("hyprctl kill")) + +-- Mouse bindings (from DWM buttons) +hl.bind(mod .. " + mouse:272", hl.dsp.window.drag()) +hl.bind(mod .. " + mouse:273", hl.dsp.window.resize()) +hl.bind(mod .. " + SHIFT + mouse:272", hl.dsp.window.resize()) + +-- ============================================================================ +-- Machine-local overrides +-- ============================================================================ +-- Sourced last so machine-specific settings (monitor scale, gaps, keybinds) +-- override the defaults above. See conf.d/local.conf. +-- Source: $HOME/.config/hypr/conf.d/*.conf (glob; resolved at runtime). Each matched .conf must be converted to .lua. +hl_source_glob("$HOME/.config/hypr/conf.d/*.lua") + +hl.on("hyprland.start", function() + hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE") + hl.exec_cmd("systemctl --user start hyprland-session.target; systemctl --user restart xdg-desktop-portal-hyprland xdg-desktop-portal-gtk; systemctl --user restart xdg-desktop-portal; waybar-active-config && waybar -c \"$XDG_RUNTIME_DIR/waybar/config\" -s ~/.config/waybar/style.css 2>&1 | grep -v \"LIBDBUSMENU-GLIB-WARNING\" > ~/.local/var/log/waybar-$(date +%Y-%m-%d-%H%M%S).log") + hl.exec_cmd("/usr/lib/polkit-kde-authentication-agent-1") + hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=pkcs11,secrets,ssh") + hl.exec_cmd("dunst > ~/.local/var/log/dunst-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("awww-daemon & sleep 1 && { settings restore-wallpaper || waypaper --restore; }") + hl.exec_cmd("touchpad-auto") + hl.exec_cmd("pkill -x hypridle; hypridle-start > ~/.local/var/log/hypridle-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("/usr/lib/geoclue-2.0/demos/agent") + hl.exec_cmd("gammastep > ~/.local/var/log/gammastep-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("mpd") + hl.exec_cmd("settings restore > ~/.local/var/log/settings-restore-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("pypr > ~/.local/var/log/pypr-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("hypr-refocus-scratchpad") + hl.exec_cmd("wait-for-tray && signal-desktop --start-in-tray --ozone-platform=wayland") + hl.exec_cmd("env QT_FONT_DPI=108 protonmail-bridge --no-window") +end) + +hl.on("config.reloaded", function() + hl.exec_cmd("for i in 1 2 3; do sleep 0.2; waybar-reserve; done") +end) + diff --git a/working/hyprland-lua-port/ratio-local.lua b/working/hyprland-lua-port/ratio-local.lua new file mode 100644 index 0000000..de190d5 --- /dev/null +++ b/working/hyprland-lua-port/ratio-local.lua @@ -0,0 +1,43 @@ +-- ratio — desktop, 1x scaling. Defaults in hyprland.lua are correct. +-- Sourced via conf.d/*.lua glob (last wins). +-- +-- Examples: +-- hl.monitor({ output = "DP-1", mode = "3440x1440@144", position = "auto", scale = 1 }) +-- hl.bind("SUPER + L", hl.dsp.exec_cmd("hyprlock")) +-- +-- Spell the modifier out. A sourced file is loaded by hl_source_glob via +-- loadfile, which gives the chunk globals only, so the shared config's `mod` +-- (and `at_start`, and the other locals) are NOT in scope here. + +-- DP-4 (Dell U3419W ultrawide) pinned to its native mode. Without an explicit +-- pin the shared catch-all monitor=,preferred,auto,auto lets an XWayland surface +-- (emacs runs X11-only on ratio) drive the mode down to 1280x720 at login. That +-- low mode also wipes DP-4's reserved area, which drops the non-exclusive waybar +-- off-screen (margin-top:-54 needs the 60px top reserve). Pinning holds both. + +hl.monitor({ + output = "DP-4", + mode = "3440x1440@60", + position = "0x0", + scale = "1", +}) + +-- org-capture popup: capped at 120 Emacs columns wide, height proportional +-- (Craig, 2026-07-14). 120 cols x 11 px/col = 1320 wide; height keeps the old +-- rule's aspect (1892:936) = 653 px (~27 lines at 24 px). The old 55% x 65% +-- scratchpad match (1892 x 936) was the "grows too large" complaint. max_size +-- holds the cap even if the frame tries to grow with capture content. +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + size = "1320 653", +}) + +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + max_size = "1320 653", +}) + diff --git a/working/hyprland-lua-port/reader-changes-for-lua.patch b/working/hyprland-lua-port/reader-changes-for-lua.patch new file mode 100644 index 0000000..747b047 --- /dev/null +++ b/working/hyprland-lua-port/reader-changes-for-lua.patch @@ -0,0 +1,241 @@ +diff --git a/common/.local/bin/dotfiles-validate b/common/.local/bin/dotfiles-validate +index 57a7505..d5e6695 100755 +--- a/common/.local/bin/dotfiles-validate ++++ b/common/.local/bin/dotfiles-validate +@@ -3,6 +3,7 @@ + # + # Walks the tree and extracts the commands that configs promise to launch: + # - hypr conf files: exec-once = CMD / exec = CMD / bind* = ..., exec, CMD ++# - hypr lua configs: at_start/at_shutdown/at_reload("CMD"), exec_cmd("CMD") + # - waybar config: "exec(-if)", "on-click*", "on-scroll-*", + # "on-double-click" values + # - systemd user units: Exec*= lines (leading -/@ modifiers stripped) +@@ -45,6 +46,30 @@ find "$root" -path '*/.config/hypr/*.conf' -type f 2>/dev/null | while read -r f + ' "$f" + done >> "$refs_file" + ++# --- hypr lua configs: autostart collectors and exec_cmd dispatchers --- ++# The Lua config manager (Hyprland 0.55+) spells the same two things as function ++# calls rather than assignments, so the .conf walk above sees none of them. Only ++# the first word is taken, as everywhere else here. The two matches are written ++# out rather than folded into a helper: awk cannot take a regex literal as a ++# function parameter -- it collapses to a boolean match against $0, which ++# silently turns every line into a bogus reference. ++find "$root" -path '*/.config/hypr/*.lua' -type f 2>/dev/null | while read -r f; do ++ awk -v file="$f" ' ++ match($0, /at_(start|shutdown|reload)\("/) { ++ rest = substr($0, RSTART + RLENGTH) ++ sub(/".*$/, "", rest) ++ n = split(rest, w, /[ \t]+/) ++ if (n > 0 && w[1] != "") print file ":" FNR ":" w[1] ++ } ++ match($0, /hl\.dsp\.exec_cmd\("/) { ++ rest = substr($0, RSTART + RLENGTH) ++ sub(/".*$/, "", rest) ++ n = split(rest, w, /[ \t]+/) ++ if (n > 0 && w[1] != "") print file ":" FNR ":" w[1] ++ } ++ ' "$f" ++done >> "$refs_file" ++ + # --- waybar configs: command-bearing JSON values --- + find "$root" -path '*/.config/waybar/*' -type f \( -name config -o -name '*.json' -o -name '*.jsonc' \) 2>/dev/null | while read -r f; do + awk -v file="$f" ' +diff --git a/tests/layout-cycle/test_layout_cycle.py b/tests/layout-cycle/test_layout_cycle.py +index 1454817..3740024 100644 +--- a/tests/layout-cycle/test_layout_cycle.py ++++ b/tests/layout-cycle/test_layout_cycle.py +@@ -18,6 +18,7 @@ Run from repo root: + + import json + import os ++import re + import subprocess + import tempfile + import unittest +@@ -25,7 +26,7 @@ import unittest + REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + SCRIPT = os.path.join(REPO_ROOT, "hyprland/.local/bin/layout-cycle") + FAKE_HYPRCTL = os.path.join(os.path.dirname(__file__), "fake-hyprctl") +-HYPRLAND_CONF = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.conf") ++HYPRLAND_CFG = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.lua") + + FLASH = "rgba(ffd24aff)" + ACTIVE = "rgba(daa520ff)" +@@ -326,7 +327,7 @@ class TestFlashAllBorders(LayoutCycleHarness): + + + class TestScrollLayoutDisabledInConfig(unittest.TestCase): +- """Pin the hyprland.conf half of the scrolling disable. ++ """Pin the hyprland.lua half of the scrolling disable. + + The script tests above prove the ring skips scrolling; these prove no + keybinding reaches it either, and that the freed Super+Shift+S chord +@@ -335,10 +336,20 @@ class TestScrollLayoutDisabledInConfig(unittest.TestCase): + + @classmethod + def setUpClass(cls): +- with open(HYPRLAND_CONF) as f: ++ with open(HYPRLAND_CFG) as f: + cls.conf = f.read() ++ # Every bind in the Lua config is a top-level hl.bind() call; the ++ # locked/mouse/repeat variants that hyprlang spelled bindl/bindm/binde ++ # are the same call with an options table, so one prefix covers them. + cls.binds = [l for l in cls.conf.splitlines() +- if l.strip().startswith(("bind", "bindl", "bindm"))] ++ if l.strip().startswith("hl.bind")] ++ ++ def test_the_binds_were_actually_found(self): ++ """Guard the guard: a renamed call would empty the list and make both ++ assertions below pass against nothing.""" ++ self.assertGreater(len(self.binds), 50, ++ "found almost no hl.bind lines -- the two assertions " ++ "below would pass vacuously") + + def test_no_bind_selects_scrolling_layout(self): + offenders = [l for l in self.binds if "general:layout scrolling" in l] +@@ -346,7 +357,7 @@ class TestScrollLayoutDisabledInConfig(unittest.TestCase): + + def test_super_shift_s_is_fullscreen_screenshot(self): + shift_s = [l for l in self.binds +- if "$mod SHIFT, S," in l] ++ if re.search(r'\bmod\s*\.\.\s*"\s*\+\s*SHIFT\s*\+\s*S"', l)] + self.assertEqual(len(shift_s), 1, msg=f"binds found: {shift_s}") + self.assertIn("screenshot fullscreen", shift_s[0]) + +diff --git a/tests/settings/test_session_restore.py b/tests/settings/test_session_restore.py +index a989d52..b2a2721 100644 +--- a/tests/settings/test_session_restore.py ++++ b/tests/settings/test_session_restore.py +@@ -585,12 +585,32 @@ class TestCompositorWiring(unittest.TestCase): + script and passing tests while never being placed in the bar. + """ + +- CONF = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.conf") ++ CONF = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.lua") + + def _lines(self): ++ """The autostart commands, in launch order. ++ ++ The Lua config collects startup commands with at_start("CMD") and one ++ hl.on("hyprland.start") handler at the bottom replays the list in order, ++ so position in this file is still position at launch -- which is what ++ the ordering assertion below reads. Returning the command strings rather ++ than raw lines means every entry here is by construction an autostart ++ command, so the old startswith("exec-once") filter has nothing left to do. ++ """ ++ out = [] + with open(self.CONF) as f: +- return [ln.strip() for ln in f +- if ln.strip() and not ln.strip().startswith("#")] ++ for ln in f: ++ m = re.search(r'at_start\("(.*)"\)', ln.strip()) ++ if m: ++ out.append(m.group(1)) ++ return out ++ ++ def test_the_autostart_commands_were_actually_found(self): ++ """Guard the guard: a renamed collector would empty the list and make ++ every assertion below pass against nothing.""" ++ self.assertGreater(len(self._lines()), 10, ++ "found almost no at_start commands -- the assertions " ++ "below would pass vacuously") + + @staticmethod + def _is_toggle_restore(line): +@@ -607,22 +627,19 @@ class TestCompositorWiring(unittest.TestCase): + + def test_restore_runs_at_session_start(self): + self.assertTrue( +- any(ln.startswith("exec-once") and self._is_toggle_restore(ln) +- for ln in self._lines()), +- "hyprland.conf has no exec-once running `settings restore` — " ++ any(self._is_toggle_restore(ln) for ln in self._lines()), ++ "hyprland.lua has no at_start running `settings restore` — " + "remembered toggles would never be replayed") + + def test_wallpaper_restore_runs_at_session_start(self): + self.assertTrue( +- any(ln.startswith("exec-once") and "settings restore-wallpaper" in ln +- for ln in self._lines()), +- "hyprland.conf has no exec-once running `settings restore-wallpaper` " ++ any("settings restore-wallpaper" in ln for ln in self._lines()), ++ "hyprland.lua has no at_start running `settings restore-wallpaper` " + "— the stored wallpaper would never be put back") + + def test_the_toggle_restore_runs_only_once(self): + """Twice means the whole re-assert budget is spent twice per login.""" +- lines = [ln for ln in self._lines() +- if ln.startswith("exec-once") and self._is_toggle_restore(ln)] ++ lines = [ln for ln in self._lines() if self._is_toggle_restore(ln)] + self.assertEqual(len(lines), 1, lines) + + def test_restore_is_ordered_after_the_backings_it_corrects(self): +@@ -631,17 +648,15 @@ class TestCompositorWiring(unittest.TestCase): + # burn attempts on backings that aren't up yet. + lines = self._lines() + restore = next(i for i, ln in enumerate(lines) +- if ln.startswith("exec-once") +- and self._is_toggle_restore(ln)) ++ if self._is_toggle_restore(ln)) + for backing in ("hypridle", "dunst"): +- launch = next(i for i, ln in enumerate(lines) +- if ln.startswith("exec-once") and backing in ln) ++ launch = next(i for i, ln in enumerate(lines) if backing in ln) + self.assertLess(launch, restore, + f"`settings restore` is ordered before {backing}") + + + class DimEnv(TempEnv): +- """Adds the hyprctl fake, whose dim default is 1 -- as hyprland.conf's is.""" ++ """Adds the hyprctl fake, whose dim default is 1 -- as hyprland.lua's is.""" + + def setUp(self): + super().setUp() +diff --git a/tests/waybar-reserve/test_reserve_pairing.py b/tests/waybar-reserve/test_reserve_pairing.py +index 7421692..fbeb95d 100644 +--- a/tests/waybar-reserve/test_reserve_pairing.py ++++ b/tests/waybar-reserve/test_reserve_pairing.py +@@ -26,7 +26,7 @@ import unittest + + REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + WAYBAR = os.path.join(REPO_ROOT, "hyprland/.config/waybar/config") +-HYPR = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.conf") ++HYPR = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.lua") + + + def waybar_config(): +@@ -35,13 +35,19 @@ def waybar_config(): + + + def reserve_exec_wired(): +- """True when hyprland.conf runs waybar-reserve via exec (not exec-once). ++ """True when the config re-runs waybar-reserve on every config reload. + +- Matches waybar-reserve invoked anywhere in an ``exec =`` line, so the +- reload-race-safe retry-loop form (``exec = for i in 1 2 3; do sleep 0.2; +- waybar-reserve; done``) counts the same as a bare ``exec = waybar-reserve``.""" ++ Matches waybar-reserve invoked anywhere in an ``at_reload(...)`` line, so the ++ reload-race-safe retry-loop form (``at_reload("for i in 1 2 3; do sleep 0.2; ++ waybar-reserve; done")``) counts the same as a bare ``at_reload("waybar-reserve")``. ++ ++ at_reload and not at_start, because hyprlang's ``exec`` ran at startup AND on ++ every reload, and the Lua port splits those two jobs. at_reload's handler is ++ ``hl.on("config.reloaded")``, which fires on the initial load as well, so it ++ alone covers both. Matching at_start instead would pass while reservations ++ died on the next reload -- which is exactly the defect this guards.""" + with open(HYPR) as f: +- return any(re.match(r"\s*exec\s*=.*\bwaybar-reserve\b", ln) for ln in f) ++ return any(re.match(r'\s*at_reload\(".*\bwaybar-reserve\b', ln) for ln in f) + + + def reserve_target(): +@@ -61,7 +67,7 @@ class ReservePairingHarness(unittest.TestCase): + + def test_hyprland_wires_the_reserve_script(self): + self.assertTrue(reserve_exec_wired(), +- "hyprland.conf lacks `exec = waybar-reserve`: reservations " ++ "hyprland.lua lacks `at_reload(... waybar-reserve ...)`: reservations " + "die on the next config reload") + + def test_reserve_target_covers_the_bar(self): diff --git a/working/hyprland-lua-port/test-desktop-for-lua.patch b/working/hyprland-lua-port/test-desktop-for-lua.patch new file mode 100644 index 0000000..5bf7612 --- /dev/null +++ b/working/hyprland-lua-port/test-desktop-for-lua.patch @@ -0,0 +1,50 @@ +diff --git a/scripts/testing/tests/test_desktop.py b/scripts/testing/tests/test_desktop.py +index 1538d6a..ca2f266 100644 +--- a/scripts/testing/tests/test_desktop.py ++++ b/scripts/testing/tests/test_desktop.py +@@ -11,6 +11,8 @@ installs `awww` (swww successor) and `pacman -Q swww` no longer matches — so + this checks awww. That divergence from the shell sweep is a correctness fix. + """ + ++import re ++ + import pytest + + +@@ -19,8 +21,12 @@ HYPRLAND_TOOLS = [ + "awww", "grim", "slurp", "gammastep", "foot", + ] + ++# Note: hyprland.lua sits beside two .conf files on purpose. Only Hyprland's own ++# config format is deprecated (removed in 0.57); hypridle and hyprlock are ++# separate projects still on hyprlang, so their .conf names are current. Don't ++# "fix" this list for consistency -- that reverts the Lua port. + HYPRLAND_CONFIGS = [ +- ".config/hypr/hyprland.conf", ++ ".config/hypr/hyprland.lua", + ".config/hypr/hypridle.conf", + ".config/hypr/hyprlock.conf", + ".config/waybar/config", +@@ -139,8 +145,19 @@ def test_bt_panel_wired(host, hyprland_installed, home): + waybar = host.file("%s/.config/waybar/config" % home) + assert "custom/bluetooth" in waybar.content_string, \ + "waybar config lacks the custom/bluetooth module" +- hyprconf = host.file("%s/.config/hypr/hyprland.conf" % home) +- assert "bt-panel" in hyprconf.content_string, \ +- "hyprland.conf lacks the bt-panel keybind" ++ hypr = host.file("%s/.config/hypr/hyprland.lua" % home).content_string ++ # Assert the dispatcher and the chord, not the bare binary name. "bt-panel" ++ # on its own still appears inside exec_cmd() when the chord that reaches it ++ # is mangled, so the weak form passes on a config where Super+Shift+B does ++ # nothing. That is not hypothetical: converting this config to Lua produced ++ # exactly that defect elsewhere ("CTRL" .. mod .. " + S" collapsing to an ++ # unparseable CTRLSUPER + S, which Hyprland rejects at parse and leaves dead). ++ # One pattern spanning the whole bind, not two independent checks: asserting ++ # the chord and the dispatcher separately passes when they sit on different ++ # lines, and a planned reshuffle of the panel keybinding family is exactly ++ # the change that would separate them. ++ assert re.search( ++ r'hl\.bind\(\s*mod\s*\.\.\s*" \+ SHIFT \+ B",\s*hl\.dsp\.exec_cmd\("bt-panel"\)', ++ hypr), "hyprland.lua lacks the Super+Shift+B bind reaching bt-panel" + assert host.file("%s/.config/themes/dupre/panel.css" % home).exists, \ + "shared panel css missing from the stowed theme" diff --git a/working/hyprland-lua-port/velox-local.lua b/working/hyprland-lua-port/velox-local.lua new file mode 100644 index 0000000..1da7359 --- /dev/null +++ b/working/hyprland-lua-port/velox-local.lua @@ -0,0 +1,64 @@ +-- velox — Framework 13, HiDPI 2256x1504. Sourced via conf.d/*.lua glob; +-- values here override hyprland.lua (last wins). + +hl.monitor({ + output = "eDP-1", + mode = "preferred", + position = "auto", + scale = "1.566667", +}) + +-- Scaling on this panel comes from the compositor alone. +-- +-- There used to be `env = GDK_SCALE,1.5` and `env = QT_SCALE_FACTOR,1.5` here, +-- to compensate for `xwayland:force_zero_scaling = true` in hyprland.lua, which +-- makes XWayland clients render unscaled and therefore tiny. That worked for +-- XWayland and broke everything else, because env vars reach every app, not just +-- the XWayland ones. Qt 6 and GTK on Wayland already take their scale from the +-- compositor, so the factor multiplied the monitor scale above: Qt saw a 960x640 +-- logical screen instead of 1440x960 and drew half again too large. Measured +-- with QScreen.geometry on 2026-08-19; 2256/1.566667 is exactly 1440, which is +-- what Qt reports once nothing overrides it. +-- +-- Turning force_zero_scaling off for this host is the other half. XWayland then +-- scales through the compositor, so those apps come out the right size and a +-- little soft rather than sharp and tiny. That cost lands only on XWayland, +-- which is the thing I avoid anyway, and correctly-sized beats crisp for the +-- occasional Zoom window the browser spawns. +-- +-- ratio needs neither: its monitor runs at scale 1, so nothing multiplies and +-- XWayland has nothing to compensate for. +hl.config({ + xwayland = { + force_zero_scaling = false, + }, +}) + +-- calibre renders oversized at the 1.57 compositor scale. Pin its own DPI so its +-- UI is comfortable without touching the desktop or other apps (validated at 96, +-- 2026-06-27). CALIBRE_OVERRIDE_DPI is calibre-only, so a session-wide env is safe. +hl.env("CALIBRE_OVERRIDE_DPI", "96") +-- No XCURSOR_SIZE here: Hyprland scales the native cursor by the monitor scale +-- already, so the shared default (24) renders correctly on HiDPI. Pre-scaling it +-- (the old 36 = 24 x 1.5) double-applied on top of the compositor's scale. + +-- org-capture popup: match the terminal scratchpad (75% x 70% of the 1437x958 +-- logical desktop = 1078 x 671 px). Native window rules ignore percentages, so +-- it's pinned in pixels here rather than in the shared hyprland.lua. +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + size = "1078 671", +}) + +-- Growth cap (Craig, 2026-07-14): the frame must never outgrow its pinned +-- size even when capture content pushes it. 1078 px is ~98 Emacs columns, +-- already under the 120-column cap ratio uses. +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + max_size = "1078 671", +}) + diff --git a/working/meeting-transcription-service/2026-09-19-work-handoff.org b/working/meeting-transcription-service/2026-09-19-work-handoff.org new file mode 100644 index 0000000..d4da1d9 --- /dev/null +++ b/working/meeting-transcription-service/2026-09-19-work-handoff.org @@ -0,0 +1,44 @@ +#+TITLE: Handoff from work: the meeting transcription service needs an install home +#+AUTHOR: Craig Jennings + +* What this is +A self-hosted meeting transcription service (whisper.cpp plus pyannote speaker diarization) that runs on +ratio, with velox as the offline fallback. It has been running on both machines since 2026-09-17. The code +arrives as meeting-transcription-service.tar.gz alongside this note. I picked archsetup as its home because +it is machine setup: a worker, two systemd user units, a Python venv and a model file. The client script and +its Emacs backend entry are handled separately. + +* How it works +- A job queue under ~/.local/state/meeting-transcribe/ with incoming/, work/, done/ and failed/. A systemd + user path unit (meeting-transcribe.path) starts a oneshot worker (meeting-transcribe.service) when a job + lands in incoming/. +- The worker (src/transcribe-worker) converts audio to 16 kHz mono, runs whisper-cli at word level, runs + pyannote (speaker-diarization-community-1) through src/diarize.py with the job's speaker count, and + merges the two by timestamp with src/merge_transcript.py. One job at a time, behind a lock. +- A finished job leaves done/<id>.txt. A failed job leaves failed/<id>.log. Nothing half-written reaches done/. +- No network listener. Tailscale ssh is the transport and the login, systemd is the daemon, the + filesystem is the queue. + +* What the install has to provide, per machine +- ~/.local/share/pyannote-diarize/.venv :: Python 3.12, torch (CPU build), pyannote.audio 4.0.7. About + 1.3 GB. Built with uv. +- ~/.local/share/whisper-models/ggml-large-v3-turbo-q5_0.bin :: the whisper model; ratio's and velox's + copies have the same checksum. whisper-cpp itself must be installed (it was already on velox). +- The src/ scripts placed where the units expect them, and the two user units enabled. +- Linger on, so the path unit runs without a login session. +- One-time, online, by hand: a Hugging Face token and acceptance of the pyannote model terms, so the + diarization model can be cached. After that neither machine needs Hugging Face. The token is a + credential: it must not be written into this repo, which is publicly cloneable. + +* State today +- Installed by hand on ratio and velox; both verified. No NVIDIA GPU and no ROCm on ratio, so it is CPU only. +- make check in the bundle runs pytest (129 tests; whisper, pyannote, ssh and scp are faked at the process + boundary), pyright and shellcheck. All green on 2026-09-19. +- Known rough edge: when two local runs overlap, the second finds the lock held, the worker returns + silently, and the client says "finished without producing a transcript". Rerunning fixes it, but the + message should say the lock was held. + +* What I'm asking archsetup for +Take ownership of the service side: the worker, diarize.py, merge_transcript.py, the two units, and an +install step that builds the venv, fetches the model and enables the units on both daily drivers. Keep +anything work-specific out of the repo; the code bundle has none (scanned before sending). diff --git a/working/meeting-transcription-service/Makefile b/working/meeting-transcription-service/Makefile new file mode 100644 index 0000000..2ed7dad --- /dev/null +++ b/working/meeting-transcription-service/Makefile @@ -0,0 +1,15 @@ +# Checks for the transcription service. transcribe-worker has no .py suffix, so +# pyright's directory scan skips it; it is named explicitly here. +.PHONY: check test types lint + +check: test types lint + +test: + python3 -m pytest tests -q + +types: + pyright + pyright src/transcribe-worker + +lint: + shellcheck src/ratio-transcribe diff --git a/working/meeting-transcription-service/pyrightconfig.json b/working/meeting-transcription-service/pyrightconfig.json new file mode 100644 index 0000000..8228120 --- /dev/null +++ b/working/meeting-transcription-service/pyrightconfig.json @@ -0,0 +1,4 @@ +{ + "extraPaths": ["src"], + "include": ["src", "tests"] +} diff --git a/working/meeting-transcription-service/src/diarize.py b/working/meeting-transcription-service/src/diarize.py new file mode 100644 index 0000000..a6ae85e --- /dev/null +++ b/working/meeting-transcription-service/src/diarize.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Run pyannote speaker diarization on one audio file and write the turns as JSON. + +Usage: diarize.py AUDIO OUT_JSON [--speakers N | --min-speakers N --max-speakers N] + +Output is a list of {"start", "end", "speaker"} in seconds, which is what +merge_transcript.py reads. HF_TOKEN is only needed the first time, to download +the gated model; after that the cached copy loads offline. + +pyannote and torch are imported inside run(), so the pure helpers here can be +tested without the multi-gigabyte environment. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from collections.abc import Iterable +from pathlib import Path +from typing import Any + +MODEL = "pyannote/speaker-diarization-community-1" + + +def turns_from_tracks(tracks: Iterable[tuple[Any, Any, str]]) -> list[dict[str, Any]]: + """Convert pyannote (segment, track, label) triples into sorted turn dicts. + + Times are rounded to milliseconds. Segments with no length are dropped. + """ + turns = [ + {"start": round(float(seg.start), 3), "end": round(float(seg.end), 3), "speaker": str(label)} + for seg, _track, label in tracks + if float(seg.end) > float(seg.start) + ] + return sorted(turns, key=lambda t: (t["start"], t["end"])) + + +def _positive_int(value: str) -> int: + number = int(value) + if number < 1: + raise argparse.ArgumentTypeError("must be 1 or more") + return number + + +def parse_args(argv: list[str]) -> argparse.Namespace: + """Parse the command line. Exits with usage on bad or contradictory counts.""" + parser = argparse.ArgumentParser(description="Speaker diarization with pyannote.") + parser.add_argument("audio") + parser.add_argument("out") + parser.add_argument("--speakers", type=_positive_int, help="exact number of speakers") + parser.add_argument("--min-speakers", type=_positive_int) + parser.add_argument("--max-speakers", type=_positive_int) + args = parser.parse_args(argv) + if args.speakers is not None and (args.min_speakers or args.max_speakers): + parser.error("--speakers cannot be combined with --min-speakers/--max-speakers") + if args.min_speakers and args.max_speakers and args.min_speakers > args.max_speakers: + parser.error("--min-speakers cannot exceed --max-speakers") + return args + + +def pipeline_kwargs(args: argparse.Namespace) -> dict[str, int]: + """Only the speaker-count options that were actually given.""" + options = { + "num_speakers": args.speakers, + "min_speakers": args.min_speakers, + "max_speakers": args.max_speakers, + } + return {name: value for name, value in options.items() if value is not None} + + +def run(args: argparse.Namespace) -> list[dict[str, Any]]: + """Load the pipeline, diarize the audio, and return the turns.""" + # Heavy, and only installed in the service venv, so imported here on purpose. + from pyannote.audio import Pipeline # pyright: ignore[reportMissingImports] + + pipeline = Pipeline.from_pretrained(MODEL, token=os.environ.get("HF_TOKEN") or None) + if pipeline is None: + raise RuntimeError(f"could not load {MODEL}: check HF_TOKEN and that its terms are accepted") + output = pipeline(args.audio, **pipeline_kwargs(args)) + # The exclusive variant never overlaps two speakers, which is what a + # word-by-word merge wants. Older pipelines return the annotation itself. + annotation = getattr(output, "exclusive_speaker_diarization", None) + if annotation is None: + annotation = getattr(output, "speaker_diarization", output) + return turns_from_tracks(annotation.itertracks(yield_label=True)) + + +def main(argv: list[str]) -> int: + """CLI entry point. Writes OUT_JSON atomically; non-zero on any failure.""" + args = parse_args(argv) + if not Path(args.audio).is_file(): + print(f"Error: audio file not found: {args.audio}", file=sys.stderr) + return 1 + try: + turns = run(args) + except Exception as err: # noqa: BLE001 - report any model failure and exit non-zero + print(f"Error: diarization failed: {err}", file=sys.stderr) + return 1 + if not turns: + print("Error: diarization found no speech", file=sys.stderr) + return 1 + out = Path(args.out) + partial = out.with_name(out.name + ".partial") + partial.write_text(json.dumps(turns), encoding="utf-8") + partial.replace(out) + speakers = len({t["speaker"] for t in turns}) + print(f"{len(turns)} turns, {speakers} speakers -> {out}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/working/meeting-transcription-service/src/merge_transcript.py b/working/meeting-transcription-service/src/merge_transcript.py new file mode 100644 index 0000000..13aec75 --- /dev/null +++ b/working/meeting-transcription-service/src/merge_transcript.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +"""Merge whisper word timings with speaker turns into transcript lines. + +Input is two JSON files: whisper-cli's ``-oj`` output (run at word level) and +the diarizer's list of speaker turns. Output is one line per stretch of speech, +``HH:MM:SS Speaker A: text``, the same shape the hosted services produced. + +Standard library only, so it runs under any Python 3.10+ without the venv. +""" + +from __future__ import annotations + +import json +import sys +from dataclasses import dataclass +from pathlib import Path + +DEFAULT_MAX_GAP_S = 3.0 +DEFAULT_SPEECH_MARGIN_S = 2.0 +DEFAULT_MAX_LOOP_S = 30.0 + + +@dataclass(frozen=True) +class Unit: + """A piece of transcribed text with its start and end in seconds.""" + + start: float + end: float + text: str + + +@dataclass(frozen=True) +class Turn: + """A stretch of audio the diarizer attributes to one speaker.""" + + start: float + end: float + speaker: str + + +def _timestamp(seconds: float) -> str: + """Render seconds as HH:MM:SS, floored.""" + whole = int(seconds) + return f"{whole // 3600:02d}:{whole % 3600 // 60:02d}:{whole % 60:02d}" + + +def _speaker_name(index: int) -> str: + """Name speakers A-Z in order of first speech, then by number.""" + return chr(ord("A") + index) if index < 26 else str(index) + + +def _speaker_for(unit: Unit, turns: list[Turn]) -> str: + """Pick the turn a unit belongs to. + + The turn overlapping most of the unit wins. A unit overlapping nothing (a + zero-length word, or one whisper timed into a silence) goes to the turn + nearest its midpoint, because whisper's word timings drift by a few hundred + milliseconds and dropping the word would be worse than a near guess. + """ + best = max(turns, key=lambda t: min(unit.end, t.end) - max(unit.start, t.start)) + if min(unit.end, best.end) - max(unit.start, best.start) > 0: + return best.speaker + mid = (unit.start + unit.end) / 2 + + def distance(turn: Turn) -> float: + if turn.start <= mid <= turn.end: + return 0.0 + return min(abs(mid - turn.start), abs(mid - turn.end)) + + return min(turns, key=distance).speaker + + +def merge(units: list[Unit], turns: list[Turn], max_gap_s: float = DEFAULT_MAX_GAP_S) -> list[str]: + """Return transcript lines for ``units`` labelled by ``turns``. + + Consecutive units from one speaker share a line. The line breaks when the + speaker changes, or when the speaker pauses longer than ``max_gap_s``, so a + long monologue still carries usable timestamps. + + Raises: + ValueError: if there are no turns, no spoken words, or ``max_gap_s`` is negative. + """ + if max_gap_s < 0: + raise ValueError("max_gap_s must not be negative") + spoken = sorted((u for u in units if u.text.strip()), key=lambda u: (u.start, u.end)) + if not spoken: + raise ValueError("no speech: the transcription holds no words") + if not turns: + raise ValueError("no speaker turns: the diarization is empty") + ordered_turns = sorted(turns, key=lambda t: (t.start, t.end)) + + names: dict[str, str] = {} + lines: list[tuple[float, str, list[str]]] = [] + previous_end = 0.0 + for unit in spoken: + speaker = _speaker_for(unit, ordered_turns) + name = names.setdefault(speaker, _speaker_name(len(names))) + if lines and lines[-1][1] == name and unit.start - previous_end <= max_gap_s: + lines[-1][2].append(unit.text) + else: + lines.append((unit.start, name, [unit.text])) + previous_end = max(previous_end, unit.end) + + return [ + f"{_timestamp(start)} Speaker {name}: {' '.join(''.join(parts).split())}" + for start, name, parts in lines + ] + + +def drop_outside_speech( + units: list[Unit], turns: list[Turn], margin_s: float = DEFAULT_SPEECH_MARGIN_S +) -> tuple[list[Unit], int]: + """Return the units that belong to speech, and how many were dropped. + + Whisper invents words ("Thank you.") when it is handed silence. The diarizer + marks where people actually spoke, so a unit that touches no turn and sits + more than ``margin_s`` from the nearest one is treated as invented. The margin + protects real words the diarizer clipped off the edge of a turn. + + Raises: + ValueError: if there are no turns, or ``margin_s`` is negative. + """ + if margin_s < 0: + raise ValueError("margin_s must not be negative") + if not turns: + raise ValueError("no speaker turns: the diarization is empty") + + def gap(unit: Unit) -> float: + # Seconds between the unit and its nearest turn; zero when they touch. + # Rounded to the millisecond, the resolution of whisper's offsets. + nearest = min(max(turn.start - unit.end, unit.start - turn.end, 0.0) for turn in turns) + return round(nearest, 3) + + kept = [unit for unit in units if gap(unit) <= margin_s] + return kept, len(units) - len(kept) + + +def _unit(item: dict) -> Unit: + """A Unit from a whisper segment or token dict (offsets in milliseconds). + + whisper-cli clamps a token's start to its segment's start without moving the + end, so some tokens arrive ending before they begin. Those become zero-length + at their start; left alone they corrupt both the overlap and the pause maths. + """ + start = item["offsets"]["from"] / 1000 + end = item["offsets"]["to"] / 1000 + return Unit(start, max(start, end), item["text"]) + + +def find_repetition( + text: str, min_words: int = 3, max_words: int = 12, min_repeats: int = 4 +) -> tuple[str, int] | None: + """Find a phrase repeated back to back, whisper's hallucination signature. + + Returns the phrase and its repeat count, or None. Four consecutive repeats of + a phrase of three or more words is the line: people say a thing two or three + times, and single-word runs ("yeah yeah yeah") are ordinary speech. + """ + words = text.split() + keys = [w.lower().strip(".,!?;:\"'") for w in words] + for size in range(min_words, max_words + 1): + for i in range(len(keys) - size * min_repeats + 1): + phrase = keys[i : i + size] + if len(set(phrase)) < 2: + continue + count = 1 + while keys[i + count * size : i + (count + 1) * size] == phrase: + count += 1 + if count >= min_repeats: + return " ".join(words[i : i + size]), count + return None + + +def collapse_repetitions( + units: list[Unit], + min_words: int = 3, + max_words: int = 12, + min_repeats: int = 4, + max_loop_s: float = DEFAULT_MAX_LOOP_S, +) -> tuple[list[Unit], list[tuple[str, int]]]: + """Collapse whisper's repetition loops, keeping one copy of the phrase. + + Whisper sometimes gets stuck and emits the same phrase over and over. A short + loop (up to ``max_loop_s`` of audio) costs a few seconds of speech, so it is + collapsed to a single occurrence and reported. A longer one means real speech + was lost for a stretch, and that is raised instead, so the job fails rather + than hand back a transcript with a hole in it. + + Returns the surviving units and a list of (phrase, repeat count) for every + loop collapsed. Units are matched word by word, so a phrase spread over word + units and a phrase sitting in one segment unit are both found. + + Raises: + ValueError: if a loop lasts longer than ``max_loop_s``, or the limit is negative. + """ + if max_loop_s < 0: + raise ValueError("max_loop_s must not be negative") + units = list(units) + collapsed: list[tuple[str, int]] = [] + + def find() -> tuple[int, int, int] | None: + # (first word index, phrase size, repeat count) of the earliest loop, or None + words = [(w, ui) for ui, u in enumerate(units) for w in u.text.split()] + keys = [w.lower().strip(".,!?;:\"'") for w, _ in words] + best: tuple[int, int, int] | None = None + for size in range(min_words, max_words + 1): + for i in range(len(keys) - size * min_repeats + 1): + if best is not None and i >= best[0]: + break + phrase = keys[i : i + size] + if len(set(phrase)) < 2: + continue + count = 1 + while keys[i + count * size : i + (count + 1) * size] == phrase: + count += 1 + if count >= min_repeats: + best = (i, size, count) + break + return best + + while True: + hit = find() + if hit is None: + return units, collapsed + i, size, count = hit + words = [(w, ui) for ui, u in enumerate(units) for w in u.text.split()] + phrase_text = " ".join(w for w, _ in words[i : i + size]) + doomed = set(range(i + size, i + count * size)) # word indexes of the repeats + touched = {ui for wi, (_, ui) in enumerate(words) if wi in doomed or i <= wi < i + size} + span_start = min(units[ui].start for ui in touched) + span_end = max(units[ui].end for ui in touched) + duration = round(span_end - span_start, 3) + if duration > max_loop_s: + raise ValueError( + f"whisper looped: {phrase_text!r} repeats {count} times over {duration:.0f} s; " + "rerun whisper with -mc 0" + ) + # Rebuild every touched unit from the words it keeps. A unit that held only + # repeats disappears; one that also held the first copy or later speech keeps + # those words. Every pass removes (count - 1) * size words, so this ends. + last_kept = max(ui for wi, (_, ui) in enumerate(words) if i <= wi < i + size) + rebuilt: list[Unit] = [] + for ui, unit in enumerate(units): + if ui not in touched: + rebuilt.append(unit) + continue + keep = [w for wi, (w, wui) in enumerate(words) if wui == ui and wi not in doomed] + if not keep: + continue + # The kept copy takes over the time the loop occupied, so the merge does + # not read the removed stretch as a pause and break the line there. + end = max(unit.end, span_end) if ui == last_kept else unit.end + rebuilt.append(Unit(unit.start, end, " " + " ".join(keep))) + units = rebuilt + collapsed.append((phrase_text, count)) + + +def load_whisper_json(path: str | Path) -> list[Unit]: + """Read whisper-cli JSON output. Offsets there are in milliseconds. + + With ``-ojf`` each segment carries its tokens and their offsets; those become + word-level units, which is what lets a speaker change land mid-segment. Plain + ``-oj`` output, or a segment with no tokens, falls back to the segment itself. + + Raises: + ValueError: if the file is not whisper's JSON shape. + """ + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + units: list[Unit] = [] + for item in data["transcription"]: + tokens = item.get("tokens") or [] + if not tokens: + units.append(_unit(item)) + continue + for token in tokens: + text = token["text"] + if not text or text.startswith("[_"): # [_BEG_], [_TT_123], [_EOT_] + continue + unit = _unit(token) + if units and not text.startswith(" "): + # A sub-word piece or punctuation: it belongs to the word before it. + previous = units[-1] + units[-1] = Unit(previous.start, max(previous.end, unit.end), previous.text + text) + else: + units.append(unit) + return units + except OSError as err: + raise ValueError(f"{path}: cannot read whisper output ({err.strerror})") from err + except (json.JSONDecodeError, KeyError, TypeError) as err: + raise ValueError(f"{path}: not whisper-cli JSON output ({err!r})") from err + + +def load_turns_json(path: str | Path) -> list[Turn]: + """Read the diarizer's turns: a list of {start, end, speaker}, in seconds. + + Raises: + ValueError: if the file is not that shape. + """ + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + if not isinstance(data, list): + raise TypeError("expected a list of turns") + return [Turn(float(t["start"]), float(t["end"]), str(t["speaker"])) for t in data] + except OSError as err: + raise ValueError(f"{path}: cannot read speaker turns ({err.strerror})") from err + except (json.JSONDecodeError, KeyError, TypeError, ValueError) as err: + raise ValueError(f"{path}: not a speaker-turns file ({err!r})") from err + + +def main(argv: list[str]) -> int: + """CLI: ``merge_transcript.py WHISPER_JSON TURNS_JSON`` prints the transcript.""" + if len(argv) != 2: + print("usage: merge_transcript.py WHISPER_JSON TURNS_JSON", file=sys.stderr) + return 2 + try: + turns = load_turns_json(argv[1]) + units, _dropped = drop_outside_speech(load_whisper_json(argv[0]), turns) + units, _collapsed = collapse_repetitions(units) + lines = merge(units, turns) + except ValueError as err: + print(f"Error: {err}", file=sys.stderr) + return 1 + print("\n".join(lines)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/working/meeting-transcription-service/src/ratio-transcribe b/working/meeting-transcription-service/src/ratio-transcribe new file mode 100755 index 0000000..8db59b6 --- /dev/null +++ b/working/meeting-transcription-service/src/ratio-transcribe @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# ratio-transcribe - Transcribe audio on my own transcription host, with speaker labels +# Usage: ratio-transcribe <audio-file> [language] +# +# Same contract as assemblyai-transcribe: the transcript goes to stdout, one line +# per speaker turn ("HH:MM:SS Speaker A: text"); progress and errors go to stderr; +# any failure exits non-zero with nothing on stdout. +# +# The work happens on a host that runs the meeting-transcribe queue (whisper-cpp +# plus pyannote). This script copies the audio over ssh, drops a job into the +# queue, waits, and prints the result. The job id is a hash of the audio and its +# options, so if the connection drops or the laptop sleeps, running the same +# command again just collects the finished transcript. If the host can't be +# reached at all, the same queue and worker run on this machine instead. +# +# Optional environment: +# SPEAKERS exact number of speakers, when you know it +# MIN_SPEAKERS, MAX_SPEAKERS a range instead +# TRANSCRIBE_HOST ssh name of the host (default: ratio) +# TRANSCRIBE_TIMEOUT seconds to wait for the job (default: 3600) +# TRANSCRIBE_POLL seconds between checks (default: 10) +# TRANSCRIBE_LOCAL=1 skip the host and run here +# TRANSCRIBE_WORKER path to the local worker + +set -euo pipefail + +AUDIO="${1:-}" +LANG_CODE="${2:-en}" +HOST="${TRANSCRIBE_HOST:-ratio}" +TIMEOUT="${TRANSCRIBE_TIMEOUT:-3600}" +POLL="${TRANSCRIBE_POLL:-10}" +WORKER="${TRANSCRIBE_WORKER:-$HOME/.local/share/pyannote-diarize/src/transcribe-worker}" +STATE=".local/state/meeting-transcribe" # relative to the home directory, on either machine + +if [[ -z "$AUDIO" ]]; then + echo "Usage: ratio-transcribe <audio-file> [language]" >&2 + echo "Example: SPEAKERS=3 ratio-transcribe meeting.m4a en" >&2 + exit 1 +fi + +if [[ ! -f "$AUDIO" ]]; then + echo "Error: Audio file not found: $AUDIO" >&2 + exit 1 +fi +# scp reads "name:with:colons" as host:path; an absolute path removes the ambiguity. +AUDIO="$(realpath -- "$AUDIO")" + +# Everything below ends up in a job file and on command lines, so check it first. +if [[ ! "$LANG_CODE" =~ ^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$ ]]; then + echo "Error: Invalid language code: $LANG_CODE" >&2 + exit 1 +fi + +for name in SPEAKERS MIN_SPEAKERS MAX_SPEAKERS; do + value="${!name:-}" + if [[ -n "$value" && ! "$value" =~ ^[1-9][0-9]*$ ]]; then + echo "Error: $name must be a positive whole number of speakers, got: $value" >&2 + exit 1 + fi +done +if [[ -n "${SPEAKERS:-}" && ( -n "${MIN_SPEAKERS:-}" || -n "${MAX_SPEAKERS:-}" ) ]]; then + echo "Error: give an exact SPEAKERS count or a MIN/MAX speaker range, not both" >&2 + exit 1 +fi +if [[ -n "${MIN_SPEAKERS:-}" && -n "${MAX_SPEAKERS:-}" ]] && (( MIN_SPEAKERS > MAX_SPEAKERS )); then + echo "Error: MIN_SPEAKERS cannot exceed MAX_SPEAKERS (speaker range)" >&2 + exit 1 +fi + +for tool in jq sha256sum; do + if ! command -v "$tool" &> /dev/null; then + echo "Error: $tool command not found" >&2 + exit 1 + fi +done + +EXT="${AUDIO##*.}" +[[ "$EXT" =~ ^[A-Za-z0-9]{1,5}$ ]] || EXT="bin" +EXT="${EXT,,}" + +if [[ -n "${SPEAKERS:-}" ]]; then + COUNT_TAG="s${SPEAKERS}" +elif [[ -n "${MIN_SPEAKERS:-}${MAX_SPEAKERS:-}" ]]; then + COUNT_TAG="r${MIN_SPEAKERS:-x}-${MAX_SPEAKERS:-x}" +else + COUNT_TAG="auto" +fi +JOB_ID="$(sha256sum "$AUDIO" | cut -c1-16)-${LANG_CODE,,}-${COUNT_TAG}" + +JOB_JSON=$(jq -cn \ + --arg language "$LANG_CODE" \ + --arg name "$(basename "$AUDIO")" \ + --arg speakers "${SPEAKERS:-}" --arg min "${MIN_SPEAKERS:-}" --arg max "${MAX_SPEAKERS:-}" \ + '{language: $language} + + (if $speakers != "" then {speakers: ($speakers | tonumber)} else {} end) + + (if $min != "" then {min_speakers: ($min | tonumber)} else {} end) + + (if $max != "" then {max_speakers: ($max | tonumber)} else {} end) + + {original_name: $name}') + +# ssh reads stdin unless told not to, which would swallow the input of any loop +# this script is called from. Only the job-file upload needs stdin. +remote() { ssh -n -o BatchMode=yes -o ConnectTimeout=8 "$HOST" "$@"; } +remote_with_stdin() { ssh -o BatchMode=yes -o ConnectTimeout=8 "$HOST" "$@"; } + +# One word for where the job stands on the host: done, failed, queued or new. +remote_status() { + remote "cd $STATE 2>/dev/null || { echo new; exit 0; } + if [ -e done/$JOB_ID.txt ]; then echo done + elif [ -e failed/$JOB_ID.log ]; then echo failed + elif [ -d incoming/$JOB_ID ] || [ -d work/$JOB_ID ]; then echo queued + else echo new; fi" +} + +print_transcript() { # $1 = the transcript text + if [[ -z "${1//[[:space:]]/}" ]]; then + echo "Error: the transcript came back empty" >&2 + exit 1 + fi + echo "Transcription complete! (${SECONDS}s total)" >&2 + printf '%s\n' "$1" +} + +run_remote() { + local status + status=$(remote_status) + + if [[ "$status" == "failed" ]]; then + echo "An earlier attempt at this job failed; trying again..." >&2 + remote "rm -f $STATE/failed/$JOB_ID.log" + status="new" + fi + + if [[ "$status" == "new" ]]; then + echo "Uploading audio file to $HOST..." >&2 + # Copy into uploading/, then rename into incoming/. The queue only ever sees + # a complete job. + remote "mkdir -p $STATE/incoming $STATE/uploading/$JOB_ID" + scp -q -o BatchMode=yes "$AUDIO" "$HOST:$STATE/uploading/$JOB_ID/audio.$EXT" < /dev/null + printf '%s' "$JOB_JSON" | remote_with_stdin "cat > $STATE/uploading/$JOB_ID/job.json" + remote "mv $STATE/uploading/$JOB_ID $STATE/incoming/$JOB_ID" + echo "Job $JOB_ID queued. Waiting for completion..." >&2 + elif [[ "$status" == "queued" ]]; then + echo "Job $JOB_ID is already queued on $HOST. Waiting for completion..." >&2 + fi + + while true; do + # A dropped connection is not a failed job; keep asking until the timeout. + status=$(remote_status 2> /dev/null) || status="unreachable" + case "$status" in + done) + print_transcript "$(remote "cat $STATE/done/$JOB_ID.txt")" + return 0 + ;; + failed) + echo "Error: transcription failed on $HOST" >&2 + remote "cat $STATE/failed/$JOB_ID.log" >&2 || true + exit 1 + ;; + esac + if (( SECONDS >= TIMEOUT )); then + echo "Error: no result after ${TIMEOUT}s. The job is still with $HOST;" >&2 + echo "run the same command again to collect the transcript." >&2 + exit 1 + fi + sleep "$POLL" + [[ "$status" == "unreachable" ]] || echo "Processing... (${SECONDS}s elapsed)" >&2 + done +} + +run_local() { + if [[ ! -x "$WORKER" ]]; then + echo "Error: $HOST is unreachable and there is no local worker at $WORKER" >&2 + exit 1 + fi + local state="$HOME/$STATE" + if [[ ! -s "$state/done/$JOB_ID.txt" ]]; then + echo "Running the transcription locally (this machine is slower; expect a wait)..." >&2 + rm -f "$state/failed/$JOB_ID.log" + rm -rf "$state/uploading/$JOB_ID" + mkdir -p "$state/incoming" "$state/uploading/$JOB_ID" + cp "$AUDIO" "$state/uploading/$JOB_ID/audio.$EXT" + printf '%s' "$JOB_JSON" > "$state/uploading/$JOB_ID/job.json" + [[ -d "$state/incoming/$JOB_ID" ]] || mv "$state/uploading/$JOB_ID" "$state/incoming/$JOB_ID" + HF_HUB_OFFLINE=1 "$WORKER" >&2 < /dev/null + fi + if [[ -e "$state/failed/$JOB_ID.log" ]]; then + echo "Error: local transcription failed" >&2 + cat "$state/failed/$JOB_ID.log" >&2 + exit 1 + fi + if [[ ! -e "$state/done/$JOB_ID.txt" ]]; then + echo "Error: the local worker finished without producing a transcript" >&2 + exit 1 + fi + print_transcript "$(< "$state/done/$JOB_ID.txt")" +} + +if [[ -z "${TRANSCRIBE_LOCAL:-}" ]] && remote true 2> /dev/null; then + run_remote +else + [[ -n "${TRANSCRIBE_LOCAL:-}" ]] || echo "$HOST is unreachable." >&2 + run_local +fi diff --git a/working/meeting-transcription-service/src/transcribe-worker b/working/meeting-transcription-service/src/transcribe-worker new file mode 100755 index 0000000..12e64b0 --- /dev/null +++ b/working/meeting-transcription-service/src/transcribe-worker @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +"""Drain the meeting-transcription queue, one job at a time. + +Layout under the state directory (default ~/.local/state/meeting-transcribe): + + incoming/<id>/ audio.<ext> and an optional job.json, dropped by the client + work/<id>/ the job being processed + done/<id>.txt the transcript, plus done/<id>.json with run metadata + failed/<id>.log what went wrong, by stage + +The client uploads into uploading/<id>/ and renames the folder into incoming/ when +the copy is complete, so the queue only ever lists whole jobs. A folder whose name +starts with a dot is skipped as well, as a second line of defence. Nothing is +written into done/ except by rename, so a reader never sees half a transcript. + +Standard library only. whisper-cli and ffmpeg come from PATH; the diarizer runs in +its own virtualenv and is called as a subprocess. +""" + +from __future__ import annotations + +import argparse +import fcntl +import json +import os +import re +import shutil +import subprocess +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import merge_transcript # noqa: E402 + +MAX_ATTEMPTS = 2 +LANGUAGE_RE = re.compile(r"^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$") +SHARE = Path.home() / ".local/share" + + +@dataclass +class Config: + """Where things live and how the tools are called.""" + + home: Path = Path.home() / ".local/state/meeting-transcribe" + whisper_model: Path = SHARE / "whisper-models/ggml-large-v3-turbo-q5_0.bin" + diarize_cmd: list[str] = field( + default_factory=lambda: [ + str(SHARE / "pyannote-diarize/.venv/bin/python"), + str(Path(__file__).resolve().parent / "diarize.py"), + ] + ) + threads: int = max(1, (os.cpu_count() or 4) // 2) + + +class JobError(Exception): + """A job failed at a named stage.""" + + def __init__(self, stage: str, detail: str) -> None: + super().__init__(f"{stage}: {detail}") + self.stage = stage + self.detail = detail + + +def _run(stage: str, argv: list[str]) -> None: + """Run one tool; raise JobError carrying the tail of its stderr on failure.""" + try: + result = subprocess.run(argv, capture_output=True, text=True, check=False, stdin=subprocess.DEVNULL) + except OSError as err: + raise JobError(stage, f"cannot run {argv[0]}: {err.strerror}") from err + if result.returncode != 0: + tail = "\n".join(result.stderr.strip().splitlines()[-15:]) + raise JobError(stage, f"exit {result.returncode}\n{tail}") + + +def _read_job(folder: Path) -> dict: + """Load and validate job.json. A missing file means all defaults.""" + path = folder / "job.json" + if not path.exists(): + return {} + try: + job = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(job, dict): + raise ValueError("expected an object") + except (OSError, ValueError) as err: + raise JobError("job", f"job.json is unreadable: {err}") from err + language = job.get("language", "en") + if not isinstance(language, str) or not LANGUAGE_RE.match(language): + raise JobError("job", f"job.json: bad language {language!r}") + for key in ("speakers", "min_speakers", "max_speakers"): + value = job.get(key) + if value is not None and (isinstance(value, bool) or not isinstance(value, int) or value < 1): + raise JobError("job", f"job.json: {key} must be a positive whole number, got {value!r}") + return job + + +def _diarize_options(job: dict) -> list[str]: + options: list[str] = [] + if job.get("speakers") is not None: + return ["--speakers", str(job["speakers"])] + if job.get("min_speakers") is not None: + options += ["--min-speakers", str(job["min_speakers"])] + if job.get("max_speakers") is not None: + options += ["--max-speakers", str(job["max_speakers"])] + return options + + +def process(folder: Path, config: Config) -> tuple[list[str], dict]: + """Run one job folder through the pipeline. Returns transcript lines and metadata.""" + job = _read_job(folder) + audio = next((p for p in sorted(folder.iterdir()) if p.name.startswith("audio.")), None) + if audio is None: + raise JobError("job", "no audio file in the job folder") + + wav = folder / "speech.wav" + _run("ffmpeg", ["ffmpeg", "-v", "error", "-y", "-i", str(audio), "-ar", "16000", "-ac", "1", str(wav)]) + + # -ojf keeps normal segments (better text) and adds token offsets for the merge. + # -mc 0 stops whisper feeding its own output back in, which is what sends it + # into repetition loops on long meetings. + prefix = folder / "words" + _run("whisper", [ + "whisper-cli", "-m", str(config.whisper_model), "-f", str(wav), + "-l", job.get("language", "en"), "-ojf", "-mc", "0", + "-t", str(config.threads), "-of", str(prefix), + ]) + + turns = folder / "turns.json" + _run("diarize", [*config.diarize_cmd, str(wav), str(turns), *_diarize_options(job)]) + + try: + loaded_turns = merge_transcript.load_turns_json(turns) + # Whisper invents words in silence. Drop them before the loop guard, so a + # quiet meeting isn't mistaken for a repetition loop. + units, dropped = merge_transcript.drop_outside_speech( + merge_transcript.load_whisper_json(prefix.with_suffix(".json")), loaded_turns + ) + # A short stutter is collapsed and recorded; a long loop still fails the job. + units, collapsed = merge_transcript.collapse_repetitions(units) + lines = merge_transcript.merge(units, loaded_turns) + except ValueError as err: + raise JobError("merge", str(err)) from err + + meta = { + "original_name": job.get("original_name"), + "language": job.get("language", "en"), + "speakers_requested": job.get("speakers"), + "speakers_found": len({t.speaker for t in loaded_turns}), + "lines": len(lines), + "words": sum(len(line.split()) - 3 for line in lines), + "dropped_outside_speech": dropped, + "loops_collapsed": [[phrase, count] for phrase, count in collapsed], + } + return lines, meta + + +def _write_atomic(path: Path, text: str) -> None: + partial = path.with_name(path.name + ".partial") + partial.write_text(text, encoding="utf-8") + partial.replace(path) + + +def _fail(failed_dir: Path, job_id: str, stage: str, detail: str) -> None: + """Record a failed job. Falls back to stderr if even the log cannot be written.""" + try: + _write_atomic(failed_dir / f"{job_id}.log", f"stage: {stage}\n{detail}\n") + except OSError as err: + print(f"{job_id}: {stage}: {detail} (and the failure log could not be written: {err})", file=sys.stderr) + + +def _bump_attempts(folder: Path) -> int: + """Count this run against the job, tolerating a missing or broken job.json.""" + path = folder / "job.json" + try: + job = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} + if not isinstance(job, dict): + return 1 + except (OSError, ValueError): + return 1 # _read_job reports the real problem + try: + attempts = int(job.get("attempts") or 0) + 1 + except (TypeError, ValueError): + attempts = 1 # a malformed counter counts as a first try + job["attempts"] = attempts + path.write_text(json.dumps(job), encoding="utf-8") + return attempts + + +def drain(config: Config) -> dict[str, int]: + """Process every waiting job. Returns counts of done and failed jobs.""" + dirs = {name: config.home / name for name in ("incoming", "work", "done", "failed")} + for folder in dirs.values(): + folder.mkdir(parents=True, exist_ok=True) + + counts = {"done": 0, "failed": 0} + with open(config.home / "lock", "w", encoding="utf-8") as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + return counts # another worker is draining; it will reach these jobs + + while True: + # Jobs stranded in work/ by a crash go first, then arrivals, oldest first. + waiting = sorted( + (p for d in (dirs["work"], dirs["incoming"]) for p in d.iterdir() + if p.is_dir() and not p.name.startswith(".")), + key=lambda p: (p.parent.name != "work", p.stat().st_mtime), + ) + if not waiting: + return counts + source = waiting[0] + job_id = source.name + done_txt = dirs["done"] / f"{job_id}.txt" + if done_txt.exists(): + shutil.rmtree(source) + continue + + folder = dirs["work"] / job_id + if source != folder: + source.rename(folder) + started = time.monotonic() + try: + if _bump_attempts(folder) > MAX_ATTEMPTS: + raise JobError("worker", f"gave up after {MAX_ATTEMPTS} attempts; the job kept crashing") + lines, meta = process(folder, config) + meta["seconds"] = round(time.monotonic() - started, 1) + _write_atomic(dirs["done"] / f"{job_id}.json", json.dumps(meta)) + _write_atomic(done_txt, "\n".join(lines) + "\n") + (dirs["failed"] / f"{job_id}.log").unlink(missing_ok=True) + counts["done"] += 1 + except JobError as err: + _fail(dirs["failed"], job_id, err.stage, err.detail) + counts["failed"] += 1 + except Exception as err: # noqa: BLE001 - one bad job must never stop the queue + _fail(dirs["failed"], job_id, "worker", f"{type(err).__name__}: {err}") + counts["failed"] += 1 + finally: + shutil.rmtree(folder, ignore_errors=True) + + +def main(argv: list[str]) -> int: + """CLI: drain the queue once and exit. systemd's path unit calls this.""" + parser = argparse.ArgumentParser(description="Drain the meeting-transcription queue.") + parser.add_argument("--home", type=Path, help="state directory (default ~/.local/state/meeting-transcribe)") + args = parser.parse_args(argv) + config = Config(home=args.home) if args.home else Config() + counts = drain(config) + print(f"done={counts['done']} failed={counts['failed']}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/working/meeting-transcription-service/systemd/meeting-transcribe.path b/working/meeting-transcription-service/systemd/meeting-transcribe.path new file mode 100644 index 0000000..821b292 --- /dev/null +++ b/working/meeting-transcription-service/systemd/meeting-transcribe.path @@ -0,0 +1,12 @@ +[Unit] +Description=Watch the meeting-transcription queue for new jobs + +[Path] +# incoming/ only ever holds complete jobs: the client uploads into uploading/ and +# renames the finished folder across. So "not empty" always means real work, and the +# worker emptying the folder is what lets this unit go quiet again. +DirectoryNotEmpty=%h/.local/state/meeting-transcribe/incoming +MakeDirectory=yes + +[Install] +WantedBy=default.target diff --git a/working/meeting-transcription-service/systemd/meeting-transcribe.service b/working/meeting-transcription-service/systemd/meeting-transcribe.service new file mode 100644 index 0000000..92f5ef4 --- /dev/null +++ b/working/meeting-transcription-service/systemd/meeting-transcribe.service @@ -0,0 +1,9 @@ +[Unit] +Description=Transcribe queued meeting recordings (whisper + pyannote) + +[Service] +Type=oneshot +ExecStart=%h/.local/share/pyannote-diarize/src/transcribe-worker +# The pyannote model is cached after its first download; never reach for the network. +Environment=HF_HUB_OFFLINE=1 +Nice=5 diff --git a/working/meeting-transcription-service/tests/test_diarize.py b/working/meeting-transcription-service/tests/test_diarize.py new file mode 100644 index 0000000..ef009b8 --- /dev/null +++ b/working/meeting-transcription-service/tests/test_diarize.py @@ -0,0 +1,74 @@ +"""Tests for diarize's pure parts. The pyannote pipeline itself is not loaded here.""" + +import sys +from collections import namedtuple +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + +import diarize # noqa: E402 + +Segment = namedtuple("Segment", "start end") + + +class TestTurnsFromTracks: + def test_diarize_turns_from_tracks_converts_and_sorts(self): + """Normal: (segment, track, label) triples become sorted turn dicts.""" + tracks = [(Segment(5.0, 9.25), "_", "SPEAKER_01"), (Segment(0.5, 4.0), "_", "SPEAKER_00")] + assert diarize.turns_from_tracks(tracks) == [ + {"start": 0.5, "end": 4.0, "speaker": "SPEAKER_00"}, + {"start": 5.0, "end": 9.25, "speaker": "SPEAKER_01"}, + ] + + def test_diarize_turns_from_tracks_rounds_to_milliseconds(self): + """Boundary: float noise from the model is rounded away.""" + tracks = [(Segment(0.03096875, 1.9980000000000002), "_", "SPEAKER_00")] + assert diarize.turns_from_tracks(tracks) == [ + {"start": 0.031, "end": 1.998, "speaker": "SPEAKER_00"} + ] + + def test_diarize_turns_from_tracks_drops_empty_segments(self): + """Boundary: zero or negative length segments carry no speech.""" + tracks = [(Segment(2.0, 2.0), "_", "SPEAKER_00"), (Segment(3.0, 2.5), "_", "SPEAKER_00")] + assert diarize.turns_from_tracks(tracks) == [] + + def test_diarize_turns_from_tracks_empty_input_is_empty_list(self): + """Boundary: no tracks.""" + assert diarize.turns_from_tracks([]) == [] + + +class TestParseArgs: + def test_diarize_parse_args_speakers_sets_exact_count(self): + """Normal: --speakers pins the count.""" + args = diarize.parse_args(["a.wav", "out.json", "--speakers", "3"]) + assert (args.audio, args.out, args.speakers) == ("a.wav", "out.json", 3) + + def test_diarize_parse_args_defaults_let_the_model_estimate(self): + """Normal: no count given.""" + args = diarize.parse_args(["a.wav", "out.json"]) + assert args.speakers is None and args.min_speakers is None and args.max_speakers is None + + @pytest.mark.parametrize("argv", [ + ["a.wav", "out.json", "--speakers", "0"], + ["a.wav", "out.json", "--speakers", "-2"], + ["a.wav", "out.json", "--speakers", "three"], + ["a.wav", "out.json", "--speakers", "3", "--max-speakers", "5"], + ["a.wav", "out.json", "--min-speakers", "4", "--max-speakers", "2"], + ["a.wav"], + ]) + def test_diarize_parse_args_rejects_bad_counts(self, argv): + """Error: non-positive, non-numeric, contradictory or missing arguments.""" + with pytest.raises(SystemExit): + diarize.parse_args(argv) + + +class TestPipelineKwargs: + def test_diarize_pipeline_kwargs_only_passes_what_was_given(self): + """Normal: unset options are not forwarded to the model.""" + args = diarize.parse_args(["a.wav", "o.json", "--min-speakers", "2", "--max-speakers", "4"]) + assert diarize.pipeline_kwargs(args) == {"min_speakers": 2, "max_speakers": 4} + args = diarize.parse_args(["a.wav", "o.json", "--speakers", "3"]) + assert diarize.pipeline_kwargs(args) == {"num_speakers": 3} + assert diarize.pipeline_kwargs(diarize.parse_args(["a.wav", "o.json"])) == {} diff --git a/working/meeting-transcription-service/tests/test_merge_transcript.py b/working/meeting-transcription-service/tests/test_merge_transcript.py new file mode 100644 index 0000000..a584043 --- /dev/null +++ b/working/meeting-transcription-service/tests/test_merge_transcript.py @@ -0,0 +1,510 @@ +"""Tests for merge_transcript: whisper words + speaker turns -> transcript lines.""" + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + +import merge_transcript as mt # noqa: E402 +from merge_transcript import Turn, Unit # noqa: E402 + + +def words(start, *tokens, step=0.5): + """Evenly spaced word units beginning at ``start`` seconds.""" + return [ + Unit(start + i * step, start + (i + 1) * step, f" {token}") + for i, token in enumerate(tokens) + ] + + +class TestMergeNormal: + def test_merge_transcript_merge_two_speakers_yields_one_line_per_turn(self): + """Normal: words fall into the turn that contains them.""" + units = words(0.0, "Good", "morning.") + words(6.0, "Sounds", "good.") + turns = [Turn(0.0, 5.0, "SPEAKER_00"), Turn(5.5, 9.0, "SPEAKER_01")] + assert mt.merge(units, turns) == [ + "00:00:00 Speaker A: Good morning.", + "00:00:06 Speaker B: Sounds good.", + ] + + def test_merge_transcript_merge_letters_follow_order_of_first_speech(self): + """Normal: Speaker A is whoever talks first, whatever pyannote called them.""" + units = words(0.0, "First.") + words(4.0, "Second.") + words(8.0, "Again.") + turns = [ + Turn(0.0, 3.0, "SPEAKER_02"), + Turn(3.5, 7.0, "SPEAKER_00"), + Turn(7.5, 10.0, "SPEAKER_02"), + ] + assert mt.merge(units, turns) == [ + "00:00:00 Speaker A: First.", + "00:00:04 Speaker B: Second.", + "00:00:08 Speaker A: Again.", + ] + + def test_merge_transcript_merge_same_speaker_across_turns_stays_one_line(self): + """Normal: pyannote splits a speaker's run into turns; the line does not.""" + units = words(0.0, "One", "two") + words(1.2, "three.") + turns = [Turn(0.0, 1.0, "SPEAKER_00"), Turn(1.1, 2.0, "SPEAKER_00")] + assert mt.merge(units, turns) == ["00:00:00 Speaker A: One two three."] + + def test_merge_transcript_merge_long_pause_starts_a_new_line(self): + """Normal: a pause past max_gap_s breaks the line so timestamps stay useful.""" + units = words(0.0, "Before.") + words(10.0, "After.") + turns = [Turn(0.0, 12.0, "SPEAKER_00")] + assert mt.merge(units, turns, max_gap_s=3.0) == [ + "00:00:00 Speaker A: Before.", + "00:00:10 Speaker A: After.", + ] + + def test_merge_transcript_merge_word_in_a_gap_goes_to_nearest_turn(self): + """Normal: whisper's timing drifts; a word between turns joins the closer one.""" + units = [Unit(4.6, 4.8, " late"), Unit(5.2, 5.4, " early")] + turns = [Turn(0.0, 4.5, "SPEAKER_00"), Turn(5.5, 9.0, "SPEAKER_01")] + assert mt.merge(units, turns) == [ + "00:00:04 Speaker A: late", + "00:00:05 Speaker B: early", + ] + + def test_merge_transcript_merge_overlapping_turns_pick_the_larger_overlap(self): + """Normal: with overlapped speech, the word goes where most of it sits.""" + # The anchor word fixes SPEAKER_00 as A, so the contested word's label + # actually shows which turn won: 0.2 s of overlap with A, 0.9 s with B. + units = [Unit(1.0, 1.5, " anchor"), Unit(4.0, 5.0, " contested")] + turns = [Turn(0.0, 4.2, "SPEAKER_00"), Turn(4.1, 9.0, "SPEAKER_01")] + assert mt.merge(units, turns) == [ + "00:00:01 Speaker A: anchor", + "00:00:04 Speaker B: contested", + ] + + +class TestMergeBoundary: + def test_merge_transcript_merge_timestamp_past_an_hour_is_floored(self): + """Boundary: 3725.9 s renders as 01:02:05.""" + units = [Unit(3725.9, 3726.4, " Late.")] + turns = [Turn(3700.0, 3800.0, "SPEAKER_00")] + assert mt.merge(units, turns) == ["01:02:05 Speaker A: Late."] + + def test_merge_transcript_merge_zero_length_and_blank_units_are_handled(self): + """Boundary: whisper emits empty units and zero-length words.""" + units = [Unit(0.0, 0.0, ""), Unit(2.18, 2.18, " we"), Unit(2.18, 2.54, " have")] + turns = [Turn(0.0, 5.0, "SPEAKER_00")] + assert mt.merge(units, turns) == ["00:00:02 Speaker A: we have"] + + def test_merge_transcript_merge_twenty_seventh_speaker_gets_a_number(self): + """Boundary: letters run out after Z.""" + units, turns = [], [] + for i in range(27): + units += [Unit(i * 10.0, i * 10.0 + 1, f" s{i}")] + turns += [Turn(i * 10.0, i * 10.0 + 5, f"SPEAKER_{i:02d}")] + lines = mt.merge(units, turns) + assert lines[25] == "00:04:10 Speaker Z: s25" + assert lines[26] == "00:04:20 Speaker 26: s26" + + def test_merge_transcript_merge_unicode_and_inner_spacing_survive(self): + """Boundary: non-ASCII text; whitespace collapses to single spaces.""" + units = [Unit(0.0, 0.5, " Բարև,"), Unit(0.5, 1.0, " Երևան"), Unit(1.0, 1.5, " — café.")] + turns = [Turn(0.0, 2.0, "SPEAKER_00")] + assert mt.merge(units, turns) == ["00:00:00 Speaker A: Բարև, Երևան — café."] + + def test_merge_transcript_merge_unsorted_input_is_sorted_first(self): + """Boundary: neither list has to arrive in time order.""" + units = words(6.0, "Second.") + words(0.0, "First.") + turns = [Turn(5.0, 9.0, "SPEAKER_01"), Turn(0.0, 4.0, "SPEAKER_00")] + assert mt.merge(units, turns) == [ + "00:00:00 Speaker A: First.", + "00:00:06 Speaker B: Second.", + ] + + +class TestMergeError: + def test_merge_transcript_merge_no_turns_raises(self): + """Error: words with no diarization cannot be labelled.""" + with pytest.raises(ValueError, match="no speaker turns"): + mt.merge(words(0.0, "Hello."), []) + + def test_merge_transcript_merge_no_words_raises(self): + """Error: an empty transcription is a failure, not an empty transcript.""" + with pytest.raises(ValueError, match="no speech"): + mt.merge([Unit(0.0, 0.0, " ")], [Turn(0.0, 5.0, "SPEAKER_00")]) + + def test_merge_transcript_merge_negative_gap_setting_raises(self): + """Error: max_gap_s must not be negative.""" + with pytest.raises(ValueError, match="max_gap_s"): + mt.merge(words(0.0, "Hi."), [Turn(0.0, 1.0, "SPEAKER_00")], max_gap_s=-1) + + +class TestLoaders: + def test_merge_transcript_load_whisper_json_reads_millisecond_offsets(self, tmp_path): + """Normal: whisper-cli -oj stores offsets in milliseconds.""" + path = tmp_path / "w.json" + path.write_text(json.dumps({"transcription": [ + {"offsets": {"from": 0, "to": 190}, "text": " if"}, + {"offsets": {"from": 190, "to": 590}, "text": " there's"}, + ]})) + assert mt.load_whisper_json(path) == [Unit(0.0, 0.19, " if"), Unit(0.19, 0.59, " there's")] + + def test_merge_transcript_load_turns_json_reads_seconds(self, tmp_path): + """Normal: the diarizer writes start/end in seconds.""" + path = tmp_path / "t.json" + path.write_text(json.dumps([{"start": 0.5, "end": 4.25, "speaker": "SPEAKER_00"}])) + assert mt.load_turns_json(path) == [Turn(0.5, 4.25, "SPEAKER_00")] + + @pytest.mark.parametrize("content", ["not json", "{}", '{"transcription": [{"text": "x"}]}']) + def test_merge_transcript_load_whisper_json_malformed_raises(self, tmp_path, content): + """Error: a truncated or foreign file is rejected with its path named.""" + path = tmp_path / "w.json" + path.write_text(content) + with pytest.raises(ValueError, match="w.json"): + mt.load_whisper_json(path) + + @pytest.mark.parametrize("content", ["not json", "{}", '[{"start": 1}]']) + def test_merge_transcript_load_turns_json_malformed_raises(self, tmp_path, content): + """Error: same for the turns file.""" + path = tmp_path / "t.json" + path.write_text(content) + with pytest.raises(ValueError, match="t.json"): + mt.load_turns_json(path) + + +class TestLoadersMissingFile: + def test_merge_transcript_load_whisper_json_missing_file_raises_value_error(self, tmp_path): + """Error: a missing whisper file is a clean ValueError naming the path.""" + with pytest.raises(ValueError, match="gone.json"): + mt.load_whisper_json(tmp_path / "gone.json") + + def test_merge_transcript_load_turns_json_missing_file_raises_value_error(self, tmp_path): + """Error: a missing turns file (the diarizer failed upstream) is a clean ValueError.""" + with pytest.raises(ValueError, match="gone.json"): + mt.load_turns_json(tmp_path / "gone.json") + + def test_merge_transcript_main_missing_turns_file_exits_one_without_traceback(self, tmp_path, capsys): + """Error: the CLI reports it in one line and prints nothing on stdout.""" + w = tmp_path / "w.json" + w.write_text('{"transcription": [{"offsets": {"from": 0, "to": 500}, "text": " Hi."}]}') + assert mt.main([str(w), str(tmp_path / "gone.json")]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "gone.json" in captured.err and "Traceback" not in captured.err + + +class TestCli: + def _files(self, tmp_path, transcription, turns): + w = tmp_path / "w.json" + t = tmp_path / "t.json" + w.write_text(json.dumps({"transcription": transcription})) + t.write_text(json.dumps(turns)) + return str(w), str(t) + + def test_merge_transcript_main_prints_transcript_and_returns_zero(self, tmp_path, capsys): + """Normal: the CLI writes the lines to stdout.""" + w, t = self._files( + tmp_path, + [{"offsets": {"from": 0, "to": 500}, "text": " Hello."}], + [{"start": 0.0, "end": 2.0, "speaker": "SPEAKER_00"}], + ) + assert mt.main([w, t]) == 0 + assert capsys.readouterr().out == "00:00:00 Speaker A: Hello.\n" + + def test_merge_transcript_main_failure_prints_nothing_on_stdout(self, tmp_path, capsys): + """Error: a failed merge exits 1 with the reason on stderr only.""" + w, t = self._files(tmp_path, [], [{"start": 0.0, "end": 2.0, "speaker": "SPEAKER_00"}]) + assert mt.main([w, t]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "no speech" in captured.err + + def test_merge_transcript_main_wrong_argument_count_returns_two(self, capsys): + """Error: usage.""" + assert mt.main([]) == 2 + assert "usage" in capsys.readouterr().err.lower() + + +def tok(start_ms, end_ms, text): + return {"text": text, "offsets": {"from": start_ms, "to": end_ms}} + + +class TestLoadWhisperTokens: + """whisper-cli -ojf keeps normal segments and adds per-token offsets inside each.""" + + def _write(self, tmp_path, segments): + path = tmp_path / "full.json" + path.write_text(json.dumps({"transcription": segments})) + return path + + def test_merge_transcript_load_whisper_json_prefers_tokens_over_segments(self, tmp_path): + """Normal: with tokens present, units are words, not whole segments.""" + path = self._write(tmp_path, [{ + "offsets": {"from": 0, "to": 2000}, "text": " we basically", + "tokens": [tok(0, 0, "[_BEG_]"), tok(10, 400, " we"), tok(500, 1900, " basically"), tok(2000, 2000, "[_TT_100]")], + }]) + assert mt.load_whisper_json(path) == [Unit(0.01, 0.4, " we"), Unit(0.5, 1.9, " basically")] + + def test_merge_transcript_load_whisper_json_joins_subword_and_punctuation_tokens(self, tmp_path): + """Normal: a token with no leading space continues the previous word.""" + path = self._write(tmp_path, [{ + "offsets": {"from": 0, "to": 3000}, "text": " Saturday, yes.", + "tokens": [tok(0, 300, " Sat"), tok(300, 700, "urday"), tok(700, 750, ","), tok(900, 1300, " yes"), tok(1300, 1350, ".")], + }]) + assert mt.load_whisper_json(path) == [Unit(0.0, 0.75, " Saturday,"), Unit(0.9, 1.35, " yes.")] + + def test_merge_transcript_load_whisper_json_segment_without_tokens_falls_back(self, tmp_path): + """Boundary: plain -oj output, or a segment whose token list is empty.""" + path = self._write(tmp_path, [ + {"offsets": {"from": 0, "to": 1000}, "text": " First.", "tokens": []}, + {"offsets": {"from": 1000, "to": 2000}, "text": " Second."}, + ]) + assert mt.load_whisper_json(path) == [Unit(0.0, 1.0, " First."), Unit(1.0, 2.0, " Second.")] + + def test_merge_transcript_load_whisper_json_leading_continuation_token_stands_alone(self, tmp_path): + """Boundary: the very first token has nothing to attach to.""" + path = self._write(tmp_path, [{ + "offsets": {"from": 0, "to": 500}, "text": "ing on", + "tokens": [tok(0, 200, "ing"), tok(200, 500, " on")], + }]) + assert mt.load_whisper_json(path) == [Unit(0.0, 0.2, "ing"), Unit(0.2, 0.5, " on")] + + def test_merge_transcript_load_whisper_json_token_without_offsets_raises(self, tmp_path): + """Error: a malformed token is rejected, naming the file.""" + path = self._write(tmp_path, [{"offsets": {"from": 0, "to": 1}, "text": " x", "tokens": [{"text": " x"}]}]) + with pytest.raises(ValueError, match="full.json"): + mt.load_whisper_json(path) + + +class TestFindRepetition: + def test_merge_transcript_find_repetition_clean_text_returns_none(self): + """Normal: ordinary speech, even with stock phrases scattered about.""" + text = " ".join(f"point {i} and i don't know if that works for us" for i in range(8)) + assert mt.find_repetition(text) is None + + def test_merge_transcript_find_repetition_back_to_back_phrase_is_reported(self): + """Normal: whisper's hallucination loop, the same phrase again and again.""" + text = "okay so " + "We don't know where we're going to be. " * 6 + "anyway moving on" + hit = mt.find_repetition(text) + assert hit is not None + phrase, count = hit + assert count >= 6 + assert "where we're going to be" in phrase.lower() + + def test_merge_transcript_find_repetition_three_repeats_is_tolerated(self): + """Boundary: people do say a thing three times; four in a row is the line.""" + assert mt.find_repetition("go back to this area " * 3) is None + assert mt.find_repetition("go back to this area " * 4) is not None + + def test_merge_transcript_find_repetition_short_fillers_are_not_loops(self): + """Boundary: 'yeah yeah yeah yeah yeah' is speech, not a loop.""" + assert mt.find_repetition("yeah " * 9 + "no no no no no") is None + + def test_merge_transcript_find_repetition_empty_text_returns_none(self): + """Boundary: nothing to scan.""" + assert mt.find_repetition("") is None + + def test_merge_transcript_main_loop_in_transcript_fails(self, tmp_path, capsys): + """Error: a looping transcription exits 1 with nothing on stdout.""" + words_ = ("we don't know where we're going to be " * 5).split() + # 40 words at 3 s each: two minutes of loop, far past the 30 s a short stutter gets + segs = [{"offsets": {"from": i * 3_000, "to": i * 3_000 + 2_500}, "text": f" {w}"} for i, w in enumerate(words_)] + w = tmp_path / "w.json" + w.write_text(json.dumps({"transcription": segs})) + t = tmp_path / "t.json" + t.write_text(json.dumps([{"start": 0.0, "end": 130.0, "speaker": "SPEAKER_00"}])) + assert mt.main([str(w), str(t)]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "repeat" in captured.err.lower() + + +class TestInvertedTokenTimes: + """whisper-cli sometimes clamps a token's start to its segment, leaving end < start.""" + + def test_merge_transcript_load_whisper_json_inverted_token_becomes_zero_length(self, tmp_path): + """Boundary: an end before the start is treated as a zero-length word at the start.""" + path = tmp_path / "inv.json" + path.write_text(json.dumps({"transcription": [{ + "offsets": {"from": 13120, "to": 16160}, "text": " which you", + "tokens": [tok(13120, 9580, " which"), tok(13120, 10140, " you")], + }]})) + assert mt.load_whisper_json(path) == [Unit(13.12, 13.12, " which"), Unit(13.12, 13.12, " you")] + + def test_merge_transcript_merge_inverted_tokens_do_not_split_a_sentence(self, tmp_path): + """Normal: the real case, after a pause longer than max_gap_s.""" + path = tmp_path / "inv.json" + path.write_text(json.dumps({"transcription": [ + {"offsets": {"from": 5360, "to": 8640}, "text": " blue pixels", + "tokens": [tok(5360, 7000, " blue"), tok(7000, 8640, " pixels")]}, + {"offsets": {"from": 13120, "to": 16160}, "text": " which you know", + "tokens": [tok(13120, 9580, " which"), tok(13120, 10140, " you"), tok(13120, 10890, " know")]}, + ]})) + turns = [Turn(0.0, 8.8, "SPEAKER_00"), Turn(13.3, 16.0, "SPEAKER_00")] + assert mt.merge(mt.load_whisper_json(path), turns) == [ + "00:00:05 Speaker A: blue pixels", + "00:00:13 Speaker A: which you know", + ] + + +class TestDropOutsideSpeech: + """Whisper invents words in silence; the diarizer knows where the speech is.""" + + def test_merge_transcript_drop_outside_speech_removes_words_far_from_any_turn(self): + """Normal: a hallucinated run in a silent stretch goes; real words stay.""" + real = words(0.0, "Good", "morning.") + invented = words(60.0, "Thank", "you.", "Thank", "you.", step=5.0) + kept, dropped = mt.drop_outside_speech(real + invented, [Turn(0.0, 3.0, "SPEAKER_00")]) + assert kept == real + assert dropped == 4 + + def test_merge_transcript_drop_outside_speech_keeps_words_between_close_turns(self): + """Normal: a word in a short gap between two turns is speech the diarizer clipped.""" + units = words(0.0, "One.") + words(3.2, "and") + words(4.0, "two.") + turns = [Turn(0.0, 3.0, "SPEAKER_00"), Turn(4.0, 6.0, "SPEAKER_01")] + assert mt.drop_outside_speech(units, turns) == (units, 0) + + def test_merge_transcript_drop_outside_speech_word_exactly_at_the_margin_is_kept(self): + """Boundary: the margin is inclusive.""" + unit = Unit(5.0, 5.5, " edge") + assert mt.drop_outside_speech([unit], [Turn(0.0, 3.0, "S")], margin_s=2.0) == ([unit], 0) + + def test_merge_transcript_drop_outside_speech_word_just_past_the_margin_is_dropped(self): + """Boundary: one millisecond further and it goes.""" + unit = Unit(5.001, 5.5, " edge") + assert mt.drop_outside_speech([unit], [Turn(0.0, 3.0, "S")], margin_s=2.0) == ([], 1) + + def test_merge_transcript_drop_outside_speech_zero_margin_needs_contact_with_a_turn(self): + """Boundary: margin 0 keeps a word touching a turn and drops one that is not.""" + touching, apart = Unit(3.0, 3.4, " touch"), Unit(3.5, 3.9, " apart") + kept, dropped = mt.drop_outside_speech([touching, apart], [Turn(0.0, 3.0, "S")], margin_s=0.0) + assert (kept, dropped) == ([touching], 1) + + def test_merge_transcript_drop_outside_speech_before_the_first_turn_counts_too(self): + """Boundary: silence at the start of a recording.""" + early = Unit(1.0, 1.5, " Thanks.") + assert mt.drop_outside_speech([early], [Turn(30.0, 40.0, "S")]) == ([], 1) + + def test_merge_transcript_drop_outside_speech_no_units_is_a_no_op(self): + """Boundary: nothing in, nothing out.""" + assert mt.drop_outside_speech([], [Turn(0.0, 3.0, "S")]) == ([], 0) + + def test_merge_transcript_drop_outside_speech_no_turns_raises(self): + """Error: without turns there is no way to tell speech from silence.""" + with pytest.raises(ValueError, match="no speaker turns"): + mt.drop_outside_speech(words(0.0, "Hello."), []) + + def test_merge_transcript_drop_outside_speech_negative_margin_raises(self): + """Error: a negative margin is a caller bug.""" + with pytest.raises(ValueError, match="margin"): + mt.drop_outside_speech(words(0.0, "Hello."), [Turn(0.0, 3.0, "S")], margin_s=-1.0) + + def test_merge_transcript_main_silence_hallucinations_do_not_trip_the_loop_guard(self, tmp_path, capsys): + """Normal: a run of invented thank-yous in silence is dropped, not reported as a loop.""" + transcription = [{"offsets": {"from": 0, "to": 900}, "text": " Good morning."}] + [ + {"offsets": {"from": 60_000 + i * 5_000, "to": 64_000 + i * 5_000}, "text": " Thank you."} + for i in range(12) + ] + whisper = tmp_path / "w.json" + whisper.write_text(json.dumps({"transcription": transcription})) + turns = tmp_path / "t.json" + turns.write_text(json.dumps([{"start": 0.0, "end": 2.0, "speaker": "SPEAKER_00"}])) + assert mt.main([str(whisper), str(turns)]) == 0 + assert capsys.readouterr().out == "00:00:00 Speaker A: Good morning.\n" + + def test_merge_transcript_main_a_loop_inside_speech_still_fails(self, tmp_path, capsys): + """Error: a real repetition loop happens while someone is talking, and is still caught.""" + transcription = [ + {"offsets": {"from": i * 1_000, "to": i * 1_000 + 900}, "text": " where we're going to be"} + for i in range(60) # a full minute of the same phrase + ] + whisper = tmp_path / "w.json" + whisper.write_text(json.dumps({"transcription": transcription})) + turns = tmp_path / "t.json" + turns.write_text(json.dumps([{"start": 0.0, "end": 70.0, "speaker": "SPEAKER_00"}])) + assert mt.main([str(whisper), str(turns)]) == 1 + assert "looped" in capsys.readouterr().err + + +class TestCollapseRepetitions: + """A short stutter is collapsed to one occurrence; a long loop still fails the job.""" + + def test_merge_transcript_collapse_repetitions_short_loop_keeps_one_copy(self): + """Normal: a six-word phrase said four times in ten seconds becomes one phrase.""" + units = words(0.0, "So", "anyway,") + words(1.0, *("fair, it's not going to be".split() * 4), step=0.4) + words(12.0, "done.") + kept, collapsed = mt.collapse_repetitions(units) + assert " ".join(u.text.strip() for u in kept) == "So anyway, fair, it's not going to be done." + assert collapsed == [("fair, it's not going to be", 4)] + + def test_merge_transcript_collapse_repetitions_clean_units_are_untouched(self): + """Normal: ordinary speech passes through with nothing collapsed.""" + units = words(0.0, "We", "have", "detection", "today,", "and", "we", "have", "a", "plan.") + assert mt.collapse_repetitions(units) == (units, []) + + def test_merge_transcript_collapse_repetitions_two_loops_both_collapse(self): + """Normal: separate stutters are each collapsed and each reported.""" + units = (words(0.0, *("go back to this area".split() * 4), step=0.3) + + words(10.0, "then") + + words(11.0, *("where we're going to be".split() * 5), step=0.3)) + kept, collapsed = mt.collapse_repetitions(units) + assert " ".join(u.text.strip() for u in kept) == "go back to this area then where we're going to be" + assert collapsed == [("go back to this area", 4), ("where we're going to be", 5)] + + def test_merge_transcript_collapse_repetitions_three_repeats_are_speech(self): + """Boundary: three repeats is emphasis, not a loop, and stays.""" + units = words(0.0, *("this is the thing".split() * 3)) + assert mt.collapse_repetitions(units) == (units, []) + + def test_merge_transcript_collapse_repetitions_single_word_runs_stay(self): + """Boundary: "yeah yeah yeah yeah" is ordinary speech.""" + units = words(0.0, *(["yeah"] * 8)) + assert mt.collapse_repetitions(units) == (units, []) + + def test_merge_transcript_collapse_repetitions_loop_at_the_limit_is_collapsed(self): + """Boundary: a loop lasting exactly max_loop_s is still a short one.""" + units = words(0.0, *("we do not know where".split() * 4), step=1.5) # 20 words, 30.0 s + kept, collapsed = mt.collapse_repetitions(units, max_loop_s=30.0) + assert collapsed == [("we do not know where", 4)] and len(kept) == 5 + + def test_merge_transcript_collapse_repetitions_loop_past_the_limit_raises(self): + """Error: a loop longer than max_loop_s means real speech was lost, so the job fails.""" + units = words(0.0, *("we do not know where".split() * 4), step=1.6) # 32.0 s + with pytest.raises(ValueError, match="looped"): + mt.collapse_repetitions(units, max_loop_s=30.0) + + def test_merge_transcript_collapse_repetitions_segment_units_collapse_too(self): + """Boundary: whisper's segment fallback puts a whole phrase in one unit.""" + units = [Unit(i * 1.0, i * 1.0 + 0.9, " where we're going to be") for i in range(6)] + kept, collapsed = mt.collapse_repetitions(units) + assert len(kept) == 1 and collapsed == [("where we're going to be", 6)] + + def test_merge_transcript_collapse_repetitions_negative_limit_raises(self): + """Error: a negative limit is a caller bug.""" + with pytest.raises(ValueError, match="max_loop_s"): + mt.collapse_repetitions(words(0.0, "hi"), max_loop_s=-1.0) + + def test_merge_transcript_main_short_loop_is_collapsed_not_fatal(self, tmp_path, capsys): + """Normal: the CLI prints the collapsed transcript and exits 0.""" + words_ = "we have detection today " .split() + ("fair, it's not going to be " * 4).split() + "easy.".split() + segs = [{"offsets": {"from": i * 300, "to": i * 300 + 250}, "text": f" {w}"} for i, w in enumerate(words_)] + w = tmp_path / "w.json"; w.write_text(json.dumps({"transcription": segs})) + t = tmp_path / "t.json"; t.write_text(json.dumps([{"start": 0.0, "end": 60.0, "speaker": "SPEAKER_00"}])) + assert mt.main([str(w), str(t)]) == 0 + assert capsys.readouterr().out == "00:00:00 Speaker A: we have detection today fair, it's not going to be easy.\n" + + +class TestCollapseRepetitionsInsideUnits: + """The repeat can live inside one unit's text, which is what whisper's segment fallback emits.""" + + def test_merge_transcript_collapse_repetitions_loop_inside_one_unit_is_collapsed(self): + """Error case turned regression: a single unit holding the phrase four times must not hang.""" + unit = Unit(0.0, 8.0, " " + " ".join(["we do not know where"] * 4)) + kept, collapsed = mt.collapse_repetitions(unit and [unit]) + assert [u.text for u in kept] == [" we do not know where"] + assert collapsed == [("we do not know where", 4)] + + def test_merge_transcript_collapse_repetitions_partial_unit_keeps_its_other_words(self): + """Boundary: a unit holding the last repeat and real words after it keeps the real words.""" + units = words(0.0, *("go back to this area".split() * 3), step=0.5) + [ + Unit(7.5, 9.0, " go back to this area and then we stopped.") + ] + kept, collapsed = mt.collapse_repetitions(units) + assert " ".join(u.text.strip() for u in kept) == "go back to this area and then we stopped." + assert collapsed == [("go back to this area", 4)] diff --git a/working/meeting-transcription-service/tests/test_ratio_transcribe.py b/working/meeting-transcription-service/tests/test_ratio_transcribe.py new file mode 100644 index 0000000..1a10b3e --- /dev/null +++ b/working/meeting-transcription-service/tests/test_ratio_transcribe.py @@ -0,0 +1,263 @@ +"""Tests for ratio-transcribe, the client. + +ssh and scp are replaced by fakes that act on a temp directory standing in for the +remote home, so the client's real logic (job ids, upload-then-rename, polling, +collecting, the local fallback) runs against a filesystem it can't tell from the host. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / "src" / "ratio-transcribe" +STATE = ".local/state/meeting-transcribe" + +FAKE_SSH = r"""#!/usr/bin/env bash +# ssh [-o k=v]... host command... -> run the command with HOME at the fake remote +[[ -n "${FAKE_SSH_DOWN:-}" ]] && exit 255 +no_stdin="" +while [[ "$1" == -* ]]; do [[ "$1" == "-n" ]] && no_stdin=1; [[ "$1" == "-o" ]] && shift; shift; done +shift # host +printf '%s\n' "$*" >> "$FAKE_SSH_LOG" +if [[ -n "$no_stdin" ]]; then + ( cd "$FAKE_REMOTE_HOME" && HOME="$FAKE_REMOTE_HOME" bash -c "$*" < /dev/null ) + rc=$? +else + ( cd "$FAKE_REMOTE_HOME" && HOME="$FAKE_REMOTE_HOME" bash -c "$*" ) + rc=$? + cat > /dev/null # like the real ssh, drain whatever stdin the command left behind +fi +if [[ "$*" == *"incoming/"* && "$*" == mv* && -n "${FAKE_ON_SUBMIT:-}" ]]; then + ( cd "$FAKE_REMOTE_HOME" && bash -c "$FAKE_ON_SUBMIT" ) +fi +exit $rc +""" + +FAKE_SCP = r"""#!/usr/bin/env bash +[[ -n "${FAKE_SSH_DOWN:-}" ]] && exit 255 +while [[ "$1" == -* ]]; do [[ "$1" == "-o" ]] && shift; shift; done +printf '%s -> %s\n' "$1" "$2" >> "$FAKE_SCP_LOG" +cp "$1" "$FAKE_REMOTE_HOME/${2#*:}" +""" + +# What the worker would do, compressed: finish or fail whatever sits in incoming/. +WORKER_OK = f"""cd {STATE}; mkdir -p done; for d in incoming/*/; do id=$(basename "$d"); + printf '00:00:00 Speaker A: Hello from the host.\\n' > done/$id.txt; rm -rf "$d"; done""" +WORKER_FAIL = f"""cd {STATE}; mkdir -p failed; for d in incoming/*/; do id=$(basename "$d"); + printf 'stage: whisper\\nexit 3\\n' > failed/$id.log; rm -rf "$d"; done""" +WORKER_EMPTY = f"""cd {STATE}; mkdir -p done; for d in incoming/*/; do id=$(basename "$d"); + : > done/$id.txt; rm -rf "$d"; done""" + + +class Rig: + def __init__(self, tmp_path): + self.tmp = tmp_path + self.remote = tmp_path / "remote-home" + self.remote.mkdir() + self.local_home = tmp_path / "local-home" + self.local_home.mkdir() + self.bin = tmp_path / "bin" + self.bin.mkdir() + for name, body in (("ssh", FAKE_SSH), ("scp", FAKE_SCP)): + path = self.bin / name + path.write_text(body) + path.chmod(0o755) + self.ssh_log = tmp_path / "ssh.log" + self.scp_log = tmp_path / "scp.log" + self.audio = tmp_path / "meeting.m4a" + self.audio.write_bytes(b"pretend audio") + + def run(self, args=None, on_submit=WORKER_OK, **env_extra): + env = { + "PATH": f"{self.bin}:{os.environ['PATH']}", + "HOME": str(self.local_home), + "FAKE_REMOTE_HOME": str(self.remote), + "FAKE_SSH_LOG": str(self.ssh_log), + "FAKE_SCP_LOG": str(self.scp_log), + "TRANSCRIBE_HOST": "testhost", + "TRANSCRIBE_POLL": "0", + "TRANSCRIBE_TIMEOUT": "5", + } + if on_submit: + env["FAKE_ON_SUBMIT"] = on_submit + env.update({k: str(v) for k, v in env_extra.items()}) + if args is None: + args = [str(self.audio)] + return subprocess.run([str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=60) + + def state(self, *parts): + return self.remote.joinpath(STATE, *parts) + + def uploads(self): + return self.scp_log.read_text().splitlines() if self.scp_log.exists() else [] + + +@pytest.fixture +def rig(tmp_path): + return Rig(tmp_path) + + +class TestClientNormal: + def test_ratio_transcribe_new_recording_is_uploaded_and_transcript_printed(self, rig): + """Normal: upload, wait, print. stdout is the transcript and nothing else.""" + result = rig.run() + assert result.returncode == 0, result.stderr + assert result.stdout == "00:00:00 Speaker A: Hello from the host.\n" + assert len(rig.uploads()) == 1 + + def test_ratio_transcribe_job_file_carries_language_speakers_and_name(self, rig): + """Normal: the options reach the host inside job.json.""" + rig.run(args=[str(rig.audio), "es"], on_submit=None, SPEAKERS=3, TRANSCRIBE_TIMEOUT=0) + jobs = list(rig.state("incoming").glob("*/job.json")) + assert len(jobs) == 1 + assert json.loads(jobs[0].read_text()) == { + "language": "es", "speakers": 3, "original_name": "meeting.m4a", + } + assert (jobs[0].parent / "audio.m4a").read_bytes() == b"pretend audio" + + def test_ratio_transcribe_finished_job_is_collected_without_uploading_again(self, rig): + """Normal: rerunning after a dropped connection costs one ssh round trip.""" + first = rig.run() + rig.scp_log.unlink() + second = rig.run(on_submit=None) + assert second.returncode == 0 + assert second.stdout == first.stdout + assert rig.uploads() == [] + + def test_ratio_transcribe_job_id_depends_on_audio_and_options(self, rig): + """Normal: same file with a different speaker count is a different job.""" + rig.run(on_submit=None, TRANSCRIBE_TIMEOUT=0) + rig.run(on_submit=None, TRANSCRIBE_TIMEOUT=0) + rig.run(on_submit=None, TRANSCRIBE_TIMEOUT=0, SPEAKERS=3) + assert len(list(rig.state("incoming").iterdir())) == 2 + + def test_ratio_transcribe_earlier_failure_is_retried(self, rig): + """Normal: a stale failure log does not block a fresh attempt.""" + assert rig.run(on_submit=WORKER_FAIL).returncode == 1 + result = rig.run(on_submit=WORKER_OK) + assert result.returncode == 0 + assert "Hello from the host" in result.stdout + assert list(rig.state("failed").glob("*.log")) == [] + + +class TestClientBoundary: + def test_ratio_transcribe_job_still_queued_is_not_uploaded_twice(self, rig): + """Boundary: a second run while the first job waits just joins the wait.""" + rig.run(on_submit=None, TRANSCRIBE_TIMEOUT=0) + rig.run(on_submit=None, TRANSCRIBE_TIMEOUT=0) + assert len(rig.uploads()) == 1 + + def test_ratio_transcribe_upload_lands_in_incoming_only_by_rename(self, rig): + """Boundary: the audio is copied into uploading/, never straight into incoming/.""" + rig.run(on_submit=None, TRANSCRIBE_TIMEOUT=0) + assert "/uploading/" in rig.uploads()[0] + assert list(rig.state("uploading").iterdir()) == [] + + def test_ratio_transcribe_awkward_filename_survives(self, rig): + """Boundary: spaces and quotes in the recording's name.""" + odd = rig.tmp / "someone's \"weekly\" sync.m4a" + odd.write_bytes(b"x") + result = rig.run(args=[str(odd)], on_submit=None, TRANSCRIBE_TIMEOUT=0) + job = json.loads(next(rig.state("incoming").glob("*/job.json")).read_text()) + assert job["original_name"] == "someone's \"weekly\" sync.m4a", result.stderr + + def test_ratio_transcribe_colon_in_a_relative_filename_still_uploads(self, rig): + """Boundary: scp reads "standup 14:30.m4a" as host:path; the client hands it an absolute path.""" + (rig.tmp / "standup 14:30.m4a").write_bytes(b"x") + env = { + "PATH": f"{rig.bin}:{os.environ['PATH']}", "HOME": str(rig.local_home), + "FAKE_REMOTE_HOME": str(rig.remote), "FAKE_SSH_LOG": str(rig.ssh_log), + "FAKE_SCP_LOG": str(rig.scp_log), "FAKE_ON_SUBMIT": WORKER_OK, + "TRANSCRIBE_HOST": "testhost", "TRANSCRIBE_POLL": "0", "TRANSCRIBE_TIMEOUT": "5", + } + result = subprocess.run( + [str(SCRIPT), "standup 14:30.m4a"], cwd=rig.tmp, env=env, capture_output=True, text=True, timeout=60, + ) + assert result.returncode == 0, result.stderr + assert rig.uploads()[0].startswith("/") + + def test_ratio_transcribe_host_unreachable_falls_back_to_local_worker(self, rig): + """Boundary: offline, the same queue and worker run on this machine.""" + worker = rig.tmp / "fake-worker" + worker.write_text( + "#!/usr/bin/env bash\n" + f"cd \"$HOME/{STATE}\" && mkdir -p done && for d in incoming/*/; do id=$(basename \"$d\");\n" + "printf '00:00:00 Speaker A: Local fallback.\\n' > done/$id.txt; rm -rf \"$d\"; done\n" + ) + worker.chmod(0o755) + result = rig.run(FAKE_SSH_DOWN=1, TRANSCRIBE_WORKER=str(worker)) + assert result.returncode == 0, result.stderr + assert result.stdout == "00:00:00 Speaker A: Local fallback.\n" + assert "local" in result.stderr.lower() + + +class TestClientStdin: + def test_ratio_transcribe_leaves_the_callers_stdin_alone(self, rig): + """Boundary: in a `while read` loop the client must not eat the loop's input.""" + env = { + "PATH": f"{rig.bin}:{os.environ['PATH']}", "HOME": str(rig.local_home), + "FAKE_REMOTE_HOME": str(rig.remote), "FAKE_SSH_LOG": str(rig.ssh_log), + "FAKE_SCP_LOG": str(rig.scp_log), "FAKE_ON_SUBMIT": WORKER_OK, + "TRANSCRIBE_HOST": "testhost", "TRANSCRIBE_POLL": "0", "TRANSCRIBE_TIMEOUT": "5", + } + result = subprocess.run( + ["bash", "-c", '"$0" "$1" > /dev/null 2>&1; cat', str(SCRIPT), str(rig.audio)], + env=env, input="next line of the caller's loop\n", capture_output=True, text=True, timeout=60, + ) + assert result.stdout == "next line of the caller's loop\n" + + +class TestClientError: + def test_ratio_transcribe_no_arguments_prints_usage(self, rig): + """Error: usage.""" + result = rig.run(args=[]) + assert result.returncode == 1 and "Usage: ratio-transcribe" in result.stderr and result.stdout == "" + + def test_ratio_transcribe_missing_file_fails_before_any_ssh(self, rig): + """Error: no such recording.""" + result = rig.run(args=[str(rig.tmp / "nope.m4a")]) + assert result.returncode == 1 and "not found" in result.stderr + assert not rig.ssh_log.exists() + + @pytest.mark.parametrize("env", [{"SPEAKERS": "0"}, {"SPEAKERS": "three"}, {"MIN_SPEAKERS": "-1"}, + {"MIN_SPEAKERS": "4", "MAX_SPEAKERS": "2"}, {"SPEAKERS": "3", "MAX_SPEAKERS": "5"}]) + def test_ratio_transcribe_bad_speaker_settings_are_rejected(self, rig, env): + """Error: non-positive, non-numeric or contradictory counts.""" + result = rig.run(**env) + assert result.returncode == 1 and "speaker" in result.stderr.lower() + assert not rig.ssh_log.exists() + + def test_ratio_transcribe_bad_language_is_rejected(self, rig): + """Error: the language ends up in a job file and a command line.""" + result = rig.run(args=[str(rig.audio), "en; rm -rf /"]) + assert result.returncode == 1 and "language" in result.stderr.lower() + assert not rig.ssh_log.exists() + + def test_ratio_transcribe_failed_job_reports_the_log_and_prints_nothing(self, rig): + """Error: the host's failure log comes back on stderr.""" + result = rig.run(on_submit=WORKER_FAIL) + assert result.returncode == 1 + assert result.stdout == "" + assert "stage: whisper" in result.stderr + + def test_ratio_transcribe_timeout_says_the_job_is_still_running(self, rig): + """Error: giving up waiting is not the job failing.""" + result = rig.run(on_submit=None, TRANSCRIBE_TIMEOUT=0) + assert result.returncode == 1 + assert result.stdout == "" + assert "again" in result.stderr.lower() + + def test_ratio_transcribe_empty_transcript_is_a_failure(self, rig): + """Error: a zero-byte result is never passed off as a transcript.""" + result = rig.run(on_submit=WORKER_EMPTY, TRANSCRIBE_TIMEOUT=1) + assert result.returncode == 1 + assert result.stdout == "" + + def test_ratio_transcribe_offline_without_local_worker_names_what_is_missing(self, rig): + """Error: no host and nothing installed locally.""" + result = rig.run(FAKE_SSH_DOWN=1, TRANSCRIBE_WORKER=str(rig.tmp / "absent")) + assert result.returncode == 1 + assert "absent" in result.stderr diff --git a/working/meeting-transcription-service/tests/test_transcribe_worker.py b/working/meeting-transcription-service/tests/test_transcribe_worker.py new file mode 100644 index 0000000..bfffd31 --- /dev/null +++ b/working/meeting-transcription-service/tests/test_transcribe_worker.py @@ -0,0 +1,359 @@ +"""Tests for transcribe-worker: the queue drain on the transcription host. + +ffmpeg, whisper-cli and the diarizer are replaced by small fake executables (the +process boundary). The queue handling, the merge and the loop guard run for real. +""" + +import importlib.machinery +import importlib.util +import json +import os +import stat +import sys +from pathlib import Path + +import pytest + +SRC = Path(__file__).resolve().parent.parent / "src" +sys.path.insert(0, str(SRC)) + + +def _load_worker(): + loader = importlib.machinery.SourceFileLoader("transcribe_worker", str(SRC / "transcribe-worker")) + spec = importlib.util.spec_from_loader("transcribe_worker", loader) + assert spec is not None + module = importlib.util.module_from_spec(spec) + sys.modules["transcribe_worker"] = module # dataclasses looks the module up by name + loader.exec_module(module) + return module + + +worker = _load_worker() + +FAKE_FFMPEG = """#!/usr/bin/env bash +# last argument is the output; the one after -i is the input +while [[ $# -gt 1 ]]; do [[ "$1" == "-i" ]] && in="$2"; shift; done +cp "$in" "$1" +exit "${FAKE_FFMPEG_EXIT:-0}" +""" + +FAKE_WHISPER = """#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$FAKE_LOG" +while [[ $# -gt 0 ]]; do [[ "$1" == "-of" ]] && prefix="$2"; shift; done +[[ "${FAKE_WHISPER_EXIT:-0}" == "0" ]] && cp "$FAKE_WHISPER_JSON" "$prefix.json" +exit "${FAKE_WHISPER_EXIT:-0}" +""" + +FAKE_DIARIZE = """#!/usr/bin/env bash +printf 'diarize %s\\n' "$*" >> "$FAKE_LOG" +[[ "${FAKE_DIARIZE_EXIT:-0}" == "0" ]] && cp "$FAKE_TURNS_JSON" "$2" +echo "fake diarizer says hello" >&2 +exit "${FAKE_DIARIZE_EXIT:-0}" +""" + + +def whisper_json(*words, step_ms=400): + return {"transcription": [ + {"offsets": {"from": i * step_ms, "to": i * step_ms + 300}, "text": f" {w}"} + for i, w in enumerate(words) + ]} + + +class Rig: + def __init__(self, tmp_path, monkeypatch): + self.home = tmp_path / "state" + self.bin = tmp_path / "bin" + self.bin.mkdir() + for name, body in (("ffmpeg", FAKE_FFMPEG), ("whisper-cli", FAKE_WHISPER), ("fake-diarize", FAKE_DIARIZE)): + path = self.bin / name + path.write_text(body) + path.chmod(path.stat().st_mode | stat.S_IXUSR) + self.log = tmp_path / "calls.log" + self.whisper_json = tmp_path / "whisper.json" + self.turns_json = tmp_path / "turns.json" + self.set_whisper(whisper_json("Good", "morning.")) + self.turns_json.write_text(json.dumps([{"start": 0.0, "end": 60.0, "speaker": "SPEAKER_00"}])) + monkeypatch.setenv("PATH", f"{self.bin}:{os.environ['PATH']}") + monkeypatch.setenv("FAKE_LOG", str(self.log)) + monkeypatch.setenv("FAKE_WHISPER_JSON", str(self.whisper_json)) + monkeypatch.setenv("FAKE_TURNS_JSON", str(self.turns_json)) + self.monkeypatch = monkeypatch + self.config = worker.Config( + home=self.home, + whisper_model=tmp_path / "model.bin", + diarize_cmd=[str(self.bin / "fake-diarize")], + threads=2, + ) + + def set_whisper(self, data): + self.whisper_json.write_text(json.dumps(data)) + + def submit(self, job_id, job=None, audio=b"audio", where="incoming"): + folder = self.home / where / job_id + folder.mkdir(parents=True) + (folder / "audio.m4a").write_bytes(audio) + if job is not False: + (folder / "job.json").write_text(json.dumps(job if job is not None else {"language": "en"})) + return folder + + def calls(self): + return self.log.read_text().splitlines() if self.log.exists() else [] + + +@pytest.fixture +def rig(tmp_path, monkeypatch): + return Rig(tmp_path, monkeypatch) + + +class TestDrainNormal: + def test_transcribe_worker_drain_one_job_writes_transcript_to_done(self, rig): + """Normal: a job in incoming/ ends as done/<id>.txt and leaves nothing behind.""" + rig.submit("job1") + assert worker.drain(rig.config) == {"done": 1, "failed": 0} + assert (rig.home / "done" / "job1.txt").read_text() == "00:00:00 Speaker A: Good morning.\n" + assert not (rig.home / "incoming" / "job1").exists() + assert not (rig.home / "work" / "job1").exists() + assert not (rig.home / "failed" / "job1.log").exists() + + def test_transcribe_worker_drain_writes_metadata_beside_the_transcript(self, rig): + """Normal: done/<id>.json records what ran, for the client and for debugging.""" + rig.submit("job1", {"language": "en", "speakers": 3, "original_name": "standup.mkv"}) + worker.drain(rig.config) + meta = json.loads((rig.home / "done" / "job1.json").read_text()) + assert meta["original_name"] == "standup.mkv" + assert meta["speakers_found"] == 1 + assert meta["lines"] == 1 + assert meta["seconds"] >= 0 + + def test_transcribe_worker_drain_passes_language_and_speaker_count_through(self, rig): + """Normal: job options reach whisper and the diarizer.""" + rig.submit("job1", {"language": "es", "speakers": 3}) + worker.drain(rig.config) + whisper_call = next(c for c in rig.calls() if not c.startswith("diarize")) + diarize_call = next(c for c in rig.calls() if c.startswith("diarize")) + assert "-l es" in whisper_call + assert "-mc 0" in whisper_call and "-ojf" in whisper_call + assert diarize_call.endswith("--speakers 3") + + def test_transcribe_worker_drain_min_and_max_speakers_are_forwarded(self, rig): + """Normal: a range instead of an exact count.""" + rig.submit("job1", {"min_speakers": 2, "max_speakers": 5}) + worker.drain(rig.config) + diarize_call = next(c for c in rig.calls() if c.startswith("diarize")) + assert "--min-speakers 2" in diarize_call and "--max-speakers 5" in diarize_call + + def test_transcribe_worker_drain_processes_every_job_oldest_first(self, rig): + """Normal: the queue drains completely, in arrival order.""" + first = rig.submit("b-first") + rig.submit("a-second") + os.utime(first, (1, 1)) + assert worker.drain(rig.config) == {"done": 2, "failed": 0} + diarize_calls = [c for c in rig.calls() if c.startswith("diarize")] + assert "b-first" in diarize_calls[0] and "a-second" in diarize_calls[1] + + +class TestDrainBoundary: + def test_transcribe_worker_drain_empty_queue_is_a_no_op(self, rig): + """Boundary: nothing to do, and the folders get created.""" + assert worker.drain(rig.config) == {"done": 0, "failed": 0} + assert (rig.home / "incoming").is_dir() and (rig.home / "done").is_dir() + + def test_transcribe_worker_drain_ignores_uploads_still_in_flight(self, rig): + """Boundary: a dot-prefixed folder is left alone (second line of defence behind uploading/).""" + rig.submit(".tmp-job9") + assert worker.drain(rig.config) == {"done": 0, "failed": 0} + assert (rig.home / "incoming" / ".tmp-job9").exists() + + def test_transcribe_worker_drain_job_already_done_is_not_rerun(self, rig): + """Boundary: resubmitting finished work costs nothing.""" + (rig.home / "done").mkdir(parents=True) + (rig.home / "done" / "job1.txt").write_text("already here\n") + rig.submit("job1") + assert worker.drain(rig.config) == {"done": 0, "failed": 0} + assert (rig.home / "done" / "job1.txt").read_text() == "already here\n" + assert not (rig.home / "incoming" / "job1").exists() + assert rig.calls() == [] + + def test_transcribe_worker_drain_missing_job_file_uses_defaults(self, rig): + """Boundary: audio with no job.json still transcribes, in English, count estimated.""" + rig.submit("job1", job=False) + assert worker.drain(rig.config) == {"done": 1, "failed": 0} + + def test_transcribe_worker_drain_retries_a_job_left_in_work_by_a_crash(self, rig): + """Boundary: a job stranded in work/ is picked up again.""" + rig.submit("job1", where="work") + assert worker.drain(rig.config) == {"done": 1, "failed": 0} + + def test_transcribe_worker_drain_gives_up_on_a_job_that_keeps_crashing(self, rig): + """Boundary: the second stranding is a failure, not a loop.""" + rig.submit("job1", {"language": "en", "attempts": 2}, where="work") + assert worker.drain(rig.config) == {"done": 0, "failed": 1} + assert "attempt" in (rig.home / "failed" / "job1.log").read_text() + + +class TestDrainError: + def test_transcribe_worker_drain_whisper_failure_is_logged_with_its_stage(self, rig): + """Error: whisper exits non-zero.""" + rig.monkeypatch.setenv("FAKE_WHISPER_EXIT", "3") + rig.submit("job1") + assert worker.drain(rig.config) == {"done": 0, "failed": 1} + log = (rig.home / "failed" / "job1.log").read_text() + assert "whisper" in log + assert not (rig.home / "done" / "job1.txt").exists() + assert not (rig.home / "work" / "job1").exists() + + def test_transcribe_worker_drain_diarizer_failure_keeps_its_stderr(self, rig): + """Error: the diarizer fails; its own words land in the log.""" + rig.monkeypatch.setenv("FAKE_DIARIZE_EXIT", "1") + rig.submit("job1") + assert worker.drain(rig.config) == {"done": 0, "failed": 1} + log = (rig.home / "failed" / "job1.log").read_text() + assert "diarize" in log and "fake diarizer says hello" in log + + def test_transcribe_worker_drain_looping_transcription_fails(self, rig): + """Error: whisper's repetition loop is a failed job, never a transcript.""" + rig.set_whisper(whisper_json(*("we do not know where we are going".split() * 5), step_ms=1000)) # 40 s + rig.submit("job1") + assert worker.drain(rig.config) == {"done": 0, "failed": 1} + assert "looped" in (rig.home / "failed" / "job1.log").read_text() + assert not (rig.home / "done" / "job1.txt").exists() + + def test_transcribe_worker_drain_short_loop_is_collapsed_and_recorded(self, rig): + """Normal: a stutter of a few seconds is collapsed to one copy and noted in the metadata.""" + rig.set_whisper(whisper_json("Okay,", *("fair, it's not going to be".split() * 4), "easy.")) + rig.submit("job1") + assert worker.drain(rig.config) == {"done": 1, "failed": 0} + assert (rig.home / "done" / "job1.txt").read_text() == "00:00:00 Speaker A: Okay, fair, it's not going to be easy.\n" + meta = json.loads((rig.home / "done" / "job1.json").read_text()) + assert meta["loops_collapsed"] == [["fair, it's not going to be", 4]] + + def test_transcribe_worker_drain_malformed_job_file_fails_that_job_only(self, rig): + """Error: one bad job does not stop the queue.""" + bad = rig.submit("bad") + (bad / "job.json").write_text("{not json") + os.utime(bad, (1, 1)) + rig.submit("good") + assert worker.drain(rig.config) == {"done": 1, "failed": 1} + assert (rig.home / "done" / "good.txt").exists() + assert "job.json" in (rig.home / "failed" / "bad.log").read_text() + + def test_transcribe_worker_drain_job_without_audio_fails(self, rig): + """Error: a job folder holding no audio file.""" + folder = rig.submit("job1") + (folder / "audio.m4a").unlink() + assert worker.drain(rig.config) == {"done": 0, "failed": 1} + assert "audio" in (rig.home / "failed" / "job1.log").read_text() + + @pytest.mark.parametrize("bad", [{"speakers": 0}, {"speakers": "three"}, {"language": "en; rm -rf"}]) + def test_transcribe_worker_drain_rejects_bad_option_values(self, rig, bad): + """Error: options are validated before they reach a command line.""" + rig.submit("job1", bad) + assert worker.drain(rig.config) == {"done": 0, "failed": 1} + assert rig.calls() == [] + + +class TestDrainSilenceHallucinations: + def _silent_stretch(self, rig): + spoken = [{"offsets": {"from": 0, "to": 900}, "text": " Good morning."}] + invented = [ + {"offsets": {"from": 60_000 + i * 5_000, "to": 64_000 + i * 5_000}, "text": " Thank you."} + for i in range(12) + ] + rig.set_whisper({"transcription": spoken + invented}) + rig.turns_json.write_text(json.dumps([{"start": 0.0, "end": 2.0, "speaker": "SPEAKER_00"}])) + + def test_transcribe_worker_drain_words_invented_in_silence_are_dropped_not_failed(self, rig): + """Normal: a quiet meeting transcribes; the invented run never reaches the transcript.""" + self._silent_stretch(rig) + rig.submit("job1") + assert worker.drain(rig.config) == {"done": 1, "failed": 0} + assert (rig.home / "done" / "job1.txt").read_text() == "00:00:00 Speaker A: Good morning.\n" + + def test_transcribe_worker_drain_metadata_counts_the_dropped_words(self, rig): + """Normal: the count is on record, so a transcript that lost a lot is visible.""" + self._silent_stretch(rig) + rig.submit("job1") + worker.drain(rig.config) + assert json.loads((rig.home / "done" / "job1.json").read_text())["dropped_outside_speech"] == 12 + + def test_transcribe_worker_drain_recording_with_no_speech_at_all_fails_cleanly(self, rig): + """Error: everything whisper produced sits in silence.""" + rig.set_whisper({"transcription": [{"offsets": {"from": 60_000, "to": 61_000}, "text": " Thank you."}]}) + rig.turns_json.write_text(json.dumps([{"start": 0.0, "end": 2.0, "speaker": "SPEAKER_00"}])) + rig.submit("job1") + assert worker.drain(rig.config) == {"done": 0, "failed": 1} + assert "no speech" in (rig.home / "failed" / "job1.log").read_text() + + +class TestRunStdin: + def test_transcribe_worker_run_gives_tools_no_stdin(self, tmp_path): + """Boundary: ffmpeg reads stdin when it can, which would eat a calling loop's input.""" + out = tmp_path / "stdin-target" + read_end, write_end = os.pipe() + saved = os.dup(0) + os.dup2(read_end, 0) + try: + worker._run("probe", ["bash", "-c", f"readlink /proc/self/fd/0 > {out}"]) + finally: + os.dup2(saved, 0) + for fd in (saved, read_end, write_end): + os.close(fd) + assert out.read_text().strip() == "/dev/null" + + +class TestDrainResilience: + def test_transcribe_worker_drain_null_attempts_counts_as_a_first_try(self, rig): + """Error: a job.json with "attempts": null must not take the worker down; it is a first try.""" + odd = rig.submit("odd", {"language": "en", "attempts": None}) + os.utime(odd, (1, 1)) + rig.submit("good") + assert worker.drain(rig.config) == {"done": 2, "failed": 0} + assert (rig.home / "done" / "odd.txt").exists() + assert (rig.home / "done" / "good.txt").exists() + + def test_transcribe_worker_drain_unexpected_error_in_one_job_is_logged_and_the_queue_goes_on(self, rig): + """Error: an exception the pipeline never anticipated lands in failed/ with its type, not on the run.""" + first = rig.submit("first") + os.utime(first, (1, 1)) + rig.submit("second") + + def explode(job: dict) -> list[str]: + raise RuntimeError("boom") + + rig.monkeypatch.setattr(worker, "_diarize_options", explode) + assert worker.drain(rig.config) == {"done": 0, "failed": 2} # both reached, neither crashed the run + for job_id in ("first", "second"): + log = (rig.home / "failed" / f"{job_id}.log").read_text() + assert "RuntimeError" in log and "boom" in log + assert not (rig.home / "work" / job_id).exists() + + def test_transcribe_worker_drain_unwritable_done_dir_is_a_failed_job_not_a_crash(self, rig): + """Error: an OSError while writing the transcript lands in failed/, and the run survives.""" + if os.geteuid() == 0: + pytest.skip("root ignores directory permissions") + (rig.home / "done").mkdir(parents=True) + (rig.home / "done").chmod(0o500) + try: + rig.submit("job1") + assert worker.drain(rig.config) == {"done": 0, "failed": 1} + assert "job1" in "".join(p.name for p in (rig.home / "failed").iterdir()) + finally: + (rig.home / "done").chmod(0o700) + + def test_transcribe_worker_drain_ffmpeg_failure_is_logged_with_its_stage(self, rig): + """Error: ffmpeg exits non-zero.""" + rig.monkeypatch.setenv("FAKE_FFMPEG_EXIT", "2") + rig.submit("job1") + assert worker.drain(rig.config) == {"done": 0, "failed": 1} + assert "ffmpeg" in (rig.home / "failed" / "job1.log").read_text() + + def test_transcribe_worker_drain_returns_at_once_when_another_worker_holds_the_lock(self, rig): + """Boundary: a second drain does not touch the queue while the first holds the lock.""" + import fcntl + rig.home.mkdir(parents=True, exist_ok=True) + rig.submit("job1") + with open(rig.home / "lock", "w", encoding="utf-8") as held: + fcntl.flock(held, fcntl.LOCK_EX | fcntl.LOCK_NB) + assert worker.drain(rig.config) == {"done": 0, "failed": 0} + assert (rig.home / "incoming" / "job1").exists() + assert rig.calls() == [] diff --git a/working/velox-reinstall/velox-reinstall-runbook.org b/working/velox-reinstall/velox-reinstall-runbook.org deleted file mode 100644 index 02671e2..0000000 --- a/working/velox-reinstall/velox-reinstall-runbook.org +++ /dev/null @@ -1,154 +0,0 @@ -#+TITLE: Velox Reinstall Runbook — DR Test of archangel + archsetup -#+AUTHOR: Craig Jennings -#+DATE: 2026-08-13 - -Context: velox's mainboard swapped Intel → AMD (Ryzen AI 9 HX 370, Radeon -890M, 96GB RAM). Old SSD intact but the new board's NVRAM has no boot entry, -and velox is ZFS root + ZFSBootMenu, so a stock Arch USB can't even read the -pool. Decision: full reinstall via archangel + archsetup, run deliberately as -a disaster-recovery test of the ISO and scripts before the Sunday flight. -Recent backup in hand; ratio available as the working machine. - -Fallback ordering if the test finds a real gap: -- Before partitioning starts: the old system is intact — the ZBM repair - route (efibootmgr entry pointing at the ZBM loader on the ESP, then - amd-ucode swap in a chroot) is still available. -- After partitioning: the floor is a manual Arch install; the backup makes - that survivable. - -* Phase 0 — Preflight on ratio (agent-driven, done before you leave the desk) - -- [ ] Rebuild the ISO with archsetup baked in: the 2026-08-02 ISO predates - the microcode vendor-detection fix (archsetup, 2026-08-08) and was built - without ARCHSETUP_DIR at all. - #+begin_src sh - cd ~/code/archangel && sudo ARCHSETUP_DIR=~/code/archsetup ./build.sh - #+end_src -- [ ] build.sh fixes before the final rebuild (archangel repo): - - rsync exclude for =.ai= (keeps =archsetup/.ai/private-design/= — the - credential audit — off the portable USB stick). - - copy =installer/velox-*.conf= to =airootfs/root/= so the machine profile - is on the ISO at =/root/velox-zfs.conf=. -- [ ] Verify the ISO carries: =/code/archsetup= (with =install_cpu_microcode=), - =/root/velox-zfs.conf=, no =.ai/private-design=. Loop-mount or unsquashfs - spot-check. -- [X] USB ready (done 2026-08-13 15:25): the new ISO was copied to the Ventoy - drive, sha256-verified against the source, and the 2026-04-09 + 2026-06-16 - archangel ISOs removed. Boot the stick and pick - =archangel-2026-08-13-vmlinuz-6.18.43-lts-x86_64.iso= from the Ventoy menu. - -* Phase 1 — UEFI setup on velox (BIOS screen, before any boot) - -- [ ] Disable Secure Boot. Mandatory — the ZFS kernel modules are unsigned; - the new board ships with it enforced by factory default. -- [ ] Set the system clock. The board swap reset the RTC to 2025-01-01; - a wrong clock breaks TLS and pacman signature checks in the live env. - Rough accuracy is fine — NTP tightens it once networked. -- [ ] While you're in setup: check boot-order UI shows the USB. - -* Phase 2 — Salvage pass (live ISO, BEFORE running the installer) — REQUIRED - -NOT optional insurance. Verified 2026-08-13: velox's newest truenas backup is -DAILY.0 = 2026-07-06 — five weeks stale. The backup timer on velox broke -around Jul 6 (truenas itself only went dark Jul 24, and it's back now; ratio -and mybitch backed up today). Everything since Jul 6 exists only on the old -SSD — including =wolf.conf.gpg= (created Jul 29), which is therefore in NO -backup at all. This pass also keeps the repair fallback alive until -partitioning starts. - -- [ ] Network up (=nmtui= or ethernet), then confirm clock: =timedatectl=. -- [ ] Import the old pool read-only and unlock: - #+begin_src sh - zpool import -N -o readonly=on -R /mnt zroot - zfs load-key zroot # passphrase prompt - zfs mount zroot/ROOT/default - zfs mount -a 2>/dev/null # home datasets etc.; ignore failures - #+end_src -- [ ] Push a full fresh backup to truenas over the LAN — mirror the layout - the backup job uses (etc + home), into a clearly-named one-off dir: - #+begin_src sh - rsync -aHAX --info=progress2 /mnt/etc /mnt/home \ - truenas:/mnt/vault/backups/velox/pre-reinstall-2026-08-13/ - #+end_src - (=/usr= is in the regular backups but is all reinstallable — skip unless - paranoid. The 96GB-RAM board will not be the bottleneck; the LAN is.) -- [ ] Spot-check the copy landed: =wolf.conf.gpg=, =.ssh=, =.gnupg=, newest - files in =~/documents= and =~/downloads=. -- [ ] Check for uncommitted repo work and either push or note it: - =~/.emacs.d= (known: the auto-dim-other-buffers.el unresolved merge), - =~/.dotfiles=, anything under =~/code=. -- [ ] Export cleanly: =cd /; zfs unmount -a; zpool export zroot=. - -* Phase 3 — Install (the actual DR test) - -- [ ] Review the profile, then run the installer: - #+begin_src sh - less /root/velox-zfs.conf # FILESYSTEM=zfs, HOSTNAME=velox, single nvme - archangel --config-file /root/velox-zfs.conf - #+end_src - Note: the profile's ZFS_PASSPHRASE / ROOT_PASSWORD are the =welcome= - placeholders — fine for install; both change post-install (=zfs change-key - zroot= for the pool, =passwd= for root). -- [ ] Record every rough edge as a DR-test finding — that's the point of - running it this way. Anything that needs a manual nudge gets a todo entry - in archangel or archsetup afterward. -- [ ] Reboot into ZBM → boot the new environment. - -* Phase 4 — archsetup (first boot of the installed system) - -- [ ] Log in as root, network up, then verify the clock synced. -- [ ] Get archsetup — two paths, test the offline one since this is a DR - drill (the online curl path is the everyday alternative): - #+begin_src sh - # offline: mount the install USB and copy the baked tree - mount /dev/disk/by-label/ARCHANGEL* /mnt 2>/dev/null || mount /dev/sdX1 /mnt - cp -r /mnt/code/archsetup /root/archsetup && cd /root/archsetup - ./archsetup - #+end_src -- [ ] Expected on the new board: =install_cpu_microcode= detects - AuthenticAMD and installs amd-ucode (verified 2026-08-13, 7/7 tests). - Podman socket, camera udev rule, tlp radio state, ZFS /tmp mask are all - in the installer now — none need manual application afterward. -- [ ] archsetup clones + stows dotfiles. The velox host tier has no Intel - assumptions (swept 2026-08-13); maint's capability probe runtime-detects - amd-pstate. - -* Phase 5 — Post-install restore + verification - -- [ ] Restore from backup (credentials, ssh keys, gpg, user data). The - secrets-bundle-in-ISO design is not built yet — manual restore is the - known gap, not a test failure. -- [ ] WireGuard: decrypt + re-place =wolf.conf.gpg= at =~/.config/wireguard/=; - re-import the NM profile (autoconnect off, as before). -- [ ] Change the placeholder passwords: =passwd=, =zfs change-key zroot=. -- [ ] PSR workaround — REQUIRED on this board. The Ryzen AI 300 has a known - idle instability (Panel Self Refresh hangs/reboots the machine; hit during - the live session 2026-08-13). Add =amdgpu.dcdebugmask=0x610= to the - installed system's kernel command line — velox boots via ZBM, so set it on - the pool: =zfs set org.zfsbootmenu:commandline="... amdgpu.dcdebugmask=0x610" zroot/ROOT/default= - (keep the existing args; append). Revisit after a BIOS update ≥3.05 or a - kernel that fixes PSR on Strix Point — track via the Framework issue - tracker (SoftwareFirmwareIssueTracker #110). -- [ ] New-hardware spot-checks: - - =journalctl -k | grep -i microcode= — amd-ucode applied. - - =cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_driver= — expect - amd-pstate(-epp). - - wifi + bluetooth up (new board radios), touchpad behavior, camera. - - =glxinfo -B= / =vulkaninfo --summary= — Radeon 890M on RADV. -- [ ] Fresh clones automatically carry the post-purge rewritten git history — - closes the clone-reconcile rider from 2026-08-11 without action. -- [ ] Fix and verify the backup timer on the fresh install — it was silently - broken since ~Jul 6. After the first manual run succeeds, confirm a new - DAILY.0 appears under =truenas:/mnt/vault/backups/velox/=. Diagnose why it - broke (timer unit dead? mount failure? credential?) if the old journal - survives in the salvage copy. -- [ ] Update the machine-identity memory: velox is now AMD (amd-pstate), - both daily drivers AMD. Fix the stale =intel_pstate= comment in - =airplane-mode= line 6 while at it (cosmetic). -- [ ] File every DR-test finding in the owning project's todo. - -* Timing - -Today is Thursday; the flight is Sunday. Target: Phases 0–4 tonight or -Friday, leaving Saturday as pure buffer. If the install stalls past Friday -evening, cut losses to the manual-install floor. diff --git a/working/velox-touchpad-interrupt/touchpad-module-underside-2026-08-15.jpg b/working/velox-touchpad-interrupt/touchpad-module-underside-2026-08-15.jpg Binary files differnew file mode 100644 index 0000000..40c71af --- /dev/null +++ b/working/velox-touchpad-interrupt/touchpad-module-underside-2026-08-15.jpg |
