Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions src-tauri/src/managers/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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<Mutex<bool>>,
monitoring_requested: Arc<AtomicBool>,
) -> Result<AudioRecorder, anyhow::Error> {
let silero = SileroVad::new(vad_engine_path(vad_path)?, 0.3)
.map_err(|e| anyhow::anyhow!("Failed to create SileroVad: {}", e))?;
Expand All @@ -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);
}
}
});

Expand All @@ -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<Mutex<bool>>,
// 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<AtomicBool>,
did_mute: Arc<Mutex<bool>>,
close_generation: Arc<AtomicU64>,
}
Expand All @@ -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)),
};
Expand Down Expand Up @@ -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(())
}
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand All @@ -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]
Expand Down
27 changes: 25 additions & 2 deletions src/components/icons/FrogMascot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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})`,
Comment thread
jamditis marked this conversation as resolved.
transformOrigin: "100px 119px",
};

const irisStyle = { transform: `translate(${irisDX}px, ${irisDY}px)` };

return (
Expand Down Expand Up @@ -137,9 +158,10 @@ const FrogMascot = ({
<circle cx="92" cy="100" r="2.8" fill="#2b5121" />
<circle cx="108" cy="100" r="2.8" fill="#2b5121" />

{/* mouth: closed smile / open croak */}
{/* mouth: closed smile / open croak, or crossfaded live by mic level */}
<path
className="mouthClosed"
style={mouthClosedStyle}
d="M60 122 Q100 156 140 122"
fill="none"
stroke="#2b5121"
Expand All @@ -148,6 +170,7 @@ const FrogMascot = ({
/>
<ellipse
className="mouthOpen"
style={mouthOpenStyle}
cx="100"
cy="130"
rx="19"
Expand Down
21 changes: 18 additions & 3 deletions src/components/icons/LiveFrog.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import { useEffect, useRef, useState } from "react";
import { getCritter } from "./critters";
import { playRibbit } from "../../lib/ribbit";
import { useMicLevel } from "@/hooks/useMicLevel";

// The "alive" layer for any critter: it blinks on its own, its eyes follow the
// cursor, and it croaks when clicked. Used for the wordmark and the sidebar nav
// icon. The blink cadence and cursor-follow math live here once, so a new critter
// is an SVG plus a registry entry rather than a reimplementation of both.
// cursor, it croaks when clicked, and it answers your voice while you dictate.
// Used for the wordmark and the sidebar nav icon. The blink cadence, cursor-follow
// math, and mic wiring live here once, so a new critter is an SVG plus a registry
// entry rather than a reimplementation of all three.
interface LiveFrogProps {
size?: number | string;
className?: string;
follow?: boolean;
idleBlink?: boolean;
clickCroak?: boolean;
/**
* Animate the critter's mic-level visual from live input while dictating (and
* while the settings mic monitor is open). Rests when no level is flowing, and
* stays at rest under prefers-reduced-motion.
*/
micLevel?: boolean;
/** Which critter to render. Falls back to the default for an unknown id. */
critter?: string;
}
Expand All @@ -22,9 +30,11 @@ const LiveFrog = ({
follow = true,
idleBlink = true,
clickCroak = true,
micLevel = true,
critter,
}: LiveFrogProps) => {
const { Component: Mascot } = getCritter(critter);
const amp = useMicLevel(micLevel);
const ref = useRef<HTMLSpanElement>(null);
const [blink, setBlink] = useState(false);
const [croak, setCroak] = useState(false);
Expand Down Expand Up @@ -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}
/>
Expand Down
120 changes: 120 additions & 0 deletions src/hooks/useMicLevel.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
});
});
Loading
Loading