#![cfg_attr(
not(bela_device),
allow(
dead_code,
reason = "only the fallback main is reachable off-device; the application code should still compile and lint"
)
)]
use core::f32::consts::TAU;
use core::num::NonZeroU32;
use core::sync::atomic::{AtomicU32, Ordering};
#[cfg(not(bela_device))]
use std::process::ExitCode;
use std::sync::Arc;
use bela::{
AuxiliaryTask, BelaApplication, BlockContext, CleanupContext, CpuTimer, FftBin, FftLength,
Priority, RealFft, RenderContext, SetupContext, ThreadInfo, rt_println,
};
const ANALYSIS_LENGTH: usize = 1024;
const MEASURED_LENGTHS: [usize; 5] = [256, 512, 1024, 2048, 4096];
const MEASUREMENTS_PER_CYCLE: u32 = 100_000;
const MONITORING_MEASUREMENTS: u32 = 2000;
const DEFAULT_THREADS: NonZeroU32 = NonZeroU32::new(1).expect("one thread is non-zero");
const TASK_PRIORITY: Priority = Priority::new(75).expect("75 is within Bela's priority range");
#[derive(Debug, Default)]
struct Published {
peak_hz: AtomicU32,
peak_magnitude: AtomicU32,
micros: [AtomicU32; MEASURED_LENGTHS.len()],
}
struct Analysis {
fft: RealFft,
window: Vec<f32>,
spectrum: Vec<FftBin>,
filled: usize,
timer: CpuTimer,
}
struct Cost {
fft: RealFft,
signal: Vec<f32>,
spectrum: Vec<FftBin>,
timer: CpuTimer,
}
struct Plans {
costs: Vec<Cost>,
next: usize,
}
struct Analyser {
published: Arc<Published>,
task: Option<AuxiliaryTask>,
analysis: Option<Analysis>,
plans: Vec<Plans>,
sample_rate: f32,
blocks: u64,
blocks_per_report: u64,
}
impl Analyser {
fn new() -> Self {
Self {
published: Arc::new(Published::default()),
task: None,
analysis: None,
plans: Vec::new(),
sample_rate: 0.0,
blocks: 0,
blocks_per_report: 1,
}
}
}
#[allow(clippy::cast_precision_loss, reason = "1024 is exact in f32")]
const fn analysis_length_as_float() -> f32 {
ANALYSIS_LENGTH as f32
}
const fn cycle() -> NonZeroU32 {
NonZeroU32::new(MEASUREMENTS_PER_CYCLE).expect("the cycle length is a non-zero constant")
}
const fn monitoring_cycle() -> NonZeroU32 {
NonZeroU32::new(MONITORING_MEASUREMENTS).expect("the cycle length is a non-zero constant")
}
fn plan_of(length: usize) -> Result<(RealFft, Vec<f32>, Vec<FftBin>), bela::Error> {
let length = FftLength::try_from(length)?;
let fft = RealFft::new(length)?;
let signal = fft.new_signal();
let spectrum = fft.new_spectrum();
Ok((fft, signal, spectrum))
}
fn analysis_plan() -> Result<Analysis, bela::Error> {
let (fft, window, spectrum) = plan_of(ANALYSIS_LENGTH)?;
Ok(Analysis {
fft,
window,
spectrum,
filled: 0,
timer: CpuTimer::new(cycle()),
})
}
fn plans_for_one_thread() -> Result<Plans, bela::Error> {
let mut costs = Vec::with_capacity(MEASURED_LENGTHS.len());
for length in MEASURED_LENGTHS {
let (fft, mut signal, spectrum) = plan_of(length)?;
for (index, sample) in signal.iter_mut().enumerate() {
#[allow(
clippy::cast_precision_loss,
reason = "an index within a transform length is far below f32's exact integer range"
)]
let phase = TAU * index as f32 / length as f32;
*sample = phase.cos();
}
costs.push(Cost {
fft,
signal,
spectrum,
timer: CpuTimer::new(cycle()),
});
}
Ok(Plans { costs, next: 0 })
}
#[allow(
clippy::cast_precision_loss,
reason = "a cycle is 100_000 measurements of a few microseconds; both are far inside f32's exact integer range"
)]
fn mean_micros(timer: &CpuTimer) -> f32 {
let usage = timer.usage();
let taken = usage.measurements_taken();
if taken == 0 {
return 0.0;
}
let nanos = usage.busy().as_nanos() as f32;
nanos / taken as f32 / 1000.0
}
fn round_trip_error() -> Result<f32, bela::Error> {
let (mut fft, mut signal, mut spectrum) = plan_of(ANALYSIS_LENGTH)?;
#[allow(
clippy::cast_precision_loss,
reason = "an index within a transform length is far below f32's exact integer range"
)]
for (index, sample) in signal.iter_mut().enumerate() {
*sample = (TAU * 4.0 * index as f32 / analysis_length_as_float()).cos();
}
let original = signal.clone();
fft.forward(&mut signal, &mut spectrum)?;
fft.inverse(&mut spectrum, &mut signal)?;
Ok(original
.iter()
.zip(&signal)
.map(|(before, after)| (before - after).abs())
.fold(0.0_f32, f32::max))
}
impl Analyser {
fn analyse(&mut self, context: &BlockContext) {
let Some(analysis) = &mut self.analysis else {
return;
};
if context.audio_in_channels() == 0 {
return;
}
for frame in 0..context.audio_frames() {
analysis.window[analysis.filled] = context.audio_read(frame, 0);
analysis.filled += 1;
if analysis.filled < analysis.window.len() {
continue;
}
analysis.filled = 0;
let transformed = {
let _section = analysis.timer.measure();
analysis
.fft
.forward(&mut analysis.window, &mut analysis.spectrum)
};
if transformed.is_err() {
rt_println!("render_post: the analysis buffers no longer fit the plan");
continue;
}
let peak = analysis
.spectrum
.iter()
.enumerate()
.skip(1)
.max_by(|(_, a), (_, b)| a.magnitude_squared().total_cmp(&b.magnitude_squared()));
if let Some((bin, value)) = peak {
#[allow(
clippy::cast_precision_loss,
reason = "a bin index is far below f32's exact integer range"
)]
let hz = self.sample_rate * bin as f32 / analysis_length_as_float();
self.published
.peak_hz
.store(hz.to_bits(), Ordering::Relaxed);
self.published
.peak_magnitude
.store(value.magnitude().to_bits(), Ordering::Relaxed);
}
}
}
}
impl BelaApplication for Analyser {
type RenderState = Option<Plans>;
fn setup(&mut self, context: &SetupContext) -> bool {
self.sample_rate = context.audio_sample_rate();
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the sample rate is a small positive number"
)]
let sample_rate_hz = self.sample_rate as u64;
self.blocks_per_report = (sample_rate_hz / context.audio_frames().max(1) as u64).max(1);
match analysis_plan() {
Ok(analysis) => self.analysis = Some(analysis),
Err(error) => {
rt_println!("setup: no analysis plan: {error}");
return false;
}
}
for thread in 0..context.thread_count() {
match plans_for_one_thread() {
Ok(plans) => self.plans.push(plans),
Err(error) => {
rt_println!("setup: no FFT plans for thread {thread}: {error}");
return false;
}
}
}
match round_trip_error() {
Ok(worst) => rt_println!("setup: round trip differs by at most {worst:.2e}"),
Err(error) => {
rt_println!("setup: the round trip failed: {error}");
return false;
}
}
let published = Arc::clone(&self.published);
let task = AuxiliaryTask::new("bela-rs-fft", TASK_PRIORITY, move || {
let hz = f32::from_bits(published.peak_hz.load(Ordering::Relaxed));
let magnitude = f32::from_bits(published.peak_magnitude.load(Ordering::Relaxed));
rt_println!("peak: {hz:.0} Hz at magnitude {magnitude:.1}");
for (length, micros) in MEASURED_LENGTHS.iter().zip(&published.micros) {
let micros = f32::from_bits(micros.load(Ordering::Relaxed));
rt_println!(" {length:>5} points: {micros:.1} us per transform");
}
});
match task {
Ok(task) => {
self.task = Some(task);
rt_println!(
"setup: {ANALYSIS_LENGTH}-point analysis over {} render thread(s), \
{:.0} Hz per bin; reporting every {} blocks",
context.thread_count(),
self.sample_rate / analysis_length_as_float(),
self.blocks_per_report
);
true
}
Err(error) => {
rt_println!("setup: could not create the task: {error}");
false
}
}
}
fn create_render_state(
&mut self,
_thread: ThreadInfo,
_context: &SetupContext,
) -> Option<Plans> {
self.plans.pop()
}
fn render(&self, state: &mut Option<Plans>, context: &mut RenderContext) {
let Some(state) = state else { return };
let channels = context
.audio_in_channels()
.min(context.audio_out_channels());
for frame in context.audio_frame_range() {
for channel in 0..channels {
context.audio_write(frame, channel, context.audio_read(frame, channel));
}
}
let index = state.next;
state.next = (index + 1) % state.costs.len();
if let Some(cost) = state.costs.get_mut(index) {
let _section = cost.timer.measure();
let _ = cost.fft.forward(&mut cost.signal, &mut cost.spectrum);
}
}
fn render_post(&mut self, states: &mut [Option<Plans>], context: &mut BlockContext) {
self.analyse(context);
self.blocks += 1;
if self.blocks % self.blocks_per_report != 0 {
return;
}
if let Some(Some(plans)) = states.first() {
for (cost, published) in plans.costs.iter().zip(&self.published.micros) {
published.store(mean_micros(&cost.timer).to_bits(), Ordering::Relaxed);
}
}
if let Some(task) = &self.task {
task.schedule(context);
}
}
fn cleanup(&mut self, states: &mut [Option<Plans>], context: &CleanupContext) {
if let Some(usage) = context.cpu_usage() {
rt_println!("cleanup: audio thread {usage}");
}
if let Some(analysis) = &self.analysis {
rt_println!(
"cleanup: {} whole-block analysis transforms at {:.1} us each",
analysis.timer.usage().measurements_taken(),
mean_micros(&analysis.timer)
);
}
if let Some(Some(plans)) = states.first() {
for (length, cost) in MEASURED_LENGTHS.iter().zip(&plans.costs) {
rt_println!(
"cleanup: {length:>5} points: {:.1} us per transform over {} of them",
mean_micros(&cost.timer),
cost.timer.usage().measurements_taken()
);
}
}
rt_println!("cleanup: {} blocks rendered", self.blocks);
}
}
fn requested_threads() -> NonZeroU32 {
use std::env;
env::args()
.nth(1)
.and_then(|argument| argument.parse().ok())
.unwrap_or(DEFAULT_THREADS)
}
#[cfg(bela_device)]
fn main() -> Result<(), bela::Error> {
bela::Bela::run(
Analyser::new(),
&bela::Settings::new()
.period_size(64)
.thread_count(requested_threads())
.cpu_monitoring(monitoring_cycle()),
)
}
#[cfg(not(bela_device))]
fn main() -> ExitCode {
eprintln!("This example must be cross-compiled for Bela Gem (aarch64-unknown-linux-gnu).");
ExitCode::FAILURE
}