Skip to main content

fft/
fft.rs

1//! Analyses the audio input with an FFT, and measures what the
2//! transform costs the audio thread.
3//!
4//! Two things at once, because they answer each other, and they run in
5//! different callbacks for a reason.
6//!
7//! The **analysis** is the ordinary use of [`RealFft`](bela::RealFft):
8//! fill a window from the input, transform it, report the loudest bin.
9//! It happens in `render_post`, which sees the whole block. `render`
10//! would be the wrong place: it is called on every render thread with
11//! that thread's share of the block, so with more than one thread each
12//! window would hold every fourth quarter of the signal spliced
13//! together, and the frequency that came out of it would be a fiction.
14//! Whole-block work belongs where the whole block is.
15//!
16//! The **measurement** is the question a program has to answer before
17//! it puts an FFT in `render` at all — how much of the block deadline
18//! one costs — and that one belongs per thread, in `render`, where the
19//! cost is actually paid. It is taken at several lengths, one per
20//! block in rotation, with a `CpuTimer` around each. Running with four
21//! render threads (`fft 4`) measures four transforms happening at
22//! once, which is not four times cheaper.
23//!
24//! The plans are built in `setup`, one per render thread, because that
25//! is the only callback that can refuse the run: `create_render_state`
26//! returns a state rather than a `Result`, and the release profile
27//! aborts on panic. `setup` hands them out through the render states.
28//!
29//! The reporting happens on an auxiliary task, as in `examples/cpu.rs`:
30//! the numbers are read on the audio thread in `render_post` and
31//! handed over through atomics.
32//!
33//! Cross-compile and run on the board (see docs/cross-compile.md):
34//!
35//! ```sh
36//! cargo build -p bela --release --target aarch64-unknown-linux-gnu --example fft
37//! ```
38//!
39//! What it prints belongs in `docs/fft.md`, which records what this
40//! board's NE10 does.
41
42#![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
62/// The window the input is analysed with. 1024 points at 44.1 kHz is
63/// 23 ms, and 43 Hz per bin.
64const ANALYSIS_LENGTH: usize = 1024;
65
66/// The lengths the cost is measured at, one per block in rotation, so
67/// that a run reports the whole curve rather than one point of it.
68const MEASURED_LENGTHS: [usize; 5] = [256, 512, 1024, 2048, 4096];
69
70/// Long enough that the counters rarely roll over mid-report: the
71/// means below are read out of the cycle in progress.
72const MEASUREMENTS_PER_CYCLE: u32 = 100_000;
73
74/// Bela's own monitoring of the whole audio thread, which reports a
75/// percentage only when a cycle completes — so it is short enough that
76/// a run of a few seconds has one, where the timers above want a long
77/// cycle for their means.
78const MONITORING_MEASUREMENTS: u32 = 2000;
79
80/// Render threads when the command line does not say: one, which is
81/// what an analysis of a whole block wants. Pass a count as the first
82/// argument to use more.
83const DEFAULT_THREADS: NonZeroU32 = NonZeroU32::new(1).expect("one thread is non-zero");
84
85/// Below the audio thread, so the report can never delay it.
86const TASK_PRIORITY: Priority = Priority::new(75).expect("75 is within Bela's priority range");
87
88/// What the audio thread publishes for the task to print: each number
89/// as `f32::to_bits`.
90#[derive(Debug, Default)]
91struct Published {
92    /// The loudest bin's frequency, in Hz.
93    peak_hz: AtomicU32,
94    /// Its magnitude, as the transform reports it.
95    peak_magnitude: AtomicU32,
96    /// Mean microseconds per transform, one per [`MEASURED_LENGTHS`].
97    micros: [AtomicU32; MEASURED_LENGTHS.len()],
98}
99
100/// The analysis plan and the window it fills.
101struct Analysis {
102    fft: RealFft,
103    window: Vec<f32>,
104    spectrum: Vec<FftBin>,
105    /// How much of `window` holds input so far.
106    filled: usize,
107    timer: CpuTimer,
108}
109
110/// One measured length: a plan, buffers, and the timer around it.
111struct Cost {
112    fft: RealFft,
113    signal: Vec<f32>,
114    spectrum: Vec<FftBin>,
115    timer: CpuTimer,
116}
117
118/// What one render thread measures with: the cost plans and where the
119/// rotation is up to. The analysis is not here — it is the
120/// application's, because it is whole-block work.
121struct Plans {
122    costs: Vec<Cost>,
123    /// Which of `costs` the next block measures.
124    next: usize,
125}
126
127struct Analyser {
128    published: Arc<Published>,
129    task: Option<AuxiliaryTask>,
130    /// The whole-block analysis, used from `render_post` and so held
131    /// by the application rather than by a render state.
132    analysis: Option<Analysis>,
133    /// Built in `setup`, one per render thread, taken in
134    /// `create_render_state`.
135    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            // Replaced in setup, once the block size is known.
151            blocks_per_report: 1,
152        }
153    }
154}
155
156/// [`ANALYSIS_LENGTH`] as the float the bin spacing is computed in.
157#[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
170/// A plan of `length` points with the buffers it transforms, or the
171/// error that stopped it.
172fn 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
180/// The whole-block analysis, built where a failure can still be
181/// reported.
182fn 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
193/// What one render thread measures with, built in the same place.
194fn 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        // A cosine at bin 1 rather than silence: what a transform
199        // costs should not be measured on a buffer of zeros, where
200        // denormals can flatter or punish it.
201        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/// The mean time one measured section took, in microseconds, or 0
221/// before the first measurement.
222///
223/// `busy` and the count are both what the *current* acquisition cycle
224/// has accumulated, so this is a mean over that cycle rather than over
225/// the run — which is why the cycle above is long.
226#[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
240/// Transforms a cosine and transforms it back, and reports how far
241/// the result strayed.
242///
243/// Not real-time work — it allocates — so `setup` is where it belongs.
244/// What it demonstrates is the scaling: an unscaled forward and an
245/// inverse that restores the original amplitudes, so the worst
246/// difference here is rounding and nothing else.
247fn 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    /// Fills the analysis window from the whole block, and transforms
270    /// it whenever it comes up full.
271    ///
272    /// Called from `render_post`, which sees every frame of the block
273    /// in order. Doing this in `render` would see only one thread's
274    /// share of it, and a window spliced together from every fourth
275    /// quarter of the signal reports a frequency that is not there.
276    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                // Both buffers came from the plan, so their lengths
300                // agree by construction; saying so beats going quiet
301                // if a later edit changes one. Once per window rather
302                // than once per block, which is why this one prints
303                // where the measurement below does not.
304                rt_println!("render_post: the analysis buffers no longer fit the plan");
305                continue;
306            }
307
308            // The loudest bin, skipping DC — which a little offset on
309            // the input would otherwise win every time.
310            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        // The one place a plan that could not be built can be
347        // reported: returning false here refuses the run before audio
348        // starts, where a panic in a later callback would abort.
349        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        // The inverse, once, where allocating and printing are free:
367        // a round trip returns the signal it started from, which is
368        // this crate's contract rather than NE10's (docs/fft.md).
369        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        // `setup` built one set per thread and agreed to start, so
413        // this hands one over rather than making it. `None` is
414        // unreachable, and costs a branch in `render` rather than an
415        // abort here.
416        self.plans.pop()
417    }
418
419    // Real-time safe: copies, arithmetic, one transform that allocates
420    // nothing, and a clock read through the CPU timer.
421    fn render(&self, state: &mut Option<Plans>, context: &mut RenderContext) {
422        let Some(state) = state else { return };
423
424        // Passthrough, so what is analysed can be heard. This thread's
425        // share of the block, which is what `render` is handed.
426        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        // One measured transform per block, at the next length in
436        // turn. Per thread on purpose: what a transform costs when
437        // four of them run at once is the number worth having, and the
438        // analysis in `render_post` is the one that has to see whole
439        // blocks.
440        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            // Deliberately dropped, where the analysis reports the
445            // same impossible error: this runs every block on every
446            // thread, and a buffer that stopped fitting would print
447            // thousands of times a second. The lengths are the plan's
448            // own, and `cleanup` shows the transform count they
449            // produced.
450            let _ = cost.fft.forward(&mut cost.signal, &mut cost.spectrum);
451        }
452    }
453
454    // Real-time safe: copies, arithmetic, a transform that allocates
455    // nothing, atomic stores and a schedule.
456    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        // Thread 0's timers stand for the rest: every thread runs the
464        // same rotation over the same lengths.
465        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
499/// The render thread count from this program's own first argument, as
500/// `examples/parallel.rs` takes it.
501///
502/// Worth passing: one plan per render thread is the arrangement the
503/// API is built around, and more than one thread is where handing them
504/// out in `create_render_state` has to be right.
505fn 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    // A small period keeps the block deadline tight, which is the
517    // point of comparison the microseconds are read against.
518    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}