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]