From 1d53f70c1d8c02638fa27a559465c8ce8b9f4033 Mon Sep 17 00:00:00 2001 From: Joe Amditis <6799804+jamditis@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:37:12 -0400 Subject: [PATCH 1/5] Make the frog answer your voice in the menu window, and open its mouth #11 asked for the mascot to move with live input on both surfaces. The overlay already did; the menu wordmark never subscribed to mic-level, and the mouth did not move on either one. The mouth was the subtler half. FrogMascot suppresses its croak CSS class whenever sacScale is set, and that class is the only thing that swaps the closed smile for the open shape -- so driving sacScale from the mic inflated the throat and left the mouth in a closed smile for the whole dictation. The mouth now rides the same signal, crossfading and scaling the jaw so it reads as opening rather than as one shape dissolving into another. The reduction and the smoothing curve move into src/lib/mic-level.ts as pure functions, so the critter feels the same in both windows instead of drifting apart in two copies; the overlay's existing math is preserved exactly. useMicLevel wraps them for the menu side and releases to rest on its own, because mic-level stops without a final zero frame and a hook that only updated on events would freeze the critter half-inflated until the next dictation. It also tracks prefers-reduced-motion as it changes rather than reading it once per mount. Fixes a listener leak found along the way: RecordingOverlay returned its useEffect cleanup from an inner async function, so useEffect got a promise instead of the cleanup and all three listeners survived unmount. LiveFrog hands over sacScale only while a level is actually driving it, since a resting 0 is still "defined" and would silently kill click-croak. wake-20260727T0805-0faa25 --- src/components/icons/FrogMascot.tsx | 27 +++++- src/components/icons/LiveFrog.tsx | 21 ++++- src/hooks/useMicLevel.ts | 136 ++++++++++++++++++++++++++++ src/lib/mic-level.test.ts | 86 ++++++++++++++++++ src/lib/mic-level.ts | 60 ++++++++++++ src/overlay/RecordingOverlay.tsx | 31 +++++-- 6 files changed, 349 insertions(+), 12 deletions(-) create mode 100644 src/hooks/useMicLevel.ts create mode 100644 src/lib/mic-level.test.ts create mode 100644 src/lib/mic-level.ts diff --git a/src/components/icons/FrogMascot.tsx b/src/components/icons/FrogMascot.tsx index 19eba0a..3153271 100644 --- a/src/components/icons/FrogMascot.tsx +++ b/src/components/icons/FrogMascot.tsx @@ -7,7 +7,9 @@ import type { MascotProps } from "./mascot"; // nav icon, the recording overlay, and the konami easter egg. // // The animatable props live in MascotProps, the contract every critter honors. -// The frog's answer to the mic-level half of that contract is its vocal sac. +// The frog's answer to the mic-level half of that contract is its vocal sac and +// its mouth: both ride sacScale, so it opens up as it inflates the way a calling +// frog does. const FrogMascot = ({ size, @@ -44,6 +46,25 @@ const FrogMascot = ({ opacity: sacScale > 0.02 ? 1 : 0, }; + // The mouth rides the same signal as the sac. A calling frog opens up as it + // inflates, and the CSS croak class that normally swaps the two mouth shapes is + // suppressed while sacScale drives (see stateClass), so without this the mouth + // would sit in its closed smile through an entire dictation while only the + // throat moved. + const mouthOpenness = sacScale === undefined ? undefined : sacScale; + const mouthClosedStyle = + mouthOpenness === undefined ? undefined : { opacity: 1 - mouthOpenness }; + const mouthOpenStyle = + mouthOpenness === undefined + ? undefined + : { + opacity: mouthOpenness, + // Scale the jaw rather than only crossfading, so it reads as opening + // rather than as one shape dissolving into another. + transform: `scaleY(${0.35 + mouthOpenness * 0.65})`, + transformOrigin: "100px 119px", + }; + const irisStyle = { transform: `translate(${irisDX}px, ${irisDY}px)` }; return ( @@ -137,9 +158,10 @@ const FrogMascot = ({ - {/* mouth: closed smile / open croak */} + {/* mouth: closed smile / open croak, or crossfaded live by mic level */} { const { Component: Mascot } = getCritter(critter); + const amp = useMicLevel(micLevel); const ref = useRef(null); const [blink, setBlink] = useState(false); const [croak, setCroak] = useState(false); @@ -88,6 +98,11 @@ const LiveFrog = ({ className={className} blink={blink} croak={croak} + // Only hand over the sac while a live level is actually driving it. A + // defined sacScale suppresses the croak class (FrogMascot), so passing a + // resting 0 would silently kill the click-croak the rest of this + // component exists to produce. + sacScale={amp > 0 ? amp : undefined} irisDX={iris.x} irisDY={iris.y} /> diff --git a/src/hooks/useMicLevel.ts b/src/hooks/useMicLevel.ts new file mode 100644 index 0000000..171b9de --- /dev/null +++ b/src/hooks/useMicLevel.ts @@ -0,0 +1,136 @@ +import { useEffect, useRef, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { + MIC_LEVEL_EVENT, + bandsToAmplitude, + smoothAmplitude, +} from "@/lib/mic-level"; + +/** No frame for this long means the stream stopped, so start releasing to rest. */ +const SILENCE_AFTER_MS = 120; + +/** How often to apply the release once frames stop arriving. */ +const RELEASE_TICK_MS = 80; + +/** Below this the critter is visually closed, so snap to exact rest and stop. */ +const REST_EPSILON = 0.01; + +const REDUCE_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; + +/** + * The reduce-motion query, or null where there is no DOM to ask (a unit test). + * A missing matchMedia is "no preference" rather than a crash. + */ +function reduceMotionQuery(): MediaQueryList | null { + if (typeof window === "undefined") return null; + if (typeof window.matchMedia !== "function") return null; + return window.matchMedia(REDUCE_MOTION_QUERY); +} + +/** + * The OS reduce-motion preference, kept current if it changes mid-session. Read + * once it would go stale for the life of the window, which for the wordmark is + * the life of the app. + */ +function usePrefersReducedMotion(): boolean { + const [reduced, setReduced] = useState( + () => reduceMotionQuery()?.matches ?? false, + ); + + useEffect(() => { + const query = reduceMotionQuery(); + if (!query) return; + + const onChange = (event: MediaQueryListEvent) => setReduced(event.matches); + query.addEventListener("change", onChange); + // Re-read on mount in case it changed between the initial state and here. + setReduced(query.matches); + + return () => query.removeEventListener("change", onChange); + }, []); + + return reduced; +} + +/** + * Live input amplitude in 0..1, smoothed, for driving a critter's mic-level + * visual (see MascotProps.sacScale). Returns exactly 0 at rest. + * + * `mic-level` only fires while the recording stream or the settings mic monitor + * is open, and it stops without a final zero frame. A hook that only updated on + * events would therefore freeze at whatever the last syllable measured and leave + * the critter half-inflated for the rest of the session, so this releases to rest + * on its own once frames stop. That is why a caller does not have to track + * recording state to know when to stop animating. + * + * Stays at 0 under `prefers-reduced-motion`, so a caller can wire it + * unconditionally and still honor the setting. + * + * @param enabled pass false to stay at rest and skip the subscription entirely. + */ +export function useMicLevel(enabled = true): number { + const [amplitude, setAmplitude] = useState(0); + const smoothedRef = useRef(0); + const lastFrameAtRef = useRef(0); + const reduceMotion = usePrefersReducedMotion(); + + useEffect(() => { + if (!enabled || reduceMotion) { + smoothedRef.current = 0; + setAmplitude(0); + return; + } + + let unlisten: (() => void) | undefined; + let cancelled = false; + + const apply = (next: number) => { + smoothedRef.current = next; + setAmplitude(next); + }; + + // Release to rest when frames stop. Without this the critter keeps the last + // frame's pose indefinitely once dictation ends. + const release = setInterval(() => { + if (smoothedRef.current === 0) return; + if (Date.now() - lastFrameAtRef.current < SILENCE_AFTER_MS) return; + + const next = smoothAmplitude(smoothedRef.current, 0); + apply(next < REST_EPSILON ? 0 : next); + }, RELEASE_TICK_MS); + + const start = async () => { + try { + const stop = await listen(MIC_LEVEL_EVENT, (event) => { + lastFrameAtRef.current = Date.now(); + apply( + smoothAmplitude( + smoothedRef.current, + bandsToAmplitude(event.payload), + ), + ); + }); + if (cancelled) { + // Unmounted while awaiting the subscription: drop it immediately + // rather than leaking a listener that outlives the component. + stop(); + return; + } + unlisten = stop; + } catch { + // No event bridge available (non-Tauri context): stay at rest. + } + }; + + start(); + + return () => { + cancelled = true; + clearInterval(release); + unlisten?.(); + smoothedRef.current = 0; + }; + }, [enabled, reduceMotion]); + + return amplitude; +} diff --git a/src/lib/mic-level.test.ts b/src/lib/mic-level.test.ts new file mode 100644 index 0000000..112dd7c --- /dev/null +++ b/src/lib/mic-level.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "bun:test"; +import { bandsToAmplitude, smoothAmplitude } from "./mic-level"; + +describe("bandsToAmplitude", () => { + it("is silent for a missing or empty frame", () => { + expect(bandsToAmplitude(undefined)).toBe(0); + expect(bandsToAmplitude(null)).toBe(0); + expect(bandsToAmplitude([])).toBe(0); + }); + + it("follows the loudest band, not the average", () => { + // A voice concentrated in one band still has to read as loud, which is the + // whole reason this reduces by peak. + const oneLoudBand = [0, 0, 0, 0, 0, 0, 0, 0.5, 0, 0, 0, 0, 0, 0, 0, 0]; + expect(bandsToAmplitude(oneLoudBand)).toBeCloseTo(0.7, 5); + }); + + it("clamps to 1 rather than overshooting past full", () => { + // The 1.4 gain means anything above ~0.72 saturates; a critter scaled past + // 1 would render outside its own body. + expect(bandsToAmplitude([0.9])).toBe(1); + expect(bandsToAmplitude([1])).toBe(1); + }); + + it("treats a malformed frame as silence instead of throwing", () => { + // The frame crosses a process boundary. A NaN that propagated into a + // transform would blank the overlay webview mid-dictation. + expect(bandsToAmplitude([Number.NaN, Number.NaN])).toBe(0); + expect(bandsToAmplitude([Number.POSITIVE_INFINITY])).toBe(0); + expect(bandsToAmplitude([-0.5, -1])).toBe(0); + }); + + it("ignores unusable entries but still reads the usable ones", () => { + expect(bandsToAmplitude([Number.NaN, 0.5, Number.NaN])).toBeCloseTo(0.7, 5); + }); +}); + +describe("smoothAmplitude", () => { + it("rises faster than it falls", () => { + // Fast attack, slow release is what makes a level read as a voice. Compare + // equal-size steps in each direction from the same midpoint. + const rise = smoothAmplitude(0.5, 0.9) - 0.5; + const fall = 0.5 - smoothAmplitude(0.5, 0.1); + expect(rise).toBeGreaterThan(fall); + }); + + it("moves toward the target from either direction", () => { + expect(smoothAmplitude(0, 1)).toBeGreaterThan(0); + expect(smoothAmplitude(0, 1)).toBeLessThan(1); + expect(smoothAmplitude(1, 0)).toBeLessThan(1); + expect(smoothAmplitude(1, 0)).toBeGreaterThan(0); + }); + + it("holds still when it is already at the target", () => { + expect(smoothAmplitude(0, 0)).toBe(0); + expect(smoothAmplitude(1, 1)).toBe(1); + }); + + it("releases to visual rest in a bounded number of ticks", () => { + // useMicLevel drives this with target 0 once frames stop, and snaps to exact + // 0 below 0.01. If the release were too slow the critter would sit visibly + // half-inflated after dictation; this pins the decay so a coefficient change + // cannot quietly regress that. + let value = 1; + let ticks = 0; + while (value >= 0.01 && ticks < 100) { + value = smoothAmplitude(value, 0); + ticks += 1; + } + expect(value).toBeLessThan(0.01); + expect(ticks).toBeLessThanOrEqual(17); // ~1.4s at the 80ms release tick + }); + + it("never returns a value outside 0..1, even fed garbage", () => { + for (const [prev, target] of [ + [Number.NaN, 0.5], + [0.5, Number.NaN], + [-1, 2], + [2, -1], + ] as const) { + const result = smoothAmplitude(prev, target); + expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeLessThanOrEqual(1); + } + }); +}); diff --git a/src/lib/mic-level.ts b/src/lib/mic-level.ts new file mode 100644 index 0000000..0687ab9 --- /dev/null +++ b/src/lib/mic-level.ts @@ -0,0 +1,60 @@ +// The backend emits a `mic-level` event carrying 16 spectrum bands in 0..1 +// (src-tauri/src/audio_toolkit/audio/visualizer.rs). It fires during the real +// recording stream and while the settings mic monitor is open, so anything that +// wants to animate with the live voice listens to this one event. +// +// Three surfaces read it: the settings meter (InputLevelMeter), the recording +// overlay's bars and critter, and the menu wordmark's critter (useMicLevel). +// The reduction to a single amplitude and the smoothing curve live here so the +// critter feels the same in the overlay and in the menu window instead of +// drifting apart in two copies. Pure functions, so the feel is unit-testable +// without a webview, a mic, or Tauri. + +export const MIC_LEVEL_EVENT = "mic-level"; + +/** Bands are 0..1; the peak is lifted by this much so ordinary speech reads. */ +const AMPLITUDE_GAIN = 1.4; + +/** Smoothing weight applied to the previous value when the level is rising. */ +const ATTACK_WEIGHT = 0.4; + +/** Smoothing weight applied to the previous value when the level is falling. */ +const RELEASE_WEIGHT = 0.75; + +function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(1, Math.max(0, value)); +} + +/** + * Reduce a spectrum frame to one 0..1 amplitude, using the loudest band so a + * voice concentrated in a few bands still reads as loud. A missing, empty, or + * non-numeric frame is silence rather than an error: the event arrives from a + * separate process, and a critter that throws on a malformed frame would take + * the overlay's webview down mid-dictation. + */ +export function bandsToAmplitude( + bands: readonly number[] | null | undefined, +): number { + if (!bands || bands.length === 0) return 0; + + let peak = 0; + for (const band of bands) { + if (Number.isFinite(band) && band > peak) peak = band; + } + + return clamp01(peak * AMPLITUDE_GAIN); +} + +/** + * Fast attack, slow release — the curve the settings meter already uses, which + * is what makes a level read as a voice rather than as flicker. Rising jumps + * most of the way to the target so a syllable lands on time; falling eases back + * so the critter does not snap shut between words. + */ +export function smoothAmplitude(previous: number, target: number): number { + const to = clamp01(target); + const from = clamp01(previous); + const weight = to > from ? ATTACK_WEIGHT : RELEASE_WEIGHT; + return from * weight + to * (1 - weight); +} diff --git a/src/overlay/RecordingOverlay.tsx b/src/overlay/RecordingOverlay.tsx index 8cdd7dd..cde54ea 100644 --- a/src/overlay/RecordingOverlay.tsx +++ b/src/overlay/RecordingOverlay.tsx @@ -6,6 +6,7 @@ import { DEFAULT_CRITTER_ID, getCritter } from "../components/icons/critters"; import "./RecordingOverlay.css"; import { commands } from "@/bindings"; import i18n, { syncLanguageFromSettings } from "@/i18n"; +import { MIC_LEVEL_EVENT, bandsToAmplitude } from "@/lib/mic-level"; import { getLanguageDirection } from "@/lib/utils/rtl"; type OverlayState = "recording" | "transcribing" | "processing"; @@ -24,6 +25,9 @@ const RecordingOverlay: React.FC = () => { const direction = getLanguageDirection(i18n.language); useEffect(() => { + let cleanup: (() => void) | undefined; + let cancelled = false; + const setupEventListeners = async () => { // Listen for show-overlay event from Rust const unlistenShow = await listen("show-overlay", async (event) => { @@ -42,7 +46,7 @@ const RecordingOverlay: React.FC = () => { }); // Listen for mic-level updates - const unlistenLevel = await listen("mic-level", (event) => { + const unlistenLevel = await listen(MIC_LEVEL_EVENT, (event) => { const newLevels = event.payload as number[]; // Apply smoothing to reduce jitter @@ -55,15 +59,29 @@ const RecordingOverlay: React.FC = () => { setLevels(smoothed.slice(0, 9)); }); - // Cleanup function - return () => { + const unlistenAll = () => { unlistenShow(); unlistenHide(); unlistenLevel(); }; + + // Unmounted while the subscriptions were still being awaited: drop them + // now. Returning a cleanup from this async function is not enough on its + // own -- useEffect receives the promise, not the function, so the + // subscriptions have to be handed back through `cleanup`. + if (cancelled) { + unlistenAll(); + return; + } + cleanup = unlistenAll; }; setupEventListeners(); + + return () => { + cancelled = true; + cleanup?.(); + }; }, []); // The overlay is its own webview, so it cannot read the menu window's state. @@ -75,10 +93,9 @@ const RecordingOverlay: React.FC = () => { // croaks along with your voice while recording, and rests while transcribing. // A critter whose micLevel is "none" ignores this, and nothing else here draws // the level, so adding one means deciding what the overlay shows instead. - const amp = - state === "recording" && levels.length - ? Math.min(1, Math.max(0, ...levels) * 1.4) - : 0; + // bandsToAmplitude is shared with the menu wordmark's critter (useMicLevel) so + // the same voice moves both the same way. + const amp = state === "recording" ? bandsToAmplitude(levels) : 0; return (
Date: Mon, 27 Jul 2026 09:45:11 -0400 Subject: [PATCH 2/5] Gate the overlay mouth on prefers-reduced-motion The overlay subscribes to mic-level itself, because it needs the per-band values for its bars, so it never went through useMicLevel and never inherited that hook's reduced-motion gating. Reduced-motion users got the mouth and vocal sac animating on every frame anyway. RecordingOverlay.css could not have caught this. The amplitude reaches the critter as an inline style, which beats a stylesheet rule, and that file's reduced-motion block only covers the transcribing text and dots. So the preference is read in the component and folded into the same expression that already rests the critter while transcribing. usePrefersReducedMotion is exported for that, since any future caller driving a critter from its own subscription has the same obligation. --- src/hooks/useMicLevel.ts | 8 +++++++- src/overlay/RecordingOverlay.tsx | 10 +++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/hooks/useMicLevel.ts b/src/hooks/useMicLevel.ts index 171b9de..3cc173e 100644 --- a/src/hooks/useMicLevel.ts +++ b/src/hooks/useMicLevel.ts @@ -31,8 +31,14 @@ function reduceMotionQuery(): MediaQueryList | null { * The OS reduce-motion preference, kept current if it changes mid-session. Read * once it would go stale for the life of the window, which for the wordmark is * the life of the app. + * + * Exported because a caller that drives a critter from its own `mic-level` + * subscription rather than from useMicLevel (the recording overlay does, since + * it needs the per-band values for its bars) has to honor the preference itself. + * A CSS `prefers-reduced-motion` rule cannot cover that case: the amplitude + * reaches the critter as inline style, which beats a stylesheet rule. */ -function usePrefersReducedMotion(): boolean { +export function usePrefersReducedMotion(): boolean { const [reduced, setReduced] = useState( () => reduceMotionQuery()?.matches ?? false, ); diff --git a/src/overlay/RecordingOverlay.tsx b/src/overlay/RecordingOverlay.tsx index cde54ea..1729638 100644 --- a/src/overlay/RecordingOverlay.tsx +++ b/src/overlay/RecordingOverlay.tsx @@ -5,6 +5,7 @@ import { CancelIcon } from "../components/icons"; import { DEFAULT_CRITTER_ID, getCritter } from "../components/icons/critters"; import "./RecordingOverlay.css"; import { commands } from "@/bindings"; +import { usePrefersReducedMotion } from "@/hooks/useMicLevel"; import i18n, { syncLanguageFromSettings } from "@/i18n"; import { MIC_LEVEL_EVENT, bandsToAmplitude } from "@/lib/mic-level"; import { getLanguageDirection } from "@/lib/utils/rtl"; @@ -22,6 +23,7 @@ const RecordingOverlay: React.FC = () => { const [isRaw, setIsRaw] = useState(false); const [levels, setLevels] = useState(Array(16).fill(0)); const smoothedLevelsRef = useRef(Array(16).fill(0)); + const reduceMotion = usePrefersReducedMotion(); const direction = getLanguageDirection(i18n.language); useEffect(() => { @@ -95,7 +97,13 @@ const RecordingOverlay: React.FC = () => { // the level, so adding one means deciding what the overlay shows instead. // bandsToAmplitude is shared with the menu wordmark's critter (useMicLevel) so // the same voice moves both the same way. - const amp = state === "recording" ? bandsToAmplitude(levels) : 0; + // + // Reduce-motion is checked here rather than left to RecordingOverlay.css, + // because the amplitude reaches the critter as an inline style and would win + // over any stylesheet rule. This window subscribes to `mic-level` directly for + // its per-band bars, so it does not inherit useMicLevel's own gating. + const amp = + state === "recording" && !reduceMotion ? bandsToAmplitude(levels) : 0; return (
Date: Mon, 27 Jul 2026 11:40:47 -0400 Subject: [PATCH 3/5] Snap to exact rest on the event path, not only on release useMicLevel had two writers of the live amplitude and only one applied the rest snap. The release tick snapped; the event listener did not. A stream of zero-valued frames -- the settings mic monitor sitting open -- keeps refreshing lastFrameAtRef, so the release tick's silence check never fires and cannot cover for the event path, which smooths asymptotically toward 0 without arriving. The visible cost is the click-croak: LiveFrog maps a nonzero amp to a defined sacScale, and FrogMascot suppresses the croak class whenever sacScale is set. So the frog looked closed but stopped croaking on click until the stream closed. Fixed in the writer both paths share rather than at the second call site, and moved the rule into mic-level.ts as settleToRest so it is unit-testable without a webview. That module exists to stop this math drifting apart in two copies, which is the shape of this bug. The new silent-frame test fails against the old inline snap. --- src/hooks/useMicLevel.ts | 18 ++++++++++------- src/lib/mic-level.test.ts | 42 ++++++++++++++++++++++++++++++++++++++- src/lib/mic-level.ts | 23 +++++++++++++++++++++ 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/hooks/useMicLevel.ts b/src/hooks/useMicLevel.ts index 3cc173e..5c4e4b3 100644 --- a/src/hooks/useMicLevel.ts +++ b/src/hooks/useMicLevel.ts @@ -3,6 +3,7 @@ import { listen } from "@tauri-apps/api/event"; import { MIC_LEVEL_EVENT, bandsToAmplitude, + settleToRest, smoothAmplitude, } from "@/lib/mic-level"; @@ -12,9 +13,6 @@ const SILENCE_AFTER_MS = 120; /** How often to apply the release once frames stop arriving. */ const RELEASE_TICK_MS = 80; -/** Below this the critter is visually closed, so snap to exact rest and stop. */ -const REST_EPSILON = 0.01; - const REDUCE_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; /** @@ -90,9 +88,16 @@ export function useMicLevel(enabled = true): number { let unlisten: (() => void) | undefined; let cancelled = false; + // The rest snap lives here so both writers get it rather than one remembering. + // "Exactly 0 at rest" is load-bearing: LiveFrog reads `amp > 0` to decide the + // frog may croak again, and exponential smoothing approaches 0 without ever + // arriving. A stream of zero-valued frames (the settings mic monitor left + // open) also keeps refreshing lastFrameAtRef, so the release tick's silence + // check never fires and cannot do the snapping on the event path's behalf. const apply = (next: number) => { - smoothedRef.current = next; - setAmplitude(next); + const settled = settleToRest(next); + smoothedRef.current = settled; + setAmplitude(settled); }; // Release to rest when frames stop. Without this the critter keeps the last @@ -101,8 +106,7 @@ export function useMicLevel(enabled = true): number { if (smoothedRef.current === 0) return; if (Date.now() - lastFrameAtRef.current < SILENCE_AFTER_MS) return; - const next = smoothAmplitude(smoothedRef.current, 0); - apply(next < REST_EPSILON ? 0 : next); + apply(smoothAmplitude(smoothedRef.current, 0)); }, RELEASE_TICK_MS); const start = async () => { diff --git a/src/lib/mic-level.test.ts b/src/lib/mic-level.test.ts index 112dd7c..5d88528 100644 --- a/src/lib/mic-level.test.ts +++ b/src/lib/mic-level.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { bandsToAmplitude, smoothAmplitude } from "./mic-level"; +import { bandsToAmplitude, settleToRest, smoothAmplitude } from "./mic-level"; describe("bandsToAmplitude", () => { it("is silent for a missing or empty frame", () => { @@ -84,3 +84,43 @@ describe("smoothAmplitude", () => { } }); }); + +describe("settleToRest", () => { + it("returns exactly 0 for a visually-closed amplitude", () => { + // Exactly 0, not merely small: LiveFrog tests `amp > 0`, so 0.0001 and 0 are + // different states to it even though they render identically. + expect(settleToRest(0.009)).toBe(0); + expect(settleToRest(0.0001)).toBe(0); + expect(settleToRest(0)).toBe(0); + }); + + it("leaves a visible amplitude alone", () => { + expect(settleToRest(0.5)).toBe(0.5); + expect(settleToRest(1)).toBe(1); + // The threshold is exclusive, so the boundary value itself still animates. + expect(settleToRest(0.01)).toBe(0.01); + }); + + it("reaches exact rest on a stream of silent frames, not just when frames stop", () => { + // The bug this guards: a mic monitor left open keeps delivering zero-valued + // frames, so the release path that used to own the snap never fires (its + // silence check keeps being reset) and smoothing alone only approaches 0. + // This is the event path, driven the way the listener drives it. + let value = 1; + for (let tick = 0; tick < 100; tick += 1) { + value = settleToRest(smoothAmplitude(value, bandsToAmplitude([0]))); + if (value === 0) break; + } + expect(value).toBe(0); + }); + + it("clamps garbage to rest rather than propagating it", () => { + // Same reasoning as the other two: the frame crosses a process boundary, and + // a NaN reaching `amp > 0` would read as false while a NaN scale would blank + // the critter's transform. + expect(settleToRest(Number.NaN)).toBe(0); + expect(settleToRest(-1)).toBe(0); + expect(settleToRest(Number.POSITIVE_INFINITY)).toBe(0); + expect(settleToRest(2)).toBe(1); + }); +}); diff --git a/src/lib/mic-level.ts b/src/lib/mic-level.ts index 0687ab9..e2d79f8 100644 --- a/src/lib/mic-level.ts +++ b/src/lib/mic-level.ts @@ -21,6 +21,9 @@ const ATTACK_WEIGHT = 0.4; /** Smoothing weight applied to the previous value when the level is falling. */ const RELEASE_WEIGHT = 0.75; +/** Below this the critter is visually closed, so it counts as rest. */ +const REST_EPSILON = 0.01; + function clamp01(value: number): number { if (!Number.isFinite(value)) return 0; return Math.min(1, Math.max(0, value)); @@ -58,3 +61,23 @@ export function smoothAmplitude(previous: number, target: number): number { const weight = to > from ? ATTACK_WEIGHT : RELEASE_WEIGHT; return from * weight + to * (1 - weight); } + +/** + * Collapse a visually-closed amplitude to exactly 0. + * + * Every writer of a live amplitude has to go through this, because "exactly 0 at + * rest" is what tells a caller the critter is idle: LiveFrog reads `amp > 0` to + * decide whether to hand FrogMascot a `sacScale`, and a defined sacScale + * suppresses the click-croak. Smoothing alone never gets there — it approaches 0 + * asymptotically — so a frame stream of silence (the settings mic monitor left + * open) would otherwise hold the value a hair above 0 indefinitely and leave the + * frog unable to croak until the stream closed. + * + * It lives here with the rest of the amplitude math for the reason this module + * exists: the snap used to be inline in one of useMicLevel's two writers, and the + * other one silently did without it. + */ +export function settleToRest(amplitude: number): number { + const value = clamp01(amplitude); + return value < REST_EPSILON ? 0 : value; +} From 28d6c23b1334844234c1716a8167b4d54417ad4d Mon Sep 17 00:00:00 2001 From: Joe Amditis <6799804+jamditis@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:02:41 -0400 Subject: [PATCH 4/5] Subscribe to reduce-motion on both MediaQueryList APIs tauri.conf.json sets minimumSystemVersion 10.15, and Catalina's WKWebView implements matchMedia without addEventListener -- that landed in Safari 14. So reduceMotionQuery() returns a live object that passes the null check and then throws the moment the effect subscribes to it. usePrefersReducedMotion mounts inside both LiveFrog and RecordingOverlay, so on those installs the throw would take the window's UI down. A blank app because of an animation preference is a bad trade. Feature-detects and falls back to addListener/removeListener. The helper is exported because the fallback branch cannot run in a modern engine: the only way to prove it is to call it with a query object shaped like the old one, which is what the new tests do. They fail if the detection is removed. --- src/hooks/useMicLevel.test.ts | 120 ++++++++++++++++++++++++++++++++++ src/hooks/useMicLevel.ts | 31 ++++++++- 2 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 src/hooks/useMicLevel.test.ts diff --git a/src/hooks/useMicLevel.test.ts b/src/hooks/useMicLevel.test.ts new file mode 100644 index 0000000..34bf332 --- /dev/null +++ b/src/hooks/useMicLevel.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "bun:test"; +import { onMediaQueryChange } from "./useMicLevel"; + +// The reduce-motion subscription has to work on both MediaQueryList APIs, because +// tauri.conf.json still ships a 10.15 minimum and Catalina's WKWebView implements +// matchMedia without addEventListener. That branch cannot execute in a modern +// engine, so the only way to prove it is to hand the function a query object +// shaped like the old one. + +type Handler = (event: MediaQueryListEvent) => void; + +interface FakeQuery { + /** The MediaQueryList stand-in, with only one of the two listener APIs. */ + query: MediaQueryList; + /** Listener calls in order, so a silent no-op cannot pass. */ + calls: string[]; + /** Fire a change the way the browser would, or throw if nothing is listening. */ + emit: (matches: boolean) => void; + isSubscribed: () => boolean; +} + +/** A Safari 14+ MediaQueryList: the modern listener API only. */ +function modernQuery(): FakeQuery { + const calls: string[] = []; + let registered: Handler | null = null; + const query = { + matches: false, + addEventListener(type: string, handler: Handler) { + calls.push(`add:${type}`); + registered = handler; + }, + removeEventListener(type: string, handler: Handler) { + calls.push(`remove:${type}`); + if (registered === handler) registered = null; + }, + }; + return { + query: query as unknown as MediaQueryList, + calls, + emit: (matches) => { + if (!registered) throw new Error("no listener registered"); + registered({ matches } as MediaQueryListEvent); + }, + isSubscribed: () => registered !== null, + }; +} + +/** A Catalina-era MediaQueryList: addListener/removeListener and nothing else. */ +function legacyQuery(): FakeQuery { + const calls: string[] = []; + let registered: Handler | null = null; + const query = { + matches: false, + addListener(handler: Handler) { + calls.push("addListener"); + registered = handler; + }, + removeListener(handler: Handler) { + calls.push("removeListener"); + if (registered === handler) registered = null; + }, + }; + return { + query: query as unknown as MediaQueryList, + calls, + emit: (matches) => { + if (!registered) throw new Error("no listener registered"); + registered({ matches } as MediaQueryListEvent); + }, + isSubscribed: () => registered !== null, + }; +} + +const noop: Handler = () => {}; + +describe("onMediaQueryChange", () => { + it("uses the modern listener API when it is available", () => { + const { query, calls, isSubscribed } = modernQuery(); + const unsubscribe = onMediaQueryChange(query, noop); + expect(calls).toEqual(["add:change"]); + expect(isSubscribed()).toBe(true); + unsubscribe(); + expect(calls).toEqual(["add:change", "remove:change"]); + expect(isSubscribed()).toBe(false); + }); + + it("falls back to addListener rather than throwing on an old WKWebView", () => { + // The regression this exists for: addEventListener is absent there, so calling + // it throws during mount, and because the hook mounts in both LiveFrog and + // RecordingOverlay the throw takes the whole UI down over an animation + // preference. + const { query, calls, isSubscribed } = legacyQuery(); + const unsubscribe = onMediaQueryChange(query, noop); + expect(calls).toEqual(["addListener"]); + expect(isSubscribed()).toBe(true); + unsubscribe(); + expect(calls).toEqual(["addListener", "removeListener"]); + expect(isSubscribed()).toBe(false); + }); + + it("delivers changes on both APIs, and stops after unsubscribe", () => { + // Registering without receiving would be a silent no-op — on the legacy path + // just as broken as the throw, only quieter. + for (const build of [modernQuery, legacyQuery]) { + const fake = build(); + const seen: boolean[] = []; + const unsubscribe = onMediaQueryChange(fake.query, (event) => + seen.push(event.matches), + ); + + fake.emit(true); + fake.emit(false); + expect(seen).toEqual([true, false]); + + unsubscribe(); + expect(() => fake.emit(true)).toThrow("no listener registered"); + expect(seen).toEqual([true, false]); + } + }); +}); diff --git a/src/hooks/useMicLevel.ts b/src/hooks/useMicLevel.ts index 5c4e4b3..547d6b0 100644 --- a/src/hooks/useMicLevel.ts +++ b/src/hooks/useMicLevel.ts @@ -25,6 +25,33 @@ function reduceMotionQuery(): MediaQueryList | null { return window.matchMedia(REDUCE_MOTION_QUERY); } +/** + * Subscribe to a media query across both listener APIs, returning the + * unsubscribe. + * + * `tauri.conf.json` still sets `minimumSystemVersion: "10.15"`, and Catalina's + * WKWebView predates `MediaQueryList.addEventListener` (Safari 14) while still + * implementing `matchMedia`. So the object exists, passes the null check, and + * throws when subscribed to. This hook mounts inside both LiveFrog and + * RecordingOverlay, so that throw would take the window's UI with it — a blank + * app because of an animation preference. + * + * Exported for the test: the fallback branch cannot run in a modern engine, so + * the only way to prove it works is to call it with a query object shaped like + * the old one. + */ +export function onMediaQueryChange( + query: MediaQueryList, + handler: (event: MediaQueryListEvent) => void, +): () => void { + if (typeof query.addEventListener === "function") { + query.addEventListener("change", handler); + return () => query.removeEventListener("change", handler); + } + query.addListener(handler); + return () => query.removeListener(handler); +} + /** * The OS reduce-motion preference, kept current if it changes mid-session. Read * once it would go stale for the life of the window, which for the wordmark is @@ -46,11 +73,11 @@ export function usePrefersReducedMotion(): boolean { if (!query) return; const onChange = (event: MediaQueryListEvent) => setReduced(event.matches); - query.addEventListener("change", onChange); + const unsubscribe = onMediaQueryChange(query, onChange); // Re-read on mount in case it changed between the initial state and here. setReduced(query.matches); - return () => query.removeEventListener("change", onChange); + return unsubscribe; }, []); return reduced; From fb45a8f97b5b4450042ec3c1d708a546815cc272 Mon Sep 17 00:00:00 2001 From: Joe Amditis <6799804+jamditis@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:59:49 -0400 Subject: [PATCH 5/5] fix: Gate mic-level events to active sessions --- src-tauri/src/managers/audio.rs | 43 +++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index 78f61be..2bc7b77 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -4,7 +4,7 @@ use crate::settings::{get_settings, AppSettings}; use crate::utils; use log::{debug, error, info}; use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tauri::Manager; @@ -116,6 +116,10 @@ pub enum MicrophoneMode { OnDemand, } +fn should_emit_mic_level(is_recording: bool, monitoring_requested: bool) -> bool { + is_recording || monitoring_requested +} + /* ──────────────────────────────────────────────────────────────── */ /// Returns the VAD model path to hand to the VAD engine. @@ -137,6 +141,8 @@ fn vad_engine_path(vad_path: &Path) -> Result<&Path, anyhow::Error> { fn create_audio_recorder( vad_path: &Path, app_handle: &tauri::AppHandle, + is_recording: Arc>, + monitoring_requested: Arc, ) -> Result { let silero = SileroVad::new(vad_engine_path(vad_path)?, 0.3) .map_err(|e| anyhow::anyhow!("Failed to create SileroVad: {}", e))?; @@ -150,7 +156,11 @@ fn create_audio_recorder( .with_level_callback({ let app_handle = app_handle.clone(); move |levels| { - utils::emit_levels(&app_handle, &levels); + let is_recording = *is_recording.lock().unwrap(); + let monitoring_requested = monitoring_requested.load(Ordering::Relaxed); + if should_emit_mic_level(is_recording, monitoring_requested) { + utils::emit_levels(&app_handle, &levels); + } } }); @@ -171,6 +181,9 @@ pub struct AudioRecordingManager { // True when the live settings-screen level meter opened the stream itself // (so it knows it owns the close on the way out). monitoring: Arc>, + // True whenever the settings meter requested live levels, including when an + // always-on stream was already open and the meter does not own that stream. + monitoring_requested: Arc, did_mute: Arc>, close_generation: Arc, } @@ -195,6 +208,7 @@ impl AudioRecordingManager { is_open: Arc::new(Mutex::new(false)), is_recording: Arc::new(Mutex::new(false)), monitoring: Arc::new(Mutex::new(false)), + monitoring_requested: Arc::new(AtomicBool::new(false)), did_mute: Arc::new(Mutex::new(false)), close_generation: Arc::new(AtomicU64::new(0)), }; @@ -295,7 +309,12 @@ impl AudioRecordingManager { tauri::path::BaseDirectory::Resource, ) .map_err(|e| anyhow::anyhow!("Failed to resolve VAD path: {}", e))?; - *recorder_opt = Some(create_audio_recorder(&vad_path, &self.app_handle)?); + *recorder_opt = Some(create_audio_recorder( + &vad_path, + &self.app_handle, + Arc::clone(&self.is_recording), + Arc::clone(&self.monitoring_requested), + )?); } Ok(()) } @@ -384,6 +403,7 @@ impl AudioRecordingManager { /// nothing else still needs it (no active recording, not always-on mode). pub fn set_monitoring(&self, enable: bool) -> Result<(), anyhow::Error> { if enable { + self.monitoring_requested.store(true, Ordering::Relaxed); // Cancel any pending lazy close from a just-finished recording so our // fresh monitor stream is not torn down underneath us. self.close_generation.fetch_add(1, Ordering::SeqCst); @@ -392,9 +412,14 @@ impl AudioRecordingManager { let already_open = *self.is_open.lock().unwrap(); *self.monitoring.lock().unwrap() = !already_open; if !already_open { - self.start_microphone_stream()?; + if let Err(error) = self.start_microphone_stream() { + *self.monitoring.lock().unwrap() = false; + self.monitoring_requested.store(false, Ordering::Relaxed); + return Err(error); + } } } else { + self.monitoring_requested.store(false, Ordering::Relaxed); let we_opened = *self.monitoring.lock().unwrap(); *self.monitoring.lock().unwrap() = false; let recording = *self.is_recording.lock().unwrap(); @@ -567,7 +592,7 @@ impl AudioRecordingManager { #[cfg(test)] mod tests { - use super::vad_engine_path; + use super::{should_emit_mic_level, vad_engine_path}; use std::ffi::OsString; use std::fs; use std::path::PathBuf; @@ -583,6 +608,14 @@ mod tests { (dir, model) } + #[test] + fn mic_level_emission_requires_recording_or_monitor_session() { + assert!(!should_emit_mic_level(false, false)); + assert!(should_emit_mic_level(true, false)); + assert!(should_emit_mic_level(false, true)); + assert!(should_emit_mic_level(true, true)); + } + // Regression test for issue #56: a Windows profile path with Cyrillic, // CJK, or accented Latin characters must survive the VAD path handoff. #[test]