Files
baby-mobile/baby_mobile_v2/baby_mobile_v2.ino
T
2026-07-04 03:28:14 -07:00

1929 lines
72 KiB
Arduino
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Baby Mobile Audio Board v2 — nRF52840 Firmware
*
* Platform: Seeed XIAO nRF52840 (or MDBT50Q module)
* Framework: Adafruit nRF52 Arduino Core
*
* Features:
* - USB Mass Storage: drag-and-drop .raw audio files
* - BLE: wireless audio upload from phone/Web Bluetooth
* - 8-bit 8kHz PWM audio playback from IS25LP128F SPI flash
* - DC motor PWM speed control
* - 8 button inputs with debounce
* - Deep sleep with button wake (~10µA)
* - Auto-shutoff timer
*
* Audio format: WAV (8 or 16-bit PCM, 8/16/32 kHz, mono) stored in LittleFS.
* Upload via upload_track.py — ffmpeg handles any source format.
*
* Board setup in Arduino IDE:
* Board: "Seeed XIAO nRF52840" (or "Adafruit Feather nRF52840")
* Install: Seeed nRF52 Boards via Board Manager
*
* Wiring (XIAO pin names):
* D0 (P0.02) - Button 1
* D1 (P0.03) - Button 2
* D2 (P0.28) - Button 3
* D3 (P0.29) - Button 4
* D4 (P0.04) - Button 5
* D5 (P0.05) - Button 6
* D6 (P1.11) - Button 7
* D7 (P1.12) - Button 8
* D4 (P0.04) - Flash ~CS (PIN_FLASH_CS)
* D8 (P0.07) - SPI SCK → Flash CLK
* D9 (P0.06) - SPI MISO → Flash DO
* D10 (P0.05) - SPI MOSI → Flash DI
* A0 (P0.02) - Audio PWM output → R+C LPF → PAM8302A
* A1 (P0.03) - Amp ~SD (HIGH=on)
* A2 (P0.28) - Motor PWM → MOSFET gate
* A3 (P0.29) - LED output
* A4 (P0.04) - Flash ~CS
* A5 (P0.05) - (spare)
*
* NOTE: Pin assignments above are illustrative. Adjust to your actual
* XIAO pinout and PCB routing. The XIAO has 11 usable GPIOs on the
* edge pins plus additional pads on the bottom.
*/
#include <SPI.h>
#include <Adafruit_TinyUSB.h>
#include <bluefruit.h>
// ============================================================
// When defined, a wake from deep sleep (button press) plays a random track
// immediately instead of waiting for user input.
#define WAKE_PLAY_RANDOM
// ============================================================
// Button playback mode:
// Undefined — short press toggles play/stop (press again to stop).
// Defined — every press always starts a track; never stops mid-play.
// If WAKE_PLAY_RANDOM is also defined, picks a random track;
// otherwise advances to the next track (wrapping around).
#define BTN_ALWAYS_START
// ============================================================
// POC MODE: stream audio from internal LittleFS instead of SPI flash.
// Upload tracks via upload_track.py (serial) or BLE (CMD 0x02).
// WAV files are stored intact; header is parsed at load time for
// dynamic bit-depth (8/16-bit) and sample-rate support.
// Disable this define to revert to full SPI flash mode.
// ============================================================
//#define POC_INTERNAL_FLASH
#ifdef POC_INTERNAL_FLASH
#include <Adafruit_LittleFS.h>
#include <InternalFileSystem.h>
using namespace Adafruit_LittleFS_Namespace;
#endif
// ============================================================
// PIN DEFINITIONS — adjust for your PCB
// ============================================================
// Using raw nRF52840 GPIO numbers for flexibility.
// Map these to your actual PCB connections.
// Audio
#define PIN_AUDIO_PWM 0 // Any PWM-capable pin
#define PIN_AMP_SD 6 // PAM8302A shutdown (HIGH=enabled)
// Motor
#define PIN_MOTOR_PWM 15 // PWM to MOSFET gate
// SPI Flash
#define PIN_FLASH_CS 4 // Flash chip select
// SPI MOSI/MISO/SCK use default SPI pins (8/9/10?)
// Buttons (directly to GPIO, active LOW with internal pull-up)
#define PIN_BTN1 1
/*
#define PIN_BTN2 1
#define PIN_BTN3 1
#define PIN_BTN4 1
#define PIN_BTN5 4
#define PIN_BTN6 5
#define PIN_BTN7 3
#define PIN_BTN8 4
*/
// PIN_LED is 11 (LED_R)
// LED_B is 13/12
// LED_G is 12/13
const uint8_t BTN_PINS[] = {
PIN_BTN1 /*, PIN_BTN2, PIN_BTN3, PIN_BTN4,*/
/*PIN_BTN5, PIN_BTN6, PIN_BTN7, PIN_BTN8*/
};
#define NUM_BUTTONS 1
// ============================================================
// FLASH CONSTANTS
// ============================================================
#define FLASH_SIZE (16UL * 1024UL * 1024UL) // 16MB
#define FLASH_SECTOR 4096
#define FLASH_PAGE 256
#define FLASH_BLOCK_64K (64UL * 1024UL)
// IS25LP128F / W25Q128 compatible commands
#define CMD_READ 0x03
#define CMD_FAST_READ 0x0B
#define CMD_WRITE_EN 0x06
#define CMD_PAGE_PROG 0x02
#define CMD_SECT_ERASE 0x20
#define CMD_BLOCK_ERASE 0xD8
#define CMD_CHIP_ERASE 0xC7
#define CMD_READ_SR1 0x05
#define CMD_JEDEC_ID 0x9F
#define CMD_POWER_DOWN 0xB9
#define CMD_WAKE 0xAB
// ============================================================
// TRACK TABLE FORMAT
// ============================================================
// Sector 0 (first 4KB) holds the track index:
// [0] uint8_t magic = 0xBB (indicates valid table)
// [1] uint8_t num_tracks
// [2..3] reserved
// [4..] Track entries, 12 bytes each:
// uint32_t start_addr (big-endian)
// uint32_t byte_length (big-endian)
// uint8_t sample_rate_khz (8 = 8kHz)
// uint8_t bits (8 = 8-bit unsigned)
// uint16_t reserved
// Audio data starts at AUDIO_START (sector 1 = 0x1000)
#define TRACK_TABLE_ADDR 0x000000
#define AUDIO_START_ADDR 0x001000
#define TABLE_MAGIC 0xBB
#define MAX_TRACKS 32
#define TRACK_ENTRY_SIZE 12
// ============================================================
// AUDIO CONFIG
// ============================================================
#define SAMPLE_RATE 16000
#define AUDIO_BUF_SIZE 512 // larger buffer = fewer SPI transactions
#define SILENCE 128 // 0x80 = center for unsigned 8-bit
// ============================================================
// USB MASS STORAGE
// ============================================================
Adafruit_USBD_MSC usb_msc;
// Simple FAT12 filesystem on the SPI flash
// We present the entire 16MB flash as a USB drive.
// The host can write raw files; firmware scans for audio on boot.
// MSC callbacks
int32_t msc_read_cb(uint32_t lba, void* buffer, uint32_t bufsize);
int32_t msc_write_cb(uint32_t lba, uint8_t* buffer, uint32_t bufsize);
void msc_flush_cb(void);
bool msc_start_stop_cb(uint8_t power_condition, bool start, bool load_eject);
#define MSC_BLOCK_SIZE 512
#define MSC_BLOCK_COUNT (FLASH_SIZE / MSC_BLOCK_SIZE)
// ============================================================
// BLE SERVICE
// ============================================================
BLEService audioSvc = BLEService("12340001-0000-1000-8000-00805f9b34fb");
BLECharacteristic audioCmd = BLECharacteristic("12340002-0000-1000-8000-00805f9b34fb");
BLECharacteristic audioData = BLECharacteristic("12340003-0000-1000-8000-00805f9b34fb");
BLECharacteristic audioStat = BLECharacteristic("12340004-0000-1000-8000-00805f9b34fb");
// BLE connection / upload state
volatile bool g_bleConnected = false; // true while a GATT connection is active
volatile bool g_bleUploading = false;
volatile uint32_t g_bleWriteLen = 0;
#ifdef POC_INTERNAL_FLASH
File g_pocFile(InternalFS); // open file handle (read or write)
uint8_t g_pocWriteTrack = 0; // track slot being written via BLE
#else
// Ring buffer between BLE callbacks and loop() flash writes.
// Callbacks return immediately; flash I/O happens in loop() so the SoftDevice
// receive buffer never stalls. Sector erase is async (non-blocking): bleFlashTick()
// kicks off the erase and returns; loop() polls completion each iteration. This
// prevents the ring buffer from filling during the ~30300 ms erase window.
// Buffer: 64 KB handles worst-case 300 ms erase at up to ~200 KB/s BLE throughput.
#define BLE_FLASH_BUF 65536u
static uint8_t g_bleBuf[BLE_FLASH_BUF];
static volatile uint32_t g_bleBufHead = 0; // advanced by BLE callback
static volatile uint32_t g_bleBufTail = 0; // advanced by loop()
static uint32_t g_bleFlashAddr = 0; // current flash write head
static uint32_t g_bleFlashStart = 0; // address where this upload began
static uint32_t g_bleFlashErased = 0; // upper boundary of erased flash
static bool g_bleErasing = false; // async sector erase in progress
static uint32_t g_bleNotifyThresh = 0; // next addr to send progress notify
static uint8_t g_bleWriteTrack = 0;
static volatile bool g_bleFinalizing = false;
// Shared next-free flash pointer — used by both BLE and serial upload paths.
// Reset to AUDIO_START_ADDR whenever track 0 is uploaded.
static uint32_t g_flashNextFree = AUDIO_START_ADDR;
#endif
// ============================================================
// GLOBAL STATE
// ============================================================
volatile bool g_playing = false;
volatile bool g_motorOn = false;
uint8_t g_motorSpeed = 160;
uint8_t g_numTracks = 0;
uint32_t g_trackStart[MAX_TRACKS];
uint32_t g_trackLen[MAX_TRACKS]; // total file size in bytes (including WAV header)
uint8_t g_trackBits[MAX_TRACKS]; // bits per sample: 8 or 16
uint32_t g_trackRate[MAX_TRACKS]; // sample rate in Hz
uint32_t g_trackDataOff[MAX_TRACKS]; // byte offset of audio data within file (44 for WAV, 0 for raw)
uint8_t g_currentTrack = 0;
bool g_loopTracks = false; // false = play once and stop; true = auto-advance through all tracks
// Audio DMA buffers — uint16_t PWM duty-cycle values for EasyDMA
static uint16_t g_pwmBuf[2][AUDIO_BUF_SIZE];
volatile bool g_bufReady[2] = {false, false};
uint32_t g_trackDoneMs = 0; // nonzero = millis() deadline after which we stop
uint32_t g_nextReadAddr = 0;
uint32_t g_playEnd = 0;
// Timers
unsigned long g_lastActivity = 0;
#define AUTO_OFF_MS (30UL * 60UL * 1000UL) // 30 min auto-shutoff
#define IDLE_SLEEP_MS (10UL * 60UL * 1000UL) // 10 min idle → sleep
// USB connected flag
volatile bool g_usbConnected = false;
// Wake-from-sleep detection. Read before SoftDevice starts (setupBLE), stored here.
static bool g_wakeFromSleep = false;
static uint8_t g_wakeTrack = 0;
static uint32_t g_debugResetreas = 0; // printed 10 s after boot for post-sleep diagnosis
#ifdef POC_INTERNAL_FLASH
static void pocFilename(uint8_t n, char *buf) { // buf must be >=16 bytes
snprintf(buf, 16, "/track%d.wav", n);
}
#endif
// ============================================================
// SPI FLASH DRIVER
// ============================================================
SPISettings flashSPI(8000000, MSBFIRST, SPI_MODE0); // 8MHz
void flashSelect() { digitalWrite(PIN_FLASH_CS, LOW); }
void flashDeselect() { digitalWrite(PIN_FLASH_CS, HIGH); }
void flashWake() {
flashSelect();
SPI.transfer(CMD_WAKE);
flashDeselect();
delayMicroseconds(5);
}
void flashSleep() {
flashSelect();
SPI.transfer(CMD_POWER_DOWN);
flashDeselect();
}
uint32_t flashReadJEDEC() {
SPI.beginTransaction(flashSPI);
flashSelect();
SPI.transfer(CMD_JEDEC_ID);
uint32_t id = (uint32_t)SPI.transfer(0) << 16;
id |= (uint32_t)SPI.transfer(0) << 8;
id |= SPI.transfer(0);
flashDeselect();
SPI.endTransaction();
return id;
}
void flashReadBytes(uint32_t addr, uint8_t *buf, uint32_t len) {
SPI.beginTransaction(flashSPI);
flashSelect();
SPI.transfer(CMD_READ);
SPI.transfer((addr >> 16) & 0xFF);
SPI.transfer((addr >> 8) & 0xFF);
SPI.transfer(addr & 0xFF);
for (uint32_t i = 0; i < len; i++) {
buf[i] = SPI.transfer(0);
}
flashDeselect();
SPI.endTransaction();
}
void flashWaitBusy() {
SPI.beginTransaction(flashSPI);
flashSelect();
SPI.transfer(CMD_READ_SR1);
while (SPI.transfer(0) & 0x01) { /* spin */ }
flashDeselect();
SPI.endTransaction();
}
// Non-blocking busy check: reads the WIP bit without spinning.
bool flashIsBusy() {
SPI.beginTransaction(flashSPI);
flashSelect();
SPI.transfer(CMD_READ_SR1);
uint8_t sr = SPI.transfer(0);
flashDeselect();
SPI.endTransaction();
return (sr & 0x01) != 0;
}
void flashWriteEnable() {
SPI.beginTransaction(flashSPI);
flashSelect();
SPI.transfer(CMD_WRITE_EN);
flashDeselect();
SPI.endTransaction();
}
void flashEraseSector(uint32_t addr) {
flashWriteEnable();
SPI.beginTransaction(flashSPI);
flashSelect();
SPI.transfer(CMD_SECT_ERASE);
SPI.transfer((addr >> 16) & 0xFF);
SPI.transfer((addr >> 8) & 0xFF);
SPI.transfer(addr & 0xFF);
flashDeselect();
SPI.endTransaction();
flashWaitBusy();
}
void flashEraseBlock64K(uint32_t addr) {
flashWriteEnable();
SPI.beginTransaction(flashSPI);
flashSelect();
SPI.transfer(CMD_BLOCK_ERASE);
SPI.transfer((addr >> 16) & 0xFF);
SPI.transfer((addr >> 8) & 0xFF);
SPI.transfer(addr & 0xFF);
flashDeselect();
SPI.endTransaction();
flashWaitBusy();
}
void flashPageProgram(uint32_t addr, const uint8_t *data, uint16_t len) {
flashWriteEnable();
SPI.beginTransaction(flashSPI);
flashSelect();
SPI.transfer(CMD_PAGE_PROG);
SPI.transfer((addr >> 16) & 0xFF);
SPI.transfer((addr >> 8) & 0xFF);
SPI.transfer(addr & 0xFF);
for (uint16_t i = 0; i < len; i++) {
SPI.transfer(data[i]);
}
flashDeselect();
SPI.endTransaction();
flashWaitBusy();
}
// ============================================================
// TRACK TABLE
// ============================================================
void loadTrackTable() {
#ifdef POC_INTERNAL_FLASH
g_numTracks = 0;
char fname[16];
for (uint8_t i = 0; i < MAX_TRACKS; i++) {
pocFilename(i, fname);
File f(InternalFS);
if (!f.open(fname, FILE_O_READ)) break;
g_trackLen[i] = f.size();
g_trackBits[i] = 8;
g_trackRate[i] = SAMPLE_RATE;
g_trackDataOff[i] = 0;
// Parse WAV header to extract format metadata
if (g_trackLen[i] >= 44) {
uint8_t hdr[44];
f.seek(0);
f.read(hdr, 44);
if (hdr[0]=='R' && hdr[1]=='I' && hdr[2]=='F' && hdr[3]=='F') {
uint16_t bits = (uint16_t)hdr[34] | ((uint16_t)hdr[35] << 8);
uint32_t rate = (uint32_t)hdr[24] | ((uint32_t)hdr[25] << 8)
| ((uint32_t)hdr[26] << 16) | ((uint32_t)hdr[27] << 24);
if ((bits == 8 || bits == 16) && rate > 0) {
g_trackBits[i] = (uint8_t)bits;
g_trackRate[i] = rate;
g_trackDataOff[i] = 44;
}
}
}
f.close();
if (g_trackLen[i] == 0) break; // stop at first empty file
g_numTracks = i + 1;
Serial.print(" track"); Serial.print(i);
Serial.print(": "); Serial.print(g_trackBits[i]); Serial.print("bit ");
Serial.print(g_trackRate[i] / 1000); Serial.print("kHz ");
uint32_t audioBytes = g_trackLen[i] - g_trackDataOff[i];
uint32_t bytesPerSample = g_trackBits[i] / 8;
Serial.print((audioBytes / bytesPerSample) / g_trackRate[i]);
Serial.println("s");
}
Serial.print("Found ");
Serial.print(g_numTracks);
Serial.println(" tracks in internal flash");
#else
uint8_t header[4];
flashReadBytes(TRACK_TABLE_ADDR, header, 4);
if (header[0] != TABLE_MAGIC) {
g_numTracks = 0;
Serial.println("No track table found (flash may be empty)");
return;
}
g_numTracks = header[1];
if (g_numTracks > MAX_TRACKS) g_numTracks = MAX_TRACKS;
uint8_t entry[TRACK_ENTRY_SIZE];
for (uint8_t i = 0; i < g_numTracks; i++) {
flashReadBytes(TRACK_TABLE_ADDR + 4 + i * TRACK_ENTRY_SIZE, entry, TRACK_ENTRY_SIZE);
g_trackStart[i] = ((uint32_t)entry[0] << 24) | ((uint32_t)entry[1] << 16) |
((uint32_t)entry[2] << 8) | entry[3];
g_trackLen[i] = ((uint32_t)entry[4] << 24) | ((uint32_t)entry[5] << 16) |
((uint32_t)entry[6] << 8) | entry[7];
// Parse WAV header from flash to get format metadata
g_trackBits[i] = 8;
g_trackRate[i] = SAMPLE_RATE;
g_trackDataOff[i] = 0;
if (g_trackLen[i] >= 44) {
uint8_t hdr[44];
flashReadBytes(g_trackStart[i], hdr, 44);
if (hdr[0]=='R' && hdr[1]=='I' && hdr[2]=='F' && hdr[3]=='F') {
uint16_t bits = (uint16_t)hdr[34] | ((uint16_t)hdr[35] << 8);
uint32_t rate = (uint32_t)hdr[24] | ((uint32_t)hdr[25] << 8)
| ((uint32_t)hdr[26] << 16) | ((uint32_t)hdr[27] << 24);
if ((bits == 8 || bits == 16) && rate > 0) {
g_trackBits[i] = (uint8_t)bits;
g_trackRate[i] = rate;
g_trackDataOff[i] = 44;
}
}
}
Serial.print(" track"); Serial.print(i);
Serial.print(": "); Serial.print(g_trackBits[i]); Serial.print("bit ");
Serial.print(g_trackRate[i] / 1000); Serial.print("kHz ");
uint32_t audioBytes = g_trackLen[i] - g_trackDataOff[i];
uint32_t bps = g_trackBits[i] / 8;
Serial.print((audioBytes / bps) / g_trackRate[i]);
Serial.println("s");
}
Serial.print("Loaded ");
Serial.print(g_numTracks);
Serial.println(" tracks from flash");
// Advance g_flashNextFree past all existing tracks so BLE/serial uploads
// to non-zero slots don't overwrite data from a previous session.
if (g_numTracks > 0) {
uint32_t hiWater = 0;
for (uint8_t i = 0; i < g_numTracks; i++) {
uint32_t trackEnd = g_trackStart[i] + g_trackLen[i];
if (trackEnd > hiWater) hiWater = trackEnd;
}
g_flashNextFree = ((hiWater + FLASH_SECTOR - 1) / FLASH_SECTOR) * FLASH_SECTOR;
}
#endif
}
void writeTrackTable() {
#ifdef POC_INTERNAL_FLASH
return; // no table needed; LittleFS files are the store
#else
// Erase sector 0
flashEraseSector(0);
// Build table in RAM
uint8_t table[4 + MAX_TRACKS * TRACK_ENTRY_SIZE];
memset(table, 0xFF, sizeof(table));
table[0] = TABLE_MAGIC;
table[1] = g_numTracks;
table[2] = 0;
table[3] = 0;
for (uint8_t i = 0; i < g_numTracks; i++) {
uint8_t *e = &table[4 + i * TRACK_ENTRY_SIZE];
e[0] = (g_trackStart[i] >> 24) & 0xFF;
e[1] = (g_trackStart[i] >> 16) & 0xFF;
e[2] = (g_trackStart[i] >> 8) & 0xFF;
e[3] = g_trackStart[i] & 0xFF;
e[4] = (g_trackLen[i] >> 24) & 0xFF;
e[5] = (g_trackLen[i] >> 16) & 0xFF;
e[6] = (g_trackLen[i] >> 8) & 0xFF;
e[7] = g_trackLen[i] & 0xFF;
e[8] = (uint8_t)(g_trackRate[i] / 1000);
e[9] = g_trackBits[i];
e[10] = 0;
e[11] = 0;
}
// Write in pages
uint16_t total = 4 + g_numTracks * TRACK_ENTRY_SIZE;
for (uint16_t offset = 0; offset < total; offset += FLASH_PAGE) {
uint16_t chunk = min((uint16_t)FLASH_PAGE, (uint16_t)(total - offset));
flashPageProgram(offset, &table[offset], chunk);
}
#endif
}
// ============================================================
// USB MASS STORAGE CALLBACKS
// ============================================================
int32_t msc_read_cb(uint32_t lba, void* buffer, uint32_t bufsize) {
uint32_t addr = lba * MSC_BLOCK_SIZE;
flashReadBytes(addr, (uint8_t*)buffer, bufsize);
return bufsize;
}
int32_t msc_write_cb(uint32_t lba, uint8_t* buffer, uint32_t bufsize) {
uint32_t addr = lba * MSC_BLOCK_SIZE;
// Erase sector if we're at a sector boundary
if ((addr % FLASH_SECTOR) == 0) {
flashEraseSector(addr);
}
// Write in page-sized chunks
uint32_t written = 0;
while (written < bufsize) {
uint16_t pageOffset = (addr + written) % FLASH_PAGE;
uint16_t chunk = min((uint16_t)(FLASH_PAGE - pageOffset), (uint16_t)(bufsize - written));
flashPageProgram(addr + written, buffer + written, chunk);
written += chunk;
}
return bufsize;
}
void msc_flush_cb(void) {
// After USB write completes, reload the track table
// (host may have written a new FAT filesystem)
// We scan for audio data on eject instead
}
bool msc_start_stop_cb(uint8_t power_condition, bool start, bool load_eject) {
if (!start && load_eject) {
// Host ejected the drive — rescan for tracks
Serial.println("USB ejected, rescanning tracks...");
loadTrackTable();
}
return true;
}
// ============================================================
// BLE SETUP
// ============================================================
void ble_connect_cb(uint16_t conn_handle) {
Serial.println("BLE connected");
g_bleConnected = true;
g_lastActivity = millis();
// Request fast connection parameters and maximum throughput features.
// The central may accept, renegotiate, or ignore these — all safe.
// NOTE: do NOT call requestMtuExchange() here. The GATT client (browser)
// initiates MTU exchange automatically during connection setup. A second
// ATT_EXCHANGE_MTU_REQ from the peripheral is a protocol violation (only
// one exchange per connection) and leaves Chrome's ATT layer in a stale
// "operation in progress" state, blocking all subsequent GATT writes.
BLEConnection* conn = Bluefruit.Connection(conn_handle);
if (conn) {
conn->requestConnectionParameter(6); // 6×1.25ms = 7.5ms interval
conn->requestDataLengthUpdate(); // LE Data Length Extension
conn->requestPHY(BLE_GAP_PHY_2MBPS); // 2M PHY if supported
}
}
void ble_disconnect_cb(uint16_t conn_handle, uint8_t reason) {
Serial.println("BLE disconnected");
g_bleConnected = false;
g_bleUploading = false;
g_bleFinalizing = false;
g_bleErasing = false; // stop tracking the in-progress erase; it will finish in HW
}
// ============================================================
// GLOBAL VOLUME
// ============================================================
// Software gain in Q8 fixed point: 256 = unity (1.0x), 128 = half (-6 dB),
// 512 = 2x. Lets us ATTENUATE below unity (smaller PWM swing → lower amp output
// → less supply current, which avoids the regulator brown-out at high volume).
// Default below unity because the fixed-gain amp is loud at full scale.
// Set via BLE CMD 0x08 (persisted) or 'u'/'d' / "VOL <pct>" over serial.
#define GAIN_UNITY 256
#define GAIN_MIN 8 // ~0.03x
#define GAIN_MAX 1024 // 4x
#define VOL_FILE "/volume"
static uint16_t g_audioGainQ8 = 128; // 0.5x (overridden by saved value on boot)
static uint8_t gainToPercent() {
uint32_t p = ((uint32_t)g_audioGainQ8 * 100u) / GAIN_UNITY;
return (uint8_t)min(p, (uint32_t)255);
}
static void setGainPercent(uint8_t pct) {
uint32_t q = ((uint32_t)pct * GAIN_UNITY) / 100u;
if (q < GAIN_MIN) q = GAIN_MIN;
if (q > GAIN_MAX) q = GAIN_MAX;
g_audioGainQ8 = (uint16_t)q;
}
static void saveVolume() {
#ifdef POC_INTERNAL_FLASH
InternalFS.remove(VOL_FILE); // FILE_O_WRITE has no truncate
File f(InternalFS);
if (f.open(VOL_FILE, FILE_O_WRITE)) {
uint8_t b[2] = { (uint8_t)(g_audioGainQ8 & 0xFF), (uint8_t)(g_audioGainQ8 >> 8) };
f.write(b, 2);
f.close();
}
#endif
}
static void loadVolume() {
#ifdef POC_INTERNAL_FLASH
File f(InternalFS);
if (f.open(VOL_FILE, FILE_O_READ)) {
uint8_t b[2];
if (f.read(b, 2) == 2) {
uint16_t v = (uint16_t)b[0] | ((uint16_t)b[1] << 8);
if (v >= GAIN_MIN && v <= GAIN_MAX) g_audioGainQ8 = v;
}
f.close();
}
Serial.print("Volume: "); Serial.print(gainToPercent()); Serial.println("%");
#endif
}
// Report current volume over BLE: [0xC0, percent, 0, 0]. Tag 0xC0 has bit7 set
// but (0xC0 & 0x7F)=64 ≥ MAX_TRACKS, so the host won't mistake it for a list entry.
static void notifyVolume() {
uint8_t pkt[4] = { 0xC0, gainToPercent(), 0, 0 };
audioStat.write(pkt, 4);
audioStat.notify(pkt, 4);
}
// BLE command characteristic: receives commands
// CMD 0x01 [num_tracks] [track_entries...] = write track table
// CMD 0x02 [track_num] = start writing audio to track slot
// CMD 0x03 = finish upload, reload tracks
// CMD 0x04 [track_num] = play track
// CMD 0x05 = stop playback
// CMD 0x06 = list tracks → audioStat notifications:
// [0x80|idx, bits, rate_kHz, dur_s] per track, then [0xFF, count, 0, 0]
// CMD 0x07 [track_num] = delete track → audioStat: [0xD0, idx, ok, 0]
// CMD 0x08 [percent] = set global volume (persisted) → audioStat: [0xC0, pct, 0, 0]
// CMD 0x09 = get global volume → audioStat: [0xC0, pct, 0, 0]
void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr,
uint8_t* data, uint16_t len) {
if (len < 1) return;
g_lastActivity = millis();
switch (data[0]) {
case 0x01: // Write track table
if (len >= 2) {
g_numTracks = data[1];
if (g_numTracks > MAX_TRACKS) g_numTracks = MAX_TRACKS;
// Parse track entries from data[2..]
for (uint8_t i = 0; i < g_numTracks && (2 + i * 8 + 7) < len; i++) {
uint8_t *e = &data[2 + i * 8];
g_trackStart[i] = ((uint32_t)e[0] << 24) | ((uint32_t)e[1] << 16) |
((uint32_t)e[2] << 8) | e[3];
g_trackLen[i] = ((uint32_t)e[4] << 24) | ((uint32_t)e[5] << 16) |
((uint32_t)e[6] << 8) | e[7];
}
writeTrackTable();
Serial.println("BLE: Track table updated");
}
break;
case 0x02: // Start audio write
#ifdef POC_INTERNAL_FLASH
if (len >= 2) {
g_pocWriteTrack = data[1];
if (g_pocFile) g_pocFile.close();
char fname[16];
pocFilename(g_pocWriteTrack, fname);
if (!g_pocFile.open(fname, FILE_O_WRITE)) {
Serial.print("BLE: cannot open ");
Serial.println(fname);
break;
}
g_bleUploading = true;
g_bleWriteLen = 0;
Serial.print("BLE: Start write track ");
Serial.println(g_pocWriteTrack);
}
#else
if (len >= 2) {
g_bleWriteTrack = data[1];
// Track 0 resets allocation; subsequent tracks append
if (g_bleWriteTrack == 0) g_flashNextFree = AUDIO_START_ADDR;
g_bleFlashAddr = g_flashNextFree;
g_bleFlashStart = g_bleFlashAddr;
g_bleFlashErased = g_bleFlashAddr; // nothing erased yet
g_bleErasing = false;
g_bleNotifyThresh = g_bleFlashAddr;
g_bleBufHead = g_bleBufTail = 0;
g_bleFinalizing = false;
g_bleUploading = true;
g_bleWriteLen = 0;
Serial.print("BLE: Start write track ");
Serial.print(g_bleWriteTrack);
Serial.print(" at 0x");
Serial.println(g_bleFlashAddr, HEX);
}
#endif
break;
case 0x03: // Finish upload
#ifdef POC_INTERNAL_FLASH
if (g_pocFile) g_pocFile.close();
g_bleUploading = false;
loadTrackTable();
Serial.println("BLE: Upload complete");
#else
// Signal loop() to finalize once the ring buffer drains.
// Because the DATA characteristic uses write-with-response, every data
// packet has been ATT-acknowledged before the client sends this command,
// so all bytes are already in the ring buffer by the time we get here.
if (g_bleUploading) {
g_bleFinalizing = true;
Serial.println("BLE: Finalizing upload...");
}
#endif
break;
case 0x04: // Play track
if (len >= 2 && data[1] < g_numTracks) {
audioStart(data[1]);
}
break;
case 0x05: // Stop
audioStop();
motorStop();
break;
case 0x06: // List tracks
// Responds via audioStat notifications (one per track + end marker).
// Per-track packet: [0x80|idx, bits, rate_kHz, duration_s]
// End packet: [0xFF, num_tracks, 0, 0]
// Byte 0 >= 0x80 distinguishes list responses from upload-progress
// packets (which always have byte 0 == 0x00 for files < 16 MB).
for (uint8_t i = 0; i < g_numTracks; i++) {
uint32_t audioBytes = g_trackLen[i] - g_trackDataOff[i];
uint32_t bps = g_trackBits[i] / 8;
uint32_t durS = (bps > 0 && g_trackRate[i] > 0)
? (audioBytes / bps) / g_trackRate[i] : 0;
uint8_t pkt[4] = {
(uint8_t)(0x80 | i),
g_trackBits[i],
(uint8_t)(g_trackRate[i] / 1000),
(uint8_t)min(durS, (uint32_t)255)
};
audioStat.write(pkt, 4);
audioStat.notify(pkt, 4);
delay(20);
}
{
uint8_t end[4] = {0xFF, g_numTracks, 0, 0};
audioStat.write(end, 4);
audioStat.notify(end, 4);
}
Serial.print("BLE: Listed "); Serial.print(g_numTracks); Serial.println(" tracks");
break;
case 0x07: // Delete track
// data[1] = track index to delete.
// Responds via audioStat: [0xD0, track_idx, success(0/1), 0]
if (len >= 2) {
uint8_t trkNum = data[1];
bool ok = false;
#ifdef POC_INTERNAL_FLASH
char fname[16];
pocFilename(trkNum, fname);
ok = InternalFS.remove(fname);
if (ok) loadTrackTable();
#else
if (trkNum < g_numTracks) {
// Shift entries down to close the gap
for (uint8_t j = trkNum; j < g_numTracks - 1; j++) {
g_trackStart[j] = g_trackStart[j+1];
g_trackLen[j] = g_trackLen[j+1];
g_trackBits[j] = g_trackBits[j+1];
g_trackRate[j] = g_trackRate[j+1];
g_trackDataOff[j] = g_trackDataOff[j+1];
}
g_numTracks--;
writeTrackTable();
loadTrackTable();
ok = true;
}
#endif
uint8_t resp[4] = {0xD0, trkNum, (uint8_t)(ok ? 1 : 0), 0};
audioStat.write(resp, 4);
audioStat.notify(resp, 4);
Serial.print("BLE: Delete track ");
Serial.print(trkNum);
Serial.println(ok ? " OK" : " FAILED");
}
break;
case 0x08: // Set global volume (percent of unity), persist, and echo back
if (len >= 2) {
setGainPercent(data[1]);
saveVolume();
notifyVolume();
Serial.print("BLE: Volume "); Serial.print(gainToPercent()); Serial.println("%");
}
break;
case 0x09: // Get global volume
notifyVolume();
break;
}
}
// BLE data characteristic: receives raw audio bytes for upload
void audioData_write_cb(uint16_t conn_handle, BLECharacteristic* chr,
uint8_t* data, uint16_t len) {
if (!g_bleUploading) return;
g_lastActivity = millis();
#ifdef POC_INTERNAL_FLASH
g_pocFile.write(data, len);
g_bleWriteLen += len;
#else
// Push into ring buffer; flash I/O happens in bleFlashTick() from loop()
// so this callback returns immediately without blocking the SoftDevice.
for (uint16_t i = 0; i < len; i++) {
uint32_t nextHead = (g_bleBufHead + 1) % BLE_FLASH_BUF;
if (nextHead == g_bleBufTail) break; // full — drop tail (shouldn't happen at 16kHz)
g_bleBuf[g_bleBufHead] = data[i];
g_bleBufHead = nextHead;
}
g_bleWriteLen += len;
#endif
// Progress notifications are intentionally omitted here: sending one BLE
// notification per 180-byte packet floods the SoftDevice TX queue (~7 k
// packets for a 1.3 MB file) and causes the last notifications to be
// dropped. A single definitive notification is sent by bleFlashTick()
// when finalization completes (after CMD_UPLOAD_END drains the ring buffer
// and writes the track table).
}
// Drain the BLE ring buffer to SPI flash.
// Sector erases are asynchronous: we issue the erase command and return immediately
// so loop() keeps running (and the ring buffer keeps draining from BLE callbacks).
// On the next call we poll the WIP bit to confirm completion before writing.
// This prevents the 30300 ms erase window from filling the ring buffer.
// A pre-erase is also kicked off as soon as we start writing each sector so
// the next sector is ready before we reach it. Writes stop while any erase is
// in progress because the flash ignores page-program when WIP=1.
#ifndef POC_INTERNAL_FLASH
static void bleFlashTick() {
if (!g_bleUploading && !g_bleFinalizing) return;
// Poll async sector erase completion.
if (g_bleErasing && !flashIsBusy()) {
g_bleErasing = false;
g_bleFlashErased += FLASH_SECTOR;
}
// Drain all available ring-buffer data into flash, one page per iteration.
while (g_bleBufHead != g_bleBufTail) {
if (g_bleErasing) break; // never write while an erase is in progress
uint32_t avail = (g_bleBufHead - g_bleBufTail + BLE_FLASH_BUF) % BLE_FLASH_BUF;
uint16_t pageOff = (uint16_t)(g_bleFlashAddr % FLASH_PAGE);
uint16_t chunk = (uint16_t)min((uint32_t)(FLASH_PAGE - pageOff), avail);
if (chunk == 0) break;
if (g_bleFlashAddr + chunk > g_bleFlashErased) {
// Write pointer has reached the erased boundary — need another sector erased.
if (!g_bleErasing) {
flashWriteEnable();
SPI.beginTransaction(flashSPI);
flashSelect();
SPI.transfer(CMD_SECT_ERASE);
SPI.transfer((g_bleFlashErased >> 16) & 0xFF);
SPI.transfer((g_bleFlashErased >> 8) & 0xFF);
SPI.transfer( g_bleFlashErased & 0xFF);
flashDeselect();
SPI.endTransaction();
g_bleErasing = true;
// Do NOT advance g_bleFlashErased — wait for WIP confirmation next call.
}
break; // return to loop(); erase runs in HW, ring buffer fills freely
}
// Erase boundary is ahead — safe to program this page.
uint8_t tmp[FLASH_PAGE];
for (uint16_t i = 0; i < chunk; i++) {
tmp[i] = g_bleBuf[(g_bleBufTail + i) % BLE_FLASH_BUF];
}
flashPageProgram(g_bleFlashAddr, tmp, chunk); // ~0.5 ms blocking
g_bleBufTail = (g_bleBufTail + chunk) % BLE_FLASH_BUF;
g_bleFlashAddr += chunk;
// Pre-erase: kick off the next sector erase as soon as we start writing
// the current sector so the erase (~30 ms typ) completes before we need it.
// Break immediately — never write while an async erase is in progress since
// the flash chip silently ignores page-program commands when WIP=1.
if (!g_bleErasing && g_bleFlashAddr > g_bleFlashErased - FLASH_SECTOR) {
flashWriteEnable();
SPI.beginTransaction(flashSPI);
flashSelect();
SPI.transfer(CMD_SECT_ERASE);
SPI.transfer((g_bleFlashErased >> 16) & 0xFF);
SPI.transfer((g_bleFlashErased >> 8) & 0xFF);
SPI.transfer( g_bleFlashErased & 0xFF);
flashDeselect();
SPI.endTransaction();
g_bleErasing = true;
break; // wait for erase; ring buffer fills freely in the meantime
}
// Periodic progress notification — keeps the Windows BLE stack from
// dropping the connection during long silent uploads, and gives the
// client real flash-write progress (not just BLE-send progress).
if (g_bleConnected && g_bleFlashAddr - g_bleNotifyThresh >= 4096u) {
g_bleNotifyThresh = g_bleFlashAddr;
uint32_t written = g_bleFlashAddr - g_bleFlashStart;
uint8_t stat[4] = {
(uint8_t)(written >> 24), (uint8_t)(written >> 16),
(uint8_t)(written >> 8), (uint8_t)(written)
};
audioStat.notify(stat, 4);
}
}
// Finalize when CMD 0x03 received and ring buffer is fully drained.
if (g_bleFinalizing && g_bleBufHead == g_bleBufTail) {
uint32_t startAddr = g_bleFlashStart;
uint32_t bytesStored = g_bleFlashAddr - g_bleFlashStart;
g_trackStart[g_bleWriteTrack] = startAddr;
g_trackLen[g_bleWriteTrack] = bytesStored;
if (g_bleWriteTrack >= g_numTracks) g_numTracks = g_bleWriteTrack + 1;
// Advance shared free pointer to next sector boundary
g_flashNextFree = ((g_bleFlashAddr + FLASH_SECTOR - 1) / FLASH_SECTOR) * FLASH_SECTOR;
g_bleUploading = false;
g_bleFinalizing = false;
g_bleErasing = false; // pre-erase of unused sector, if any, will finish in HW
writeTrackTable();
loadTrackTable();
Serial.printf("BLE: Upload finalized — start=0x%08lX stored=%lu bytes\n", startAddr, bytesStored);
// Confirm to client: send final stat with bytes actually stored
uint8_t stat[4];
stat[0] = (bytesStored >> 24) & 0xFF;
stat[1] = (bytesStored >> 16) & 0xFF;
stat[2] = (bytesStored >> 8) & 0xFF;
stat[3] = bytesStored & 0xFF;
audioStat.write(stat, 4);
audioStat.notify(stat, 4);
}
}
#endif
void setupBLE() {
/* Note BLE requires a custom app to communicate, not regular bluetooth */
// Configure for maximum bandwidth so the SoftDevice allocates buffers large
// enough to accept 180-byte ATT payloads (MTU 183). Must be called before begin().
Bluefruit.configPrphBandwidth(BANDWIDTH_MAX);
Bluefruit.begin();
Bluefruit.setName("BabyMobile");
Bluefruit.setTxPower(0); // 0 dBm — save power, short range is fine
Bluefruit.Periph.setConnectCallback(ble_connect_cb);
Bluefruit.Periph.setDisconnectCallback(ble_disconnect_cb);
// Audio service
audioSvc.begin();
// Command characteristic: write-with-response so JS can await full OS-level
// completion before issuing the next operation on any characteristic.
audioCmd.setProperties(CHR_PROPS_WRITE);
audioCmd.setPermission(SECMODE_OPEN, SECMODE_OPEN);
audioCmd.setMaxLen(240);
audioCmd.setWriteCallback(audioCmd_write_cb);
audioCmd.begin();
// Data characteristic (write with response — ATT flow control ensures the
// SoftDevice has acknowledged every packet before the client sends the next,
// so CMD_UPLOAD_END can never race ahead of the data stream)
audioData.setProperties(CHR_PROPS_WRITE);
audioData.setPermission(SECMODE_OPEN, SECMODE_OPEN);
audioData.setMaxLen(244); // must match DATA_CHUNK in ble.js (MTU 247 - 3 = 244)
audioData.setWriteCallback(audioData_write_cb);
audioData.begin();
// Status characteristic (read + notify)
audioStat.setProperties(CHR_PROPS_READ | CHR_PROPS_NOTIFY);
audioStat.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS);
audioStat.setMaxLen(4);
audioStat.begin();
// Configure advertising packet.
// The 128-bit service UUID (18 bytes) plus flags + TxPower nearly fills the
// 31-byte advertisement, leaving room for only ~5 name characters — which
// truncated the name to "BabyM" over the air. Put the full name in the scan
// response (its own separate 31 bytes) so scanners see "BabyMobile" intact.
Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE);
Bluefruit.Advertising.addTxPower();
Bluefruit.Advertising.addService(audioSvc);
Bluefruit.ScanResponse.addName(); // full name here, not in the ad packet
Bluefruit.Advertising.restartOnDisconnect(false); // don't auto-re-advertise after disconnect
Bluefruit.Advertising.setInterval(160, 320); // 100-200ms
// Do NOT start advertising here — user must hold BTN1 for 5 s to enable it.
Serial.println("BLE ready (hold BTN1 for 5 s to advertise)");
}
// Start BLE advertising for up to 60 seconds.
// Called when the user holds BTN1 for 5 s. Advertising stops automatically
// after the timeout, or immediately when a connection is made.
#define BLE_ADV_TIMEOUT_S 60
static void startBLEAdvertising() {
if (Bluefruit.Advertising.isRunning()) {
Serial.println("BLE already advertising");
return;
}
Bluefruit.Advertising.start(BLE_ADV_TIMEOUT_S);
Serial.println("BLE advertising started (60 s)");
}
// ============================================================
// AUDIO PLAYBACK — nRF52840 PWM EasyDMA
// ============================================================
//
// NRF_PWM0 drives the audio pin directly via DMA — no per-sample ISR.
// PRESCALER = 0 → 16 MHz base clock
// COUNTERTOP = 500 → PWM carrier = 16 MHz / 500 = 32 kHz
// SEQ REFRESH = 1 → each sample plays 2 carrier cycles → 16 kHz sample rate
//
// Double-buffer: SEQ[0] / SEQ[1] auto-chain via SHORTS.
// ISR fires only on SEQEND (every 512 samples = 32 ms), sets g_bufReady[b]=false.
// loop() calls audioFillBuf() to reload the finished buffer.
#define PWM_COUNTERTOP 500 // 16 MHz / 500 = 32 kHz carrier
#define PWM_SILENCE 250 // midpoint of 0500 duty-cycle range
// Audio gain (g_audioGainQ8 + GAIN_* + volume persistence) is defined earlier,
// before the BLE command callback that sets it. See "GLOBAL VOLUME" section.
// Read PCM into g_pwmBuf[b], pad tail with silence.
// Supports 8-bit unsigned and 16-bit signed WAV; format read from g_trackBits[].
// On the first pure-silence fill (track exhausted), arms the stop timer.
static void audioFillBuf(uint8_t b) {
bool is16 = (g_trackBits[g_currentTrack] == 16);
uint32_t bytesPerSample = is16 ? 2 : 1;
uint32_t toReadBytes = 0;
if (g_nextReadAddr < g_playEnd) {
uint32_t remaining = g_playEnd - g_nextReadAddr;
toReadBytes = min((uint32_t)(AUDIO_BUF_SIZE * bytesPerSample), remaining);
if (is16) toReadBytes &= ~1u; // keep sample-aligned
}
uint32_t samples = toReadBytes / bytesPerSample;
if (is16) {
uint8_t raw[AUDIO_BUF_SIZE * 2];
if (toReadBytes) {
#ifdef POC_INTERNAL_FLASH
g_pocFile.read(raw, toReadBytes);
#else
flashReadBytes(g_nextReadAddr, raw, toReadBytes);
#endif
g_nextReadAddr += toReadBytes;
}
for (uint32_t i = 0; i < samples; i++) {
int16_t s = (int16_t)((uint16_t)raw[i * 2] | ((uint16_t)raw[i * 2 + 1] << 8));
int32_t sv = ((int32_t)s * g_audioGainQ8) >> 8;
if (sv > 32767) sv = 32767;
if (sv < -32768) sv = -32768;
g_pwmBuf[b][i] = (uint16_t)(((uint32_t)(sv + 32768)) * PWM_COUNTERTOP / 65536);
}
} else {
uint8_t pcm[AUDIO_BUF_SIZE];
if (toReadBytes) {
#ifdef POC_INTERNAL_FLASH
g_pocFile.read(pcm, toReadBytes);
#else
flashReadBytes(g_nextReadAddr, pcm, toReadBytes);
#endif
g_nextReadAddr += toReadBytes;
}
for (uint32_t i = 0; i < samples; i++) {
int32_t s = (((int32_t)pcm[i] - 128) * g_audioGainQ8) >> 8;
if (s > 127) s = 127;
if (s < -128) s = -128;
g_pwmBuf[b][i] = (uint16_t)((uint8_t)(s + 128)) * PWM_COUNTERTOP / 256;
}
}
for (uint32_t i = samples; i < AUDIO_BUF_SIZE; i++) {
g_pwmBuf[b][i] = PWM_SILENCE;
}
g_bufReady[b] = true;
// Arm stop timer on first pure-silence fill (all audio already sent to DMA)
if (toReadBytes == 0 && g_trackDoneMs == 0 && g_playing) {
g_trackDoneMs = millis() + 100; // 100ms > 3 buffer lengths (3 × 32ms)
}
}
// PWM0 ISR — fires every 32 ms (512 samples at 16 kHz).
// Just signals which buffer needs refilling; loop() does the actual I/O.
// ISR just chains buffers — end-of-track is handled entirely in loop()
extern "C" void PWM0_IRQHandler() {
if (NRF_PWM0->EVENTS_SEQEND[0]) {
NRF_PWM0->EVENTS_SEQEND[0] = 0;
if (g_playing) { g_bufReady[0] = false; NRF_PWM0->TASKS_SEQSTART[1] = 1; }
}
if (NRF_PWM0->EVENTS_SEQEND[1]) {
NRF_PWM0->EVENTS_SEQEND[1] = 0;
if (g_playing) { g_bufReady[1] = false; NRF_PWM0->TASKS_SEQSTART[0] = 1; }
}
}
void audioInit() {
// Silence both DMA buffers
for (int i = 0; i < AUDIO_BUF_SIZE; i++) {
g_pwmBuf[0][i] = PWM_SILENCE;
g_pwmBuf[1][i] = PWM_SILENCE;
}
// Configure NRF_PWM0 for EasyDMA sequence mode
NRF_PWM0->PSEL.OUT[0] = g_ADigitalPinMap[PIN_AUDIO_PWM];
NRF_PWM0->PSEL.OUT[1] = 0x80000000UL; // disconnected
NRF_PWM0->PSEL.OUT[2] = 0x80000000UL;
NRF_PWM0->PSEL.OUT[3] = 0x80000000UL;
NRF_PWM0->ENABLE = PWM_ENABLE_ENABLE_Enabled;
NRF_PWM0->MODE = PWM_MODE_UPDOWN_Up;
NRF_PWM0->PRESCALER = PWM_PRESCALER_PRESCALER_DIV_1; // 16 MHz
NRF_PWM0->COUNTERTOP = PWM_COUNTERTOP;
NRF_PWM0->LOOP = 0;
NRF_PWM0->DECODER = (PWM_DECODER_LOAD_Common << PWM_DECODER_LOAD_Pos) |
(PWM_DECODER_MODE_RefreshCount << PWM_DECODER_MODE_Pos);
NRF_PWM0->SEQ[0].PTR = (uint32_t)g_pwmBuf[0];
NRF_PWM0->SEQ[0].CNT = AUDIO_BUF_SIZE;
NRF_PWM0->SEQ[0].REFRESH = 1; // each value plays for 2 PWM periods
NRF_PWM0->SEQ[0].ENDDELAY = 0;
NRF_PWM0->SEQ[1].PTR = (uint32_t)g_pwmBuf[1];
NRF_PWM0->SEQ[1].CNT = AUDIO_BUF_SIZE;
NRF_PWM0->SEQ[1].REFRESH = 1;
NRF_PWM0->SEQ[1].ENDDELAY = 0;
NRF_PWM0->SHORTS = 0; // no auto-shorts; ISR handles sequence chaining
NRF_PWM0->INTENSET = PWM_INTENSET_SEQEND0_Msk | PWM_INTENSET_SEQEND1_Msk;
NVIC_SetPriority(PWM0_IRQn, 7);
NVIC_EnableIRQ(PWM0_IRQn);
// Start PWM outputting silence (amp is off, so this is silent)
NRF_PWM0->TASKS_SEQSTART[0] = 1;
}
void audioStart(uint8_t trackNum) {
Serial.print("Playing track ");
Serial.print(trackNum + 1);
Serial.print(" of ");
Serial.println(g_numTracks);
if (trackNum >= g_numTracks) return;
g_currentTrack = trackNum;
#ifdef POC_INTERNAL_FLASH
if (g_pocFile) g_pocFile.close();
char fname[16];
pocFilename(trackNum, fname);
if (!g_pocFile.open(fname, FILE_O_READ)) {
Serial.print("audioStart: cannot open "); Serial.println(fname);
return;
}
g_pocFile.seek(g_trackDataOff[trackNum]);
g_nextReadAddr = g_trackDataOff[trackNum];
g_playEnd = g_trackLen[trackNum];
#else
g_nextReadAddr = g_trackStart[trackNum] + g_trackDataOff[trackNum];
g_playEnd = g_trackStart[trackNum] + g_trackLen[trackNum];
#endif
// Set PWM REFRESH for this track's sample rate.
// carrier = 32kHz; effective_rate = 32000 / (REFRESH + 1)
// 16kHz → REFRESH=1, 8kHz → REFRESH=3, 32kHz → REFRESH=0
{
uint32_t rate = g_trackRate[trackNum];
if (rate == 0) rate = SAMPLE_RATE;
uint8_t refresh = (uint8_t)((32000u / rate) - 1);
NRF_PWM0->SEQ[0].REFRESH = refresh;
NRF_PWM0->SEQ[1].REFRESH = refresh;
}
g_trackDoneMs = 0;
g_bufReady[0] = false;
g_bufReady[1] = false;
// Stop DMA cleanly, fill both buffers, restart
NRF_PWM0->TASKS_STOP = 1;
uint32_t t = millis();
while (!NRF_PWM0->EVENTS_STOPPED && millis() - t < 10) {}
NRF_PWM0->EVENTS_STOPPED = 0;
audioFillBuf(0);
audioFillBuf(1);
NRF_PWM0->SEQ[0].PTR = (uint32_t)g_pwmBuf[0];
NRF_PWM0->SEQ[1].PTR = (uint32_t)g_pwmBuf[1];
// Enable amplifier
digitalWrite(PIN_AMP_SD, HIGH);
delay(10);
g_playing = true;
NRF_PWM0->TASKS_SEQSTART[0] = 1;
}
void audioStop() {
g_playing = false;
g_trackDoneMs = 0; // cancel any pending auto-advance
NRF_PWM0->TASKS_STOP = 1;
#ifdef POC_INTERNAL_FLASH
if (g_pocFile) g_pocFile.close();
#endif
// Disable amp
digitalWrite(PIN_AMP_SD, LOW);
Serial.println("Playback stopped");
}
// ============================================================
// MOTOR
// ============================================================
void motorInit() {
pinMode(PIN_MOTOR_PWM, OUTPUT);
analogWrite(PIN_MOTOR_PWM, 0);
}
void motorStart(uint8_t speed) {
g_motorSpeed = speed;
g_motorOn = true;
analogWrite(PIN_MOTOR_PWM, speed);
}
void motorStop() {
g_motorOn = false;
analogWrite(PIN_MOTOR_PWM, 0);
}
// ============================================================
// BUTTONS
// ============================================================
void buttonsInit() {
for (uint8_t i = 0; i < NUM_BUTTONS; i++) {
pinMode(BTN_PINS[i], INPUT_PULLUP);
}
}
// Returns 1-4, or 0 if none pressed. No blocking delay — safe to call every loop().
uint8_t buttonRead() {
for (uint8_t i = 0; i < NUM_BUTTONS; i++) {
if (digitalRead(BTN_PINS[i]) == LOW) return i + 1;
}
return 0;
}
// Wait until the button is physically released. No timeout — prevents the 800ms
// cooldown from expiring while the button is still held and re-triggering.
void waitButtonRelease(uint8_t btn) {
while (buttonRead() == btn) {
if (g_playing) {
// Keep DMA buffers fed while waiting
for (uint8_t b = 0; b < 2; b++) {
if (!g_bufReady[b]) audioFillBuf(b);
}
// Stop amp as soon as track ends — don't wait for loop() to resume
if (g_trackDoneMs != 0 && millis() >= g_trackDoneMs) {
g_trackDoneMs = 0;
audioStop();
}
}
}
delay(20); // debounce after release
}
// ============================================================
// SLEEP / WAKE
// ============================================================
// One byte from the nRF52840 hardware true random number generator.
static uint8_t hwRandom() {
NRF_RNG->CONFIG = RNG_CONFIG_DERCEN_Enabled << RNG_CONFIG_DERCEN_Pos; // bias correction on
NRF_RNG->EVENTS_VALRDY = 0;
NRF_RNG->TASKS_START = 1;
while (!NRF_RNG->EVENTS_VALRDY) {}
uint8_t v = (uint8_t)NRF_RNG->VALUE;
NRF_RNG->TASKS_STOP = 1;
return v;
}
void enterDeepSleep() {
// Don't sleep if any button is currently LOW — would wake instantly.
// Also catches floating pins that the internal pull-up isn't winning against.
for (uint8_t i = 0; i < NUM_BUTTONS; i++) {
if (digitalRead(BTN_PINS[i]) == LOW) {
g_lastActivity = millis(); // postpone
return;
}
}
Serial.println("Entering deep sleep...");
audioStop();
motorStop();
#ifndef POC_INTERNAL_FLASH
flashSleep();
#endif
digitalWrite(PIN_LED, LOW);
// Stop BLE advertising to save power
Bluefruit.Advertising.stop();
// Configure buttons as wake sources.
// nrf_gpio_cfg_sense_input takes nRF GPIO numbers, not Arduino pin numbers —
// use g_ADigitalPinMap[] to convert.
for (uint8_t i = 0; i < NUM_BUTTONS; i++) {
nrf_gpio_cfg_sense_input(g_ADigitalPinMap[BTN_PINS[i]],
NRF_GPIO_PIN_PULLUP,
NRF_GPIO_PIN_SENSE_LOW);
}
// System OFF — lowest power, wakes via GPIO sense or reset
sd_power_system_off();
// Execution stops here. Device resets on wake.
// (This line is never reached)
}
// ============================================================
// SETUP
// ============================================================
void setup() {
// LED early — visual feedback
pinMode(PIN_LED, OUTPUT);
digitalWrite(PIN_LED, HIGH);
Serial.begin(115200);
Serial.println("=== Baby Mobile v2 ===");
Serial.println("nRF52840 + IS25LP128F + PAM8302A");
// Pin setup
pinMode(PIN_AMP_SD, OUTPUT);
digitalWrite(PIN_AMP_SD, LOW);
#ifdef POC_INTERNAL_FLASH
InternalFS.begin();
Serial.println("InternalFS mounted");
loadVolume(); // restore persisted global volume
#else
pinMode(PIN_FLASH_CS, OUTPUT);
digitalWrite(PIN_FLASH_CS, HIGH);
// SPI
SPI.begin();
// Flash
flashWake();
delay(1);
uint32_t jedec = flashReadJEDEC();
Serial.print("Flash JEDEC: 0x");
Serial.println(jedec, HEX);
if (jedec == 0x9D6018 || jedec == 0x9D6017 || // IS25LP128F / IS25LP064
jedec == 0xEF4018 || jedec == 0xEF4017) { // W25Q128 / W25Q064
Serial.println("Flash detected OK");
} else if (jedec == 0x000000 || jedec == 0xFFFFFF) {
Serial.println("WARNING: No flash detected! Check SPI wiring.");
}
#endif
// Load tracks
loadTrackTable();
// Buttons
buttonsInit();
// Motor
motorInit();
// Audio
audioInit();
// USB Mass Storage
#ifndef POC_INTERNAL_FLASH
usb_msc.setID("BabyMobile", "Audio Drive", "2.0");
usb_msc.setReadWriteCallback(msc_read_cb, msc_write_cb, msc_flush_cb);
//usb_msc.setStartStopCallback(msc_start_stop_cb); // unused in nrf?
usb_msc.setCapacity(MSC_BLOCK_COUNT, MSC_BLOCK_SIZE);
//usb_msc.setReadOnly(false);
usb_msc.setUnitReady(true);
usb_msc.begin();
#endif
// Read RESETREAS and collect a random byte BEFORE starting the SoftDevice.
// Once Bluefruit.begin() enables S140, the SoftDevice owns NRF_RNG and
// NRF_POWER — direct register access after that point causes a hard fault.
uint32_t resetreas = NRF_POWER->RESETREAS;
NRF_POWER->RESETREAS = 0xFFFFFFFFUL; // write-1-to-clear
g_wakeFromSleep = (resetreas & POWER_RESETREAS_OFF_Msk) != 0;
g_debugResetreas = resetreas; // printed 10 s after boot once serial reconnects
// Seed Arduino random() from hardware RNG now, before Bluefruit.begin().
// After the SoftDevice starts it owns NRF_RNG — hwRandom() must not be
// called again after that point. Use random() everywhere in loop().
randomSeed(hwRandom());
#ifdef WAKE_PLAY_RANDOM
if (g_wakeFromSleep && g_numTracks > 0) {
g_wakeTrack = (uint8_t)random(g_numTracks);
}
#endif
// BLE
setupBLE();
g_lastActivity = millis();
#ifdef WAKE_PLAY_RANDOM
if (g_wakeFromSleep && g_numTracks > 0) {
Serial.print("Auto-play random track "); Serial.println(g_wakeTrack);
audioStart(g_wakeTrack);
}
#endif
digitalWrite(PIN_LED, HIGH);
Serial.println("Ready! Plug in USB to upload audio, or press a button.");
}
// ============================================================
// MAIN LOOP
// ============================================================
// ============================================================
// SERIAL UPLOAD STATE MACHINE
// Works in both POC_INTERNAL_FLASH and SPI flash modes.
// Protocol: UPLOAD <track> <total_wav_bytes> (full WAV file, header included)
// DUMP <track> DUMP <track>
// p / l / u / d / r
// DELETE <track> (POC only)
// FORMAT (POC only)
// ============================================================
enum SerUploadState { SER_IDLE, SER_RECEIVING };
static SerUploadState g_serState = SER_IDLE;
static char g_serLineBuf[64] = {0};
static uint8_t g_serLineLen = 0;
static uint8_t g_serTrack = 0;
static uint32_t g_serBytesExpected = 0;
static uint32_t g_serBytesReceived = 0;
#ifdef POC_INTERNAL_FLASH
static File g_serFile(InternalFS);
#else
// SPI flash upload: tracks are packed sequentially starting at AUDIO_START_ADDR.
// Uploading track 0 resets g_flashNextFree (shared with BLE upload path).
static uint32_t g_serFlashCurAddr = 0; // current write head
static uint32_t g_serFlashErasedThru = 0; // highest erased byte address (exclusive)
#endif
static void serUploadTick() {
if (g_serState == SER_IDLE) {
while (Serial.available()) {
char c = (char)Serial.read();
if (c == '\n' || c == '\r') {
g_serLineBuf[g_serLineLen] = '\0';
if (g_serLineLen == 0) { g_serLineLen = 0; break; }
unsigned int utrk = 0, ulen = 0;
if (sscanf(g_serLineBuf, "UPLOAD %u %u", &utrk, &ulen) == 2) {
g_serTrack = (uint8_t)utrk;
g_serBytesExpected = (uint32_t)ulen;
g_serBytesReceived = 0;
#ifdef POC_INTERNAL_FLASH
char fname[16];
pocFilename(g_serTrack, fname);
if (g_serFile) g_serFile.close();
InternalFS.remove(fname);
if (!g_serFile.open(fname, FILE_O_WRITE)) {
Serial.print("ERR cannot open "); Serial.println(fname);
} else {
g_serState = SER_RECEIVING;
g_usbConnected = true;
Serial.println("READY");
}
#else
// Track 0 resets flash allocation
if (g_serTrack == 0) g_flashNextFree = AUDIO_START_ADDR;
g_serFlashCurAddr = g_flashNextFree;
g_trackStart[g_serTrack] = g_serFlashCurAddr;
// Erase first sector now; subsequent sectors erased lazily during receive
uint32_t firstSector = (g_serFlashCurAddr / FLASH_SECTOR) * FLASH_SECTOR;
flashEraseSector(firstSector);
g_serFlashErasedThru = firstSector + FLASH_SECTOR - 1;
g_serState = SER_RECEIVING;
g_usbConnected = true;
Serial.println("READY");
#endif
} else if (sscanf(g_serLineBuf, "DUMP %u", &utrk) == 1) {
#ifdef POC_INTERNAL_FLASH
char fname[16];
pocFilename((uint8_t)utrk, fname);
File df(InternalFS);
if (df.open(fname, FILE_O_READ)) {
uint32_t fsz = df.size();
Serial.print("SIZE "); Serial.println(fsz);
uint8_t dbuf[16];
uint32_t limit = min(fsz, (uint32_t)256);
uint32_t off = 0;
while (off < limit) {
int rd = df.read(dbuf, min((uint32_t)sizeof(dbuf), limit - off));
if (rd <= 0) break;
for (int j = 0; j < rd; j++) {
if (dbuf[j] < 0x10) Serial.print("0");
Serial.print(dbuf[j], HEX);
Serial.print(j % 16 == 15 || (off + j + 1) == limit ? "\n" : " ");
}
off += rd;
}
df.close();
Serial.println("END");
} else {
Serial.println("NO FILE");
}
#else
if ((uint8_t)utrk < g_numTracks) {
Serial.print("SIZE "); Serial.println(g_trackLen[utrk]);
uint8_t dbuf[16];
uint32_t limit = min(g_trackLen[utrk], (uint32_t)256);
for (uint32_t off = 0; off < limit; ) {
uint32_t rd = min((uint32_t)sizeof(dbuf), limit - off);
flashReadBytes(g_trackStart[utrk] + off, dbuf, rd);
for (uint32_t j = 0; j < rd; j++) {
if (dbuf[j] < 0x10) Serial.print("0");
Serial.print(dbuf[j], HEX);
Serial.print(j % 16 == 15 || (off + j + 1) == limit ? "\n" : " ");
}
off += rd;
}
Serial.println("END");
} else {
Serial.println("NO FILE");
}
#endif
} else if (g_serLineLen == 1 && (g_serLineBuf[0] == 'u' || g_serLineBuf[0] == 'd')) {
// Nudge gain by 0.125 (32 in Q8), clamped to [GAIN_MIN, GAIN_MAX].
if (g_serLineBuf[0] == 'u') {
g_audioGainQ8 = (g_audioGainQ8 + 32 > GAIN_MAX) ? GAIN_MAX : g_audioGainQ8 + 32;
} else {
g_audioGainQ8 = (g_audioGainQ8 < GAIN_MIN + 32) ? GAIN_MIN : g_audioGainQ8 - 32;
}
Serial.print("GAIN "); Serial.print((g_audioGainQ8 * 100) / GAIN_UNITY);
Serial.println("%");
} else if (sscanf(g_serLineBuf, "VOL %u", &utrk) == 1) {
// Set absolute volume as a percent of unity (0400%).
uint32_t q = ((uint32_t)utrk * GAIN_UNITY) / 100u;
if (q < GAIN_MIN) q = GAIN_MIN;
if (q > GAIN_MAX) q = GAIN_MAX;
g_audioGainQ8 = (uint16_t)q;
Serial.print("GAIN "); Serial.print((g_audioGainQ8 * 100) / GAIN_UNITY);
Serial.println("%");
} else if (g_serLineLen == 1 && g_serLineBuf[0] == 'p') {
if (g_playing) { audioStop(); Serial.println("STOP"); }
else if (g_numTracks > 0) { audioStart(g_currentTrack); Serial.println("PLAY"); }
else Serial.println("ERR no tracks");
} else if (g_serLineLen == 1 && g_serLineBuf[0] == 'l') {
g_loopTracks = !g_loopTracks;
Serial.print("LOOP "); Serial.println(g_loopTracks ? "ON" : "OFF");
} else if (g_serLineLen == 1 && g_serLineBuf[0] == 'r') {
Serial.println("REBOOT");
delay(10);
NVIC_SystemReset();
} else if (strcmp(g_serLineBuf, "FORMAT") == 0) {
audioStop();
#ifdef POC_INTERNAL_FLASH
Serial.println("Formatting LittleFS...");
InternalFS.format();
g_numTracks = 0;
#else
Serial.println("Clearing track table...");
g_numTracks = 0;
g_flashNextFree = AUDIO_START_ADDR;
writeTrackTable();
loadTrackTable();
#endif
Serial.println("FORMAT OK");
} else if (sscanf(g_serLineBuf, "DELETE %u", &utrk) == 1) {
#ifdef POC_INTERNAL_FLASH
char fname[16];
pocFilename((uint8_t)utrk, fname);
if (InternalFS.remove(fname)) {
Serial.print("DELETED "); Serial.println(utrk);
} else {
Serial.print("ERR no file "); Serial.println(utrk);
}
loadTrackTable();
#else
if ((uint8_t)utrk < g_numTracks) {
for (uint8_t j = (uint8_t)utrk; j < g_numTracks - 1; j++) {
g_trackStart[j] = g_trackStart[j+1];
g_trackLen[j] = g_trackLen[j+1];
g_trackBits[j] = g_trackBits[j+1];
g_trackRate[j] = g_trackRate[j+1];
g_trackDataOff[j] = g_trackDataOff[j+1];
}
g_numTracks--;
writeTrackTable();
loadTrackTable();
Serial.print("DELETED "); Serial.println(utrk);
} else {
Serial.print("ERR no track "); Serial.println(utrk);
}
#endif
} else {
Serial.print("ERR bad cmd: ");
Serial.println(g_serLineBuf);
}
g_serLineLen = 0;
} else {
if (g_serLineLen < (sizeof(g_serLineBuf) - 1)) {
g_serLineBuf[g_serLineLen++] = c;
}
}
}
} else { // SER_RECEIVING
g_lastActivity = millis(); // keep device awake during long transfers
uint8_t chunk[64];
while (Serial.available() && g_serBytesReceived < g_serBytesExpected) {
int n = Serial.readBytes(chunk, min((int)sizeof(chunk),
(int)(g_serBytesExpected - g_serBytesReceived)));
if (n <= 0) break;
#ifdef POC_INTERNAL_FLASH
int32_t wr = g_serFile.write(chunk, (uint16_t)n);
if (wr != n) {
g_serFile.close();
g_usbConnected = false;
g_serState = SER_IDLE;
Serial.print("ERR WRITE_FAIL at=");
Serial.println(g_serBytesReceived);
break;
}
#else
// Lazily erase the next sector as we reach it
uint32_t chunkEnd = g_serFlashCurAddr + (uint32_t)n - 1;
if (chunkEnd > g_serFlashErasedThru) {
uint32_t nextSector = (g_serFlashErasedThru + 1) / FLASH_SECTOR * FLASH_SECTOR;
flashEraseSector(nextSector);
g_serFlashErasedThru = nextSector + FLASH_SECTOR - 1;
}
// Write to flash in page-aligned chunks
uint16_t written = 0;
while (written < (uint16_t)n) {
uint16_t pageOff = (uint16_t)((g_serFlashCurAddr + written) % FLASH_PAGE);
uint16_t pageChunk = min((uint16_t)(FLASH_PAGE - pageOff), (uint16_t)(n - written));
flashPageProgram(g_serFlashCurAddr + written, chunk + written, pageChunk);
written += pageChunk;
}
g_serFlashCurAddr += (uint32_t)n;
#endif
g_serBytesReceived += (uint32_t)n;
}
if (g_serState != SER_RECEIVING) return;
if (g_serBytesReceived >= g_serBytesExpected) {
#ifdef POC_INTERNAL_FLASH
g_serFile.close();
char fname2[16]; pocFilename(g_serTrack, fname2);
File tmp(InternalFS);
uint32_t fsz = 0;
if (tmp.open(fname2, FILE_O_READ)) { fsz = tmp.size(); tmp.close(); }
g_usbConnected = false;
g_serState = SER_IDLE;
loadTrackTable();
Serial.print("OK "); Serial.print(fsz);
Serial.print("/"); Serial.println(g_serBytesReceived);
#else
// Advance shared free pointer to next sector boundary
g_flashNextFree = ((g_serFlashCurAddr + FLASH_SECTOR - 1) / FLASH_SECTOR) * FLASH_SECTOR;
g_trackLen[g_serTrack] = g_serBytesReceived;
if (g_serTrack >= g_numTracks) g_numTracks = g_serTrack + 1;
writeTrackTable();
loadTrackTable();
g_usbConnected = false;
g_serState = SER_IDLE;
Serial.print("OK "); Serial.print(g_serBytesReceived);
Serial.print("/"); Serial.println(g_serBytesExpected);
#endif
}
}
}
void loop() {
if (g_playing || g_bleUploading) g_lastActivity = millis();
// Print RESETREAS once, 10 s after boot, so serial has time to reconnect
// after a wake-from-sleep reset.
static bool resetreasLogged = false;
if (!resetreasLogged && millis() > 10000) {
resetreasLogged = true;
Serial.print("RESETREAS: 0x"); Serial.println(g_debugResetreas, HEX);
Serial.print("g_wakeFromSleep: "); Serial.println(g_wakeFromSleep);
Serial.print("g_numTracks: "); Serial.println(g_numTracks);
}
// ---- Serial upload ----
serUploadTick();
#ifndef POC_INTERNAL_FLASH
// ---- BLE flash drain ----
bleFlashTick();
#endif
// ---- Refill audio buffers ----
if (g_playing) {
for (uint8_t b = 0; b < 2; b++) {
if (!g_bufReady[b]) audioFillBuf(b);
}
}
// ---- End of track: timer-based stop ----
// g_trackDoneMs is armed 100ms after first silence fill.
// By then both DMA buffers have definitely cycled through silence.
if (g_playing && g_trackDoneMs != 0 && millis() >= g_trackDoneMs) {
g_trackDoneMs = 0;
audioStop();
if (g_loopTracks && g_numTracks > 0) {
g_currentTrack = (g_currentTrack + 1) % g_numTracks;
audioStart(g_currentTrack);
}
}
// ---- Handle buttons ----
// Per-button cooldown prevents phantom/stuck pins from re-firing
static unsigned long btnLastMs[NUM_BUTTONS + 1] = {0};
uint8_t btn = buttonRead();
if (btn > 0 && millis() - btnLastMs[btn] > 800) {
btnLastMs[btn] = millis();
Serial.print("BTN"); Serial.println(btn);
g_lastActivity = millis();
switch (btn) {
case 1: { // Short press: Play/Pause. Hold 5 s: start BLE advertising.
unsigned long pressStart = millis();
bool longPress = false;
while (buttonRead() == 1) {
if (millis() - pressStart >= 5000UL) { longPress = true; break; }
if (g_playing) {
for (uint8_t b = 0; b < 2; b++) if (!g_bufReady[b]) audioFillBuf(b);
if (g_trackDoneMs != 0 && millis() >= g_trackDoneMs) {
g_trackDoneMs = 0; audioStop();
}
} else {
delay(1); // yield to RTOS idle task so the watchdog gets fed
}
}
if (longPress) {
startBLEAdvertising();
waitButtonRelease(1);
} else {
#ifdef BTN_ALWAYS_START
if (g_numTracks > 0) {
if (g_playing) audioStop();
#ifdef WAKE_PLAY_RANDOM
audioStart((uint8_t)random(g_numTracks));
#else
audioStart((g_currentTrack + 1) % g_numTracks);
#endif
}
#else
if (g_playing) { audioStop(); }
else if (g_numTracks > 0) { audioStart(g_currentTrack); }
#endif
}
break;
}
case 2:
case 3:
case 4:
case 5:
#ifdef BTN_ALWAYS_START
if (g_numTracks > 0) {
if (g_playing) audioStop();
#ifdef WAKE_PLAY_RANDOM
audioStart((uint8_t)random(g_numTracks));
#else
audioStart((g_currentTrack + 1) % g_numTracks);
#endif
}
#else
if (g_playing) {
audioStop();
} else if (g_numTracks > 0) {
audioStart(g_currentTrack);
}
#endif
break;
/*
case 4: // Next track
if (g_numTracks > 0) {
g_currentTrack = (g_currentTrack + 1) % g_numTracks;
if (g_playing) { audioStop(); audioStart(g_currentTrack); }
}
break;
case 5: // Previous track
if (g_numTracks > 0) {
g_currentTrack = (g_currentTrack == 0) ? g_numTracks - 1 : g_currentTrack - 1;
if (g_playing) { audioStop(); audioStart(g_currentTrack); }
}
break;
*/
case 6: // Motor toggle
if (g_motorOn) motorStop(); else motorStart(g_motorSpeed);
break;
case 7: // Motor faster
if (g_motorSpeed < 240) g_motorSpeed += 20;
if (g_motorOn) analogWrite(PIN_MOTOR_PWM, g_motorSpeed);
break;
case 8: // Motor slower
if (g_motorSpeed > 60) g_motorSpeed -= 20;
if (g_motorOn) analogWrite(PIN_MOTOR_PWM, g_motorSpeed);
break;
case 9: // Play all (music + motor)
motorStart(g_motorSpeed);
if (!g_playing && g_numTracks > 0) audioStart(0);
break;
case 10: // Stop all
audioStop();
motorStop();
break;
}
waitButtonRelease(btn);
}
// ---- Auto shutoff / idle sleep ----
// Never sleep while BLE is connected — upload may be in progress or about
// to begin, and the CMD callback sets g_bleUploading asynchronously.
if (!g_bleConnected) {
if (millis() - g_lastActivity > AUTO_OFF_MS) {
enterDeepSleep();
}
if (!g_playing && !g_motorOn && !g_usbConnected) {
if (millis() - g_lastActivity > IDLE_SLEEP_MS) {
enterDeepSleep();
}
}
}
}