1#![cfg_attr(
43 not(bela_device),
44 allow(
45 dead_code,
46 reason = "only the fallback main is reachable off-device; the application code should still compile and lint"
47 )
48)]
49
50use core::f32::consts::TAU;
51use core::num::NonZeroU32;
52use core::sync::atomic::{AtomicU32, Ordering};
53#[cfg(not(bela_device))]
54use std::process::ExitCode;
55use std::sync::Arc;
56
57use bela::{
58 AuxiliaryTask, BelaApplication, BlockContext, CleanupContext, CpuTimer, FftBin, FftLength,
59 Priority, RealFft, RenderContext, SetupContext, ThreadInfo, rt_println,
60};
61
62const ANALYSIS_LENGTH: usize = 1024;
65
66const MEASURED_LENGTHS: [usize; 5] = [256, 512, 1024, 2048, 4096];
69
70const MEASUREMENTS_PER_CYCLE: u32 = 100_000;
73
74const MONITORING_MEASUREMENTS: u32 = 2000;
79
80const DEFAULT_THREADS: NonZeroU32 = NonZeroU32::new(1).expect("one thread is non-zero");
84
85const TASK_PRIORITY: Priority = Priority::new(75).expect("75 is within Bela's priority range");
87
88#[derive(Debug, Default)]
91struct Published {
92 peak_hz: AtomicU32,
94 peak_magnitude: AtomicU32,
96 micros: [AtomicU32; MEASURED_LENGTHS.len()],
98}
99
100struct Analysis {
102 fft: RealFft,
103 window: Vec<f32>,
104 spectrum: Vec<FftBin>,
105 filled: usize,
107 timer: CpuTimer,
108}
109
110struct Cost {
112 fft: RealFft,
113 signal: Vec<f32>,
114 spectrum: Vec<FftBin>,
115 timer: CpuTimer,
116}
117
118struct Plans {
122 costs: Vec<Cost>,
123 next: usize,
125}
126
127struct Analyser {
128 published: Arc<Published>,
129 task: Option<AuxiliaryTask>,
130 analysis: Option<Analysis>,
133 plans: Vec<Plans>,
136 sample_rate: f32,
137 blocks: u64,
138 blocks_per_report: u64,
139}
140
141impl Analyser {
142 fn new() -> Self {
143 Self {
144 published: Arc::new(Published::default()),
145 task: None,
146 analysis: None,
147 plans: Vec::new(),
148 sample_rate: 0.0,
149 blocks: 0,
150 blocks_per_report: 1,
152 }
153 }
154}
155
156#[allow(clippy::cast_precision_loss, reason = "1024 is exact in f32")]
158const fn analysis_length_as_float() -> f32 {
159 ANALYSIS_LENGTH as f32
160}
161
162const fn cycle() -> NonZeroU32 {
163 NonZeroU32::new(MEASUREMENTS_PER_CYCLE).expect("the cycle length is a non-zero constant")
164}
165
166const fn monitoring_cycle() -> NonZeroU32 {
167 NonZeroU32::new(MONITORING_MEASUREMENTS).expect("the cycle length is a non-zero constant")
168}
169
170fn plan_of(length: usize) -> Result<(RealFft, Vec<f32>, Vec<FftBin>), bela::Error> {
173 let length = FftLength::try_from(length)?;
174 let fft = RealFft::new(length)?;
175 let signal = fft.new_signal();
176 let spectrum = fft.new_spectrum();
177 Ok((fft, signal, spectrum))
178}
179
180fn analysis_plan() -> Result<Analysis, bela::Error> {
183 let (fft, window, spectrum) = plan_of(ANALYSIS_LENGTH)?;
184 Ok(Analysis {
185 fft,
186 window,
187 spectrum,
188 filled: 0,
189 timer: CpuTimer::new(cycle()),
190 })
191}
192
193fn plans_for_one_thread() -> Result<Plans, bela::Error> {
195 let mut costs = Vec::with_capacity(MEASURED_LENGTHS.len());
196 for length in MEASURED_LENGTHS {
197 let (fft, mut signal, spectrum) = plan_of(length)?;
198 for (index, sample) in signal.iter_mut().enumerate() {
202 #[allow(
203 clippy::cast_precision_loss,
204 reason = "an index within a transform length is far below f32's exact integer range"
205 )]
206 let phase = TAU * index as f32 / length as f32;
207 *sample = phase.cos();
208 }
209 costs.push(Cost {
210 fft,
211 signal,
212 spectrum,
213 timer: CpuTimer::new(cycle()),
214 });
215 }
216
217 Ok(Plans { costs, next: 0 })
218}
219
220#[allow(
227 clippy::cast_precision_loss,
228 reason = "a cycle is 100_000 measurements of a few microseconds; both are far inside f32's exact integer range"
229)]
230fn mean_micros(timer: &CpuTimer) -> f32 {
231 let usage = timer.usage();
232 let taken = usage.measurements_taken();
233 if taken == 0 {
234 return 0.0;
235 }
236 let nanos = usage.busy().as_nanos() as f32;
237 nanos / taken as f32 / 1000.0
238}
239
240fn round_trip_error() -> Result<f32, bela::Error> {
248 let (mut fft, mut signal, mut spectrum) = plan_of(ANALYSIS_LENGTH)?;
249 #[allow(
250 clippy::cast_precision_loss,
251 reason = "an index within a transform length is far below f32's exact integer range"
252 )]
253 for (index, sample) in signal.iter_mut().enumerate() {
254 *sample = (TAU * 4.0 * index as f32 / analysis_length_as_float()).cos();
255 }
256 let original = signal.clone();
257
258 fft.forward(&mut signal, &mut spectrum)?;
259 fft.inverse(&mut spectrum, &mut signal)?;
260
261 Ok(original
262 .iter()
263 .zip(&signal)
264 .map(|(before, after)| (before - after).abs())
265 .fold(0.0_f32, f32::max))
266}
267
268impl Analyser {
269 fn analyse(&mut self, context: &BlockContext) {
277 let Some(analysis) = &mut self.analysis else {
278 return;
279 };
280 if context.audio_in_channels() == 0 {
281 return;
282 }
283
284 for frame in 0..context.audio_frames() {
285 analysis.window[analysis.filled] = context.audio_read(frame, 0);
286 analysis.filled += 1;
287 if analysis.filled < analysis.window.len() {
288 continue;
289 }
290 analysis.filled = 0;
291
292 let transformed = {
293 let _section = analysis.timer.measure();
294 analysis
295 .fft
296 .forward(&mut analysis.window, &mut analysis.spectrum)
297 };
298 if transformed.is_err() {
299 rt_println!("render_post: the analysis buffers no longer fit the plan");
305 continue;
306 }
307
308 let peak = analysis
311 .spectrum
312 .iter()
313 .enumerate()
314 .skip(1)
315 .max_by(|(_, a), (_, b)| a.magnitude_squared().total_cmp(&b.magnitude_squared()));
316 if let Some((bin, value)) = peak {
317 #[allow(
318 clippy::cast_precision_loss,
319 reason = "a bin index is far below f32's exact integer range"
320 )]
321 let hz = self.sample_rate * bin as f32 / analysis_length_as_float();
322 self.published
323 .peak_hz
324 .store(hz.to_bits(), Ordering::Relaxed);
325 self.published
326 .peak_magnitude
327 .store(value.magnitude().to_bits(), Ordering::Relaxed);
328 }
329 }
330 }
331}
332
333impl BelaApplication for Analyser {
334 type RenderState = Option<Plans>;
335
336 fn setup(&mut self, context: &SetupContext) -> bool {
337 self.sample_rate = context.audio_sample_rate();
338 #[allow(
339 clippy::cast_possible_truncation,
340 clippy::cast_sign_loss,
341 reason = "the sample rate is a small positive number"
342 )]
343 let sample_rate_hz = self.sample_rate as u64;
344 self.blocks_per_report = (sample_rate_hz / context.audio_frames().max(1) as u64).max(1);
345
346 match analysis_plan() {
350 Ok(analysis) => self.analysis = Some(analysis),
351 Err(error) => {
352 rt_println!("setup: no analysis plan: {error}");
353 return false;
354 }
355 }
356 for thread in 0..context.thread_count() {
357 match plans_for_one_thread() {
358 Ok(plans) => self.plans.push(plans),
359 Err(error) => {
360 rt_println!("setup: no FFT plans for thread {thread}: {error}");
361 return false;
362 }
363 }
364 }
365
366 match round_trip_error() {
370 Ok(worst) => rt_println!("setup: round trip differs by at most {worst:.2e}"),
371 Err(error) => {
372 rt_println!("setup: the round trip failed: {error}");
373 return false;
374 }
375 }
376
377 let published = Arc::clone(&self.published);
378 let task = AuxiliaryTask::new("bela-rs-fft", TASK_PRIORITY, move || {
379 let hz = f32::from_bits(published.peak_hz.load(Ordering::Relaxed));
380 let magnitude = f32::from_bits(published.peak_magnitude.load(Ordering::Relaxed));
381 rt_println!("peak: {hz:.0} Hz at magnitude {magnitude:.1}");
382 for (length, micros) in MEASURED_LENGTHS.iter().zip(&published.micros) {
383 let micros = f32::from_bits(micros.load(Ordering::Relaxed));
384 rt_println!(" {length:>5} points: {micros:.1} us per transform");
385 }
386 });
387
388 match task {
389 Ok(task) => {
390 self.task = Some(task);
391 rt_println!(
392 "setup: {ANALYSIS_LENGTH}-point analysis over {} render thread(s), \
393 {:.0} Hz per bin; reporting every {} blocks",
394 context.thread_count(),
395 self.sample_rate / analysis_length_as_float(),
396 self.blocks_per_report
397 );
398 true
399 }
400 Err(error) => {
401 rt_println!("setup: could not create the task: {error}");
402 false
403 }
404 }
405 }
406
407 fn create_render_state(
408 &mut self,
409 _thread: ThreadInfo,
410 _context: &SetupContext,
411 ) -> Option<Plans> {
412 self.plans.pop()
417 }
418
419 fn render(&self, state: &mut Option<Plans>, context: &mut RenderContext) {
422 let Some(state) = state else { return };
423
424 let channels = context
427 .audio_in_channels()
428 .min(context.audio_out_channels());
429 for frame in context.audio_frame_range() {
430 for channel in 0..channels {
431 context.audio_write(frame, channel, context.audio_read(frame, channel));
432 }
433 }
434
435 let index = state.next;
441 state.next = (index + 1) % state.costs.len();
442 if let Some(cost) = state.costs.get_mut(index) {
443 let _section = cost.timer.measure();
444 let _ = cost.fft.forward(&mut cost.signal, &mut cost.spectrum);
451 }
452 }
453
454 fn render_post(&mut self, states: &mut [Option<Plans>], context: &mut BlockContext) {
457 self.analyse(context);
458
459 self.blocks += 1;
460 if self.blocks % self.blocks_per_report != 0 {
461 return;
462 }
463 if let Some(Some(plans)) = states.first() {
466 for (cost, published) in plans.costs.iter().zip(&self.published.micros) {
467 published.store(mean_micros(&cost.timer).to_bits(), Ordering::Relaxed);
468 }
469 }
470 if let Some(task) = &self.task {
471 task.schedule(context);
472 }
473 }
474
475 fn cleanup(&mut self, states: &mut [Option<Plans>], context: &CleanupContext) {
476 if let Some(usage) = context.cpu_usage() {
477 rt_println!("cleanup: audio thread {usage}");
478 }
479 if let Some(analysis) = &self.analysis {
480 rt_println!(
481 "cleanup: {} whole-block analysis transforms at {:.1} us each",
482 analysis.timer.usage().measurements_taken(),
483 mean_micros(&analysis.timer)
484 );
485 }
486 if let Some(Some(plans)) = states.first() {
487 for (length, cost) in MEASURED_LENGTHS.iter().zip(&plans.costs) {
488 rt_println!(
489 "cleanup: {length:>5} points: {:.1} us per transform over {} of them",
490 mean_micros(&cost.timer),
491 cost.timer.usage().measurements_taken()
492 );
493 }
494 }
495 rt_println!("cleanup: {} blocks rendered", self.blocks);
496 }
497}
498
499fn requested_threads() -> NonZeroU32 {
506 use std::env;
507
508 env::args()
509 .nth(1)
510 .and_then(|argument| argument.parse().ok())
511 .unwrap_or(DEFAULT_THREADS)
512}
513
514#[cfg(bela_device)]
515fn main() -> Result<(), bela::Error> {
516 bela::Bela::run(
519 Analyser::new(),
520 &bela::Settings::new()
521 .period_size(64)
522 .thread_count(requested_threads())
523 .cpu_monitoring(monitoring_cycle()),
524 )
525}
526
527#[cfg(not(bela_device))]
528fn main() -> ExitCode {
529 eprintln!("This example must be cross-compiled for Bela Gem (aarch64-unknown-linux-gnu).");
530 ExitCode::FAILURE
531}