Skip to content

Performance: Zero-allocation optimization for AudioEngine metrics polling#236

Open
ysdede wants to merge 1 commit intomasterfrom
performance/zero-allocation-metrics-10576770497986049063
Open

Performance: Zero-allocation optimization for AudioEngine metrics polling#236
ysdede wants to merge 1 commit intomasterfrom
performance/zero-allocation-metrics-10576770497986049063

Conversation

@ysdede
Copy link
Copy Markdown
Owner

@ysdede ysdede commented Mar 31, 2026

What changed

  • Modified AudioSegmentProcessor.getStats and getStateInfo to optionally accept an out parameter to mutate directly instead of allocating new objects.
  • Updated AudioEngine to instantiate and use pre-allocated cached objects (_cachedStats and _cachedStateInfo) when calling these getters from the high-frequency handleAudioChunk loop.
  • Added a new codebase journal entry documenting this zero-allocation pattern.

Why it was needed

The handleAudioChunk method runs continuously at 100+ frames per second during audio capture. Previously, polling getStats() and getStateInfo() inside this hot path caused new objects to be allocated on every frame, leading to significant garbage collection churn.

Impact

Eliminated object allocations and spread operations in the audio metrics polling loop. This significantly reduces GC overhead during active transcription streaming.

How to verify

  1. Start the app locally with npm run dev.
  2. Begin a recording.
  3. Use Chrome DevTools Performance or Memory Profiler to inspect allocations during streaming; you should observe fewer short-lived allocations related to CurrentStats and ProcessorState.
  4. Run npm run test to verify AudioSegmentProcessor behaviors remain unchanged.

PR created automatically by Jules for task 10576770497986049063 started by @ysdede

Summary by Sourcery

Optimize audio metrics polling to avoid per-frame allocations in the hot audio processing path.

Enhancements:

  • Allow AudioSegmentProcessor metrics getters to write into optional preallocated output objects to support zero-allocation usage.
  • Use cached stats and state containers in AudioEngine when updating UI metrics during audio chunk handling to reduce garbage collection overhead.
  • Document the zero-allocation metrics polling pattern in the codebase journal for future performance-oriented refactors.

Summary by CodeRabbit

  • Refactoring

    • Optimized audio metrics collection to improve performance during real-time processing, reducing computational overhead.
  • Documentation

    • Updated documentation to reflect audio processing improvements.

@google-labs-jules
Copy link
Copy Markdown
Contributor

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 31, 2026

📝 Walkthrough

Walkthrough

This change implements zero-allocation metric retrieval by introducing optional pre-allocated output parameters to AudioSegmentProcessor's getStats() and getStateInfo() methods. AudioEngine is updated to leverage cached objects via these new parameters, eliminating per-chunk garbage collection overhead.

Changes

Cohort / File(s) Summary
Documentation
.jules/bolt.md
Added changelog entry documenting the refactor for zero-allocation metrics retrieval via optional out parameters.
Core Metric Methods
src/lib/audio/AudioSegmentProcessor.ts
Modified getStats() and getStateInfo() to accept optional pre-allocated out parameters. When provided, methods write hot-path metrics into the supplied object instead of allocating new ones; defaults to existing behavior when parameter is absent.
Engine Integration
src/lib/audio/AudioEngine.ts
Introduced _cachedStats and _cachedStateInfo cached objects and updated per-chunk metric retrievals to use the new optional out parameters, with appropriate fallback defaults for missing fields.

Possibly Related PRs

Poem

🐰 No more trash heaps piling high,
Objects dance and multiply,
Reused with cache, they never die,
Zero allocation—oh my, oh my!
Garbage collection bids goodbye! ♻️

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: a performance optimization that eliminates object allocations in AudioEngine's high-frequency metrics polling loop.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch performance/zero-allocation-metrics-10576770497986049063

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've left some high level feedback:

  • The getStats / getStateInfo signatures now claim to always return fully populated objects but can actually return partially-populated out objects; consider using overloads or a separate getStatsInto(out)/getStateInfoInto(out) API to avoid misleading types and the as/any casts.
  • The cached objects in AudioEngine are typed as Partial<ReturnType<...>>, but only a few scalar fields are ever written or read; tightening these types to explicit lightweight interfaces would make the hot-path contract clearer and reduce the chance of accidental future misuse.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `getStats` / `getStateInfo` signatures now claim to always return fully populated objects but can actually return partially-populated `out` objects; consider using overloads or a separate `getStatsInto(out)`/`getStateInfoInto(out)` API to avoid misleading types and the `as`/`any` casts.
- The cached objects in `AudioEngine` are typed as `Partial<ReturnType<...>>`, but only a few scalar fields are ever written or read; tightening these types to explicit lightweight interfaces would make the hot-path contract clearer and reduce the chance of accidental future misuse.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a zero-allocation pattern for high-frequency metrics polling in the audio processing pipeline by allowing callers to pass pre-allocated objects to be mutated. While this improves performance, the reviewer noted that the current implementation of getStats violates the CurrentStats type contract by returning an incomplete object, which could lead to runtime errors for other consumers. Additionally, the reviewer suggested replacing the as any cast in getStateInfo with a more specific type cast to improve type safety.

Comment on lines +528 to +539
getStats(out?: Partial<CurrentStats>): CurrentStats {
const stats = this.state.currentStats;
if (out) {
out.noiseFloor = stats.noiseFloor;
out.snr = stats.snr;
out.snrThreshold = stats.snrThreshold;
out.minSnrThreshold = stats.minSnrThreshold;
out.energyRiseThreshold = stats.energyRiseThreshold;
// We only need top-level primitives for hot-path metrics polling
// Safely cast out to CurrentStats since the caller only reads these fields
return out as CurrentStats;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The implementation of getStats with an out parameter violates the CurrentStats type contract. The CurrentStats interface requires silence and speech properties, but this path only populates the primitive fields.

While the internal comment suggests this is safe because the current caller only reads specific fields, AudioSegmentProcessor is an exported class. Any other consumer using the out parameter will receive an object that is missing required properties, leading to potential runtime crashes (e.g., TypeError: Cannot read properties of undefined) when accessing stats.silence.

To maintain type safety and support zero-allocation, consider requiring the caller to provide the nested objects in the out parameter if they are needed, or use function overloads to return a more accurate type.

out.noiseFloor = this.state.noiseFloor;
out.snr = this.state.currentStats.snr;
out.speechStartTime = this.state.speechStartTime;
return out as any;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Avoid using as any for the return cast. Since all fields required by the return type are explicitly assigned within the if (out) block, you should cast to the actual return type to maintain type safety.

Suggested change
return out as any;
return out as { inSpeech: boolean; noiseFloor: number; snr: number; speechStartTime: number | null };

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1f105804-90c3-46c2-a0fb-3d21cbb028c8

📥 Commits

Reviewing files that changed from the base of the PR and between 474dbe6 and e27df89.

📒 Files selected for processing (3)
  • .jules/bolt.md
  • src/lib/audio/AudioEngine.ts
  • src/lib/audio/AudioSegmentProcessor.ts

Comment on lines +528 to +539
getStats(out?: Partial<CurrentStats>): CurrentStats {
const stats = this.state.currentStats;
if (out) {
out.noiseFloor = stats.noiseFloor;
out.snr = stats.snr;
out.snrThreshold = stats.snrThreshold;
out.minSnrThreshold = stats.minSnrThreshold;
out.energyRiseThreshold = stats.energyRiseThreshold;
// We only need top-level primitives for hot-path metrics polling
// Safely cast out to CurrentStats since the caller only reads these fields
return out as CurrentStats;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate and examine the AudioSegmentProcessor.ts file
fd -t f "AudioSegmentProcessor.ts"

Repository: ysdede/keet

Length of output: 93


🏁 Script executed:

# Check the actual implementation at the specified lines
sed -n '528,560p' src/lib/audio/AudioSegmentProcessor.ts

Repository: ysdede/keet

Length of output: 1433


🏁 Script executed:

# Find the CurrentStats type definition
rg "type CurrentStats|interface CurrentStats" -A 10

Repository: ysdede/keet

Length of output: 716


🏁 Script executed:

# Search for usages of getStats and getStateInfo methods
rg "\.getStats\(|\.getStateInfo\(" -B 2 -A 2

Repository: ysdede/keet

Length of output: 8531


Fix unsound return typing in out-parameter getters.

When getStats(out) is called with an out parameter, only 5 fields are written (noiseFloor, snr, snrThreshold, minSnrThreshold, energyRiseThreshold), but the required fields silence and speech remain undefined. The as CurrentStats cast hides this, allowing the return type to claim completeness while the object is incomplete. Similarly, getStateInfo uses as any to escape type safety.

The legitimate zero-allocation pattern for metrics polling in hot paths should be preserved, but through typed overloads instead of unsafe casts:

Proposed typed-overload fix (keeps zero-allocation path, removes unsafe casts)
+type HotPathStatsOut = Pick<CurrentStats, 'noiseFloor' | 'snr' | 'snrThreshold' | 'minSnrThreshold' | 'energyRiseThreshold'>;
+type StateInfoSnapshot = { inSpeech: boolean; noiseFloor: number; snr: number; speechStartTime: number | null };
+
-    getStats(out?: Partial<CurrentStats>): CurrentStats {
+    getStats(): CurrentStats;
+    getStats<T extends HotPathStatsOut>(out: T): T;
+    getStats(out?: HotPathStatsOut): CurrentStats | HotPathStatsOut {
         const stats = this.state.currentStats;
         if (out) {
             out.noiseFloor = stats.noiseFloor;
             out.snr = stats.snr;
             out.snrThreshold = stats.snrThreshold;
             out.minSnrThreshold = stats.minSnrThreshold;
             out.energyRiseThreshold = stats.energyRiseThreshold;
-            // We only need top-level primitives for hot-path metrics polling
-            // Safely cast out to CurrentStats since the caller only reads these fields
-            return out;
+            return out;
         }
         return {
             ...stats,
             silence: { ...stats.silence },
             speech: { ...stats.speech }
         };
     }
@@
-    getStateInfo(out?: { inSpeech?: boolean; noiseFloor?: number; snr?: number; speechStartTime?: number | null }): { inSpeech: boolean; noiseFloor: number; snr: number; speechStartTime: number | null } {
+    getStateInfo(): StateInfoSnapshot;
+    getStateInfo<T extends StateInfoSnapshot>(out: T): T;
+    getStateInfo(out?: StateInfoSnapshot): StateInfoSnapshot {
         if (out) {
             out.inSpeech = this.state.inSpeech;
             out.noiseFloor = this.state.noiseFloor;
             out.snr = this.state.currentStats.snr;
             out.speechStartTime = this.state.speechStartTime;
-            return out as any;
+            return out;
         }
         return {
             inSpeech: this.state.inSpeech,
             noiseFloor: this.state.noiseFloor,
             snr: this.state.currentStats.snr,
             speechStartTime: this.state.speechStartTime
         };
     }

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