/* * 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 #include #include // ============================================================ // 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 #include 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 4 // ============================================================ // 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 ~30–300 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; #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. BLEConnection* conn = Bluefruit.Connection(conn_handle); if (conn) { conn->requestConnectionParameter(6); // 6×1.25ms = 7.5ms interval conn->requestMtuExchange(247); // 244-byte ATT payload 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 } // 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] 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 after ring buffer drains. // Don't call loadTrackTable() here — we're in a BLE callback. 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; } } // 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 30–300 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) audioCmd.setProperties(CHR_PROPS_WRITE); audioCmd.setPermission(SECMODE_OPEN, SECMODE_OPEN); audioCmd.setMaxLen(240); audioCmd.setWriteCallback(audioCmd_write_cb); audioCmd.begin(); // Data characteristic (write without response for speed) audioData.setProperties(CHR_PROPS_WRITE_WO_RESP); audioData.setPermission(SECMODE_OPEN, SECMODE_OPEN); audioData.setMaxLen(240); 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(); // Start advertising. // 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(true); Bluefruit.Advertising.setInterval(160, 320); // 100-200ms Bluefruit.Advertising.start(0); // advertise forever Serial.println("BLE advertising as 'BabyMobile'"); } // ============================================================ // 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 0–500 duty-cycle range // Software gain: 1=unity, 2=2x, etc. Adjustable via 'u'/'d' serial. static uint8_t g_audioGain = 1; // 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_audioGain; 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++) { int16_t s = (int16_t)pcm[i] - 128; s *= g_audioGain; 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 // ============================================================ 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("..."); int c=0; while(c<10) { // pinMode(c, OUTPUT); delay(250); digitalWrite(PIN_LED, HIGH); // digitalWrite(c, HIGH); // Serial.println("0"); delay(250); digitalWrite(PIN_LED, LOW); // digitalWrite(c, LOW); // Serial.println(c); c++; } 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"); #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 // BLE setupBLE(); g_lastActivity = millis(); 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 (full WAV file, header included) // DUMP DUMP // p / l / u / d / r // DELETE (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')) { if (g_serLineBuf[0] == 'u') g_audioGain++; else if (g_audioGain > 1) g_audioGain--; Serial.print("GAIN "); Serial.println(g_audioGain); } 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(); // ---- 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: // Play / Pause case 2: case 3: case 4: case 5: if (g_playing) { audioStop(); } else if (g_numTracks > 0) { audioStart(g_currentTrack); } 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(); } } } }