Audio Engine
Real-time audio: the engine, its buffers, source apps, MIDI and the input settings
Point the app's .engine_cb at an engine_callbacks_t. The firmware installs it after init(), passes the app's model as ctx, and removes it before deinit(). Start and stop it with engine_set_active(). Callbacks run on the audio task: never block or allocate in them.
Audio Buffers
mixer_get_out(mix)- output, interleaved stereo (L, R, L, R, ...), zeroed beforeprocess().mixer_get_in(mix)- input. A source app writes its sound here, see below.mixer_get_fx(mix)- the FX send buffer.mixer_get_fs(mix)- samples per block, L and R counted separately.mixer_frames(mix)is the frame count.- Every buffer runs at
TAPP_SAMPLE_RATE(48 kHz); a block is at mostTAPP_BLOCK_FRAMES_MAXframes.
Example
static void my_process(void* ctx, mixer_t* mix) { // ctx = the app model
my_model_t* m = ctx;
float* out = mixer_get_out(mix);
for(uint32_t i = 0, n = mixer_get_fs(mix); i < n; i += 2)
out[i] = out[i + 1] = m->gain * next_sample(m);
}
static void my_active(void* ctx, bool on) { ((my_model_t*)ctx)->playing = on; }
static uint_fast8_t my_is_active(void* ctx) { return ((my_model_t*)ctx)->playing; }
static const engine_callbacks_t my_engine = {
.process = my_process, .active = my_active, .is_active = my_is_active,
};
// descriptor: .engine_cb = &my_engine start/stop: engine_set_active(true/false)Source apps
Declare TAPP_DECLARE_TYPE(AppTypeSrc); once at file scope. A standalone app, which replaces the tape decks, declares AppTypeStandalone; an app with neither is a utility.
A source app feeds the tape decks: they keep running and record its sound together with the live input. Its level is the user's src level.
Write all of mixer_get_in() every block - overwrite it, don't add to it. On entry it holds silence, or the live input when the user has WET on; process it in place to run the input through the app.
mixer_get_fx() holds the decks' FX sends, one block late (2.7 ms) and silent when no deck sends. Whatever is left in it after process() is what the user hears:
- Leave it alone: the sends play dry. This is the default.
- Overwrite it: the user hears your processed version.
- Mix it into your output, then zero it: the sends are heard once, through your app. Without the zeroing they are heard twice.
Note — Mixing mixer_get_fx() into mixer_get_in() makes a feedback loop: the sends are recorded, played back and sent again. Do it only with care.
A source app starts with the statusbar hidden and the hints shown (Screen defaults per app type). Holding BTN4 / BTN5 always goes back to the main menu / out to the tape screen, even with the hints hidden - but the hints are the only place those holds are named, so hide them only if the app is obvious without labels.
Leaving a source app only puts it in the background: it keeps playing and can still be recorded. The user unloads it from the source picker. For MIDI, see midi_bus().
Computing at a lower rate
The buffers are always 48 kHz, but an app can compute at a lower rate to save CPU. For 24 kHz, render half as many samples into a scratch buffer and upsample with ups2x_f32_add(); two stages give 12 kHz. dec2x_f32() goes the other way, for processing the live input. 24 kHz dulls the sound noticeably from about 10 kHz, 12 kHz from about 2.5 kHz, so keep 12 kHz for lo-fi sounds and pads. The Q15 tables (lutsin_q15, lutcos_q15, lutexp_neg_q15, lutsat_q15) suit 24 kHz work; float works too.
Start or stop the app's audio engine
| bool | state | true to start, false to stop |
Call it on every play / stop, not only in init(). After a few seconds with nothing playing the firmware pauses audio and stops calling process(), and only engine_set_active(true) starts it again - an app that just flips its own playing flag stays silent. The call runs the app's active callback before it returns, and keeps the firmware's idle and sleep decisions up to date.
Calling it from init() is fine: the firmware applies it once the callbacks are installed. A call from an app without an engine is ignored.
The MIDI bus. Call it once in init() and keep the pointer.
| bool | claim | true: notes, CCs, pitch bend, aftertouch and program changes go to the app instead of the tape decks (or a loaded radio station) until the app unloads. false: read only - the decks keep playing the notes. A source app that only follows the tape's sync passes false. |
Every incoming MIDI message and every sync pulse lands on the bus, which also keeps the current state. Clock, sync and transport reach every reader whatever the claim. While a source app is loaded, the decks ignore Start / Continue / Stop and leave the transport to it.
The readers are inline loads, cheap enough for the audio task:
- State: midi_note_held(), midi_velocity(), midi_cc(), midi_bend(), midi_aftertouch(), midi_program(), midi_transport().
- Events, in order: midi_poll(), decoded with midi_status(), midi_channel(), midi_data1(), midi_data2(), midi_note_on() and midi_bend_of().
- Sync pulses: midi_sync(), or
MIDI_SYNCevents with midi_sync_offset() for the frame.
midi_poll() reads through your own cursor, so any number of readers can coexist. Keep it in the app model and start it at bus->head; a cursor of 0 replays up to the last 64 messages.
Returns — The bus: read-only, owned by the firmware, valid for the life of the device.
// init()
m->bus = midi_bus(true); // true: channel-voice MIDI is the app's, not the decks'
m->cursor = m->bus->head; // from now on, not the ring's backlog
// process()
if(midi_note_held(m->bus, 60)) ...; // state: what is held / last CC / bend ...
if(midi_sync(m->bus, &m->sync_seen)) restart_loop(); // a sync pulse since last call
uint32_t w; // or the events, in order:
while(midi_poll(m->bus, &m->cursor, &w)) {
switch(midi_status(w)) {
case MIDI_NOTE_ON: if(midi_note_on(w)) note_on(midi_data1(w), midi_data2(w)); else note_off(midi_data1(w)); break;
case MIDI_NOTE_OFF: note_off(midi_data1(w)); break;
case MIDI_CC: cc(midi_data1(w), midi_data2(w)); break;
case MIDI_SYNC: restart_loop_at(midi_sync_offset(w)); break; // frame within this block
}
}The mixer, for init() and tick()
process() is handed it directly. mixer_t is opaque: use the accessors above.
Switch the input between line and mics
This changes the device's input setting: it is saved and stays after the app exits, so call it only when the user asks. It also updates monitoring - the mics are monitored only with headphones plugged in, or the speaker would feed back.
Returns — The input now selected: 0 = line, 1 = mics
The current input, without changing it: 0 = line, 1 = mics
Already settled when init() runs, so it is safe to set up the app's UI from it.
Is the live input audible (monitoring)?
The firmware also changes it when the input is switched or headphones are plugged in or out, so read it when drawing rather than caching it.
Turn monitoring of the live input on or off
| bool | on | true to turn monitoring on |
Turning it on is refused for the mics with no headphones plugged in (the speaker would feed back), so use the return value. The request is remembered: switching the input or plugging headphones in later restores it.
Device-wide but not saved: monitoring is on again after a restart. It only affects the live input, not what the app writes to mixer_get_out().
Returns — The state actually in effect
Is the live input going through the FX chain (WET)?
Read it when drawing rather than caching it.
Send the live input through the FX chain - the device's WET mode
| bool | on | true for wet |
With wet on, the input and the decks' FX sends go through the FX chain together, and the result is what gets recorded (mixer_get_record() returns it). Monitoring and the codec's own mic mix turn off, since the input is already heard through the chain. With an empty chain the sound passes through unprocessed.
Device-wide, like os_audio_switch_input(), so call it only when the user asks. It is not saved, and when the app exits wet goes back to what it was before the app changed it - also when init() fails.
Returns — The state actually in effect