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] 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.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 new file mode 100644 index 0000000..547d6b0 --- /dev/null +++ b/src/hooks/useMicLevel.ts @@ -0,0 +1,173 @@ +import { useEffect, useRef, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { + MIC_LEVEL_EVENT, + bandsToAmplitude, + settleToRest, + 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; + +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); +} + +/** + * 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 + * 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. + */ +export function usePrefersReducedMotion(): boolean { + const [reduced, setReduced] = useState( + () => reduceMotionQuery()?.matches ?? false, + ); + + useEffect(() => { + const query = reduceMotionQuery(); + if (!query) return; + + const onChange = (event: MediaQueryListEvent) => setReduced(event.matches); + const unsubscribe = onMediaQueryChange(query, onChange); + // Re-read on mount in case it changed between the initial state and here. + setReduced(query.matches); + + return unsubscribe; + }, []); + + 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; + + // 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) => { + const settled = settleToRest(next); + smoothedRef.current = settled; + setAmplitude(settled); + }; + + // 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; + + apply(smoothAmplitude(smoothedRef.current, 0)); + }, 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..5d88528 --- /dev/null +++ b/src/lib/mic-level.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "bun:test"; +import { bandsToAmplitude, settleToRest, 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); + } + }); +}); + +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 new file mode 100644 index 0000000..e2d79f8 --- /dev/null +++ b/src/lib/mic-level.ts @@ -0,0 +1,83 @@ +// 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; + +/** 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)); +} + +/** + * 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); +} + +/** + * 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; +} diff --git a/src/overlay/RecordingOverlay.tsx b/src/overlay/RecordingOverlay.tsx index 8cdd7dd..1729638 100644 --- a/src/overlay/RecordingOverlay.tsx +++ b/src/overlay/RecordingOverlay.tsx @@ -5,7 +5,9 @@ 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"; type OverlayState = "recording" | "transcribing" | "processing"; @@ -21,9 +23,13 @@ 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(() => { + 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 +48,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 +61,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 +95,15 @@ 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. + // bandsToAmplitude is shared with the menu wordmark's critter (useMicLevel) so + // the same voice moves both the same way. + // + // 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" && levels.length - ? Math.min(1, Math.max(0, ...levels) * 1.4) - : 0; + state === "recording" && !reduceMotion ? bandsToAmplitude(levels) : 0; return (