Skip to content

Animate the frog's mouth and menu-window critter from live mic level - #233

Merged
jamditis merged 5 commits into
mainfrom
feat/11-mouth-openness
Jul 28, 2026
Merged

Animate the frog's mouth and menu-window critter from live mic level#233
jamditis merged 5 commits into
mainfrom
feat/11-mouth-openness

Conversation

@jamditis

Copy link
Copy Markdown
Owner

Progresses #11. The overlay critter already moved with your voice; the menu wordmark never subscribed to mic-level, and the mouth did not move on either surface.

The mouth was the part that was actually broken

FrogMascot suppresses its croak CSS class whenever sacScale is set (stateClass), and .frog.croak is the only rule that swaps mouthClosed for mouthOpen in FrogMascot.css. So mic-driven animation inflated the throat sac and left the mouth sitting in its closed smile for an entire dictation — the one thing #11's acceptance criteria names.

The mouth now rides the same signal as the sac: opacity crossfades between the two shapes and the open shape scales on Y from a transformOrigin at the top of the jaw, so it reads as opening rather than as one shape dissolving into another. No new prop and no contract change — sacScale stays the single mic-level input every critter honors.

One signal, one feel

src/lib/mic-level.ts holds the two pure functions both surfaces need:

  • bandsToAmplitude — 16 spectrum bands to one 0..1 value, by peak so a voice concentrated in a few bands still reads as loud. This is the overlay's previous inline expression moved verbatim (peak x 1.4, clamped), so the overlay's feel is unchanged.
  • smoothAmplitude — the fast-attack / slow-release curve InputLevelMeter already uses, which is what makes a level read as a voice instead of as flicker.

Pure, so the feel is unit-testable without a webview, a mic, or Tauri. useMicLevel wraps them for the menu side.

Two bugs found while wiring it

The hook has to release to rest by itself. mic-level stops firing when dictation ends and sends no final zero frame, so a hook that only updated on events would freeze at whatever the last syllable measured and leave the critter half-inflated until the next dictation. It now decays to exact 0 once frames stop, which is also why a caller needs no recording-state gate.

RecordingOverlay leaked its listeners. The useEffect cleanup was returned from the inner async function, so useEffect received a promise rather than the cleanup and all three listeners survived unmount. Fixed with the cleanup/cancelled pattern, which also covers unmounting mid-await.

Details worth knowing

Verification

bun test src 67 pass / 0 fail (10 new), tsc --noEmit clean, eslint src clean, prettier --check clean — the four things CI gates. Two codex passes, no findings.

Not claiming #11 done: the acceptance criteria also cover whichever critter is selected (#8), and the frog is still the only critter, so that half is untestable until a second one lands.

wake-20260727T0805-0faa25

#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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1d53f70c1d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/components/icons/FrogMascot.tsx
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.
@jamditis

Copy link
Copy Markdown
Owner Author

Fixed in c855e32. Confirmed the mechanism before fixing: RecordingOverlay subscribes to mic-level directly (it needs the per-band values for its bars), so it never passed through useMicLevel and never inherited that hook's reduced-motion gating. And RecordingOverlay.css's prefers-reduced-motion block covers only .transcribing-text, .state-dots i, and the ::after, so it would not have caught the critter even if inline styles did not already beat stylesheet rules.

The preference is now read in the component and folded into the expression that already rests the critter while transcribing, and usePrefersReducedMotion is exported so any future caller driving a critter from its own subscription has the same tool.

One pre-existing sibling I am not changing here: the overlay's level bars set height, opacity, and a transition inline from the same levels state, so they are unaffected by the CSS rule for the same reason and still animate under reduced motion. That predates this PR and changing it would alter existing behavior beyond the mouth animation, so it is filed as #234 rather than folded in.

@jamditis
jamditis marked this pull request as draft July 27, 2026 14:06
@jamditis
jamditis marked this pull request as ready for review July 27, 2026 14:06

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c855e326bd

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/hooks/useMicLevel.ts
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.
@jamditis

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a04ad13609

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/hooks/useMicLevel.ts Outdated
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.
@jamditis

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 28d6c23b13

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/hooks/useMicLevel.ts
@jamditis

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: fb45a8f97b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@jamditis
jamditis merged commit 991f616 into main Jul 28, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant