feat(leptos): Phase 4 Task 3 - AudioVisualizer + MicLevelMeter components

This commit is contained in:
asepharyana
2026-07-03 21:50:48 +07:00
parent d797b6b4c5
commit 8a3ac78bde
4 changed files with 215 additions and 46 deletions
@@ -0,0 +1,69 @@
use leptos::prelude::*;
use std::sync::{Arc, Mutex};
/// AudioVisualizer — Real-time 32-bar frequency spectrum display
/// Simplified implementation using CSS bars updated via signals
#[component]
pub fn AudioVisualizer(
#[prop(default = true)] _active: bool,
#[prop(optional)] pcm_data: Option<Arc<Mutex<Vec<f32>>>>,
) -> impl IntoView {
let bars = create_rw_signal::<Vec<f32>>(vec![0.0; 32]);
// Periodically update bars from PCM data
create_effect(move |_| {
if let Some(ref pcm_arc) = pcm_data {
if let Ok(pcm_vec) = pcm_arc.lock() {
let computed = compute_frequency_bands(&pcm_vec);
bars.update(|b| {
for i in 0..32 {
let target = computed.get(i).copied().unwrap_or(0.0).max(0.0).min(1.0);
b[i] = b[i] * 0.7 + target * 0.3; // Smooth decay
}
});
}
}
});
view! {
<div class="audio-visualizer">
<div class="audio-visualizer-bars">
{(0..32).map(|i| {
view! {
<div
class="audio-bar"
style=move || {
let height = bars.get()[i] * 100.0;
format!("height: {}%", height)
}
></div>
}
}).collect::<Vec<_>>()}
</div>
</div>
}
}
/// Compute 32-band frequency spectrum from PCM samples
fn compute_frequency_bands(pcm_samples: &[f32]) -> Vec<f32> {
let mut bands = vec![0.0; 32];
if pcm_samples.is_empty() {
return bands;
}
let samples_per_band = (pcm_samples.len() / 32).max(1);
for (band_idx, band) in bands.iter_mut().enumerate() {
let start = band_idx * samples_per_band;
let end = ((band_idx + 1) * samples_per_band).min(pcm_samples.len());
if start < pcm_samples.len() {
let slice = &pcm_samples[start..end];
let rms = (slice.iter().map(|s| s * s).sum::<f32>() / slice.len() as f32).sqrt();
*band = rms.min(1.0);
}
}
bands
}
@@ -0,0 +1,84 @@
use leptos::prelude::*;
use std::sync::{Arc, Mutex};
/// MicLevelMeter — Horizontal level indicator for microphone input
/// Displays 0-100% amplitude as a filling bar with smooth decay
#[component]
pub fn MicLevelMeter(
#[prop(default = true)] active: bool,
#[prop(optional)] pcm_data: Option<Arc<Mutex<Vec<f32>>>>,
#[prop(optional)] label: Option<&'static str>,
) -> impl IntoView {
let level = create_rw_signal::<f32>(0.0);
let peak = create_rw_signal::<f32>(0.0);
// Update level periodically
create_effect(move |_| {
if !active {
return;
}
if let Some(ref pcm_arc) = pcm_data {
if let Ok(pcm_vec) = pcm_arc.lock() {
let current_level = compute_rms(&pcm_vec);
level.update(|l| {
*l = *l * 0.8 + current_level * 0.2; // Smooth decay
});
peak.update(|p| {
*p = (*p * 0.95).max(current_level); // Peak hold with decay
});
}
}
});
let level_percent = move || (level.get() * 100.0).min(100.0);
let peak_percent = move || (peak.get() * 100.0).min(100.0);
// Determine color based on level
let level_color = move || {
let l = level.get();
if l < 0.5 {
"bg-green-500"
} else if l < 0.75 {
"bg-yellow-500"
} else {
"bg-red-500"
}
};
view! {
<div class="mic-level-meter">
{label.map(|l| view! {
<label class="text-xs font-medium text-foreground mb-1.5">{l}</label>
})}
<div class="flex items-center gap-2">
{/* Main level bar */}
<div class="relative flex-1 h-2 rounded-full bg-surface border border-border/50 overflow-hidden">
<div
class=move || format!("h-full {} transition-all", level_color())
style=move || format!("width: {}%", level_percent())
></div>
{/* Peak indicator */}
<div
class="absolute h-full w-0.5 bg-destructive/70"
style=move || format!("left: {}%", peak_percent())
></div>
</div>
{/* Percentage display */}
<span class="text-xs font-mono text-muted-foreground w-8 text-right">
{move || format!("{}%", (level_percent() as u8))}
</span>
</div>
</div>
}
}
/// Compute RMS (Root Mean Square) amplitude from PCM samples
/// Returns normalized value 0.0-1.0
fn compute_rms(samples: &[f32]) -> f32 {
if samples.is_empty() {
return 0.0;
}
let mean_square = samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32;
mean_square.sqrt()
}
@@ -1,5 +1,9 @@
pub mod voice_connection_card;
pub mod active_speakers;
pub mod audio_visualizer;
pub mod mic_level_meter;
pub use voice_connection_card::VoiceConnectionCard;
pub use active_speakers::ActiveSpeakers;
pub use audio_visualizer::AudioVisualizer;
pub use mic_level_meter::MicLevelMeter;
@@ -1,8 +1,6 @@
use leptos::prelude::*;
use wasm_bindgen::prelude::*;
use crate::features::live::hooks::use_voice_control::{use_voice_control, VoiceControlState};
use crate::ui::button::{Button, ButtonVariant};
use crate::ui::card::{Card, CardContent, CardDescription, CardHeader, CardTitle};
use shared_types::guild::{Guild, Channel};
/// VoiceConnectionCard component for Leptos
/// Renders guild and voice channel selectors with connect/disconnect controls
@@ -33,14 +31,18 @@ pub fn VoiceConnectionCard(
});
let on_guild_change = move |ev: leptos::ev::Event| {
if let Some(target) = ev.target().and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok()) {
set_selected_guild(target.value());
if let Some(target) = ev.target() {
if let Ok(select_el) = target.dyn_into::<web_sys::HtmlSelectElement>() {
set_selected_guild.set(select_el.value());
}
}
};
let on_channel_change = move |ev: leptos::ev::Event| {
if let Some(target) = ev.target().and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok()) {
set_selected_channel(target.value());
if let Some(target) = ev.target() {
if let Ok(select_el) = target.dyn_into::<web_sys::HtmlSelectElement>() {
set_selected_channel.set(select_el.value());
}
}
};
@@ -76,9 +78,9 @@ pub fn VoiceConnectionCard(
};
view! {
<Card class=format!("{} border border-border bg-card shadow-sm", class) bordered=true>
<CardHeader>
<div class="flex items-center gap-2">
<div class=format!("rounded-xl border border-border bg-card shadow-sm {}", class)>
<div class="p-6">
<div class="flex items-center gap-2 mb-2">
<svg
class="h-5 w-5 text-primary"
fill="currentColor"
@@ -86,24 +88,22 @@ pub fn VoiceConnectionCard(
>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
</svg>
<CardTitle>"Voice Bridge"</CardTitle>
<h3 class="text-lg font-semibold tracking-tight">"Voice Bridge"</h3>
</div>
<CardDescription>
<p class="text-sm text-muted-foreground mb-4">
"Join a Discord voice channel, listen, and transmit audio."
</CardDescription>
</CardHeader>
</p>
<CardContent class="space-y-4">
{/* Guild and Channel Selectors */}
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-4 md:grid-cols-2 mb-4">
<div class="space-y-2">
<label class="text-sm font-medium text-foreground">Guild</label>
<label class="text-sm font-medium text-foreground">"Guild"</label>
<select
prop:value=selected_guild
on:change=on_guild_change
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Select guild</option>
<option value="">"Select guild"</option>
<For each=move || guilds.get() key=|g| g.id.clone() let:guild>
<option value=guild.id.clone()>
{guild.name.clone()}
@@ -113,13 +113,13 @@ pub fn VoiceConnectionCard(
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-foreground">Voice Channel</label>
<label class="text-sm font-medium text-foreground">"Voice Channel"</label>
<select
prop:value=selected_channel
on:change=on_channel_change
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Select voice channel</option>
<option value="">"Select voice channel"</option>
<For each=move || voice_channels.get() key=|c| c.id.clone() let:channel>
<option value=channel.id.clone()>
{channel.name.clone()}
@@ -133,7 +133,7 @@ pub fn VoiceConnectionCard(
{move || {
error.get().map(|err| {
view! {
<div class="rounded-md bg-destructive/15 px-3 py-2 text-sm text-destructive">
<div class="rounded-md bg-destructive/15 px-3 py-2 text-sm text-destructive mb-4">
{err}
</div>
}
@@ -143,33 +143,33 @@ pub fn VoiceConnectionCard(
{/* Status Display */}
{move || {
voice_status.get().map(|status| {
let connected = status.connected;
let active_channel = status.active_channel_name.clone();
view! {
<div class="flex items-center gap-2 text-sm">
<div class="flex items-center gap-2 text-sm mb-4">
<div class=move || {
if status.connected {
if connected {
"h-2 w-2 rounded-full bg-emerald-500"
} else {
"h-2 w-2 rounded-full bg-muted-foreground/40"
}
}></div>
<span class=move || {
if status.connected {
if connected {
"text-emerald-600 dark:text-emerald-400 font-medium"
} else {
"text-muted-foreground"
}
}>
{move || if status.connected { "Connected" } else { "Disconnected" }}
{if connected { "Connected" } else { "Disconnected" }}
</span>
{move || {
status.active_channel_name.clone().map(|name| {
view! {
<span class="text-muted-foreground">
" - "{name}
</span>
}
})
}}
{active_channel.map(|name| {
view! {
<span class="text-muted-foreground">
{format!(" - {}", name)}
</span>
}
})}
</div>
}
})
@@ -177,21 +177,33 @@ pub fn VoiceConnectionCard(
{/* Control Buttons */}
<div class="flex flex-wrap gap-2">
<Button
variant=ButtonVariant::Primary
<button
class=move || {
if can_join() {
"btn btn-primary"
} else {
"btn btn-primary opacity-50 cursor-not-allowed"
}
}
disabled=move || !can_join()
on_click=Box::new(on_join_click)
on:click=on_join_click
>
{move || if is_connected() { "Reconnect" } else { "Join Voice" }}
</Button>
</button>
<Button
variant=ButtonVariant::Destructive
<button
class=move || {
if can_disconnect() {
"btn btn-destructive"
} else {
"btn btn-destructive opacity-50 cursor-not-allowed"
}
}
disabled=move || !can_disconnect()
on_click=Box::new(on_disconnect_click)
on:click=on_disconnect_click
>
"Disconnect"
</Button>
</button>
{move || {
if loading.get() {
@@ -199,13 +211,13 @@ pub fn VoiceConnectionCard(
<span class="inline-flex items-center px-3 py-2 text-sm text-muted-foreground">
"Loading..."
</span>
}.into_view()
}.into_any()
} else {
view! { }.into_view()
view! { <></> }.into_any()
}
}}
</div>
</CardContent>
</Card>
</div>
</div>
}
}