Record Now, Send Later: Audio Capture That Yields the Bus

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.