Skip to content

Added __Real-Time Audio Visualization Panel__ has been implemented wi…#391

Open
TomHacker69 wants to merge 1 commit into
sudip-mondal-2002:mainfrom
TomHacker69:main
Open

Added __Real-Time Audio Visualization Panel__ has been implemented wi…#391
TomHacker69 wants to merge 1 commit into
sudip-mondal-2002:mainfrom
TomHacker69:main

Conversation

@TomHacker69

@TomHacker69 TomHacker69 commented Jun 28, 2026

Copy link
Copy Markdown

Real-Time Audio Visualization Panel has been implemented with the following components:
#379

1. web/visualizer.js - JavaScript Visualization Module

  • Linear Waveform mode: Scrolling canvas that maps audio amplitude history over time, mirroring the SoundCloud-style track overview. Playback progress is highlighted with a dynamic gradient across the waveform, and a vertical position indicator shows the current playback point.
  • Circular Pulse mode: A minimalist radial ring centered in the panel that expands and pulses dynamically to bass/low frequencies (from the first ~128 FFT bins). Frequency tick marks around the ring react to sample energy.
  • 5 Color Themes: Amber, Cyan, Violet, Green, Sunset - each with primary/secondary/glow colors for consistency with the Amplitron UI.
  • Sensitivity slider (0.1–3.0 range) to adjust visual response amplitude.
  • Fallback/loading state: Shows a "Waiting for audio..." placeholder with a subtle animated sine wave when no analyzer data is available yet.
  • Performance optimized: Uses requestAnimationFrame loop, HiDPI-aware Canvas rendering via devicePixelRatio, and automatically enables/disables the C++ analyzer capture when the panel is opened/closed to avoid unnecessary CPU usage.

2. src/main.cpp - C++ Bridge Functions (3 new Emscripten exports)

  • enable_analyzer(int enabled) - Toggles the AnalyzerCapture component on/off
  • get_analyzer_sequence() - Returns the current snapshot sequence counter (for change detection)
  • copy_analyzer_snapshot() - Copies 2048 pre/post-chain audio samples into JS-accessible WASM heap buffers

3. web/shell.html - Integration

  • Script tag added to load visualizer.js in the Emscripten shell page
  • The panel renders as a fixed-bottom collapsible overlay with a toggle button centered at the bottom of the screen

Architecture

The panel connects to the existing AnalyzerCapture infrastructure in the C++ audio engine, which captures 2048-sample ring buffers of both pre-chain (input) and post-chain (output) audio. The JS module polls the sequence counter each frame and copies snapshot data via Module.ccall with temporary WASM heap allocations, then renders using the HTML5 Canvas 2D API.

Summary by CodeRabbit

  • New Features
    • Added an in-page audio visualizer panel with live canvas-based rendering.
    • Users can show or hide the panel, switch between waveform and circular views, change the color theme, and adjust sensitivity.
    • The visualizer updates in real time and includes a fallback message when no audio data is available.

@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Three Emscripten-exported C bridge functions (enable_analyzer, get_analyzer_sequence, copy_analyzer_snapshot) are added to src/main.cpp. A new web/visualizer.js (665 lines) implements a real-time audio visualization panel using HTML5 Canvas, polling the WASM bridge via ccall to render waveform and circular modes. web/shell.html loads the new script.

Changes

Audio Visualizer Panel

Layer / File(s) Summary
C++ WASM bridge functions
src/main.cpp
Adds enable_analyzer, get_analyzer_sequence, and copy_analyzer_snapshot as EMSCRIPTEN_KEEPALIVE C ABI exports, with null/range checks and returning sample counts.
JS state, theme table, and WASM polling
web/visualizer.js
Defines all mutable visualization state and theme table, then implements the polling function that allocates heap buffers, calls copy_analyzer_snapshot, computes smoothed bass energy, downsamples peaks, and appends frames to scrolling history.
Waveform and circular canvas rendering
web/visualizer.js
Waveform renderer draws scrolling mirrored peaks with a progress-highlighted gradient and waiting placeholder. Circular renderer draws a bass-driven pulsing glow ring with tick marks and a BASS RESPONSE label. renderFrame() routes to the active mode and drives the requestAnimationFrame loop.
UI controls, panel wiring, and init
web/visualizer.js, web/shell.html
init() constructs all panel DOM elements (toggle, mode switch, theme select, sensitivity input, close button), wires ResizeObserver and window resize, hooks #audio-unlock, and calls enable_analyzer via ccall on panel show/hide. shell.html loads visualizer.js.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • sudip-mondal-2002/Amplitron#24: Adds the AudioEngine ring-buffer capture, analyzer_sequence_, and copy_analyzer_snapshot C++ implementation that the new JS bridge functions in this PR call into.

Suggested labels

type:feature, type:design

Suggested reviewers

  • sudip-mondal-2002

Poem

🐰 Hoppity hop, the waveforms glow,
Bass rings pulse with a circular show,
WASM bridges pass samples through,
Canvas paints the audio true,
A visualizer blooms — watch it go! 🎵

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.82% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly refers to the main change: a real-time audio visualization panel.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

Warning

⚠️ This pull request shows signs of AI-generated slop (defensive_cruft). It has been flagged by CodeRabbit slop detection and should be reviewed carefully.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/main.cpp`:
- Around line 319-324: The exported bridge function copy_analyzer_snapshot
currently accepts any positive sample_count and forwards it directly to
g_engine_ptr->copy_analyzer_snapshot, so enforce the documented 2048-sample
contract here before copying. Update copy_analyzer_snapshot to reject or clamp
any oversized request above the analyzer snapshot size, while keeping the
existing null-pointer and nonpositive checks intact, so the ABI never passes an
out-of-range length to the engine.

In `@web/visualizer.js`:
- Around line 84-92: The bass-energy calculation in the visualizer is using the
first 128 values from outView as if they were FFT bins, but outView is
time-domain data, so this should be changed to derive bass from actual
low-frequency content instead of an arbitrary buffer slice. Update the logic in
the bass-energy section of visualizer.js to use the appropriate frequency-domain
source or a proper low-frequency aggregation path, and keep the smoothing via
smoothedBass unchanged once bassEnergy is computed correctly.
- Around line 62-120: The snapshot sampling logic in visualizer.js leaks WASM
heap buffers when an exception occurs after _malloc but before the normal
cleanup path. Update the snapshot block around copy_analyzer_snapshot, the
Float32Array view creation, and the downstream processing so inPtr and outPtr
are always freed in a finally-style cleanup, even when the catch returns false;
alternatively, reuse persistent buffers for the panel lifetime if that better
fits the surrounding code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3bb153d4-674d-4fa3-ae58-6c9d87e511c2

📥 Commits

Reviewing files that changed from the base of the PR and between 106d0e4 and 0ad9963.

📒 Files selected for processing (3)
  • src/main.cpp
  • web/shell.html
  • web/visualizer.js

Comment thread src/main.cpp
Comment on lines +319 to +324
extern "C" EMSCRIPTEN_KEEPALIVE int copy_analyzer_snapshot(float* input_dest_ptr,
float* output_dest_ptr,
int sample_count) {
if (!g_engine_ptr || !input_dest_ptr || !output_dest_ptr || sample_count <= 0) return 0;
bool ok = g_engine_ptr->copy_analyzer_snapshot(input_dest_ptr, output_dest_ptr, sample_count);
return ok ? sample_count : 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Clamp or reject oversized snapshot requests before copying.

The bridge accepts any positive sample_count and passes it to the engine, despite documenting a clamp to the analyzer size. Since this is an exported pointer+length ABI, enforce the 2048-sample contract here before copying.

Proposed fix
 extern "C" EMSCRIPTEN_KEEPALIVE int copy_analyzer_snapshot(float* input_dest_ptr,
                                                            float* output_dest_ptr,
                                                            int sample_count) {
     if (!g_engine_ptr || !input_dest_ptr || !output_dest_ptr || sample_count <= 0) return 0;
-    bool ok = g_engine_ptr->copy_analyzer_snapshot(input_dest_ptr, output_dest_ptr, sample_count);
-    return ok ? sample_count : 0;
+    constexpr int max_analyzer_samples = 2048;
+    const int samples_to_copy =
+        sample_count > max_analyzer_samples ? max_analyzer_samples : sample_count;
+    bool ok =
+        g_engine_ptr->copy_analyzer_snapshot(input_dest_ptr, output_dest_ptr, samples_to_copy);
+    return ok ? samples_to_copy : 0;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
extern "C" EMSCRIPTEN_KEEPALIVE int copy_analyzer_snapshot(float* input_dest_ptr,
float* output_dest_ptr,
int sample_count) {
if (!g_engine_ptr || !input_dest_ptr || !output_dest_ptr || sample_count <= 0) return 0;
bool ok = g_engine_ptr->copy_analyzer_snapshot(input_dest_ptr, output_dest_ptr, sample_count);
return ok ? sample_count : 0;
extern "C" EMSCRIPTEN_KEEPALIVE int copy_analyzer_snapshot(float* input_dest_ptr,
float* output_dest_ptr,
int sample_count) {
if (!g_engine_ptr || !input_dest_ptr || !output_dest_ptr || sample_count <= 0) return 0;
constexpr int max_analyzer_samples = 2048;
const int samples_to_copy =
sample_count > max_analyzer_samples ? max_analyzer_samples : sample_count;
bool ok =
g_engine_ptr->copy_analyzer_snapshot(input_dest_ptr, output_dest_ptr, samples_to_copy);
return ok ? samples_to_copy : 0;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main.cpp` around lines 319 - 324, The exported bridge function
copy_analyzer_snapshot currently accepts any positive sample_count and forwards
it directly to g_engine_ptr->copy_analyzer_snapshot, so enforce the documented
2048-sample contract here before copying. Update copy_analyzer_snapshot to
reject or clamp any oversized request above the analyzer snapshot size, while
keeping the existing null-pointer and nonpositive checks intact, so the ABI
never passes an out-of-range length to the engine.

Comment thread web/visualizer.js
Comment on lines +62 to +120
const inPtr = Module._malloc(n * 4);
const outPtr = Module._malloc(n * 4);
if (!inPtr || !outPtr) {
if (inPtr) Module._free(inPtr);
if (outPtr) Module._free(outPtr);
return hasData;
}

const count = Module.ccall(
"copy_analyzer_snapshot",
"number",
["number", "number", "number"],
[inPtr, outPtr, n]
);

if (count > 0) {
const inView = new Float32Array(Module.HEAPF32.buffer, inPtr, n);
const outView = new Float32Array(Module.HEAPF32.buffer, outPtr, n);
currentInputSamples.set(inView);
currentOutputSamples.set(outView);
hasData = true;

// Compute bass energy (first ~5% of FFT bins = low frequencies)
// For time-domain, look at low-frequency content via downsampled RMS
let sum = 0;
const bassBins = Math.min(128, n);
for (let i = 0; i < bassBins; i++) {
sum += Math.abs(outView[i]);
}
bassEnergy = sum / bassBins;
smoothedBass = smoothedBass * 0.8 + bassEnergy * 0.2;

// Build waveform frame: downsample 2048 -> ~256 for display
const frameLen = 256;
const frame = new Float32Array(frameLen);
const step = Math.max(1, Math.floor(n / frameLen));
for (let i = 0; i < frameLen; i++) {
let peak = 0;
const start = i * step;
const end = Math.min(start + step, n);
for (let j = start; j < end; j++) {
const abs = Math.abs(outView[j]);
if (abs > peak) peak = abs;
}
frame[i] = peak;
}
waveformHistory.push(frame);
if (waveformHistory.length > WAVEFORM_HISTORY_LEN) {
waveformHistory.shift();
}
// Simple progress simulation (could be driven by transport position)
playbackProgress = (playbackProgress + 0.001) % 1.0;
}

Module._free(inPtr);
Module._free(outPtr);
return count > 0;
} catch (e) {
return false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Free WASM heap buffers on every exit path.

If copy_analyzer_snapshot or view construction throws after _malloc, the catch returns without freeing the allocated buffers. Wrap the allocation block in finally or reuse persistent buffers for the panel lifetime.

Proposed fix
-      const inPtr = Module._malloc(n * 4);
-      const outPtr = Module._malloc(n * 4);
-      if (!inPtr || !outPtr) {
-        if (inPtr) Module._free(inPtr);
-        if (outPtr) Module._free(outPtr);
-        return hasData;
-      }
+      let inPtr = 0;
+      let outPtr = 0;
 
-      const count = Module.ccall(
-        "copy_analyzer_snapshot",
-        "number",
-        ["number", "number", "number"],
-        [inPtr, outPtr, n]
-      );
+      try {
+        inPtr = Module._malloc(n * 4);
+        outPtr = Module._malloc(n * 4);
+        if (!inPtr || !outPtr) return hasData;
+
+        const count = Module.ccall(
+          "copy_analyzer_snapshot",
+          "number",
+          ["number", "number", "number"],
+          [inPtr, outPtr, n]
+        );
 
-      if (count > 0) {
+        if (count > 0) {
           ...
-      }
+        }
 
-      Module._free(inPtr);
-      Module._free(outPtr);
-      return count > 0;
+        return count > 0;
+      } finally {
+        if (inPtr) Module._free(inPtr);
+        if (outPtr) Module._free(outPtr);
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const inPtr = Module._malloc(n * 4);
const outPtr = Module._malloc(n * 4);
if (!inPtr || !outPtr) {
if (inPtr) Module._free(inPtr);
if (outPtr) Module._free(outPtr);
return hasData;
}
const count = Module.ccall(
"copy_analyzer_snapshot",
"number",
["number", "number", "number"],
[inPtr, outPtr, n]
);
if (count > 0) {
const inView = new Float32Array(Module.HEAPF32.buffer, inPtr, n);
const outView = new Float32Array(Module.HEAPF32.buffer, outPtr, n);
currentInputSamples.set(inView);
currentOutputSamples.set(outView);
hasData = true;
// Compute bass energy (first ~5% of FFT bins = low frequencies)
// For time-domain, look at low-frequency content via downsampled RMS
let sum = 0;
const bassBins = Math.min(128, n);
for (let i = 0; i < bassBins; i++) {
sum += Math.abs(outView[i]);
}
bassEnergy = sum / bassBins;
smoothedBass = smoothedBass * 0.8 + bassEnergy * 0.2;
// Build waveform frame: downsample 2048 -> ~256 for display
const frameLen = 256;
const frame = new Float32Array(frameLen);
const step = Math.max(1, Math.floor(n / frameLen));
for (let i = 0; i < frameLen; i++) {
let peak = 0;
const start = i * step;
const end = Math.min(start + step, n);
for (let j = start; j < end; j++) {
const abs = Math.abs(outView[j]);
if (abs > peak) peak = abs;
}
frame[i] = peak;
}
waveformHistory.push(frame);
if (waveformHistory.length > WAVEFORM_HISTORY_LEN) {
waveformHistory.shift();
}
// Simple progress simulation (could be driven by transport position)
playbackProgress = (playbackProgress + 0.001) % 1.0;
}
Module._free(inPtr);
Module._free(outPtr);
return count > 0;
} catch (e) {
return false;
let inPtr = 0;
let outPtr = 0;
try {
inPtr = Module._malloc(n * 4);
outPtr = Module._malloc(n * 4);
if (!inPtr || !outPtr) return hasData;
const count = Module.ccall(
"copy_analyzer_snapshot",
"number",
["number", "number", "number"],
[inPtr, outPtr, n]
);
if (count > 0) {
const inView = new Float32Array(Module.HEAPF32.buffer, inPtr, n);
const outView = new Float32Array(Module.HEAPF32.buffer, outPtr, n);
currentInputSamples.set(inView);
currentOutputSamples.set(outView);
hasData = true;
// Compute bass energy (first ~5% of FFT bins = low frequencies)
// For time-domain, look at low-frequency content via downsampled RMS
let sum = 0;
const bassBins = Math.min(128, n);
for (let i = 0; i < bassBins; i++) {
sum += Math.abs(outView[i]);
}
bassEnergy = sum / bassBins;
smoothedBass = smoothedBass * 0.8 + bassEnergy * 0.2;
// Build waveform frame: downsample 2048 -> ~256 for display
const frameLen = 256;
const frame = new Float32Array(frameLen);
const step = Math.max(1, Math.floor(n / frameLen));
for (let i = 0; i < frameLen; i++) {
let peak = 0;
const start = i * step;
const end = Math.min(start + step, n);
for (let j = start; j < end; j++) {
const abs = Math.abs(outView[j]);
if (abs > peak) peak = abs;
}
frame[i] = peak;
}
waveformHistory.push(frame);
if (waveformHistory.length > WAVEFORM_HISTORY_LEN) {
waveformHistory.shift();
}
// Simple progress simulation (could be driven by transport position)
playbackProgress = (playbackProgress + 0.001) % 1.0;
}
return count > 0;
} finally {
if (inPtr) Module._free(inPtr);
if (outPtr) Module._free(outPtr);
}
} catch (e) {
return false;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/visualizer.js` around lines 62 - 120, The snapshot sampling logic in
visualizer.js leaks WASM heap buffers when an exception occurs after _malloc but
before the normal cleanup path. Update the snapshot block around
copy_analyzer_snapshot, the Float32Array view creation, and the downstream
processing so inPtr and outPtr are always freed in a finally-style cleanup, even
when the catch returns false; alternatively, reuse persistent buffers for the
panel lifetime if that better fits the surrounding code.

Comment thread web/visualizer.js
Comment on lines +84 to +92
// Compute bass energy (first ~5% of FFT bins = low frequencies)
// For time-domain, look at low-frequency content via downsampled RMS
let sum = 0;
const bassBins = Math.min(128, n);
for (let i = 0; i < bassBins; i++) {
sum += Math.abs(outView[i]);
}
bassEnergy = sum / bassBins;
smoothedBass = smoothedBass * 0.8 + bassEnergy * 0.2;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Compute bass from low-frequency content, not the first samples.

outView is time-domain data, so the first 128 samples are not “FFT bins” or low frequencies. This makes circular mode respond to an arbitrary slice of the buffer instead of bass energy.

Proposed fix
-        // Compute bass energy (first ~5% of FFT bins = low frequencies)
-        // For time-domain, look at low-frequency content via downsampled RMS
-        let sum = 0;
-        const bassBins = Math.min(128, n);
-        for (let i = 0; i < bassBins; i++) {
-          sum += Math.abs(outView[i]);
-        }
-        bassEnergy = sum / bassBins;
+        // Approximate low-frequency energy with a simple one-pole low-pass over the full buffer.
+        let low = 0;
+        let lowSquareSum = 0;
+        const lowPassAlpha = 0.08;
+        for (let i = 0; i < n; i++) {
+          low += lowPassAlpha * (outView[i] - low);
+          lowSquareSum += low * low;
+        }
+        bassEnergy = Math.sqrt(lowSquareSum / n);
         smoothedBass = smoothedBass * 0.8 + bassEnergy * 0.2;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Compute bass energy (first ~5% of FFT bins = low frequencies)
// For time-domain, look at low-frequency content via downsampled RMS
let sum = 0;
const bassBins = Math.min(128, n);
for (let i = 0; i < bassBins; i++) {
sum += Math.abs(outView[i]);
}
bassEnergy = sum / bassBins;
smoothedBass = smoothedBass * 0.8 + bassEnergy * 0.2;
// Approximate low-frequency energy with a simple one-pole low-pass over the full buffer.
let low = 0;
let lowSquareSum = 0;
const lowPassAlpha = 0.08;
for (let i = 0; i < n; i++) {
low += lowPassAlpha * (outView[i] - low);
lowSquareSum += low * low;
}
bassEnergy = Math.sqrt(lowSquareSum / n);
smoothedBass = smoothedBass * 0.8 + bassEnergy * 0.2;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/visualizer.js` around lines 84 - 92, The bass-energy calculation in the
visualizer is using the first 128 values from outView as if they were FFT bins,
but outView is time-domain data, so this should be changed to derive bass from
actual low-frequency content instead of an arbitrary buffer slice. Update the
logic in the bass-energy section of visualizer.js to use the appropriate
frequency-domain source or a proper low-frequency aggregation path, and keep the
smoothing via smoothedBass unchanged once bassEnergy is computed correctly.

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