feat(leptos): Phase 4 Task 6 - PCM audio playback + mic transmit hooks

This commit is contained in:
asepharyana
2026-07-03 22:05:37 +07:00
parent aaa8faad55
commit ca89dd7a03
9 changed files with 394 additions and 0 deletions
@@ -0,0 +1,2 @@
pub mod ring_buffer;
pub mod pcm_decoder;
@@ -0,0 +1,69 @@
/// PCM Frame decoded from binary WebSocket data
/// Format: [u32 userId (4 bytes)][i16 samples (N bytes)]
pub struct PcmFrame {
pub user_id: u32,
pub samples: Vec<f32>, // Normalized to [-1.0, 1.0]
}
/// Decode a binary WebSocket message into PCM frames
/// Returns None if data is too short or malformed
pub fn decode_pcm_frame(data: &[u8]) -> Option<PcmFrame> {
if data.len() < 4 {
return None;
}
let user_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
let sample_bytes = &data[4..];
let sample_count = sample_bytes.len() / 2;
if sample_count == 0 {
return None;
}
let samples = decode_i16_samples(sample_bytes);
Some(PcmFrame { user_id, samples })
}
/// Decode raw i16 PCM bytes to normalized f32 samples [-1.0, 1.0]
pub fn decode_i16_samples(data: &[u8]) -> Vec<f32> {
let count = data.len() / 2;
let mut out = Vec::with_capacity(count);
for i in 0..count {
let offset = i * 2;
if offset + 1 < data.len() {
let sample = i16::from_le_bytes([data[offset], data[offset + 1]]);
out.push((sample as f32) / 32768.0);
}
}
out
}
/// Encode f32 samples [-1.0, 1.0] to base64 for WebSocket transmission
/// Uses JavaScript btoa for encoding
pub fn encode_samples_to_base64(samples: &[f32]) -> String {
// Convert f32 samples to i16 bytes
let mut bytes = Vec::with_capacity(samples.len() * 2);
for &sample in samples {
let clamped = sample.max(-1.0).min(1.0);
let int_sample = (clamped * 32767.0) as i16;
bytes.extend_from_slice(&int_sample.to_le_bytes());
}
encode_bytes_base64(&bytes)
}
/// Encode raw bytes to base64 using JavaScript's btoa
fn encode_bytes_base64(data: &[u8]) -> String {
// Build binary string for btoa
let binary: String = data.iter().map(|&b| b as char).collect();
// Call btoa from JavaScript via js_sys::eval
let js_code = format!("btoa('{}')", binary.replace('\'', "\\'"));
js_sys::eval(&js_code)
.ok()
.and_then(|r| r.as_string())
.unwrap_or_default()
}
use wasm_bindgen::prelude::*;
@@ -0,0 +1,124 @@
use std::sync::{Arc, Mutex};
/// AudioRingBuffer — Fixed-size circular buffer for real-time PCM streaming
/// Provides thread-safe write/read with automatic overwrite protection
pub struct AudioRingBuffer {
buffer: Vec<f32>,
capacity: usize,
write_pos: usize,
read_pos: usize,
available: usize,
}
impl AudioRingBuffer {
/// Create a new ring buffer with given capacity (in samples)
pub fn new(capacity: usize) -> Self {
Self {
buffer: vec![0.0; capacity],
capacity,
write_pos: 0,
read_pos: 0,
available: 0,
}
}
/// Write samples to the ring buffer. Overwrites oldest data if full.
pub fn write(&mut self, samples: &[f32]) {
let mut written = 0;
while written < samples.len() {
let chunk = (samples.len() - written).min(self.capacity - self.write_pos);
let src = &samples[written..written + chunk];
let dest = &mut self.buffer[self.write_pos..self.write_pos + chunk];
dest.copy_from_slice(src);
written += chunk;
self.write_pos = (self.write_pos + chunk) % self.capacity;
self.available = (self.available + chunk).min(self.capacity);
// If we overwrote unread data, advance read_pos
if self.available == self.capacity {
self.read_pos = self.write_pos;
}
}
}
/// Read up to `max_samples` from the buffer. Returns the samples read.
pub fn read(&mut self, max_samples: usize) -> Vec<f32> {
let to_read = max_samples.min(self.available);
let mut out = Vec::with_capacity(to_read);
let mut remaining = to_read;
while remaining > 0 {
let chunk = remaining.min(self.capacity - self.read_pos);
out.extend_from_slice(&self.buffer[self.read_pos..self.read_pos + chunk]);
remaining -= chunk;
self.read_pos = (self.read_pos + chunk) % self.capacity;
}
self.available -= to_read;
out
}
/// Number of samples available to read
pub fn available_samples(&self) -> usize {
self.available
}
/// Clear all buffered data
pub fn clear(&mut self) {
self.write_pos = 0;
self.read_pos = 0;
self.available = 0;
}
}
/// Thread-safe wrapper around AudioRingBuffer
pub struct SharedRingBuffer {
inner: Arc<Mutex<AudioRingBuffer>>,
}
impl SharedRingBuffer {
pub fn new(capacity: usize) -> Self {
Self {
inner: Arc::new(Mutex::new(AudioRingBuffer::new(capacity))),
}
}
pub fn write(&self, samples: &[f32]) {
if let Ok(mut guard) = self.inner.lock() {
guard.write(samples);
}
}
pub fn read(&self, max_samples: usize) -> Vec<f32> {
if let Ok(mut guard) = self.inner.lock() {
guard.read(max_samples)
} else {
Vec::new()
}
}
pub fn available_samples(&self) -> usize {
if let Ok(guard) = self.inner.lock() {
guard.available_samples()
} else {
0
}
}
pub fn clear(&self) {
if let Ok(mut guard) = self.inner.lock() {
guard.clear();
}
}
pub fn clone_inner(&self) -> Arc<Mutex<AudioRingBuffer>> {
self.inner.clone()
}
}
impl Clone for SharedRingBuffer {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
}
}
}