io_config/io_config.rs
1//! Hardware probe for how a board configures its analog and digital
2//! I/O.
3//!
4//! Written to answer the first half of #11. The analog and digital
5//! accessors on the context types have only ever run against hand-built
6//! contexts, and two of the claims their documentation rests on — that
7//! a Gem's analog outputs are its audio outputs with a +2 channel
8//! offset, and that `uniformSampleRate` is on by default — come from
9//! Bela's migration guide rather than from a measurement. Both are
10//! about numbers a board reports, so both can be settled before any
11//! wire is connected. `scripts/probe-io.sh` drives this, and what it
12//! finds belongs in `docs/board-facts.md`.
13//!
14//! Nothing here reads or writes a sample. Confirming that a pin does
15//! what the accessor says needs a voltage on it, which is the other
16//! half of #11 and a different instrument.
17//!
18//! It is not a check with a right answer, so it is not part of
19//! `scripts/smoke-test.sh`: what it prints is the board's description
20//! of itself, and a pass/fail gate has nothing to compare that against
21//! until the facts are written down.
22//!
23//! # What each run does
24//!
25//! - `hardware` — what libbela says the board is, before any audio
26//! system exists: the detected board and the version of the library
27//! that detected it, the `BelaHwConfig` that hardware implies, and
28//! the analog and digital fields of `Bela_defaultSettings`. The only
29//! probe here that brings nothing up, so it is also the only one
30//! whose answer cannot depend on the settings it was asked for.
31//! - `context [options]` — brings one audio system up with the options
32//! given, reports the `BelaContext` that `setup` sees and the one the
33//! first block sees, renders for a moment and tears it down. The
34//! options are the ones that decide the shape of the block:
35//!
36//! ```text
37//! --period <frames> --uniform on|off
38//! --analog on|off --digital on|off
39//! --analog-in <channels> --analog-out <channels>
40//! --digital-channels <channels>
41//! ```
42//!
43//! Unset options are left to `Bela_defaultSettings`, which is the
44//! point: the run with no options at all is the one that says what a
45//! board does when nobody asks for anything.
46//!
47//! # Both ends of the block are reported
48//!
49//! `setup` and the first block are asked the same questions because
50//! they need not give the same answers: `setup` runs inside
51//! `Bela_initAudio`, before an audio thread exists, and a frame count
52//! that is only filled in once one does would show up as a difference
53//! between the two. The accessors are documented as if there were no
54//! difference, so a difference is a finding.
55//!
56//! # One configuration per process
57//!
58//! A failed `Bela_initAudio` poisons the process it happened in
59//! (`docs/board-facts.md`), and every configuration here is one that
60//! might fail. Each run therefore takes one configuration and exits,
61//! and the driving script starts a fresh process for the next.
62//!
63//! Cross-compile and run on the board (see docs/cross-compile.md):
64//!
65//! ```sh
66//! cargo build -p bela --release --target aarch64-unknown-linux-gnu --example io_config
67//! ```
68
69#![cfg_attr(
70 not(bela_device),
71 allow(
72 dead_code,
73 reason = "only the fallback main is reachable off-device; the probe code should still compile and lint"
74 )
75)]
76
77use std::process::ExitCode;
78
79use bela::{BelaApplication, BlockContext, RenderContext, SetupContext, ThreadInfo, rt_println};
80
81/// Reports the shape of the block from both ends of it.
82struct Report {
83 /// Whether the next block is the first one. The report is per run,
84 /// not per block: at 2760 blocks a second the second one would only
85 /// bury the first.
86 first_block: bool,
87}
88
89impl BelaApplication for Report {
90 type RenderState = ();
91
92 fn setup(&mut self, context: &SetupContext) -> bool {
93 // `println!`, not `rt_println!`: `setup` runs inside
94 // `Bela_initAudio` before there is an audio thread, and a
95 // flushed write is what a script reading this over ssh needs.
96 // The block report below is on the audio thread and cannot use
97 // it.
98 report_context("setup", context);
99 true
100 }
101
102 fn create_render_state(&mut self, _thread: ThreadInfo, _context: &SetupContext) {}
103
104 fn render_pre(&mut self, _states: &mut [()], context: &mut BlockContext) {
105 if !self.first_block {
106 return;
107 }
108 self.first_block = false;
109 // Bela's own real-time printing, because this is the audio
110 // thread. One key per line: a message is capped at
111 // `MESSAGE_CAPACITY` bytes and a truncated one would read as a
112 // missing field.
113 rt_println!(
114 "io-config: block-audio=frames:{},in:{},out:{},rate:{}",
115 context.audio_frames(),
116 context.audio_in_channels(),
117 context.audio_out_channels(),
118 context.audio_sample_rate()
119 );
120 rt_println!(
121 "io-config: block-analog=frames:{},in:{},out:{},rate:{}",
122 context.analog_frames(),
123 context.analog_in_channels(),
124 context.analog_out_channels(),
125 context.analog_sample_rate()
126 );
127 rt_println!(
128 "io-config: block-digital=frames:{},channels:{},rate:{}",
129 context.digital_frames(),
130 context.digital_channels(),
131 context.digital_sample_rate()
132 );
133 rt_println!(
134 "io-config: block-threads=this:{},count:{},counted:{}",
135 context.as_sys().thisThread,
136 context.as_sys().threadCount,
137 context.thread_count()
138 );
139 }
140
141 // Silence: this probe is about how the audio system was configured,
142 // not about what it renders.
143 fn render(&self, _state: &mut (), _context: &mut RenderContext) {}
144}
145
146/// Reports one step, on the spot.
147///
148/// Flushed, because stdout is a pipe when the driving script is
149/// listening and an audio system that goes on to fail should not take
150/// what was already established with it.
151fn report(key: &str, value: &str) {
152 use std::io::{self, Write};
153
154 println!("io-config: {key}={value}");
155 let _ = io::stdout().flush();
156}
157
158/// The whole shape of the block, as `setup` sees it.
159///
160/// Split across three lines by domain, so that a run with analog or
161/// digital disabled shows a line of zeros rather than a gap.
162fn report_context(phase: &str, context: &SetupContext) {
163 report(
164 &format!("{phase}-audio"),
165 &format!(
166 "frames:{},in:{},out:{},rate:{}",
167 context.audio_frames(),
168 context.audio_in_channels(),
169 context.audio_out_channels(),
170 context.audio_sample_rate()
171 ),
172 );
173 report(
174 &format!("{phase}-analog"),
175 &format!(
176 "frames:{},in:{},out:{},rate:{}",
177 context.analog_frames(),
178 context.analog_in_channels(),
179 context.analog_out_channels(),
180 context.analog_sample_rate()
181 ),
182 );
183 report(
184 &format!("{phase}-digital"),
185 &format!(
186 "frames:{},channels:{},rate:{}",
187 context.digital_frames(),
188 context.digital_channels(),
189 context.digital_sample_rate()
190 ),
191 );
192 // Raw as well as counted. `thread_count()` reads a `threadCount`
193 // of 0 as 1, which is a `BelaContext`'s other way of spelling one
194 // render thread — so the crate's number cannot say which of the
195 // two libbela wrote, and a record of board behaviour wants the
196 // field as it stands.
197 report(
198 &format!("{phase}-threads"),
199 &format!(
200 "this:{},count:{},counted:{}",
201 context.as_sys().thisThread,
202 context.as_sys().threadCount,
203 context.thread_count()
204 ),
205 );
206}
207
208#[cfg(bela_device)]
209mod probes {
210 use core::time::Duration;
211 use std::thread;
212
213 use bela::bela_sys::{
214 Bela_HwConfig_delete, Bela_HwConfig_new, Bela_InitSettings_alloc, Bela_InitSettings_free,
215 Bela_defaultSettings,
216 };
217 use bela::{Bela, Board, DetectMode, Settings, Version};
218
219 use super::{Report, report};
220
221 /// How long the audio system renders before it is torn down. Long
222 /// enough that a working one reports thousands of blocks, so that
223 /// "came up but never rendered" cannot be mistaken for it.
224 const RENDER_TIME: Duration = Duration::from_secs(1);
225
226 /// Reads the cached detection rather than scanning.
227 ///
228 /// `DetectMode::Cache` is what the daemon leaves behind in
229 /// `/run/bela/belaconfig`, and a scan would go out over I²C to find
230 /// out something this probe only wants reported.
231 const DETECT_CACHED: DetectMode = DetectMode::Cache;
232
233 /// The board with the number it is, so that the record says
234 /// `GemStereo(2)` rather than either on its own.
235 ///
236 /// A value the crate has no name for is worth more than "unknown":
237 /// it means the board's libbela knows a hardware the vendored
238 /// headers do not, and [`Board::Unrecognised`] prints as
239 /// `unrecognised(<n>)` rather than losing the number.
240 fn hardware_name(board: Board) -> String {
241 if board.is_recognised() {
242 format!("{board}({raw})", raw = board.to_sys())
243 } else {
244 board.to_string()
245 }
246 }
247
248 /// What the board is, and what libbela expects of it, with no audio
249 /// system anywhere in the picture.
250 pub(crate) fn hardware() {
251 let board = Board::detect(DETECT_CACHED);
252 report("detect-hw", &hardware_name(board));
253 // The library that answered, which is not necessarily the one
254 // this was built against: every number below is a claim about a
255 // particular libbela, and this is which one.
256 report("version", &Version::running().to_string());
257 let hw = board.to_sys();
258
259 // The configuration libbela associates with that hardware,
260 // which is where a Gem's channel counts come from before any
261 // settings are applied. Null is an answer too: it is what a
262 // hardware libbela has no configuration for looks like.
263 let config = unsafe { Bela_HwConfig_new(hw) };
264 if config.is_null() {
265 report("hw-config", "null");
266 } else {
267 let config_ref = unsafe { &*config };
268 report(
269 "hw-config",
270 &format!(
271 "rate:{},audio-in:{},audio-out:{},analog-in:{},analog-out:{},digital:{}",
272 config_ref.audioSampleRate,
273 config_ref.audioInChannels,
274 config_ref.audioOutChannels,
275 config_ref.analogInChannels,
276 config_ref.analogOutChannels,
277 config_ref.digitalChannels
278 ),
279 );
280 unsafe { Bela_HwConfig_delete(config) };
281 }
282
283 // The defaults an application inherits by setting nothing,
284 // which is what `Settings`'s "unset fields keep the values
285 // produced by `Bela_defaultSettings()`" means in practice.
286 let raw = unsafe { Bela_InitSettings_alloc() };
287 if raw.is_null() {
288 report("defaults", "alloc-failed");
289 return;
290 }
291 unsafe { Bela_defaultSettings(raw) };
292 let defaults = unsafe { &*raw };
293 report(
294 "defaults-analog",
295 &format!(
296 "use:{},in:{},out:{},uniform:{}",
297 defaults.useAnalog,
298 defaults.numAnalogInChannels,
299 defaults.numAnalogOutChannels,
300 defaults.uniformSampleRate
301 ),
302 );
303 report(
304 "defaults-digital",
305 &format!(
306 "use:{},channels:{}",
307 defaults.useDigital, defaults.numDigitalChannels
308 ),
309 );
310 report(
311 "defaults-audio",
312 &format!(
313 "period:{},rate:{},threads:{}",
314 defaults.periodSize, defaults.audioSampleRate, defaults.threadCount
315 ),
316 );
317 unsafe { Bela_InitSettings_free(raw) };
318 }
319
320 /// One audio system, one configuration, both ends of one block.
321 pub(crate) fn context(settings: &Settings) {
322 let app = Report { first_block: true };
323 let mut bela = match Bela::new(app, settings) {
324 Ok(bela) => bela,
325 Err(error) => {
326 // A configuration the board will not have is a finding,
327 // not a failure of the probe: which combinations are
328 // refused is part of what #11 asks.
329 report("init", &format!("failed-{error:?}"));
330 return;
331 }
332 };
333 report("init", "created");
334 if let Err(error) = bela.start() {
335 report("start", &format!("failed-{error:?}"));
336 return;
337 }
338 report("start", "started");
339 thread::sleep(RENDER_TIME);
340 drop(bela);
341 report("run", "stopped");
342 }
343}
344
345/// Turns the command line into the settings to bring an audio system up
346/// with.
347///
348/// Every option is optional and an unset one is left to
349/// `Bela_defaultSettings`, so `context` with no options is a valid run
350/// and the interesting one.
351#[cfg(bela_device)]
352fn parse_settings(arguments: &[String]) -> Option<bela::Settings> {
353 fn switch(value: &str) -> Option<bool> {
354 match value {
355 "on" => Some(true),
356 "off" => Some(false),
357 _ => None,
358 }
359 }
360
361 let mut settings = bela::Settings::new();
362 let mut rest = arguments.iter();
363 while let Some(option) = rest.next() {
364 let value = rest.next()?;
365 settings = match option.as_str() {
366 "--period" => settings.period_size(value.parse().ok()?),
367 "--uniform" => settings.uniform_sample_rate(switch(value)?),
368 "--analog" => settings.use_analog(switch(value)?),
369 "--digital" => settings.use_digital(switch(value)?),
370 "--analog-in" => settings.num_analog_in_channels(value.parse().ok()?),
371 "--analog-out" => settings.num_analog_out_channels(value.parse().ok()?),
372 "--digital-channels" => settings.num_digital_channels(value.parse().ok()?),
373 _ => return None,
374 };
375 }
376 Some(settings)
377}
378
379#[cfg(bela_device)]
380fn main() -> ExitCode {
381 use std::env::args;
382
383 let arguments: Vec<String> = args().skip(1).collect();
384 match arguments.split_first() {
385 Some((probe, rest)) if probe == "hardware" && rest.is_empty() => probes::hardware(),
386 Some((probe, rest)) if probe == "context" => {
387 let Some(settings) = parse_settings(rest) else {
388 eprintln!("context: cannot read the options {rest:?}");
389 return ExitCode::FAILURE;
390 };
391 // Echoed so that a log read on its own says which run it is.
392 let asked_for = if rest.is_empty() {
393 "defaults".to_owned()
394 } else {
395 rest.join(" ")
396 };
397 report("settings", &asked_for);
398 probes::context(&settings);
399 }
400 _ => {
401 eprintln!(
402 "usage: io_config (hardware | context [options])\n\
403 \x20 options: --period <frames> | --uniform on|off | --analog on|off\n\
404 \x20 | --digital on|off | --analog-in <channels>\n\
405 \x20 | --analog-out <channels> | --digital-channels <channels>\n\
406 one configuration per run: a failed initialisation poisons its process"
407 );
408 return ExitCode::FAILURE;
409 }
410 }
411 ExitCode::SUCCESS
412}
413
414#[cfg(not(bela_device))]
415fn main() -> ExitCode {
416 eprintln!("This example must be cross-compiled for Bela Gem (aarch64-unknown-linux-gnu).");
417 ExitCode::FAILURE
418}