feat(leptos): Phase 4 Task 3 - AudioVisualizer + MicLevelMeter components
This commit is contained in:
@@ -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 voice_connection_card;
|
||||||
pub mod active_speakers;
|
pub mod active_speakers;
|
||||||
|
pub mod audio_visualizer;
|
||||||
|
pub mod mic_level_meter;
|
||||||
|
|
||||||
pub use voice_connection_card::VoiceConnectionCard;
|
pub use voice_connection_card::VoiceConnectionCard;
|
||||||
pub use active_speakers::ActiveSpeakers;
|
pub use active_speakers::ActiveSpeakers;
|
||||||
|
pub use audio_visualizer::AudioVisualizer;
|
||||||
|
pub use mic_level_meter::MicLevelMeter;
|
||||||
|
|||||||
+58
-46
@@ -1,8 +1,6 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
use crate::features::live::hooks::use_voice_control::{use_voice_control, VoiceControlState};
|
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
|
/// VoiceConnectionCard component for Leptos
|
||||||
/// Renders guild and voice channel selectors with connect/disconnect controls
|
/// 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| {
|
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()) {
|
if let Some(target) = ev.target() {
|
||||||
set_selected_guild(target.value());
|
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| {
|
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()) {
|
if let Some(target) = ev.target() {
|
||||||
set_selected_channel(target.value());
|
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! {
|
view! {
|
||||||
<Card class=format!("{} border border-border bg-card shadow-sm", class) bordered=true>
|
<div class=format!("rounded-xl border border-border bg-card shadow-sm {}", class)>
|
||||||
<CardHeader>
|
<div class="p-6">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<svg
|
<svg
|
||||||
class="h-5 w-5 text-primary"
|
class="h-5 w-5 text-primary"
|
||||||
fill="currentColor"
|
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" />
|
<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>
|
</svg>
|
||||||
<CardTitle>"Voice Bridge"</CardTitle>
|
<h3 class="text-lg font-semibold tracking-tight">"Voice Bridge"</h3>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<p class="text-sm text-muted-foreground mb-4">
|
||||||
"Join a Discord voice channel, listen, and transmit audio."
|
"Join a Discord voice channel, listen, and transmit audio."
|
||||||
</CardDescription>
|
</p>
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4">
|
|
||||||
{/* Guild and Channel Selectors */}
|
{/* 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">
|
<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
|
<select
|
||||||
prop:value=selected_guild
|
prop:value=selected_guild
|
||||||
on:change=on_guild_change
|
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"
|
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>
|
<For each=move || guilds.get() key=|g| g.id.clone() let:guild>
|
||||||
<option value=guild.id.clone()>
|
<option value=guild.id.clone()>
|
||||||
{guild.name.clone()}
|
{guild.name.clone()}
|
||||||
@@ -113,13 +113,13 @@ pub fn VoiceConnectionCard(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<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
|
<select
|
||||||
prop:value=selected_channel
|
prop:value=selected_channel
|
||||||
on:change=on_channel_change
|
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"
|
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>
|
<For each=move || voice_channels.get() key=|c| c.id.clone() let:channel>
|
||||||
<option value=channel.id.clone()>
|
<option value=channel.id.clone()>
|
||||||
{channel.name.clone()}
|
{channel.name.clone()}
|
||||||
@@ -133,7 +133,7 @@ pub fn VoiceConnectionCard(
|
|||||||
{move || {
|
{move || {
|
||||||
error.get().map(|err| {
|
error.get().map(|err| {
|
||||||
view! {
|
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}
|
{err}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -143,33 +143,33 @@ pub fn VoiceConnectionCard(
|
|||||||
{/* Status Display */}
|
{/* Status Display */}
|
||||||
{move || {
|
{move || {
|
||||||
voice_status.get().map(|status| {
|
voice_status.get().map(|status| {
|
||||||
|
let connected = status.connected;
|
||||||
|
let active_channel = status.active_channel_name.clone();
|
||||||
view! {
|
view! {
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<div class="flex items-center gap-2 text-sm mb-4">
|
||||||
<div class=move || {
|
<div class=move || {
|
||||||
if status.connected {
|
if connected {
|
||||||
"h-2 w-2 rounded-full bg-emerald-500"
|
"h-2 w-2 rounded-full bg-emerald-500"
|
||||||
} else {
|
} else {
|
||||||
"h-2 w-2 rounded-full bg-muted-foreground/40"
|
"h-2 w-2 rounded-full bg-muted-foreground/40"
|
||||||
}
|
}
|
||||||
}></div>
|
}></div>
|
||||||
<span class=move || {
|
<span class=move || {
|
||||||
if status.connected {
|
if connected {
|
||||||
"text-emerald-600 dark:text-emerald-400 font-medium"
|
"text-emerald-600 dark:text-emerald-400 font-medium"
|
||||||
} else {
|
} else {
|
||||||
"text-muted-foreground"
|
"text-muted-foreground"
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
{move || if status.connected { "Connected" } else { "Disconnected" }}
|
{if connected { "Connected" } else { "Disconnected" }}
|
||||||
</span>
|
</span>
|
||||||
{move || {
|
{active_channel.map(|name| {
|
||||||
status.active_channel_name.clone().map(|name| {
|
view! {
|
||||||
view! {
|
<span class="text-muted-foreground">
|
||||||
<span class="text-muted-foreground">
|
{format!(" - {}", name)}
|
||||||
" - "{name}
|
</span>
|
||||||
</span>
|
}
|
||||||
}
|
})}
|
||||||
})
|
|
||||||
}}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -177,21 +177,33 @@ pub fn VoiceConnectionCard(
|
|||||||
|
|
||||||
{/* Control Buttons */}
|
{/* Control Buttons */}
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Button
|
<button
|
||||||
variant=ButtonVariant::Primary
|
class=move || {
|
||||||
|
if can_join() {
|
||||||
|
"btn btn-primary"
|
||||||
|
} else {
|
||||||
|
"btn btn-primary opacity-50 cursor-not-allowed"
|
||||||
|
}
|
||||||
|
}
|
||||||
disabled=move || !can_join()
|
disabled=move || !can_join()
|
||||||
on_click=Box::new(on_join_click)
|
on:click=on_join_click
|
||||||
>
|
>
|
||||||
{move || if is_connected() { "Reconnect" } else { "Join Voice" }}
|
{move || if is_connected() { "Reconnect" } else { "Join Voice" }}
|
||||||
</Button>
|
</button>
|
||||||
|
|
||||||
<Button
|
<button
|
||||||
variant=ButtonVariant::Destructive
|
class=move || {
|
||||||
|
if can_disconnect() {
|
||||||
|
"btn btn-destructive"
|
||||||
|
} else {
|
||||||
|
"btn btn-destructive opacity-50 cursor-not-allowed"
|
||||||
|
}
|
||||||
|
}
|
||||||
disabled=move || !can_disconnect()
|
disabled=move || !can_disconnect()
|
||||||
on_click=Box::new(on_disconnect_click)
|
on:click=on_disconnect_click
|
||||||
>
|
>
|
||||||
"Disconnect"
|
"Disconnect"
|
||||||
</Button>
|
</button>
|
||||||
|
|
||||||
{move || {
|
{move || {
|
||||||
if loading.get() {
|
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">
|
<span class="inline-flex items-center px-3 py-2 text-sm text-muted-foreground">
|
||||||
"Loading..."
|
"Loading..."
|
||||||
</span>
|
</span>
|
||||||
}.into_view()
|
}.into_any()
|
||||||
} else {
|
} else {
|
||||||
view! { }.into_view()
|
view! { <></> }.into_any()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user