Skip to content

Commit 1875e69

Browse files
committed
feat: add getAudioDuration function and corresponding tests
1 parent 35107ee commit 1875e69

6 files changed

Lines changed: 388 additions & 161 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ wacore-libsignal = { git = "https://github.com/jlucaso1/whatsapp-rust.git", bran
4141
waproto = { git = "https://github.com/jlucaso1/whatsapp-rust.git", branch = "main", package = "waproto" }
4242
wasm-bindgen = "0.2.100"
4343
wasm-bindgen-futures = "0.4.55"
44+
web-sys = { version = "0.3", features = [
45+
"ReadableStream",
46+
"ReadableStreamDefaultReader",
47+
] }
4448

4549
[profile.release]
4650
lto = "fat"

src/audio.rs

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
use js_sys::{ArrayBuffer, Reflect, Uint8Array};
2+
use std::io::Cursor;
3+
use symphonia::core::audio::SampleBuffer;
4+
use symphonia::core::codecs::{CODEC_TYPE_NULL, Decoder, DecoderOptions};
5+
use symphonia::core::errors::Error;
6+
use symphonia::core::formats::{FormatOptions, FormatReader};
7+
use symphonia::core::io::MediaSourceStream;
8+
use symphonia::core::meta::MetadataOptions;
9+
use symphonia::core::probe::Hint;
10+
use wasm_bindgen::{JsCast, prelude::*};
11+
use wasm_bindgen_futures::JsFuture;
12+
use web_sys::{ReadableStream, ReadableStreamDefaultReader};
13+
14+
/// WhatsApp uses 64 buckets for visual waveforms.
15+
const WAVEFORM_SAMPLES: usize = 64;
16+
17+
#[wasm_bindgen(js_name = generateAudioWaveform)]
18+
pub fn generate_audio_waveform(audio_data: &[u8]) -> Result<Uint8Array, JsValue> {
19+
if audio_data.is_empty() {
20+
return Err(JsValue::from_str("Audio buffer is empty"));
21+
}
22+
23+
// Feed the raw bytes into Symphonia via an in-memory cursor.
24+
let cursor = Cursor::new(audio_data.to_vec());
25+
let mss = MediaSourceStream::new(Box::new(cursor), Default::default());
26+
27+
let hint = Hint::new();
28+
let format_opts = FormatOptions::default();
29+
let metadata_opts = MetadataOptions::default();
30+
let decoder_opts = DecoderOptions::default();
31+
32+
let probed = symphonia::default::get_probe()
33+
.format(&hint, mss, &format_opts, &metadata_opts)
34+
.map_err(|e| JsValue::from_str(&format!("Failed to probe audio format: {e}")))?;
35+
36+
let mut format = probed.format;
37+
38+
let track = format
39+
.tracks()
40+
.iter()
41+
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
42+
.ok_or_else(|| JsValue::from_str("No supported audio track found"))?;
43+
44+
let mut decoder = symphonia::default::get_codecs()
45+
.make(&track.codec_params, &decoder_opts)
46+
.map_err(|e| JsValue::from_str(&format!("Failed to create decoder: {e}")))?;
47+
48+
let track_id = track.id;
49+
let mut samples: Vec<f32> = Vec::new();
50+
51+
loop {
52+
let packet = match format.next_packet() {
53+
Ok(packet) => packet,
54+
Err(Error::IoError(ref e))
55+
if matches!(
56+
e.kind(),
57+
std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::NotFound
58+
) =>
59+
{
60+
break;
61+
}
62+
Err(Error::ResetRequired) => {
63+
decoder.reset();
64+
continue;
65+
}
66+
Err(e) => {
67+
return Err(JsValue::from_str(&format!("Audio decode error: {e}")));
68+
}
69+
};
70+
71+
if packet.track_id() != track_id {
72+
continue;
73+
}
74+
75+
match decoder.decode(&packet) {
76+
Ok(audio_buf) => {
77+
let spec = *audio_buf.spec();
78+
let channel_count = spec.channels.count();
79+
if channel_count == 0 {
80+
continue;
81+
}
82+
83+
let capacity = audio_buf.capacity() as u64;
84+
let mut sample_buf = SampleBuffer::<f32>::new(capacity, spec);
85+
sample_buf.copy_interleaved_ref(audio_buf);
86+
87+
let data = sample_buf.samples();
88+
let frame_count = data.len() / channel_count;
89+
90+
for frame_idx in 0..frame_count {
91+
let mut sum = 0.0;
92+
for channel in 0..channel_count {
93+
let sample = data[frame_idx * channel_count + channel];
94+
sum += sample;
95+
}
96+
samples.push(sum / channel_count as f32);
97+
}
98+
}
99+
Err(Error::IoError(ref e))
100+
if matches!(
101+
e.kind(),
102+
std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::NotFound
103+
) =>
104+
{
105+
break;
106+
}
107+
Err(Error::DecodeError(_)) => continue,
108+
Err(Error::ResetRequired) => {
109+
decoder.reset();
110+
continue;
111+
}
112+
Err(e) => {
113+
return Err(JsValue::from_str(&format!(
114+
"Failed to decode audio frame: {e}"
115+
)));
116+
}
117+
}
118+
}
119+
120+
if samples.is_empty() {
121+
return Err(JsValue::from_str("No audio samples decoded"));
122+
}
123+
124+
let waveform = process_waveform(&samples, WAVEFORM_SAMPLES);
125+
Ok(Uint8Array::from(waveform.as_slice()))
126+
}
127+
128+
#[wasm_bindgen(js_name = getAudioDuration, skip_typescript)]
129+
pub async fn get_audio_duration(input: JsValue) -> Result<f64, JsValue> {
130+
let audio_bytes = normalize_audio_input(input).await?;
131+
compute_audio_duration(&audio_bytes)
132+
}
133+
134+
#[wasm_bindgen(typescript_custom_section)]
135+
const TS_AUDIO_DURATION: &str = r#"
136+
export type AudioDurationInput =
137+
| Uint8Array
138+
| ArrayBuffer
139+
| ReadableStream<Uint8Array | ArrayBuffer | ArrayBufferView>;
140+
141+
export function getAudioDuration(input: AudioDurationInput): Promise<number>;
142+
"#;
143+
144+
fn compute_audio_duration(audio_data: &[u8]) -> Result<f64, JsValue> {
145+
if audio_data.is_empty() {
146+
return Err(JsValue::from_str("Audio buffer is empty"));
147+
}
148+
149+
let DecoderContext {
150+
mut format,
151+
mut decoder,
152+
track_id,
153+
sample_rate,
154+
} = prepare_decoder(audio_data)?;
155+
156+
let sample_rate =
157+
sample_rate.ok_or_else(|| JsValue::from_str("Audio track missing sample rate"))?;
158+
159+
let mut total_frames: u64 = 0;
160+
161+
loop {
162+
let packet = match format.next_packet() {
163+
Ok(packet) => packet,
164+
Err(Error::IoError(ref e))
165+
if matches!(
166+
e.kind(),
167+
std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::NotFound
168+
) =>
169+
{
170+
break;
171+
}
172+
Err(Error::ResetRequired) => {
173+
decoder.reset();
174+
continue;
175+
}
176+
Err(e) => {
177+
return Err(JsValue::from_str(&format!("Audio decode error: {e}")));
178+
}
179+
};
180+
181+
if packet.track_id() != track_id {
182+
continue;
183+
}
184+
185+
match decoder.decode(&packet) {
186+
Ok(audio_buf) => {
187+
total_frames += audio_buf.frames() as u64;
188+
}
189+
Err(Error::IoError(ref e))
190+
if matches!(
191+
e.kind(),
192+
std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::NotFound
193+
) =>
194+
{
195+
break;
196+
}
197+
Err(Error::DecodeError(_)) => continue,
198+
Err(Error::ResetRequired) => {
199+
decoder.reset();
200+
continue;
201+
}
202+
Err(e) => {
203+
return Err(JsValue::from_str(&format!(
204+
"Failed to decode audio frame: {e}"
205+
)));
206+
}
207+
}
208+
}
209+
210+
if total_frames == 0 {
211+
return Err(JsValue::from_str("No audio samples decoded"));
212+
}
213+
214+
Ok(total_frames as f64 / sample_rate as f64)
215+
}
216+
217+
fn process_waveform(samples: &[f32], target_bins: usize) -> Vec<u8> {
218+
if samples.is_empty() {
219+
return vec![0; target_bins];
220+
}
221+
222+
let total_samples = samples.len();
223+
let chunk_size = (total_samples as f64 / target_bins as f64).max(1.0);
224+
225+
let mut bins = Vec::with_capacity(target_bins);
226+
let mut max_val: f32 = 0.0;
227+
228+
for i in 0..target_bins {
229+
let start = (i as f64 * chunk_size).floor() as usize;
230+
let end = (((i + 1) as f64 * chunk_size).floor() as usize).min(total_samples);
231+
232+
if start >= end {
233+
bins.push(0.0);
234+
continue;
235+
}
236+
237+
let mut sum = 0.0;
238+
for sample in &samples[start..end] {
239+
sum += sample.abs();
240+
}
241+
let avg = sum / (end - start) as f32;
242+
max_val = max_val.max(avg);
243+
bins.push(avg);
244+
}
245+
246+
let multiplier = if max_val > 0.0 { 100.0 / max_val } else { 0.0 };
247+
bins.iter()
248+
.map(|val| (val * multiplier).clamp(0.0, 100.0) as u8)
249+
.collect()
250+
}
251+
252+
async fn normalize_audio_input(input: JsValue) -> Result<Vec<u8>, JsValue> {
253+
if input.is_instance_of::<Uint8Array>() {
254+
let arr = Uint8Array::new(&input);
255+
return Ok(copy_uint8_array(arr));
256+
}
257+
258+
if input.is_instance_of::<ArrayBuffer>() {
259+
let arr = Uint8Array::new(&input);
260+
return Ok(copy_uint8_array(arr));
261+
}
262+
263+
if input.is_instance_of::<ReadableStream>() {
264+
let stream: ReadableStream = input.dyn_into()?;
265+
return read_stream(stream).await;
266+
}
267+
268+
Err(JsValue::from_str(
269+
"Unsupported input type. Expected Uint8Array, ArrayBuffer, or ReadableStream",
270+
))
271+
}
272+
273+
fn copy_uint8_array(array: Uint8Array) -> Vec<u8> {
274+
let mut buffer = vec![0; array.length() as usize];
275+
array.copy_to(&mut buffer);
276+
buffer
277+
}
278+
279+
async fn read_stream(stream: ReadableStream) -> Result<Vec<u8>, JsValue> {
280+
let reader = stream.get_reader();
281+
let reader: ReadableStreamDefaultReader = reader.dyn_into()?;
282+
read_from_reader(reader).await
283+
}
284+
285+
async fn read_from_reader(reader: ReadableStreamDefaultReader) -> Result<Vec<u8>, JsValue> {
286+
let mut chunks: Vec<u8> = Vec::new();
287+
288+
loop {
289+
let promise = reader.read();
290+
let result = JsFuture::from(promise).await?;
291+
292+
let done = Reflect::get(&result, &JsValue::from_str("done"))?
293+
.as_bool()
294+
.unwrap_or(false);
295+
if done {
296+
break;
297+
}
298+
299+
let value = Reflect::get(&result, &JsValue::from_str("value"))?;
300+
if !value.is_undefined() && !value.is_null() {
301+
let chunk = Uint8Array::new(&value);
302+
let mut buffer = vec![0; chunk.length() as usize];
303+
chunk.copy_to(&mut buffer);
304+
chunks.extend_from_slice(&buffer);
305+
}
306+
}
307+
308+
reader.release_lock();
309+
Ok(chunks)
310+
}
311+
312+
struct DecoderContext {
313+
format: Box<dyn FormatReader>,
314+
decoder: Box<dyn Decoder>,
315+
track_id: u32,
316+
sample_rate: Option<u32>,
317+
}
318+
319+
fn prepare_decoder(audio_data: &[u8]) -> Result<DecoderContext, JsValue> {
320+
// Feed the raw bytes into Symphonia via an in-memory cursor.
321+
let cursor = Cursor::new(audio_data.to_vec());
322+
let mss = MediaSourceStream::new(Box::new(cursor), Default::default());
323+
324+
let hint = Hint::new();
325+
let format_opts = FormatOptions::default();
326+
let metadata_opts = MetadataOptions::default();
327+
let decoder_opts = DecoderOptions::default();
328+
329+
let probed = symphonia::default::get_probe()
330+
.format(&hint, mss, &format_opts, &metadata_opts)
331+
.map_err(|e| JsValue::from_str(&format!("Failed to probe audio format: {e}")))?;
332+
333+
let format = probed.format;
334+
335+
let track = format
336+
.tracks()
337+
.iter()
338+
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
339+
.ok_or_else(|| JsValue::from_str("No supported audio track found"))?;
340+
341+
let codec_params = track.codec_params.clone();
342+
let track_id = track.id;
343+
344+
let decoder = symphonia::default::get_codecs()
345+
.make(&codec_params, &decoder_opts)
346+
.map_err(|e| JsValue::from_str(&format!("Failed to create decoder: {e}")))?;
347+
348+
Ok(DecoderContext {
349+
format,
350+
decoder,
351+
track_id,
352+
sample_rate: codec_params.sample_rate,
353+
})
354+
}

0 commit comments

Comments
 (0)