Rust ❤️ Bela – Making Connections
Posted
Back from a winter holiday break, we return to our series on using Rust with Bela. Last time, we split out our library crate into a separate package, so we can ensure the no_std
attribute is respected. Furthermore, we began parsing MIDI events and connecting them to the virtual tone wheels. However, we did so in the simplest possible, one-to-one manner. Let's fix that.
At this point, we have to look at some additional details on how tone wheel organs work. In the second part of this series, we had a brief look into the interior of such an organ and already saw that there are a lot of wires. This time, let's have a look at what the musician sees to clarify some terms.
From top to bottom, only looking at the main elements for now, we have:
- The drawbars, emulating the stops of a pipe organ, grouped in two groups of nine, a pair of drawbars, and two more groups of nine.
- Two keyboards (the upper and lower manuals) with 12 keys in an inverted color scheme (the preset keys) and 61 keys in the usual color scheme.
- A pedalboard (pedal clavier) with 25 pedals.
Each drawbar is a switch (not a potentiometer) with nine settings numbered 0 through 8. The zero-setting shunts the corresponding signal to ground, while the others go to the different taps of a matching transformer with 6, 8, 11, 16, 22, 32, 45, and 64 turns, approximating a geometric series with a step of . So let's add a lookup table of gains.
// gains based on
// http://www.dairiki.org/HammondWiki/MatchingTransformer
static DRAWBAR_GAINS: [f32; 9] = [
0.0,
6.0 / 64.0,
8.0 / 64.0,
11.0 / 64.0,
16.0 / 64.0,
22.0 / 64.0,
32.0 / 64.0,
45.0 / 64.0,
1.0,
];
But what about the 9-9-2-9-9 grouping? Both the upper manual and the lower manual have two pairs of nine drawbars, for two presets corresponding to preset keys A♯ and B. The other nine presets (for preset keys C♯ through A) are wired on the interior preset panel and can't be changed while playing. The final preset key C is a cancel key and turns all signals off. The pedalboard only has two drawbars and no preset pedals. We'll get to what signals each drawbar corresponds to in a moment. For now, we know that we'll need to remember the current drawbar settings and presets, so let's add them as fields to our TonewheelOrgan
:
pub struct TonewheelOrgan {
/* ... */
upper_manual_presets: [[u8; 9]; 12],
current_upper_preset: u8,
lower_manual_presets: [[u8; 9]; 12],
current_lower_preset: u8,
pedal_drawbars: [u8; 2],
}
impl TonewheelOrgan {
pub fn new(sample_rate: f32) -> Self {
/* ... */
// presets based on
// - http://www.dairiki.org/HammondWiki/StandardPresets
// - http://www.dairiki.org/HammondWiki/StandardJazzRegistrations
// final two presets are drawbar settings
let upper_manual_presets = [
[0; 9], // off / cancel
[0, 0, 5, 3, 2, 0, 0, 0, 0], // Stopped Flute (pp)
[0, 0, 4, 4, 3, 2, 0, 0, 0], // Dulciana (ppp)
[0, 0, 8, 7, 4, 0, 0, 0, 0], // French Horn (mf)
[0, 0, 4, 5, 4, 4, 2, 2, 2], // Salicional (pp)
[0, 0, 5, 4, 0, 3, 0, 0, 0], // Flutes 8' & 4' (p)
[0, 0, 4, 6, 7, 5, 3, 0, 0], // Oboe Horn (mf)
[0, 0, 5, 6, 4, 4, 3, 2, 0], // Swell Diapason (mf)
[0, 0, 6, 8, 7, 6, 5, 4, 0], // Trumpet (f)
[3, 2, 7, 6, 4, 5, 2, 2, 2], // Full Swell (ff)
[8, 8, 8, 8, 8, 8, 8, 8, 8], /* A# preset upper (drawbar
* set 1) */
[8, 8, 8, 0, 0, 0, 0, 0, 4], /* B preset upper (drawbar
* set 2) */
];
let current_upper_preset = 1;
let lower_manual_presets = [
[0; 9], // off / cancel
[0, 0, 4, 5, 4, 5, 4, 4, 0], // Cello (mp)
[0, 0, 4, 4, 2, 3, 2, 2, 0], // Flute & String (mp)
[0, 0, 7, 3, 7, 3, 4, 3, 0], // Clarinet (mf)
[0, 0, 4, 5, 4, 4, 2, 2, 0], /* Diapason, Gamba & Flute
* (mf) */
[0, 0, 6, 6, 4, 4, 3, 2, 2], // Great, no reeds (f)
[0, 0, 5, 6, 4, 2, 2, 0, 0], // Open Diapason (f)
[0, 0, 6, 8, 4, 5, 4, 3, 3], // Full Great (ff)
[0, 0, 8, 0, 3, 0, 0, 0, 0], // Tibia Clausa (f)
[4, 2, 7, 8, 6, 6, 2, 4, 4], // Full Great with 16' (fff)
[0, 0, 8, 6, 0, 0, 0, 0, 0], /* A# preset upper (drawbar
* set 1) */
[8, 3, 8, 0, 0, 0, 0, 0, 0], /* B preset upper (drawbar
* set 2) */
];
let current_lower_preset = 1;
let pedal_drawbars = [8, 0];
TonewheelOrgan {
/* ... */
upper_manual_presets,
current_upper_preset,
lower_manual_presets,
current_lower_preset,
pedal_drawbars,
}
}
/* ... */
}
There's a reason we saw so many cables on that interior photos. And it gets worse. Running underneath the keys of the manuals are nine bus bars each. One for every drawbar. Each key has nine palladium contacts connected to nine tone wheels each. Since we only have 91 tone wheels and more than a thousand switches for the manuals alone, not counting the pedalboard, quite a few are reused, some even multiple times on a single keys due to wraparound. A good overview on which tone wheels are connected to which keys can be found on Jeff Dariki's site. These connections and the drawbar labels are based on the harmonic series:
Harmonic | Footage |
---|---|
Sub-Fundamental | 16' |
Sub-Third | 5 ⅓' |
Fundamental | 8' |
2nd Harmonic | 4' |
3rd Harmonic | 2 ⅔' |
4th Harmonic | 2' |
5th Harmonic | 1 ⅗' |
6th Harmonic | 1 ⅓' |
8th Harmonic | 1' |
The “Sub-Fundamental” (an octave below the fundamental) and “Sub-Third” (a fifth above the fundamental) obviously aren't actually harmonics, but give more flexibility in shaping the tone. If you're thinking “this sounds like a primitive form of additive synthesis,” you'd be exactly right. In any case, the footage labels on the drawbar tabs are based on the footage labels on pipe organ stops. The frequency of a sine is inversely related to its wavelength, and thereby the length a pipe needs to be to form it.
In any case, we can use the information available to store which key needs to be connected to which tone wheel, making sure to adjust the connections to zero-based indexing and to account for the dummy tone wheels:
const MANUAL_KEYS: usize = 61;
const PEDAL_KEYS: usize = 25;
// connections based on
// http://www.dairiki.org/hammond/wiring/
// tone wheel numbers (1-91) changed to indices (0-90), same for key
// numbers, final tone wheel indices shifted to account for dummy wheels
static MANUAL_KEY_GENERATORS: [[usize; 9]; MANUAL_KEYS] = [
[12, 19, 12, 24, 31, 36, 40, 43, 48], // 0 C
[13, 20, 13, 25, 32, 37, 41, 44, 49], // 1 C#
[14, 21, 14, 26, 33, 38, 42, 45, 50], // 2 D
[15, 22, 15, 27, 34, 39, 43, 46, 51], // 3 D#
[16, 23, 16, 28, 35, 40, 44, 47, 52], // 4 E
[17, 24, 17, 29, 36, 41, 45, 48, 53], // 5 F
[18, 25, 18, 30, 37, 42, 46, 49, 54], // 6 F#
[19, 26, 19, 31, 38, 43, 47, 50, 55], // 7 G
[20, 27, 20, 32, 39, 44, 48, 51, 56], // 8 G#
[21, 28, 21, 33, 40, 45, 49, 52, 57], // 9 A
[22, 29, 22, 34, 41, 46, 50, 53, 58], // 10 A#
[23, 30, 23, 35, 42, 47, 51, 54, 59], // 11 B
[12, 31, 24, 36, 43, 48, 52, 55, 60], // 12 C
[13, 32, 25, 37, 44, 49, 53, 56, 61], // 13 C#
[14, 33, 26, 38, 45, 50, 54, 57, 62], // 14 D
[15, 34, 27, 39, 46, 51, 55, 58, 63], // 15 D#
[16, 35, 28, 40, 47, 52, 56, 59, 64], // 16 E
[17, 36, 29, 41, 48, 53, 57, 60, 65], // 17 F
[18, 37, 30, 42, 49, 54, 58, 61, 66], // 18 F#
[19, 38, 31, 43, 50, 55, 59, 62, 67], // 19 G
[20, 39, 32, 44, 51, 56, 60, 63, 68], // 20 G#
[21, 40, 33, 45, 52, 57, 61, 64, 69], // 21 A
[22, 41, 34, 46, 53, 58, 62, 65, 70], // 22 A#
[23, 42, 35, 47, 54, 59, 63, 66, 71], // 23 B
[24, 43, 36, 48, 55, 60, 64, 67, 72], // 24 C
[25, 44, 37, 49, 56, 61, 65, 68, 73], // 25 C#
[26, 45, 38, 50, 57, 62, 66, 69, 74], // 26 D
[27, 46, 39, 51, 58, 63, 67, 70, 75], // 27 D#
[28, 47, 40, 52, 59, 64, 68, 71, 76], // 28 E
[29, 48, 41, 53, 60, 65, 69, 72, 77], // 29 F
[30, 49, 42, 54, 61, 66, 70, 73, 78], // 30 F#
[31, 50, 43, 55, 62, 67, 71, 74, 79], // 31 G
[32, 51, 44, 56, 63, 68, 72, 75, 80], // 32 G#
[33, 52, 45, 57, 64, 69, 73, 76, 81], // 33 A
[34, 53, 46, 58, 65, 70, 74, 77, 82], // 34 A#
[35, 54, 47, 59, 66, 71, 75, 78, 83], // 35 B
[36, 55, 48, 60, 67, 72, 76, 79, 89], // 36 C
[37, 56, 49, 61, 68, 73, 77, 80, 90], // 37 C#
[38, 57, 50, 62, 69, 74, 78, 81, 91], // 38 D
[39, 58, 51, 63, 70, 75, 79, 82, 92], // 39 D#
[40, 59, 52, 64, 71, 76, 80, 83, 93], // 40 E
[41, 60, 53, 65, 72, 77, 81, 89, 94], // 41 F
[42, 61, 54, 66, 73, 78, 82, 90, 95], // 42 F#
[43, 62, 55, 67, 74, 79, 83, 91, 79], // 43 G
[44, 63, 56, 68, 75, 80, 89, 92, 80], // 44 G#
[45, 64, 57, 69, 76, 81, 90, 93, 81], // 45 A
[46, 65, 58, 70, 77, 82, 91, 94, 82], // 46 A#
[47, 66, 59, 71, 78, 83, 92, 95, 83], // 47 B
[48, 67, 60, 72, 79, 89, 93, 79, 89], // 48 C
[49, 68, 61, 73, 80, 90, 94, 80, 90], // 49 C#
[50, 69, 62, 74, 81, 91, 95, 81, 91], // 50 D
[51, 70, 63, 75, 82, 92, 79, 82, 92], // 51 D#
[52, 71, 64, 76, 83, 93, 80, 83, 93], // 52 E
[53, 72, 65, 77, 89, 94, 81, 89, 94], // 53 F
[54, 73, 66, 78, 90, 95, 82, 90, 95], // 54 F#
[55, 74, 67, 79, 91, 79, 83, 91, 79], // 55 G
[56, 75, 68, 80, 92, 80, 89, 92, 80], // 56 G#
[57, 76, 69, 81, 93, 81, 90, 93, 81], // 57 A
[58, 77, 70, 82, 94, 82, 91, 94, 82], // 58 A#
[59, 78, 71, 83, 95, 83, 92, 95, 83], // 59 B
[60, 79, 72, 89, 79, 89, 93, 79, 89], // 60 C
];
Probably (well… definitely), not the most efficient way to do this, but a close correspondence to the real thing. Now, we also need to store which keys (or pedals) are pressed, ignoring things like contact bounce or the delay between different circuits closing for the time being, and adjust our MIDI processing correspondingly. Haven't decided which control changes to use for which drawbar yet, so we'll skip those. The manuals and pedalboard will use one MIDI channel each.
pub struct TonewheelOrgan {
/* ... */
// replacing active_notes:
upper_active_notes: [bool; MANUAL_KEYS],
lower_active_notes: [bool; MANUAL_KEYS],
pedal_active_notes: [bool; PEDAL_KEYS],
}
impl TonewheelOrgan {
/* ... */
pub fn process_midi_message(&mut self, msg: &[u8]) {
let TonewheelOrgan {
upper_active_notes,
current_upper_preset,
lower_active_notes,
current_lower_preset,
pedal_active_notes,
..
} = self;
// use wmidi to parse msg
match MidiMessage::try_from(msg) {
Ok(MidiMessage::NoteOn(channel, note, _velocity)) => {
let note = note as u8;
// preset key range: C3 (24) to B3 (35)
if (24..36).contains(¬e) {
match channel {
Channel::Ch1 => {
*current_upper_preset = note - 24
}
Channel::Ch2 => {
*current_lower_preset = note - 24
}
_ => {}
}
}
// manual range: C4 (36) to C9 (96)
if (36..=96).contains(¬e) {
match channel {
Channel::Ch1 => {
upper_active_notes[(note - 36) as usize] =
true
}
Channel::Ch2 => {
lower_active_notes[(note - 36) as usize] =
true
}
_ => {}
}
}
}
Ok(MidiMessage::NoteOff(channel, note, _velocity)) => {
let note = note as u8;
// manual range: C4 (36) to C9 (96)
if (36..=96).contains(¬e) {
match channel {
Channel::Ch1 => {
upper_active_notes[(note - 36) as usize] =
false
}
Channel::Ch2 => {
lower_active_notes[(note - 36) as usize] =
false
}
_ => {}
}
}
}
Ok(MidiMessage::Reset) => {
// deactivate all notes on reset
*upper_active_notes = [false; MANUAL_KEYS];
*lower_active_notes = [false; MANUAL_KEYS];
*pedal_active_notes = [false; PEDAL_KEYS];
}
_ => {}
}
}
/* ... */
}
You may have noticed that we switched away from using an array of u8x4
for the active notes. The reason for that is the somewhat chaotic indexing and most SIMD instruction sets not supporting any form of indexing (gather or scatter operations). Finally, we need to update our render_sample
function:
impl TonewheelOrgan {
/* ... */
pub fn render_sample(&mut self) -> f32 {
// generate tone wheel base signals
let mut signals = MaybeUninit::uninit_array();
self.generate_base_signals(&mut signals);
let signals =
unsafe { MaybeUninit::array_assume_init(signals) };
// cast f32x4 array to f32 array (can we do this without
// unsafe?)
let signals_scalar: &[f32; 4 * ROUNDED_TONE_WHEEL_CHUNKS] =
unsafe { transmute(&signals) };
let mut upper_partial_signals = [0.0; 9];
let mut lower_partial_signals = [0.0; 9];
for (upper_active, lower_active, generators) in itertools::izip!(
self.upper_active_notes.iter(),
self.lower_active_notes.iter(),
MANUAL_KEY_GENERATORS
) {
// convert bools to bit masks
let upper_active_mask =
(*upper_active as u32).wrapping_neg();
let lower_active_mask =
(*lower_active as u32).wrapping_neg();
for (upper_signal, lower_signal, generator) in itertools::izip!(
upper_partial_signals.iter_mut(),
lower_partial_signals.iter_mut(),
generators
) {
let generator =
unsafe { *signals_scalar.get_unchecked(generator) };
*upper_signal += f32::from_bits(
generator.to_bits() & upper_active_mask,
);
*lower_signal += f32::from_bits(
generator.to_bits() & lower_active_mask,
);
}
}
// look up drawbar gains using unsafe get_unchecked to prevent
// bounds checks the optimizer doesn't manage to remove
let upper_gains = {
let mut upper_gains = [0.0; 9];
for (gain, preset) in upper_gains.iter_mut().zip(unsafe {
*self
.upper_manual_presets
.get_unchecked(self.current_upper_preset as usize)
}) {
*gain = unsafe {
*DRAWBAR_GAINS.get_unchecked(preset as usize)
};
}
upper_gains
};
let lower_gains = {
let mut lower_gains = [0.0; 9];
for (gain, preset) in lower_gains.iter_mut().zip(unsafe {
*self
.lower_manual_presets
.get_unchecked(self.current_lower_preset as usize)
}) {
*gain = unsafe {
*DRAWBAR_GAINS.get_unchecked(preset as usize)
};
}
lower_gains
};
// apply drawbar gains and sum signals
let upper_signal = upper_partial_signals
.into_iter()
.zip(upper_gains)
.map(|(partial, gain)| partial * gain)
.sum::<f32>();
let lower_signal = lower_partial_signals
.into_iter()
.zip(lower_gains)
.map(|(partial, gain)| partial * gain)
.sum::<f32>();
let signal = 0.05 * (upper_signal + lower_signal);
// soft clipping
let signal = signal.max(-1.0).min(1.0);
let signal = 1.5 * signal - 0.5 * signal.powi(3);
signal
}
/* ... */
}
You may notice that we still haven't added pedal support here and that there are a number of unsafe
optimizations already. Furthermore, instead of separately computing the upper and lower manuals separately, we have a single loop that goes through both at once. Isn't premature optimization the root of all evil?
I originally didn't explicitly remove the bounds checks and had two separate loops and ran into the Xenomai watchdog timer because the resulting render
function was too slow. And that's without adding the pedals! Merging the loops helps, because fewer indirect lookups are necessary.
To get a better idea of how much of our time budget of 22.7 μs we have left over, let's benchmark the render_sample
function, updating tonewheel-organ/Cargo.toml
correspondingly:
use criterion::{
black_box, criterion_group, criterion_main, Criterion,
};
use tonewheel_organ::*;
fn criterion_benchmark(c: &mut Criterion) {
let mut organ = TonewheelOrgan::new(44_100.0);
c.bench_function("render_sample", |b| {
b.iter(|| TonewheelOrgan::render_sample(black_box(&mut organ)))
});
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);
At this point, I actually ran into an issue with my cross compilation tool chain that I didn't encounter before:
target/armv7-unknown-linux-gnueabihf/release/deps/render_sample-eac071ee5aed3608: /lib/arm-linux-gnueabihf/libc.so.6: version `GLIBC_2.25' not found (required by target/armv7-unknown-linux-gnueabihf/release/deps/render_sample-eac071ee5aed3608)
error: bench failed
This means that the cross-compiler is linking to a newer version of libc.so
(or corresponding stubs) than is used on the target system. After downgrading to the Linaro GCC 6.3 tool chain, the benchmark built and ran. I also went ahead and updated the first article in this series in which we set up the cross-compilation environment. So let's run that benchmark now.
render_sample time: [28.470 us 28.560 us 28.679 us]
Found 7 outliers among 100 measurements (7.00%)
1 (1.00%) low mild
6 (6.00%) high severe
Whoops. That's not good. We have about −5.9 μs left over in our time budget. Why did it work at all? Probably only due to the better cache and optimization behavior of having a block size greater than one in the main binary! So guess what we'll be doing next time: more SIMD optimization! But to not leave you hanging, here's a quick sound sample:
The first half is just the output of the Bela —with notch filters at 1.5 kHz, 3 kHz, 4.5 kHz, etc. due to some annoying buzz I haven't managed to get rid of, even using a professional sound card. Maybe it's a Bela issue, or I have something in my room emitting electromagnetic interference at 1.5 kHz. For the second half, I added amp and rotary speaker simulations using a VST plugin.
As usual, feel free to follow me and send me a DM on Mastodon if you have any questions or comments. I'm also active on the Bela forum.