diff --git a/baby-mobile-panel.kicad_prl b/baby-mobile-panel.kicad_prl new file mode 100644 index 0000000..51cd092 --- /dev/null +++ b/baby-mobile-panel.kicad_prl @@ -0,0 +1,97 @@ +{ + "board": { + "active_layer": 0, + "active_layer_preset": "", + "auto_track_width": true, + "hidden_netclasses": [], + "hidden_nets": [], + "high_contrast_mode": 0, + "net_color_mode": 1, + "opacity": { + "images": 0.6, + "pads": 1.0, + "shapes": 1.0, + "tracks": 1.0, + "vias": 1.0, + "zones": 0.6 + }, + "selection_filter": { + "dimensions": true, + "footprints": true, + "graphics": true, + "keepouts": true, + "lockedItems": false, + "otherItems": true, + "pads": true, + "text": true, + "tracks": true, + "vias": true, + "zones": true + }, + "visible_items": [ + "vias", + "footprint_text", + "footprint_anchors", + "ratsnest", + "grid", + "footprints_front", + "footprints_back", + "footprint_values", + "footprint_references", + "tracks", + "drc_errors", + "drawing_sheet", + "bitmaps", + "pads", + "zones", + "drc_warnings", + "locked_item_shadows", + "conflict_shadows", + "shapes" + ], + "visible_layers": "ffffffff_ffffffff_ffffffff_ffffffff", + "zone_display_mode": 0 + }, + "git": { + "repo_type": "", + "repo_username": "", + "ssh_key": "" + }, + "meta": { + "filename": "baby-mobile-panel.kicad_prl", + "version": 5 + }, + "net_inspector_panel": { + "col_hidden": [], + "col_order": [], + "col_widths": [], + "custom_group_rules": [], + "expanded_rows": [], + "filter_by_net_name": true, + "filter_by_netclass": true, + "filter_text": "", + "group_by_constraint": false, + "group_by_netclass": false, + "show_unconnected_nets": false, + "show_zero_pad_nets": false, + "sort_ascending": true, + "sorting_column": -1 + }, + "open_jobsets": [], + "project": { + "files": [] + }, + "schematic": { + "selection_filter": { + "graphics": true, + "images": true, + "labels": true, + "lockedItems": false, + "otherItems": true, + "pins": true, + "symbols": true, + "text": true, + "wires": true + } + } +} diff --git a/baby-mobile-panel.kicad_pro b/baby-mobile-panel.kicad_pro new file mode 100644 index 0000000..dc9752a --- /dev/null +++ b/baby-mobile-panel.kicad_pro @@ -0,0 +1,284 @@ +{ + "board": { + "3dviewports": [], + "design_settings": { + "defaults": { + "apply_defaults_to_fp_fields": false, + "apply_defaults_to_fp_shapes": false, + "apply_defaults_to_fp_text": false, + "board_outline_line_width": 0.05, + "copper_line_width": 0.2, + "copper_text_italic": false, + "copper_text_size_h": 1.5, + "copper_text_size_v": 1.5, + "copper_text_thickness": 0.3, + "copper_text_upright": false, + "courtyard_line_width": 0.05, + "dimension_precision": 4, + "dimension_units": 3, + "dimensions": { + "arrow_length": 1270000, + "extension_offset": 500000, + "keep_text_aligned": true, + "suppress_zeroes": true, + "text_position": 0, + "units_format": 0 + }, + "fab_line_width": 0.1, + "fab_text_italic": false, + "fab_text_size_h": 1.0, + "fab_text_size_v": 1.0, + "fab_text_thickness": 0.15, + "fab_text_upright": false, + "other_line_width": 0.1, + "other_text_italic": false, + "other_text_size_h": 1.0, + "other_text_size_v": 1.0, + "other_text_thickness": 0.15, + "other_text_upright": false, + "pads": { + "drill": 0.8, + "height": 1.27, + "width": 2.54 + }, + "silk_line_width": 0.1, + "silk_text_italic": false, + "silk_text_size_h": 1.0, + "silk_text_size_v": 1.0, + "silk_text_thickness": 0.1, + "silk_text_upright": false, + "zones": { + "min_clearance": 0.5 + } + }, + "diff_pair_dimensions": [], + "drc_exclusions": [], + "meta": { + "version": 2 + }, + "rule_severities": { + "annular_width": "error", + "clearance": "error", + "connection_width": "warning", + "copper_edge_clearance": "error", + "copper_sliver": "warning", + "courtyards_overlap": "error", + "creepage": "error", + "diff_pair_gap_out_of_range": "error", + "diff_pair_uncoupled_length_too_long": "error", + "drill_out_of_range": "error", + "duplicate_footprints": "warning", + "extra_footprint": "warning", + "footprint": "error", + "footprint_filters_mismatch": "ignore", + "footprint_symbol_mismatch": "warning", + "footprint_type_mismatch": "ignore", + "hole_clearance": "error", + "hole_to_hole": "warning", + "holes_co_located": "warning", + "invalid_outline": "error", + "isolated_copper": "warning", + "item_on_disabled_layer": "error", + "items_not_allowed": "error", + "length_out_of_range": "error", + "lib_footprint_issues": "warning", + "lib_footprint_mismatch": "warning", + "malformed_courtyard": "error", + "microvia_drill_out_of_range": "error", + "mirrored_text_on_front_layer": "warning", + "missing_courtyard": "ignore", + "missing_footprint": "warning", + "net_conflict": "warning", + "nonmirrored_text_on_back_layer": "warning", + "npth_inside_courtyard": "ignore", + "padstack": "warning", + "pth_inside_courtyard": "ignore", + "shorting_items": "error", + "silk_edge_clearance": "warning", + "silk_over_copper": "warning", + "silk_overlap": "warning", + "skew_out_of_range": "error", + "solder_mask_bridge": "error", + "starved_thermal": "error", + "text_height": "warning", + "text_on_edge_cuts": "error", + "text_thickness": "warning", + "through_hole_pad_without_hole": "error", + "too_many_vias": "error", + "track_angle": "error", + "track_dangling": "warning", + "track_segment_length": "error", + "track_width": "error", + "tracks_crossing": "error", + "unconnected_items": "error", + "unresolved_variable": "error", + "via_dangling": "warning", + "zones_intersect": "error" + }, + "rules": { + "max_error": 0.005, + "min_clearance": 0.0, + "min_connection": 0.0, + "min_copper_edge_clearance": 0.5, + "min_groove_width": 0.0, + "min_hole_clearance": 0.25, + "min_hole_to_hole": 0.25, + "min_microvia_diameter": 0.2, + "min_microvia_drill": 0.1, + "min_resolved_spokes": 2, + "min_silk_clearance": 0.0, + "min_text_height": 0.8, + "min_text_thickness": 0.08, + "min_through_hole_diameter": 0.3, + "min_track_width": 0.15, + "min_via_annular_width": 0.1, + "min_via_diameter": 0.4, + "solder_mask_to_copper_clearance": 0.0, + "use_height_for_length_calcs": true + }, + "teardrop_options": [ + { + "td_onpthpad": true, + "td_onroundshapesonly": false, + "td_onsmdpad": true, + "td_ontrackend": false, + "td_onvia": true + } + ], + "teardrop_parameters": [ + { + "td_allow_use_two_tracks": true, + "td_curve_segcount": 0, + "td_height_ratio": 1.0, + "td_length_ratio": 0.5, + "td_maxheight": 2.0, + "td_maxlen": 1.0, + "td_on_pad_in_zone": false, + "td_target_name": "td_round_shape", + "td_width_to_size_filter_ratio": 0.9 + }, + { + "td_allow_use_two_tracks": true, + "td_curve_segcount": 0, + "td_height_ratio": 1.0, + "td_length_ratio": 0.5, + "td_maxheight": 2.0, + "td_maxlen": 1.0, + "td_on_pad_in_zone": false, + "td_target_name": "td_rect_shape", + "td_width_to_size_filter_ratio": 0.9 + }, + { + "td_allow_use_two_tracks": true, + "td_curve_segcount": 0, + "td_height_ratio": 1.0, + "td_length_ratio": 0.5, + "td_maxheight": 2.0, + "td_maxlen": 1.0, + "td_on_pad_in_zone": false, + "td_target_name": "td_track_end", + "td_width_to_size_filter_ratio": 0.9 + } + ], + "track_widths": [], + "tuning_pattern_settings": { + "diff_pair_defaults": { + "corner_radius_percentage": 80, + "corner_style": 1, + "max_amplitude": 1.0, + "min_amplitude": 0.2, + "single_sided": false, + "spacing": 1.0 + }, + "diff_pair_skew_defaults": { + "corner_radius_percentage": 80, + "corner_style": 1, + "max_amplitude": 1.0, + "min_amplitude": 0.2, + "single_sided": false, + "spacing": 0.6 + }, + "single_track_defaults": { + "corner_radius_percentage": 80, + "corner_style": 1, + "max_amplitude": 1.0, + "min_amplitude": 0.2, + "single_sided": false, + "spacing": 0.6 + } + }, + "via_dimensions": [], + "zones_allow_external_fillets": false + }, + "ipc2581": { + "dist": "", + "distpn": "", + "internal_id": "", + "mfg": "", + "mpn": "" + }, + "layer_pairs": [], + "layer_presets": [], + "viewports": [] + }, + "boards": [], + "cvpcb": { + "equivalence_files": [] + }, + "libraries": { + "pinned_footprint_libs": [], + "pinned_symbol_libs": [] + }, + "meta": { + "filename": "baby-mobile-panel.kicad_pro", + "version": 3 + }, + "net_settings": { + "classes": [ + { + "bus_width": 12, + "clearance": 0.2, + "diff_pair_gap": 0.25, + "diff_pair_via_gap": 0.25, + "diff_pair_width": 0.2, + "line_style": 0, + "microvia_diameter": 0.3, + "microvia_drill": 0.1, + "name": "Default", + "pcb_color": "rgba(0, 0, 0, 0.000)", + "priority": 2147483647, + "schematic_color": "rgba(0, 0, 0, 0.000)", + "track_width": 0.2, + "via_diameter": 0.6, + "via_drill": 0.3, + "wire_width": 6 + } + ], + "meta": { + "version": 4 + }, + "net_colors": null, + "netclass_assignments": null, + "netclass_patterns": [] + }, + "pcbnew": { + "last_paths": { + "gencad": "", + "idf": "", + "netlist": "", + "plot": "", + "pos_files": "", + "specctra_dsn": "", + "step": "", + "svg": "", + "vrml": "" + }, + "page_layout_descr_file": "" + }, + "schematic": { + "legacy_lib_dir": "", + "legacy_lib_list": [] + }, + "sheets": [], + "text_variables": {} +} diff --git a/baby_mobile_v2/baby_mobile_v2.ino b/baby_mobile_v2/baby_mobile_v2.ino index 57582dd..dec37e2 100644 --- a/baby_mobile_v2/baby_mobile_v2.ino +++ b/baby_mobile_v2/baby_mobile_v2.ino @@ -48,6 +48,20 @@ #include #include +// ============================================================ +// POC MODE: stream audio from internal LittleFS instead of SPI flash. +// Upload tracks via BLE (CMD 0x02 [track_num] + data packets). +// WAV files are accepted — 44-byte PCM header is stripped on receipt. +// 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 // ============================================================ @@ -56,25 +70,27 @@ // Map these to your actual PCB connections. // Audio -#define PIN_AUDIO_PWM 13 // Any PWM-capable pin +#define PIN_AUDIO_PWM 8 // Any PWM-capable pin #define PIN_AMP_SD 14 // PAM8302A shutdown (HIGH=enabled) // Motor #define PIN_MOTOR_PWM 15 // PWM to MOSFET gate // SPI Flash -#define PIN_FLASH_CS 5 // Flash chip select +#define PIN_FLASH_CS 2 // Flash chip select // SPI MOSI/MISO/SCK use default SPI pins // Buttons (directly to GPIO, active LOW with internal pull-up) -#define PIN_BTN1 2 -#define PIN_BTN2 3 -#define PIN_BTN3 4 -#define PIN_BTN4 28 -#define PIN_BTN5 29 -#define PIN_BTN6 30 -#define PIN_BTN7 31 -#define PIN_BTN8 12 +#define PIN_BTN1 3 +#define PIN_BTN2 4 +#define PIN_BTN3 5 +#define PIN_BTN4 3 +/* +#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 @@ -82,9 +98,9 @@ const uint8_t BTN_PINS[] = { PIN_BTN1, PIN_BTN2, PIN_BTN3, PIN_BTN4, - PIN_BTN5, PIN_BTN6, PIN_BTN7, PIN_BTN8 + /*PIN_BTN5, PIN_BTN6, PIN_BTN7, PIN_BTN8*/ }; -#define NUM_BUTTONS 8 +#define NUM_BUTTONS 4 // ============================================================ // FLASH CONSTANTS @@ -170,6 +186,12 @@ volatile bool g_bleUploading = false; volatile uint32_t g_bleWriteAddr = 0; 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 +bool g_pocSkipHeader = false; // strip WAV header from first data packet +#endif + // ============================================================ // GLOBAL STATE // ============================================================ @@ -199,6 +221,11 @@ unsigned long g_lastActivity = 0; // 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.raw", n); +} +#endif // ============================================================ // SPI FLASH DRIVER @@ -313,6 +340,21 @@ void flashPageProgram(uint32_t addr, const uint8_t *data, uint16_t len) { // ============================================================ 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(); + f.close(); + g_numTracks = i + 1; + } + Serial.print("Found "); + Serial.print(g_numTracks); + Serial.println(" tracks in internal flash"); +#else uint8_t header[4]; flashReadBytes(TRACK_TABLE_ADDR, header, 4); @@ -346,9 +388,13 @@ void loadTrackTable() { Serial.print(g_trackLen[i] / SAMPLE_RATE); Serial.println("s"); } +#endif } void writeTrackTable() { +#ifdef POC_INTERNAL_FLASH + return; // no table needed; LittleFS files are the store +#else // Erase sector 0 flashEraseSector(0); @@ -382,6 +428,7 @@ void writeTrackTable() { uint16_t chunk = min((uint16_t)FLASH_PAGE, (uint16_t)(total - offset)); flashPageProgram(offset, &table[offset], chunk); } +#endif } @@ -475,6 +522,24 @@ void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr, 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_pocSkipHeader = true; + g_bleWriteLen = 0; + Serial.print("BLE: Start write track "); + Serial.println(g_pocWriteTrack); + } +#else if (len >= 4) { g_bleWriteAddr = ((uint32_t)data[1] << 16) | ((uint32_t)data[2] << 8) | data[3]; g_bleUploading = true; @@ -483,9 +548,14 @@ void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr, Serial.print("BLE: Start write at 0x"); Serial.println(g_bleWriteAddr, HEX); } +#endif break; case 0x03: // Finish upload +#ifdef POC_INTERNAL_FLASH + if (g_pocFile) g_pocFile.close(); + g_pocSkipHeader = false; +#endif g_bleUploading = false; loadTrackTable(); Serial.println("BLE: Upload complete"); @@ -510,6 +580,27 @@ void audioData_write_cb(uint16_t conn_handle, BLECharacteristic* chr, if (!g_bleUploading) return; g_lastActivity = millis(); +#ifdef POC_INTERNAL_FLASH + uint8_t *src = data; + uint16_t srcLen = len; + + // Strip 44-byte WAV header from first packet if present + if (g_pocSkipHeader) { + g_pocSkipHeader = false; + if (srcLen >= 4 && src[0]=='R' && src[1]=='I' && src[2]=='F' && src[3]=='F') { + if (srcLen > 44) { + src += 44; + srcLen -= 44; + } else { + // Header spans packets — drop whole packet (document: send raw PCM instead) + return; + } + } + } + + g_pocFile.write(src, srcLen); + g_bleWriteLen += srcLen; +#else // Erase new sectors as we cross boundaries uint32_t endAddr = g_bleWriteAddr + len; uint32_t currentSector = g_bleWriteAddr / FLASH_SECTOR; @@ -529,6 +620,7 @@ void audioData_write_cb(uint16_t conn_handle, BLECharacteristic* chr, g_bleWriteAddr += len; g_bleWriteLen += len; +#endif // Update status characteristic with bytes written uint8_t stat[4]; @@ -594,14 +686,20 @@ void setupBLE() { // For Arduino compatibility, we use analogWrite for the PWM // and a software timer for sample feeding. -// Timer callback for audio sample rate -volatile bool g_timerFired = false; +// micros() timestamp for next audio sample (loop-driven at 8kHz) +uint32_t g_nextSampleUs = 0; -void timerCallback(void) { - if (!g_playing) return; +// Software gain: 1=unity, 2=2x, etc. (clips at 0/255). Adjustable via 'u'/'d' serial. +static uint8_t g_audioGain = 3; - // Output sample - analogWrite(PIN_AUDIO_PWM, g_audioBuf[g_activeBuf][g_bufPos]); +// Called from loop() every 125µs while g_playing +void audioTick(void) { + // Output sample with software gain (stretches away from center 128) + int16_t s = (int16_t)g_audioBuf[g_activeBuf][g_bufPos] - 128; + s *= g_audioGain; + if (s > 127) s = 127; + if (s < -128) s = -128; + analogWrite(PIN_AUDIO_PWM, (uint8_t)(s + 128)); g_bufPos++; if (g_bufPos >= AUDIO_BUF_SIZE) { @@ -610,36 +708,57 @@ void timerCallback(void) { g_bufPos = 0; if (!g_bufReady[g_activeBuf]) { - // Buffer underrun + // Buffer underrun — stop cleanly g_playing = false; analogWrite(PIN_AUDIO_PWM, SILENCE); } } } -// Use nRF52 SoftwareTimer (built into Adafruit BSP) -SoftwareTimer audioTimer; - -void audioTimerHandler(TimerHandle_t xTimer) { - timerCallback(); -} - void audioInit() { pinMode(PIN_AUDIO_PWM, OUTPUT); analogWrite(PIN_AUDIO_PWM, SILENCE); analogWriteResolution(8); // 8-bit PWM - - // Create a FreeRTOS software timer at 8kHz - // Note: For better timing, use a hardware TIMER peripheral - // This works well enough for 8kHz audio - audioTimer.begin(1000 / 8, audioTimerHandler, NULL, true); // ~8kHz - // Better approach: use nrf_drv_timer for precise 125µs intervals } void audioStart(uint8_t trackNum) { + Serial.print("Playing track"); + Serial.print(trackNum); + Serial.print(" of "); + Serial.println(g_numTracks); if (trackNum >= g_numTracks) return; g_currentTrack = trackNum; + g_playEnd = g_trackLen[trackNum]; + g_nextReadAddr = 0; + +#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; + } + + // Pre-fill buf 0 + uint32_t toRead = min((uint32_t)AUDIO_BUF_SIZE, g_playEnd - g_nextReadAddr); + g_pocFile.read(g_audioBuf[0], toRead); + g_nextReadAddr += toRead; + g_bufReady[0] = true; + + // Pre-fill buf 1 + if (g_nextReadAddr < g_playEnd) { + toRead = min((uint32_t)AUDIO_BUF_SIZE, g_playEnd - g_nextReadAddr); + g_pocFile.read(g_audioBuf[1], toRead); + g_nextReadAddr += toRead; + g_bufReady[1] = true; + } else { + memset(g_audioBuf[1], SILENCE, AUDIO_BUF_SIZE); + g_bufReady[1] = true; + } +#else uint32_t start = g_trackStart[trackNum]; g_playEnd = start + g_trackLen[trackNum]; g_nextReadAddr = start; @@ -659,6 +778,7 @@ void audioStart(uint8_t trackNum) { memset(g_audioBuf[1], SILENCE, AUDIO_BUF_SIZE); g_bufReady[1] = true; } +#endif g_activeBuf = 0; g_bufPos = 0; @@ -668,17 +788,20 @@ void audioStart(uint8_t trackNum) { delay(10); g_playing = true; - audioTimer.start(); + g_nextSampleUs = micros(); Serial.print("Playing track "); Serial.println(trackNum + 1); } void audioStop() { - audioTimer.stop(); g_playing = false; analogWrite(PIN_AUDIO_PWM, SILENCE); +#ifdef POC_INTERNAL_FLASH + if (g_pocFile) g_pocFile.close(); +#endif + // Disable amp digitalWrite(PIN_AMP_SD, LOW); @@ -720,9 +843,10 @@ void buttonsInit() { // Returns 1-8, or 0 if none pressed uint8_t buttonRead() { for (uint8_t i = 0; i < NUM_BUTTONS; i++) { - if (digitalRead(BTN_PINS[i]) == LOW) { + // Buttons pull to ground + if (analogRead(BTN_PINS[i]) < 128) { delay(20); // debounce - if (digitalRead(BTN_PINS[i]) == LOW) { + if (analogRead(BTN_PINS[i]) < 128) { return i + 1; } } @@ -731,8 +855,16 @@ uint8_t buttonRead() { } void waitButtonRelease(uint8_t btn) { - while (buttonRead() == btn) { - delay(10); + unsigned long deadline = millis() + 300; + while (buttonRead() == btn && millis() < deadline) { + // Keep audio running while waiting for button release + if (g_playing) { + uint32_t now = micros(); + if ((int32_t)(now - g_nextSampleUs) >= 0) { + g_nextSampleUs += 125; + audioTick(); + } + } } } @@ -745,7 +877,9 @@ void enterDeepSleep() { Serial.println("Entering deep sleep..."); audioStop(); motorStop(); +#ifndef POC_INTERNAL_FLASH flashSleep(); +#endif digitalWrite(PIN_LED, LOW); // Stop BLE advertising to save power @@ -798,11 +932,16 @@ void setup() { Serial.println("nRF52840 + IS25LP128F + PAM8302A"); // Pin setup - pinMode(PIN_FLASH_CS, OUTPUT); pinMode(PIN_AMP_SD, OUTPUT); - digitalWrite(PIN_FLASH_CS, HIGH); 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(); @@ -819,6 +958,7 @@ void setup() { } else if (jedec == 0x000000 || jedec == 0xFFFFFF) { Serial.println("WARNING: No flash detected! Check SPI wiring."); } +#endif // Load tracks loadTrackTable(); @@ -832,7 +972,70 @@ void setup() { // Audio audioInit(); +#ifdef POC_INTERNAL_FLASH + // Boot test tone: 440Hz square wave for 1 second + // If you hear a beep, the amp/PWM chain is working. + Serial.println("Boot tone..."); + digitalWrite(PIN_AMP_SD, HIGH); + delay(20); + for (int i = 0; i < 8000; i++) { + // 440Hz at 8kHz sample rate: 8000/440 ≈ 18 samples/cycle + analogWrite(PIN_AUDIO_PWM, (i % 18) < 9 ? 255 : 0); + delayMicroseconds(125); + } + analogWrite(PIN_AUDIO_PWM, SILENCE); + delay(20); + digitalWrite(PIN_AMP_SD, LOW); + Serial.println("Boot tone done"); + + // Hex dump track 0 + blocking playback diagnostic + // (after audioInit so amp/PWM are ready) + if (g_numTracks > 0) { + char diagName[16]; pocFilename(0, diagName); + File diagF(InternalFS); + if (diagF.open(diagName, FILE_O_READ)) { + uint32_t fsz = diagF.size(); + Serial.print("Track0 size: "); Serial.println(fsz); + uint8_t hbuf[64]; + int nr = diagF.read(hbuf, sizeof(hbuf)); + diagF.close(); + Serial.print("First "); Serial.print(nr); Serial.println(" bytes (hex):"); + for (int i = 0; i < nr; i++) { + if (hbuf[i] < 0x10) Serial.print("0"); + Serial.print(hbuf[i], HEX); + Serial.print(i % 16 == 15 ? "\n" : " "); + } + Serial.println(); + } else { + Serial.println("Cannot open track0 for read!"); + } + + Serial.println("Diag: blocking play track0 for 3s..."); + audioStart(0); + uint32_t diagEnd = millis() + 3000; + while (millis() < diagEnd && g_playing) { + uint32_t now = micros(); + if ((int32_t)(now - g_nextSampleUs) >= 0) { + g_nextSampleUs += 125; + audioTick(); + } + for (uint8_t b = 0; b < 2; b++) { + if (!g_bufReady[b] && g_nextReadAddr < g_playEnd) { + uint32_t toRead = min((uint32_t)AUDIO_BUF_SIZE, g_playEnd - g_nextReadAddr); + g_pocFile.read(g_audioBuf[b], toRead); + for (uint32_t i = toRead; i < AUDIO_BUF_SIZE; i++) g_audioBuf[b][i] = SILENCE; + g_nextReadAddr += toRead; + g_bufReady[b] = true; + } + } + } + audioStop(); + Serial.println("Diag done"); + } +#endif + // 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? @@ -840,6 +1043,7 @@ void setup() { //usb_msc.setReadOnly(false); usb_msc.setUnitReady(true); usb_msc.begin(); +#endif // BLE setupBLE(); @@ -855,7 +1059,164 @@ void setup() { // MAIN LOOP // ============================================================ +// ============================================================ +// SERIAL UPLOAD STATE MACHINE (POC_INTERNAL_FLASH only) +// ============================================================ +#ifdef POC_INTERNAL_FLASH +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; +static bool g_serSkipHeader = false; +static File g_serFile(InternalFS); + +static void serUploadTick() { + if (g_serState == SER_IDLE) { + // Accumulate characters until newline + while (Serial.available()) { + // Serial.print("r"); + char c = (char)Serial.read(); + if (c == '\n' || c == '\r') { + g_serLineBuf[g_serLineLen] = '\0'; + if (g_serLineLen == 0) { g_serLineLen = 0; break; } + // Parse: UPLOAD + 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; + g_serSkipHeader = true; + + char fname[16]; + pocFilename(g_serTrack, fname); + if (g_serFile) g_serFile.close(); + // Remove first — FILE_O_WRITE has no truncate flag + 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 if (sscanf(g_serLineBuf, "DUMP %u", &utrk) == 1) { + // Hex-dump first 256 bytes of a track file + 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 n = df.read(dbuf, min((uint32_t)sizeof(dbuf), limit - off)); + if (n <= 0) break; + for (int j = 0; j < n; j++) { + if (dbuf[j] < 0x10) Serial.print("0"); + Serial.print(dbuf[j], HEX); + Serial.print(j % 16 == 15 || (off + j + 1) == limit ? "\n" : " "); + } + off += n; + } + df.close(); + Serial.println("END"); + } else { + Serial.println("NO FILE"); + } + } 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 { + Serial.print("ERR bad cmd: "); + Serial.println(g_serLineBuf); + } + g_serLineLen = 0; + // Serial.print("0"); + } else { + if (g_serLineLen < (sizeof(g_serLineBuf) - 1)) { + g_serLineBuf[g_serLineLen++] = c; + } + // Serial.print(","); + } + } + } else { // SER_RECEIVING + // Serial.print("e"); + uint8_t chunk[64]; + while (Serial.available() && g_serBytesReceived < g_serBytesExpected) { + // Serial.print(";"); + int n = Serial.readBytes(chunk, min((int)sizeof(chunk), + (int)(g_serBytesExpected - g_serBytesReceived))); + if (n <= 0) break; + + uint8_t *src = chunk; + uint16_t srcLen = (uint16_t)n; + + // Strip 44-byte WAV header from first chunk if present + if (g_serSkipHeader) { + g_serSkipHeader = false; + if (srcLen >= 4 && src[0]=='R' && src[1]=='I' && src[2]=='F' && src[3]=='F') { + if (srcLen > 44) { src += 44; srcLen -= 44; } + else { g_serBytesReceived += n; continue; } + } + } + + int32_t wr = g_serFile.write(src, srcLen); + if (wr != (int32_t)srcLen) { + // Write failed (LittleFS full or error) — abort immediately. + // Do NOT loop printing errors; that fills USB CDC TX and hangs. + g_serFile.close(); + g_usbConnected = false; + g_serState = SER_IDLE; + Serial.print("ERR WRITE_FAIL at="); + Serial.println(g_serBytesReceived); + break; + } + g_serBytesReceived += n; + } + + if (g_serState != SER_RECEIVING) return; // aborted in write-fail handler above + + if (g_serBytesReceived >= g_serBytesExpected) { + g_serFile.close(); + // Reopen to read the actual flushed size from LittleFS + 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); + } + } +} +#endif + void loop() { + // ---- Audio sample output (8kHz, loop-driven) ---- + if (g_playing) { + uint32_t now = micros(); + if ((int32_t)(now - g_nextSampleUs) >= 0) { + g_nextSampleUs += 125; // 1/8000s = 125µs + audioTick(); + } + } + + // ---- Serial upload (POC mode) ---- +#ifdef POC_INTERNAL_FLASH + serUploadTick(); +#endif + // ---- Refill audio buffers ---- if (g_playing) { for (uint8_t b = 0; b < 2; b++) { @@ -863,7 +1224,11 @@ void loop() { uint32_t remaining = g_playEnd - g_nextReadAddr; uint32_t toRead = min((uint32_t)AUDIO_BUF_SIZE, remaining); +#ifdef POC_INTERNAL_FLASH + g_pocFile.read(g_audioBuf[b], toRead); +#else flashReadBytes(g_nextReadAddr, g_audioBuf[b], toRead); +#endif // Pad with silence for (uint32_t i = toRead; i < AUDIO_BUF_SIZE; i++) { g_audioBuf[b][i] = SILENCE; @@ -884,8 +1249,12 @@ void loop() { } // ---- 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) { + if (btn > 0 && millis() - btnLastMs[btn] > 800) { + btnLastMs[btn] = millis(); + Serial.print("BTN"); Serial.println(btn); g_lastActivity = millis(); switch (btn) { @@ -951,6 +1320,4 @@ void loop() { } } - // Small delay to prevent tight-looping - delay(1); } diff --git a/baby_mobile_v2/upload_track.py b/baby_mobile_v2/upload_track.py new file mode 100644 index 0000000..c82c04a --- /dev/null +++ b/baby_mobile_v2/upload_track.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +""" +upload_track.py — serial audio uploader for baby_mobile_v2 POC_INTERNAL_FLASH mode + +Usage: + python upload_track.py + +Supported formats: .raw, .wav, .mp3, .ogg, .flac, .aac, .m4a (anything ffmpeg handles) + +Requires: pyserial (pip install pyserial) + ffmpeg in PATH for non-WAV/RAW files +""" + +import sys +import os +import shutil +import struct +import subprocess +import serial +import time + +CHUNK_SIZE = 512 +BAUD_RATE = 115200 +TIMEOUT_S = 10.0 + +RAW_EXTS = {'.raw'} +WAV_EXTS = {'.wav'} + + +def convert_to_pcm(path: str) -> bytes: + ext = os.path.splitext(path)[1].lower() + + if ext in RAW_EXTS: + with open(path, "rb") as f: + return f.read() + + if ext in WAV_EXTS: + with open(path, "rb") as f: + data = f.read() + if data[:4] == b"RIFF": + try: + audio_format = struct.unpack_from(" None: + total = len(pcm) + print(f"Uploading {total} bytes as track {track_num} via {port} ...") + + # dsrdtr=False / rtscts=False prevents Windows from toggling DTR/RTS on + # port open, which would reset the nRF52840 and stall the 5-second boot loop. + with serial.Serial(port, BAUD_RATE, timeout=2.0, + dsrdtr=False, rtscts=False) as ser: + # Drain any boot output; send a bare newline first to flush any + # partial line sitting in the firmware's line buffer. + time.sleep(0.5) + ser.reset_input_buffer() + ser.write(b"\n") + ser.flush() + time.sleep(0.1) + ser.reset_input_buffer() + + # Send UPLOAD command + cmd = f"UPLOAD {track_num} {total}\n" + ser.write(cmd.encode()) + ser.flush() + + # Wait for READY — print everything received so failures are diagnosable + deadline = time.time() + TIMEOUT_S + while True: + if time.time() > deadline: + sys.exit("Timed out waiting for READY") + line = ser.readline().decode(errors="replace").strip() + if line == "READY": + break + if line: + print(f" firmware: {line}") + + # Stream PCM in chunks; stop early if firmware sends ERR + sent = 0 + err_line = None + ser.timeout = 0.05 # short timeout so we can poll for ERR while sending + while sent < total: + chunk = pcm[sent:sent + CHUNK_SIZE] + ser.write(chunk) + sent += len(chunk) + pct = sent * 100 // total + print(f"\r {sent}/{total} bytes ({pct}%) ", end="", flush=True) + # Check for early ERR from firmware (e.g. LittleFS full) + line = ser.readline().decode(errors="replace").strip() + if line.startswith("ERR"): + err_line = line + break + print() + ser.timeout = 2.0 + + if err_line: + print(f"ERROR from firmware: {err_line}") + # at= tells us how many bytes were accepted before the failure + if "at=" in err_line: + accepted = int(err_line.split("at=")[1].split()[0]) + safe = int(accepted * 0.9) # 10% headroom + max_s = safe / 8000 + print(f" LittleFS accepted {accepted} bytes before full.") + print(f" Safe clip length: ~{max_s:.1f}s") + print(f" Trim with: ffmpeg -i input.mp3 -t {max_s:.0f} -ar 8000 -ac 1 -f u8 out.raw") + sys.exit(1) + + # Wait for OK + deadline = time.time() + TIMEOUT_S + while True: + if time.time() > deadline: + sys.exit("Timed out waiting for OK") + line = ser.readline().decode(errors="replace").strip() + if line.startswith("OK"): + print(f"Upload done: {line}") + break + if line.startswith("ERR"): + print(f"ERROR from firmware: {line}") + sys.exit(1) + if line: + print(f" firmware: {line}") + + # Verify: request hex dump of first 256 bytes and compare + print("Verifying...") + ser.reset_input_buffer() + ser.write(f"DUMP {track_num}\n".encode()) + ser.flush() + + deadline = time.time() + TIMEOUT_S + fw_size = None + fw_bytes = bytearray() + while True: + if time.time() > deadline: + print("WARNING: timed out waiting for DUMP response") + break + line = ser.readline().decode(errors="replace").strip() + if not line: + continue + if line.startswith("SIZE"): + fw_size = int(line.split()[1]) + elif line == "END": + break + elif line == "NO FILE": + print("ERROR: firmware says file does not exist!") + break + else: + # Parse hex bytes + for tok in line.split(): + try: + fw_bytes.append(int(tok, 16)) + except ValueError: + pass + + if fw_size is not None: + print(f" Firmware file size: {fw_size} bytes") + if fw_size == 0: + print(" ERROR: file is empty — write failed (LittleFS full?)") + elif fw_size < total: + print(f" WARNING: only {fw_size}/{total} bytes written (LittleFS full?)") + else: + print(f" Size OK: {fw_size}/{total}") + + compare_len = min(len(fw_bytes), len(pcm), 256) + if compare_len > 0: + mismatches = sum(1 for i in range(compare_len) if fw_bytes[i] != pcm[i]) + if mismatches == 0: + print(f" Data OK: first {compare_len} bytes match") + else: + print(f" ERROR: {mismatches}/{compare_len} byte mismatches in first {compare_len} bytes") + print(f" Expected: {' '.join(f'{b:02X}' for b in pcm[:16])}") + print(f" Got: {' '.join(f'{b:02X}' for b in fw_bytes[:16])}") + + +def main(): + if len(sys.argv) != 4: + print(__doc__) + sys.exit(1) + + port = sys.argv[1] + track_num = int(sys.argv[2]) + path = sys.argv[3] + + if not 0 <= track_num < 32: + sys.exit("track_num must be 0–31") + + pcm = convert_to_pcm(path) + if not pcm: + sys.exit("File is empty after conversion") + + upload(port, track_num, pcm) + + +if __name__ == "__main__": + main()