Record Now, Send Later: Audio Capture That Yields the Bus
Inside a pet feeding station, two sensor modules share one CAN FD bus with the compute module that owns the uplink. The camera module sends images and thermal frames. The audio module records what the pet sounds like while it eats. The same event triggers both, and the bus cannot carry an image and a ten-second audio clip at once without one of them losing.
This post is about the firmware on the audio module that resolves that contention without a central scheduler. It records to local flash the instant a trigger arrives, escalates to continuous recording when triggers cluster, and transmits only when it can see the bus is quiet, pausing the moment the camera module speaks. Underneath it sits a raw-flash clip store with no filesystem, designed so a power cut can never leave a clip half-recorded or half-sent.
The Problem: Two Talkers, One Wire
A feeding event is noisy in every sense. The proximity sensor fires, the camera module captures a frame and streams it as hundreds of 64-byte CAN FD packets, and the audio module starts a ten-second recording. If the audio module tried to stream its clip live, it would be competing with the image transfer for the same bus, and the compute module's receiver would see two interleaved firehoses.
The design constraint was therefore simple to state and awkward to meet. The audio module must never drop a trigger, must never collide with a live image transfer, must survive a reset mid-session, and must eventually deliver every clip it recorded. And it must do all of that on a microcontroller with a few hundred kilobytes of RAM and no operating-system filesystem to lean on.
The Approach: Store First, Transmit When Quiet
The capture manager is a FreeRTOS task driving a state machine. Its policy is written in the header, and every number in it is a deliberate choice:
34 /** Number of triggers within the window to enter continuous mode.
35 * First 4 triggers: camera image + 10s audio each.
36 * 4th trigger within 1 minute: enters continuous audio-only mode. */
37 #define ACM_CONTINUOUS_TRIGGER_COUNT 4
38
39 /** Time window for trigger counting (ms) */
40 #define ACM_TRIGGER_WINDOW_MS (60 * 1000) /* 1 minute */
41
42 /** Maximum clips to record per session before forcing TX. */
43 #define ACM_CONTINUOUS_MAX_CLIPS 54
44
45 /** Clip duration for each capture (seconds) */
46 #define ACM_CLIP_DURATION_SEC 10
47
48 /** Bus idle timeout — no IMAGE/AUDIO frames for this long → bus idle (ms) */
49 #define ACM_BUS_IDLE_TIMEOUT_MS (5 * 1000) /* 5 seconds */
A single trigger produces one image and one ten-second clip. Four triggers inside a minute mean the pet is really there and really eating, so the module stops taking pictures and records audio continuously, clip after clip, until its flash is full or the session ends. Nothing is transmitted during any of this. Transmission is a separate phase that begins only when the bus has carried no heavy traffic for five seconds.
The Process
Never lose a trigger
Triggers arrive from an interrupt context while the task may be busy recording. Task notifications merge their bits, so two triggers during one capture would look like one. The module keeps a separate counter that is incremented atomically and consumed only when the task is free:
157 void acm_notify_trigger(void) {
158 if (g_ota_active) return;
159
160 /* Atomically increment pending trigger count — never lost even if task is busy */
161 g_pending_triggers++;
162
163 if (g_acm_task_handle) {
164 xTaskNotify(g_acm_task_handle, ACM_NOTIFY_TRIGGER, eSetBits);
165 }
166 }
Listen to the bus before using it
The module does not ask anyone for permission to transmit. It watches the frames that pass on the bus and classifies them. Only start and data frames count as heavy; an end frame signals that a transfer has finished and must not reset the idle timer:
168 void acm_notify_bus_frame(uint8_t vbus_subtype) {
169 /* Check if this is SOM1 heavy traffic (ongoing data transfer).
170 * Only START and DATA frames count — END frames signal transfer
171 * completion and should NOT reset the bus idle timer.
172 * (Phantom IMAGE_END frames from CM4 were preventing bus idle.) */
173 bool is_heavy = false;
174 switch (vbus_subtype) {
175 case ACM_VBUS_IMAGE_START:
176 case ACM_VBUS_IMAGE_DATA:
177 case ACM_VBUS_AUDIO_START:
178 case ACM_VBUS_AUDIO_DATA:
179 case ACM_VBUS_THERMAL:
180 is_heavy = true;
181 break;
From that, "idle" becomes a two-part test: the camera module is not mid-transfer, and nothing heavy has been seen for the timeout.
244 static bool is_bus_idle(void) {
245 if (g_som1_active) return false;
246 uint32_t elapsed = HAL_GetTick() - g_last_heavy_frame_tick;
247 return (elapsed >= ACM_BUS_IDLE_TIMEOUT_MS);
248 }
Yield the moment the camera speaks
Even once transmission has started, the camera module has priority. If an image transfer begins, the audio module pauses after the clip it is on and waits for silence again. New triggers during a transmit session are discarded deliberately, because a half-drained flash must be emptied before it can be safely refilled.
783 static void handle_transmitting(uint32_t notifications) {
784 if (notifications & ACM_NOTIFY_OTA_START) {
785 set_state(ACM_STATE_OTA_ACTIVE);
786 return;
787 }
788
789 /* SOM1 started sending — pause our TX */
790 if ((notifications & ACM_NOTIFY_BUS_ACTIVE) || g_som1_active) {
791 LOG_WARN_TAG(TAG, "[TIMING] SOM1 active — pausing TX (after %lu clips)",
792 (unsigned long)g_clips_transmitted);
793 set_state(ACM_STATE_PAUSED_TX);
794 return;
795 }
871 /* Check if bus is idle again → resume TX */
872 if (is_bus_idle()) {
873 LOG_WARN_TAG(TAG, "[TIMING] Bus idle again — resuming TX");
874 set_state(ACM_STATE_TRANSMITTING);
875 }
A firmware update on any module takes precedence over everything: every state has an exit to an OTA-active state, and triggers are ignored until the update ends.

A clip store that survives power loss
Clips are compressed on the module and written to external flash. There is no filesystem. The storage is a ring of fixed-size slots, and every slot carries its own metadata in its final page:
46 #define AUDIO_FLASH_ERASE_BLOCK (64 * 1024) /* 64KB HW erase block */
47 #define AUDIO_SLOT_SIZE (512 * 1024) /* 512KB = 8 erase blocks */
48 #define AUDIO_MAX_SLOTS (AUDIO_RAW_SIZE / AUDIO_SLOT_SIZE) /* 54 */
49
50 /* Metadata lives in last 256-byte page of each slot */
51 #define AUDIO_META_OFFSET (AUDIO_SLOT_SIZE - 256)
52 #define AUDIO_META_PAGE_SIZE 256
53 #define AUDIO_META_MAGIC 0x41554449 /* "AUDI" */
A clip's lifecycle is encoded as a status byte that only ever clears bits. Flash cells can go from one to zero without an erase, so moving a clip from captured to transmitted is a single page write with no erase cycle and no window where the slot has no valid state:
84 /* Flash erased state = 0xFF. Each transition clears one more bit.
85 * EMPTY(0xFF) → CAPTURED(0x7F) → TRANSMITTED(0x3F) or FAILED(0x1F)
86 * This allows in-place status updates without erasing the metadata page. */
87
88 #define AUDIO_CLIP_EMPTY 0xFF /* Erased / never written */
89 #define AUDIO_CLIP_CAPTURED 0x7F /* Written, pending TX (bit 7 cleared) */
90 #define AUDIO_CLIP_TRANSMITTED 0x3F /* Sent successfully (bit 6 cleared) */
91 #define AUDIO_CLIP_FAILED 0x1F /* TX failed (bit 5 cleared) */
The update routine enforces that rule in code, refusing any transition that would need a bit to go back to one, and re-signs the page with a CRC:
352 /* Verify this is a valid bit-clearing transition (new_status has MORE 0s) */
353 if ((meta->status & new_status) != new_status) {
354 LOG_ERROR_TAG(TAG, "Invalid status transition: 0x%02X → 0x%02X",
355 meta->status, new_status);
356 return -1;
357 }
358
359 /* Update status and recompute CRC */
360 meta->status = new_status;
361 meta->crc32 = crc32_compute(buf, offsetof(AudioClipMetadata, crc32));
Metadata is trusted only if both its magic word and its CRC check out, which is what makes a torn write harmless: a page interrupted by power loss simply fails validation and the slot reads as empty.
58 static bool meta_is_valid(const AudioClipMetadata *meta) {
59 if (meta->magic != AUDIO_META_MAGIC) return false;
60 uint32_t crc = crc32_compute((const uint8_t *)meta,
61 offsetof(AudioClipMetadata, crc32));
62 return (crc == meta->crc32);
63 }
Rebuild state from flash at every boot
Because the metadata is self-describing, the module keeps no index that could go stale. At boot it reads the metadata page of all 54 slots, finds the highest clip ID to continue numbering from, the lowest still-captured ID to resume transmitting from, and which slots are already erased:
101 if (meta_is_valid(meta) && status_is_active(meta->status)) {
102 if (meta->clip_id > max_id || !found_any) {
103 max_id = meta->clip_id;
104 }
105 found_any = true;
106
107 /* Track oldest CAPTURED clip for TX scan start */
108 if (meta->status == AUDIO_CLIP_CAPTURED && meta->clip_id < min_captured_id) {
109 min_captured_id = meta->clip_id;
110 found_captured = true;
111 }
112 } else {
113 /* No valid metadata — slot is empty/erased */
114 /* Check if first byte is 0xFF (fully erased) */
115 if (meta_buf[0] == 0xFF) {
116 g_slot_erased[slot] = true;
117 }
118 }
The same full scan is used every time the transmitter asks for the next clip, so no pointer can ever advance past an unsent recording. The source notes the cost:
438 * one live clip (slot = clip_id % AUDIO_MAX_SLOTS), so this 54-slot scan
439 * (~7 ms, same cost as the boot scan) always returns the true oldest
440 * un-transmitted clip and never leaves one behind. */
(The preceding comment lines, which reference a specific test unit, are omitted.)
Finally, erasing 512 KB takes long enough to matter during a recording session, so the module pre-erases the next two slots ahead of time, and refuses to erase any slot that still holds an untransmitted clip:
470 for (uint32_t i = 1; i <= AUDIO_PRE_ERASE_AHEAD; i++) {
471 uint32_t slot_index = (g_next_clip_id + i) % AUDIO_MAX_SLOTS;
472 if (g_slot_erased[slot_index]) continue;
473
474 /* Don't erase slots with untransmitted data */
475 uint8_t buf[AUDIO_META_PAGE_SIZE] __attribute__((aligned(4)));
476 if (octospi_raw_read(slot_meta_addr(slot_index), buf,
477 AUDIO_META_PAGE_SIZE) == 0) {
478 AudioClipMetadata *meta = (AudioClipMetadata *)buf;
479 if (meta_is_valid(meta) && meta->status == AUDIO_CLIP_CAPTURED) {
480 LOG_WARN_TAG(TAG, "Skip pre-erase slot %lu: untransmitted",
481 (unsigned long)slot_index);
482 continue;
483 }
484 }

The Results
The audio module now behaves like a considerate guest on the bus. It records immediately, stores locally, and drains its clips in the quiet gaps between image transfers, pausing and resuming without any coordination message from the other modules. Capture persists across a reset, and every recorded clip is eventually delivered because the transmitter always searches for the true oldest unsent clip rather than trusting a pointer.
This firmware shipped as part of the audio module's 4.0.0 release, delivered to the fleet unattended by the hub's on-boot module flasher described in an earlier post. The release validation across the updated units recorded zero missing audio packets, and the capture-to-delivery path was confirmed to survive a reset with all clips draining afterwards.
Why It Matters at Hoomanely
Hoomanely is reinventing healthcare for pets — replacing reactive, imprecise care with continuous, clinical-grade monitoring that catches problems early. Our devices form a Physical Intelligence ecosystem: sensors fused at the edge, feeding the Biosense AI Engine that turns raw signals into personalized, preventive insights.
The sound of a pet eating is clinical data. Changes in chewing rhythm, swallowing, or the small coughs between mouthfuls are early signals a vet would want, and they only exist for the ten seconds the pet is at the bowl. A module that records first and negotiates the bus later is how we make sure that data is captured every time, not only when the bus happens to be free.
It also reflects how we build at the edge: modules that are self-sufficient, that keep their own truth in flash, and that cooperate by observing rather than by being told. A system of polite, independent sensors scales to a home in a way a central scheduler never would.
Key Takeaways
- Separate capture from delivery. Recording to local flash the instant a trigger arrives removes the bus from the critical path entirely.
- Observe the bus instead of asking it. Classifying passing frames as heavy or not gives each module an idle signal with no coordination protocol.
- Give the higher-value stream priority by yielding, not by arbitration. Pausing after the current clip and resuming on silence is simple and never deadlocks.
- Design flash state so it only clears bits. A status byte that moves from 0xFF toward zero needs no erase and has no torn-write window.
- Keep no index you cannot rebuild. Self-describing slots with a magic word and CRC let the module recover its full state from flash at every boot.
Author's Note
This runs on the audio sensor module inside Hoomanely's Everbowl, alongside the camera module and the compute module that have featured in earlier posts. It is a small state machine and a few hundred lines of flash handling, and it embodies a rule I keep relearning: on a shared bus, the most reliable protocol is often the one where nobody has to send a message to agree.