Dynamic WAV format support: 8/16-bit, variable sample rate, BLE list/delete, FORMAT command
This commit is contained in:
@@ -13,8 +13,8 @@
|
|||||||
* - Deep sleep with button wake (~10µA)
|
* - Deep sleep with button wake (~10µA)
|
||||||
* - Auto-shutoff timer
|
* - Auto-shutoff timer
|
||||||
*
|
*
|
||||||
* Audio format: unsigned 8-bit PCM, 8000 Hz, mono
|
* Audio format: WAV (8 or 16-bit PCM, 8/16/32 kHz, mono) stored in LittleFS.
|
||||||
* Convert: ffmpeg -i song.mp3 -ar 8000 -ac 1 -f u8 -acodec pcm_u8 song.raw
|
* Upload via upload_track.py — ffmpeg handles any source format.
|
||||||
*
|
*
|
||||||
* Board setup in Arduino IDE:
|
* Board setup in Arduino IDE:
|
||||||
* Board: "Seeed XIAO nRF52840" (or "Adafruit Feather nRF52840")
|
* Board: "Seeed XIAO nRF52840" (or "Adafruit Feather nRF52840")
|
||||||
@@ -50,8 +50,9 @@
|
|||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// POC MODE: stream audio from internal LittleFS instead of SPI flash.
|
// POC MODE: stream audio from internal LittleFS instead of SPI flash.
|
||||||
// Upload tracks via BLE (CMD 0x02 [track_num] + data packets).
|
// Upload tracks via upload_track.py (serial) or BLE (CMD 0x02).
|
||||||
// WAV files are accepted — 44-byte PCM header is stripped on receipt.
|
// 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.
|
// Disable this define to revert to full SPI flash mode.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
#define POC_INTERNAL_FLASH
|
#define POC_INTERNAL_FLASH
|
||||||
@@ -77,14 +78,14 @@ using namespace Adafruit_LittleFS_Namespace;
|
|||||||
#define PIN_MOTOR_PWM 15 // PWM to MOSFET gate
|
#define PIN_MOTOR_PWM 15 // PWM to MOSFET gate
|
||||||
|
|
||||||
// SPI Flash
|
// SPI Flash
|
||||||
#define PIN_FLASH_CS 2 // Flash chip select
|
#define PIN_FLASH_CS 4 // Flash chip select
|
||||||
// SPI MOSI/MISO/SCK use default SPI pins
|
// SPI MOSI/MISO/SCK use default SPI pins (8/9/10?)
|
||||||
|
|
||||||
// Buttons (directly to GPIO, active LOW with internal pull-up)
|
// Buttons (directly to GPIO, active LOW with internal pull-up)
|
||||||
#define PIN_BTN1 4
|
#define PIN_BTN1 1
|
||||||
#define PIN_BTN2 1
|
#define PIN_BTN2 1
|
||||||
#define PIN_BTN3 5
|
#define PIN_BTN3 1
|
||||||
#define PIN_BTN4 3
|
#define PIN_BTN4 1
|
||||||
/*
|
/*
|
||||||
#define PIN_BTN5 4
|
#define PIN_BTN5 4
|
||||||
#define PIN_BTN6 5
|
#define PIN_BTN6 5
|
||||||
@@ -189,7 +190,6 @@ volatile uint32_t g_bleWriteLen = 0;
|
|||||||
#ifdef POC_INTERNAL_FLASH
|
#ifdef POC_INTERNAL_FLASH
|
||||||
File g_pocFile(InternalFS); // open file handle (read or write)
|
File g_pocFile(InternalFS); // open file handle (read or write)
|
||||||
uint8_t g_pocWriteTrack = 0; // track slot being written via BLE
|
uint8_t g_pocWriteTrack = 0; // track slot being written via BLE
|
||||||
bool g_pocSkipHeader = false; // strip WAV header from first data packet
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -202,7 +202,10 @@ uint8_t g_motorSpeed = 160;
|
|||||||
|
|
||||||
uint8_t g_numTracks = 0;
|
uint8_t g_numTracks = 0;
|
||||||
uint32_t g_trackStart[MAX_TRACKS];
|
uint32_t g_trackStart[MAX_TRACKS];
|
||||||
uint32_t g_trackLen[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;
|
uint8_t g_currentTrack = 0;
|
||||||
bool g_loopTracks = false; // false = play once and stop; true = auto-advance through all tracks
|
bool g_loopTracks = false; // false = play once and stop; true = auto-advance through all tracks
|
||||||
|
|
||||||
@@ -223,7 +226,7 @@ volatile bool g_usbConnected = false;
|
|||||||
|
|
||||||
#ifdef POC_INTERNAL_FLASH
|
#ifdef POC_INTERNAL_FLASH
|
||||||
static void pocFilename(uint8_t n, char *buf) { // buf must be >=16 bytes
|
static void pocFilename(uint8_t n, char *buf) { // buf must be >=16 bytes
|
||||||
snprintf(buf, 16, "/track%d.raw", n);
|
snprintf(buf, 16, "/track%d.wav", n);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -348,9 +351,38 @@ void loadTrackTable() {
|
|||||||
File f(InternalFS);
|
File f(InternalFS);
|
||||||
if (!f.open(fname, FILE_O_READ)) break;
|
if (!f.open(fname, FILE_O_READ)) break;
|
||||||
g_trackLen[i] = f.size();
|
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();
|
f.close();
|
||||||
|
|
||||||
if (g_trackLen[i] == 0) break; // stop at first empty file
|
if (g_trackLen[i] == 0) break; // stop at first empty file
|
||||||
g_numTracks = i + 1;
|
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("Found ");
|
||||||
Serial.print(g_numTracks);
|
Serial.print(g_numTracks);
|
||||||
@@ -495,10 +527,13 @@ void ble_disconnect_cb(uint16_t conn_handle, uint8_t reason) {
|
|||||||
|
|
||||||
// BLE command characteristic: receives commands
|
// BLE command characteristic: receives commands
|
||||||
// CMD 0x01 [num_tracks] [track_entries...] = write track table
|
// CMD 0x01 [num_tracks] [track_entries...] = write track table
|
||||||
// CMD 0x02 [addr_3bytes] = start writing audio at address
|
// CMD 0x02 [track_num] = start writing audio to track slot
|
||||||
// CMD 0x03 = finish upload, reload tracks
|
// CMD 0x03 = finish upload, reload tracks
|
||||||
// CMD 0x04 [track_num] = play track
|
// CMD 0x04 [track_num] = play track
|
||||||
// CMD 0x05 = stop playback
|
// 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,
|
void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr,
|
||||||
uint8_t* data, uint16_t len) {
|
uint8_t* data, uint16_t len) {
|
||||||
if (len < 1) return;
|
if (len < 1) return;
|
||||||
@@ -535,7 +570,6 @@ void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr,
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
g_bleUploading = true;
|
g_bleUploading = true;
|
||||||
g_pocSkipHeader = true;
|
|
||||||
g_bleWriteLen = 0;
|
g_bleWriteLen = 0;
|
||||||
Serial.print("BLE: Start write track ");
|
Serial.print("BLE: Start write track ");
|
||||||
Serial.println(g_pocWriteTrack);
|
Serial.println(g_pocWriteTrack);
|
||||||
@@ -555,7 +589,6 @@ void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr,
|
|||||||
case 0x03: // Finish upload
|
case 0x03: // Finish upload
|
||||||
#ifdef POC_INTERNAL_FLASH
|
#ifdef POC_INTERNAL_FLASH
|
||||||
if (g_pocFile) g_pocFile.close();
|
if (g_pocFile) g_pocFile.close();
|
||||||
g_pocSkipHeader = false;
|
|
||||||
#endif
|
#endif
|
||||||
g_bleUploading = false;
|
g_bleUploading = false;
|
||||||
loadTrackTable();
|
loadTrackTable();
|
||||||
@@ -572,6 +605,57 @@ void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr,
|
|||||||
audioStop();
|
audioStop();
|
||||||
motorStop();
|
motorStop();
|
||||||
break;
|
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).
|
||||||
|
#ifdef POC_INTERNAL_FLASH
|
||||||
|
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");
|
||||||
|
#endif
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 0x07: // Delete track
|
||||||
|
// data[1] = track index to delete.
|
||||||
|
// Responds via audioStat: [0xD0, track_idx, success(0/1), 0]
|
||||||
|
#ifdef POC_INTERNAL_FLASH
|
||||||
|
if (len >= 2) {
|
||||||
|
uint8_t trkNum = data[1];
|
||||||
|
char fname[16];
|
||||||
|
pocFilename(trkNum, fname);
|
||||||
|
bool ok = InternalFS.remove(fname);
|
||||||
|
if (ok) loadTrackTable();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,25 +666,8 @@ void audioData_write_cb(uint16_t conn_handle, BLECharacteristic* chr,
|
|||||||
g_lastActivity = millis();
|
g_lastActivity = millis();
|
||||||
|
|
||||||
#ifdef POC_INTERNAL_FLASH
|
#ifdef POC_INTERNAL_FLASH
|
||||||
uint8_t *src = data;
|
g_pocFile.write(data, len);
|
||||||
uint16_t srcLen = len;
|
g_bleWriteLen += 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
|
#else
|
||||||
// Erase new sectors as we cross boundaries
|
// Erase new sectors as we cross boundaries
|
||||||
uint32_t endAddr = g_bleWriteAddr + len;
|
uint32_t endAddr = g_bleWriteAddr + len;
|
||||||
@@ -697,32 +764,63 @@ void setupBLE() {
|
|||||||
static uint8_t g_audioGain = 1;
|
static uint8_t g_audioGain = 1;
|
||||||
|
|
||||||
// Read PCM into g_pwmBuf[b], pad tail with silence.
|
// 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.
|
// On the first pure-silence fill (track exhausted), arms the stop timer.
|
||||||
static void audioFillBuf(uint8_t b) {
|
static void audioFillBuf(uint8_t b) {
|
||||||
uint8_t pcm[AUDIO_BUF_SIZE];
|
bool is16 = (g_trackBits[g_currentTrack] == 16);
|
||||||
uint32_t toRead = 0;
|
uint32_t bytesPerSample = is16 ? 2 : 1;
|
||||||
|
uint32_t toReadBytes = 0;
|
||||||
|
|
||||||
if (g_nextReadAddr < g_playEnd) {
|
if (g_nextReadAddr < g_playEnd) {
|
||||||
toRead = min((uint32_t)AUDIO_BUF_SIZE, g_playEnd - g_nextReadAddr);
|
uint32_t remaining = g_playEnd - g_nextReadAddr;
|
||||||
#ifdef POC_INTERNAL_FLASH
|
toReadBytes = min((uint32_t)(AUDIO_BUF_SIZE * bytesPerSample), remaining);
|
||||||
g_pocFile.read(pcm, toRead);
|
if (is16) toReadBytes &= ~1u; // keep sample-aligned
|
||||||
#else
|
|
||||||
flashReadBytes(g_nextReadAddr, pcm, toRead);
|
|
||||||
#endif
|
|
||||||
g_nextReadAddr += toRead;
|
|
||||||
}
|
}
|
||||||
for (uint32_t i = 0; i < toRead; i++) {
|
|
||||||
|
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;
|
int16_t s = (int16_t)pcm[i] - 128;
|
||||||
s *= g_audioGain;
|
s *= g_audioGain;
|
||||||
if (s > 127) s = 127;
|
if (s > 127) s = 127;
|
||||||
if (s < -128) s = -128;
|
if (s < -128) s = -128;
|
||||||
g_pwmBuf[b][i] = (uint16_t)((uint8_t)(s + 128)) * PWM_COUNTERTOP / 256;
|
g_pwmBuf[b][i] = (uint16_t)((uint8_t)(s + 128)) * PWM_COUNTERTOP / 256;
|
||||||
}
|
}
|
||||||
for (uint32_t i = toRead; i < AUDIO_BUF_SIZE; i++) {
|
}
|
||||||
|
|
||||||
|
for (uint32_t i = samples; i < AUDIO_BUF_SIZE; i++) {
|
||||||
g_pwmBuf[b][i] = PWM_SILENCE;
|
g_pwmBuf[b][i] = PWM_SILENCE;
|
||||||
}
|
}
|
||||||
g_bufReady[b] = true;
|
g_bufReady[b] = true;
|
||||||
// Arm stop timer on first pure-silence fill (all audio already sent to DMA)
|
// Arm stop timer on first pure-silence fill (all audio already sent to DMA)
|
||||||
if (toRead == 0 && g_trackDoneMs == 0 && g_playing) {
|
if (toReadBytes == 0 && g_trackDoneMs == 0 && g_playing) {
|
||||||
g_trackDoneMs = millis() + 100; // 100ms > 3 buffer lengths (3 × 32ms)
|
g_trackDoneMs = millis() + 100; // 100ms > 3 buffer lengths (3 × 32ms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -796,13 +894,25 @@ void audioStart(uint8_t trackNum) {
|
|||||||
Serial.print("audioStart: cannot open "); Serial.println(fname);
|
Serial.print("audioStart: cannot open "); Serial.println(fname);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
g_nextReadAddr = 0;
|
g_pocFile.seek(g_trackDataOff[trackNum]);
|
||||||
|
g_nextReadAddr = g_trackDataOff[trackNum];
|
||||||
g_playEnd = g_trackLen[trackNum];
|
g_playEnd = g_trackLen[trackNum];
|
||||||
#else
|
#else
|
||||||
g_nextReadAddr = g_trackStart[trackNum];
|
g_nextReadAddr = g_trackStart[trackNum] + g_trackDataOff[trackNum];
|
||||||
g_playEnd = g_nextReadAddr + g_trackLen[trackNum];
|
g_playEnd = g_trackStart[trackNum] + g_trackLen[trackNum];
|
||||||
#endif
|
#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_trackDoneMs = 0;
|
||||||
g_bufReady[0] = false;
|
g_bufReady[0] = false;
|
||||||
g_bufReady[1] = false;
|
g_bufReady[1] = false;
|
||||||
@@ -1044,7 +1154,6 @@ static uint8_t g_serLineLen = 0;
|
|||||||
static uint8_t g_serTrack = 0;
|
static uint8_t g_serTrack = 0;
|
||||||
static uint32_t g_serBytesExpected = 0;
|
static uint32_t g_serBytesExpected = 0;
|
||||||
static uint32_t g_serBytesReceived = 0;
|
static uint32_t g_serBytesReceived = 0;
|
||||||
static bool g_serSkipHeader = false;
|
|
||||||
static File g_serFile(InternalFS);
|
static File g_serFile(InternalFS);
|
||||||
|
|
||||||
static void serUploadTick() {
|
static void serUploadTick() {
|
||||||
@@ -1062,7 +1171,6 @@ static void serUploadTick() {
|
|||||||
g_serTrack = (uint8_t)utrk;
|
g_serTrack = (uint8_t)utrk;
|
||||||
g_serBytesExpected = (uint32_t)ulen;
|
g_serBytesExpected = (uint32_t)ulen;
|
||||||
g_serBytesReceived = 0;
|
g_serBytesReceived = 0;
|
||||||
g_serSkipHeader = true;
|
|
||||||
|
|
||||||
char fname[16];
|
char fname[16];
|
||||||
pocFilename(g_serTrack, fname);
|
pocFilename(g_serTrack, fname);
|
||||||
@@ -1127,6 +1235,12 @@ static void serUploadTick() {
|
|||||||
Serial.print("ERR no file "); Serial.println(fname);
|
Serial.print("ERR no file "); Serial.println(fname);
|
||||||
}
|
}
|
||||||
loadTrackTable();
|
loadTrackTable();
|
||||||
|
} else if (strcmp(g_serLineBuf, "FORMAT") == 0) {
|
||||||
|
audioStop();
|
||||||
|
Serial.println("Formatting LittleFS...");
|
||||||
|
InternalFS.format();
|
||||||
|
g_numTracks = 0;
|
||||||
|
Serial.println("FORMAT OK");
|
||||||
} else {
|
} else {
|
||||||
Serial.print("ERR bad cmd: ");
|
Serial.print("ERR bad cmd: ");
|
||||||
Serial.println(g_serLineBuf);
|
Serial.println(g_serLineBuf);
|
||||||
@@ -1149,20 +1263,8 @@ static void serUploadTick() {
|
|||||||
(int)(g_serBytesExpected - g_serBytesReceived)));
|
(int)(g_serBytesExpected - g_serBytesReceived)));
|
||||||
if (n <= 0) break;
|
if (n <= 0) break;
|
||||||
|
|
||||||
uint8_t *src = chunk;
|
int32_t wr = g_serFile.write(chunk, (uint16_t)n);
|
||||||
uint16_t srcLen = (uint16_t)n;
|
if (wr != 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.
|
// Write failed (LittleFS full or error) — abort immediately.
|
||||||
// Do NOT loop printing errors; that fills USB CDC TX and hangs.
|
// Do NOT loop printing errors; that fills USB CDC TX and hangs.
|
||||||
g_serFile.close();
|
g_serFile.close();
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ upload_track.py — serial audio uploader for baby_mobile_v2 POC_INTERNAL_FLASH
|
|||||||
Usage:
|
Usage:
|
||||||
python upload_track.py <port> <track_num> <file>
|
python upload_track.py <port> <track_num> <file>
|
||||||
|
|
||||||
Supported formats: .raw, .wav, .mp3, .ogg, .flac, .aac, .m4a (anything ffmpeg handles)
|
Supported formats: .wav, .mp3, .ogg, .flac, .aac, .m4a, .raw (anything ffmpeg handles)
|
||||||
|
|
||||||
|
Output format: 16-bit signed PCM WAV, 16 kHz, mono (WAV header included).
|
||||||
|
- Already-conformant WAV files (PCM, mono, 8 or 16-bit, 8/16/32 kHz) are sent as-is.
|
||||||
|
- All other formats are converted via ffmpeg to 16-bit 16 kHz mono WAV.
|
||||||
|
- .raw files are treated as legacy 8-bit unsigned 16 kHz and wrapped in a WAV header.
|
||||||
|
|
||||||
Requires: pyserial (pip install pyserial)
|
Requires: pyserial (pip install pyserial)
|
||||||
ffmpeg in PATH for non-WAV/RAW files
|
ffmpeg in PATH for non-WAV/RAW files or non-conformant WAV files
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
@@ -23,69 +28,96 @@ CHUNK_SIZE = 512
|
|||||||
BAUD_RATE = 115200
|
BAUD_RATE = 115200
|
||||||
TIMEOUT_S = 10.0
|
TIMEOUT_S = 10.0
|
||||||
|
|
||||||
RAW_EXTS = {'.raw'}
|
# Sample rates the firmware PWM can reproduce (32kHz carrier / (REFRESH+1))
|
||||||
WAV_EXTS = {'.wav'}
|
SUPPORTED_RATES = {8000, 16000, 32000}
|
||||||
|
|
||||||
|
|
||||||
def convert_to_pcm(path: str) -> bytes:
|
def _make_wav_header(num_samples: int, sample_rate: int, bits: int) -> bytes:
|
||||||
|
"""Build a minimal 44-byte PCM WAV header."""
|
||||||
|
num_channels = 1
|
||||||
|
byte_rate = sample_rate * num_channels * bits // 8
|
||||||
|
block_align = num_channels * bits // 8
|
||||||
|
data_size = num_samples * block_align
|
||||||
|
chunk_size = 36 + data_size
|
||||||
|
return struct.pack('<4sI4s4sIHHIIHH4sI',
|
||||||
|
b'RIFF', chunk_size, b'WAVE',
|
||||||
|
b'fmt ', 16, 1, num_channels, sample_rate,
|
||||||
|
byte_rate, block_align, bits,
|
||||||
|
b'data', data_size)
|
||||||
|
|
||||||
|
|
||||||
|
def load_wav(path: str) -> bytes:
|
||||||
|
"""Return full WAV bytes (header + PCM data) ready to upload."""
|
||||||
ext = os.path.splitext(path)[1].lower()
|
ext = os.path.splitext(path)[1].lower()
|
||||||
|
|
||||||
if ext in RAW_EXTS:
|
# ── Legacy .raw: 8-bit unsigned 16 kHz mono ─────────────────────────────
|
||||||
with open(path, "rb") as f:
|
if ext == '.raw':
|
||||||
return f.read()
|
with open(path, 'rb') as f:
|
||||||
|
pcm = f.read()
|
||||||
|
print(f"RAW file: wrapping {len(pcm):,} bytes as 8-bit 16 kHz mono WAV")
|
||||||
|
header = _make_wav_header(len(pcm), 16000, 8)
|
||||||
|
return header + pcm
|
||||||
|
|
||||||
if ext in WAV_EXTS:
|
# ── WAV: check if already conformant ────────────────────────────────────
|
||||||
with open(path, "rb") as f:
|
if ext == '.wav':
|
||||||
|
with open(path, 'rb') as f:
|
||||||
data = f.read()
|
data = f.read()
|
||||||
if data[:4] == b"RIFF":
|
if data[:4] == b'RIFF' and len(data) >= 44:
|
||||||
try:
|
try:
|
||||||
audio_format = struct.unpack_from("<H", data, 20)[0]
|
audio_format = struct.unpack_from('<H', data, 20)[0]
|
||||||
num_channels = struct.unpack_from("<H", data, 22)[0]
|
num_channels = struct.unpack_from('<H', data, 22)[0]
|
||||||
sample_rate = struct.unpack_from("<I", data, 24)[0]
|
sample_rate = struct.unpack_from('<I', data, 24)[0]
|
||||||
bits_per_samp = struct.unpack_from("<H", data, 34)[0]
|
bits_per_samp = struct.unpack_from('<H', data, 34)[0]
|
||||||
needs_convert = (audio_format != 1 or num_channels != 1
|
conformant = (
|
||||||
or sample_rate != 16000 or bits_per_samp != 8)
|
audio_format == 1 and # PCM
|
||||||
|
num_channels == 1 and # mono
|
||||||
|
bits_per_samp in (8, 16) and
|
||||||
|
sample_rate in SUPPORTED_RATES
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
needs_convert = True
|
conformant = False
|
||||||
|
|
||||||
if not needs_convert:
|
if conformant:
|
||||||
print("WAV is already 8-bit mono 16kHz — stripping header")
|
duration = (len(data) - 44) / (bits_per_samp // 8) / sample_rate
|
||||||
return data[44:]
|
print(f"WAV already conformant: {bits_per_samp}-bit {sample_rate // 1000}kHz "
|
||||||
print("WAV needs resampling — converting via ffmpeg")
|
f"mono — {duration:.1f}s")
|
||||||
# fall through to ffmpeg conversion
|
return data # send as-is, header included
|
||||||
|
print("WAV needs conversion — running ffmpeg")
|
||||||
|
# fall through to ffmpeg
|
||||||
|
|
||||||
# Use ffmpeg for everything else (mp3, ogg, flac, aac, non-conformant wav...)
|
# ── All other formats (and non-conformant WAV): convert via ffmpeg ───────
|
||||||
if not shutil.which('ffmpeg'):
|
if not shutil.which('ffmpeg'):
|
||||||
sys.exit(
|
sys.exit(
|
||||||
"ERROR: ffmpeg not found. Install it or convert manually:\n"
|
"ERROR: ffmpeg not found. Install it or convert manually:\n"
|
||||||
f" ffmpeg -i \"{path}\" -ar 8000 -ac 1 -f u8 -acodec pcm_u8 out.raw"
|
f" ffmpeg -i \"{path}\" -ar 16000 -ac 1 -acodec pcm_s16le out.wav"
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"Converting {os.path.basename(path)} via ffmpeg...")
|
print(f"Converting {os.path.basename(path)} via ffmpeg -> 16-bit 16 kHz mono WAV...")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['ffmpeg', '-y', '-i', path,
|
['ffmpeg', '-y', '-i', path,
|
||||||
'-ar', '16000', '-ac', '1', '-f', 'u8', '-acodec', 'pcm_u8', 'pipe:1'],
|
'-ar', '16000', '-ac', '1', '-f', 'wav', '-acodec', 'pcm_s16le', 'pipe:1'],
|
||||||
capture_output=True
|
capture_output=True
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
sys.exit(f"ERROR: ffmpeg failed:\n{result.stderr.decode(errors='replace')[-400:]}")
|
sys.exit(f"ERROR: ffmpeg failed:\n{result.stderr.decode(errors='replace')[-400:]}")
|
||||||
|
|
||||||
pcm = result.stdout
|
wav = result.stdout
|
||||||
duration = len(pcm) / 16000
|
if len(wav) >= 44:
|
||||||
print(f" Converted: {len(pcm):,} bytes ({duration:.1f}s)")
|
bits = struct.unpack_from('<H', wav, 34)[0]
|
||||||
return pcm
|
rate = struct.unpack_from('<I', wav, 24)[0]
|
||||||
|
audio_bytes = len(wav) - 44
|
||||||
|
duration = audio_bytes / (bits // 8) / rate
|
||||||
|
print(f" Converted: {audio_bytes:,} audio bytes ({duration:.1f}s), "
|
||||||
|
f"{bits}-bit {rate // 1000}kHz")
|
||||||
|
return wav
|
||||||
|
|
||||||
|
|
||||||
def upload(port: str, track_num: int, pcm: bytes) -> None:
|
def upload(port: str, track_num: int, wav: bytes) -> None:
|
||||||
total = len(pcm)
|
total = len(wav)
|
||||||
print(f"Uploading {total} bytes as track {track_num} via {port} ...")
|
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,
|
with serial.Serial(port, BAUD_RATE, timeout=2.0,
|
||||||
dsrdtr=False, rtscts=False) as ser:
|
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)
|
time.sleep(0.5)
|
||||||
ser.reset_input_buffer()
|
ser.reset_input_buffer()
|
||||||
ser.write(b"\n")
|
ser.write(b"\n")
|
||||||
@@ -98,7 +130,7 @@ def upload(port: str, track_num: int, pcm: bytes) -> None:
|
|||||||
ser.write(cmd.encode())
|
ser.write(cmd.encode())
|
||||||
ser.flush()
|
ser.flush()
|
||||||
|
|
||||||
# Wait for READY — print everything received so failures are diagnosable
|
# Wait for READY
|
||||||
deadline = time.time() + TIMEOUT_S
|
deadline = time.time() + TIMEOUT_S
|
||||||
while True:
|
while True:
|
||||||
if time.time() > deadline:
|
if time.time() > deadline:
|
||||||
@@ -109,17 +141,16 @@ def upload(port: str, track_num: int, pcm: bytes) -> None:
|
|||||||
if line:
|
if line:
|
||||||
print(f" firmware: {line}")
|
print(f" firmware: {line}")
|
||||||
|
|
||||||
# Stream PCM in chunks; stop early if firmware sends ERR
|
# Stream WAV data in chunks; poll for ERR
|
||||||
sent = 0
|
sent = 0
|
||||||
err_line = None
|
err_line = None
|
||||||
ser.timeout = 0.05 # short timeout so we can poll for ERR while sending
|
ser.timeout = 0.05
|
||||||
while sent < total:
|
while sent < total:
|
||||||
chunk = pcm[sent:sent + CHUNK_SIZE]
|
chunk = wav[sent:sent + CHUNK_SIZE]
|
||||||
ser.write(chunk)
|
ser.write(chunk)
|
||||||
sent += len(chunk)
|
sent += len(chunk)
|
||||||
pct = sent * 100 // total
|
pct = sent * 100 // total
|
||||||
print(f"\r {sent}/{total} bytes ({pct}%) ", end="", flush=True)
|
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()
|
line = ser.readline().decode(errors="replace").strip()
|
||||||
if line.startswith("ERR"):
|
if line.startswith("ERR"):
|
||||||
err_line = line
|
err_line = line
|
||||||
@@ -129,14 +160,18 @@ def upload(port: str, track_num: int, pcm: bytes) -> None:
|
|||||||
|
|
||||||
if err_line:
|
if err_line:
|
||||||
print(f"ERROR from firmware: {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:
|
if "at=" in err_line:
|
||||||
accepted = int(err_line.split("at=")[1].split()[0])
|
accepted = int(err_line.split("at=")[1].split()[0])
|
||||||
safe = int(accepted * 0.9) # 10% headroom
|
safe = int(accepted * 0.9)
|
||||||
max_s = safe / 16000
|
# estimate audio bytes (subtract header)
|
||||||
|
audio_accepted = max(0, safe - 44)
|
||||||
|
bits = struct.unpack_from('<H', wav, 34)[0] if len(wav) >= 44 else 16
|
||||||
|
rate = struct.unpack_from('<I', wav, 24)[0] if len(wav) >= 44 else 16000
|
||||||
|
max_s = audio_accepted / (bits // 8) / rate
|
||||||
print(f" LittleFS accepted {accepted} bytes before full.")
|
print(f" LittleFS accepted {accepted} bytes before full.")
|
||||||
print(f" Safe clip length: ~{max_s:.1f}s")
|
print(f" Safe clip length: ~{max_s:.1f}s")
|
||||||
print(f" Trim with: ffmpeg -i input.mp3 -t {max_s:.0f} -ar 16000 -ac 1 -f u8 out.raw")
|
print(f" Trim with: ffmpeg -i input.mp3 -t {max_s:.0f} -ar 16000 -ac 1 "
|
||||||
|
f"-acodec pcm_s16le out.wav")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Wait for OK
|
# Wait for OK
|
||||||
@@ -154,7 +189,7 @@ def upload(port: str, track_num: int, pcm: bytes) -> None:
|
|||||||
if line:
|
if line:
|
||||||
print(f" firmware: {line}")
|
print(f" firmware: {line}")
|
||||||
|
|
||||||
# Verify: request hex dump of first 256 bytes and compare
|
# Verify: hex-dump first 256 bytes and check WAV header is intact
|
||||||
print("Verifying...")
|
print("Verifying...")
|
||||||
ser.reset_input_buffer()
|
ser.reset_input_buffer()
|
||||||
ser.write(f"DUMP {track_num}\n".encode())
|
ser.write(f"DUMP {track_num}\n".encode())
|
||||||
@@ -178,7 +213,6 @@ def upload(port: str, track_num: int, pcm: bytes) -> None:
|
|||||||
print("ERROR: firmware says file does not exist!")
|
print("ERROR: firmware says file does not exist!")
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
# Parse hex bytes
|
|
||||||
for tok in line.split():
|
for tok in line.split():
|
||||||
try:
|
try:
|
||||||
fw_bytes.append(int(tok, 16))
|
fw_bytes.append(int(tok, 16))
|
||||||
@@ -194,14 +228,15 @@ def upload(port: str, track_num: int, pcm: bytes) -> None:
|
|||||||
else:
|
else:
|
||||||
print(f" Size OK: {fw_size}/{total}")
|
print(f" Size OK: {fw_size}/{total}")
|
||||||
|
|
||||||
compare_len = min(len(fw_bytes), len(pcm), 256)
|
# Compare first 256 bytes (includes WAV header — confirms format metadata made it)
|
||||||
|
compare_len = min(len(fw_bytes), len(wav), 256)
|
||||||
if compare_len > 0:
|
if compare_len > 0:
|
||||||
mismatches = sum(1 for i in range(compare_len) if fw_bytes[i] != pcm[i])
|
mismatches = sum(1 for i in range(compare_len) if fw_bytes[i] != wav[i])
|
||||||
if mismatches == 0:
|
if mismatches == 0:
|
||||||
print(f" Data OK: first {compare_len} bytes match")
|
print(f" Data OK: first {compare_len} bytes match (WAV header intact)")
|
||||||
else:
|
else:
|
||||||
print(f" ERROR: {mismatches}/{compare_len} byte mismatches in first {compare_len} bytes")
|
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" Expected: {' '.join(f'{b:02X}' for b in wav[:16])}")
|
||||||
print(f" Got: {' '.join(f'{b:02X}' for b in fw_bytes[:16])}")
|
print(f" Got: {' '.join(f'{b:02X}' for b in fw_bytes[:16])}")
|
||||||
|
|
||||||
|
|
||||||
@@ -217,11 +252,11 @@ def main():
|
|||||||
if not 0 <= track_num < 32:
|
if not 0 <= track_num < 32:
|
||||||
sys.exit("track_num must be 0–31")
|
sys.exit("track_num must be 0–31")
|
||||||
|
|
||||||
pcm = convert_to_pcm(path)
|
wav = load_wav(path)
|
||||||
if not pcm:
|
if not wav:
|
||||||
sys.exit("File is empty after conversion")
|
sys.exit("File is empty after conversion")
|
||||||
|
|
||||||
upload(port, track_num, pcm)
|
upload(port, track_num, wav)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user