Skip to content

Commit 35107ee

Browse files
committed
feat: implement audio waveform generation and add tests
1 parent c4deef2 commit 35107ee

5 files changed

Lines changed: 352 additions & 1 deletion

File tree

Cargo.lock

Lines changed: 156 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ serde_bytes = "0.11"
3030
simd-adler32 = { version = "0.3.7", default-features = false, features = [
3131
"std",
3232
] }
33+
34+
symphonia = { version = "0.5.4", default-features = false, features = ["mp3", "aac", "ogg", "vorbis", "pcm", "isomp4"] }
3335
uuid = { version = "1.18.1", features = ["v4", "js"] }
3436

3537
wacore-binary = { git = "https://github.com/jlucaso1/whatsapp-rust.git", features = [

src/audio_waveform.rs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
use js_sys::Uint8Array;
2+
use std::io::Cursor;
3+
use symphonia::core::audio::SampleBuffer;
4+
use symphonia::core::codecs::{CODEC_TYPE_NULL, DecoderOptions};
5+
use symphonia::core::errors::Error;
6+
use symphonia::core::formats::FormatOptions;
7+
use symphonia::core::io::MediaSourceStream;
8+
use symphonia::core::meta::MetadataOptions;
9+
use symphonia::core::probe::Hint;
10+
use wasm_bindgen::prelude::*;
11+
12+
/// WhatsApp uses 64 buckets for visual waveforms.
13+
const WAVEFORM_SAMPLES: usize = 64;
14+
15+
#[wasm_bindgen(js_name = generateAudioWaveform)]
16+
pub fn generate_audio_waveform(audio_data: &[u8]) -> Result<Uint8Array, JsValue> {
17+
if audio_data.is_empty() {
18+
return Err(JsValue::from_str("Audio buffer is empty"));
19+
}
20+
21+
// Feed the raw bytes into Symphonia via an in-memory cursor.
22+
let cursor = Cursor::new(audio_data.to_vec());
23+
let mss = MediaSourceStream::new(Box::new(cursor), Default::default());
24+
25+
let hint = Hint::new();
26+
let format_opts = FormatOptions::default();
27+
let metadata_opts = MetadataOptions::default();
28+
let decoder_opts = DecoderOptions::default();
29+
30+
let probed = symphonia::default::get_probe()
31+
.format(&hint, mss, &format_opts, &metadata_opts)
32+
.map_err(|e| JsValue::from_str(&format!("Failed to probe audio format: {e}")))?;
33+
34+
let mut format = probed.format;
35+
36+
let track = format
37+
.tracks()
38+
.iter()
39+
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
40+
.ok_or_else(|| JsValue::from_str("No supported audio track found"))?;
41+
42+
let mut decoder = symphonia::default::get_codecs()
43+
.make(&track.codec_params, &decoder_opts)
44+
.map_err(|e| JsValue::from_str(&format!("Failed to create decoder: {e}")))?;
45+
46+
let track_id = track.id;
47+
let mut samples: Vec<f32> = Vec::new();
48+
49+
loop {
50+
let packet = match format.next_packet() {
51+
Ok(packet) => packet,
52+
Err(Error::IoError(ref e))
53+
if matches!(
54+
e.kind(),
55+
std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::NotFound
56+
) =>
57+
{
58+
break;
59+
}
60+
Err(Error::ResetRequired) => {
61+
decoder.reset();
62+
continue;
63+
}
64+
Err(e) => {
65+
return Err(JsValue::from_str(&format!("Audio decode error: {e}")));
66+
}
67+
};
68+
69+
if packet.track_id() != track_id {
70+
continue;
71+
}
72+
73+
match decoder.decode(&packet) {
74+
Ok(audio_buf) => {
75+
let spec = *audio_buf.spec();
76+
let channel_count = spec.channels.count();
77+
if channel_count == 0 {
78+
continue;
79+
}
80+
81+
let capacity = audio_buf.capacity() as u64;
82+
let mut sample_buf = SampleBuffer::<f32>::new(capacity, spec);
83+
sample_buf.copy_interleaved_ref(audio_buf);
84+
85+
let data = sample_buf.samples();
86+
let frame_count = data.len() / channel_count;
87+
88+
for frame_idx in 0..frame_count {
89+
let mut sum = 0.0;
90+
for channel in 0..channel_count {
91+
let sample = data[frame_idx * channel_count + channel];
92+
sum += sample;
93+
}
94+
samples.push(sum / channel_count as f32);
95+
}
96+
}
97+
Err(Error::IoError(ref e))
98+
if matches!(
99+
e.kind(),
100+
std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::NotFound
101+
) =>
102+
{
103+
break;
104+
}
105+
Err(Error::DecodeError(_)) => continue,
106+
Err(Error::ResetRequired) => {
107+
decoder.reset();
108+
continue;
109+
}
110+
Err(e) => {
111+
return Err(JsValue::from_str(&format!(
112+
"Failed to decode audio frame: {e}"
113+
)));
114+
}
115+
}
116+
}
117+
118+
if samples.is_empty() {
119+
return Err(JsValue::from_str("No audio samples decoded"));
120+
}
121+
122+
let waveform = process_waveform(&samples, WAVEFORM_SAMPLES);
123+
Ok(Uint8Array::from(waveform.as_slice()))
124+
}
125+
126+
fn process_waveform(samples: &[f32], target_bins: usize) -> Vec<u8> {
127+
if samples.is_empty() {
128+
return vec![0; target_bins];
129+
}
130+
131+
let total_samples = samples.len();
132+
let chunk_size = (total_samples as f64 / target_bins as f64).max(1.0);
133+
134+
let mut bins = Vec::with_capacity(target_bins);
135+
let mut max_val: f32 = 0.0;
136+
137+
for i in 0..target_bins {
138+
let start = (i as f64 * chunk_size).floor() as usize;
139+
let end = (((i + 1) as f64 * chunk_size).floor() as usize).min(total_samples);
140+
141+
if start >= end {
142+
bins.push(0.0);
143+
continue;
144+
}
145+
146+
let mut sum = 0.0;
147+
for sample in &samples[start..end] {
148+
sum += sample.abs();
149+
}
150+
let avg = sum / (end - start) as f32;
151+
max_val = max_val.max(avg);
152+
bins.push(avg);
153+
}
154+
155+
let multiplier = if max_val > 0.0 { 100.0 / max_val } else { 0.0 };
156+
bins.iter()
157+
.map(|val| (val * multiplier).clamp(0.0, 100.0) as u8)
158+
.collect()
159+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod audio_waveform;
12
pub mod binary;
23
pub mod curve;
34
pub mod group_cipher;

0 commit comments

Comments
 (0)