Dynamic WAV format support: 8/16-bit, variable sample rate, BLE list/delete, FORMAT command

This commit is contained in:
zyphlar
2026-07-04 03:28:14 -07:00
parent 7b1dc8e118
commit e6a2a65b82
2 changed files with 261 additions and 124 deletions
+171 -69
View File
@@ -13,8 +13,8 @@
* - Deep sleep with button wake (~10µA)
* - Auto-shutoff timer
*
* Audio format: unsigned 8-bit PCM, 8000 Hz, mono
* Convert: ffmpeg -i song.mp3 -ar 8000 -ac 1 -f u8 -acodec pcm_u8 song.raw
* 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")
@@ -50,8 +50,9 @@
// ============================================================
// 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.
// 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
@@ -77,14 +78,14 @@ using namespace Adafruit_LittleFS_Namespace;
#define PIN_MOTOR_PWM 15 // PWM to MOSFET gate
// SPI Flash
#define PIN_FLASH_CS 2 // Flash chip select
// SPI MOSI/MISO/SCK use default SPI pins
#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 4
#define PIN_BTN1 1
#define PIN_BTN2 1
#define PIN_BTN3 5
#define PIN_BTN4 3
#define PIN_BTN3 1
#define PIN_BTN4 1
/*
#define PIN_BTN5 4
#define PIN_BTN6 5
@@ -189,7 +190,6 @@ 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
// ============================================================
@@ -202,7 +202,10 @@ uint8_t g_motorSpeed = 160;
uint8_t g_numTracks = 0;
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;
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
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
@@ -347,10 +350,39 @@ void loadTrackTable() {
pocFilename(i, fname);
File f(InternalFS);
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();
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);
@@ -495,10 +527,13 @@ void ble_disconnect_cb(uint16_t conn_handle, uint8_t reason) {
// BLE command characteristic: receives commands
// 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 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;
@@ -535,7 +570,6 @@ void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr,
break;
}
g_bleUploading = true;
g_pocSkipHeader = true;
g_bleWriteLen = 0;
Serial.print("BLE: Start write track ");
Serial.println(g_pocWriteTrack);
@@ -555,7 +589,6 @@ void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr,
case 0x03: // Finish upload
#ifdef POC_INTERNAL_FLASH
if (g_pocFile) g_pocFile.close();
g_pocSkipHeader = false;
#endif
g_bleUploading = false;
loadTrackTable();
@@ -572,6 +605,57 @@ void audioCmd_write_cb(uint16_t conn_handle, BLECharacteristic* chr,
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).
#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();
#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;
g_pocFile.write(data, len);
g_bleWriteLen += len;
#else
// Erase new sectors as we cross boundaries
uint32_t endAddr = g_bleWriteAddr + len;
@@ -697,32 +764,63 @@ void setupBLE() {
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) {
uint8_t pcm[AUDIO_BUF_SIZE];
uint32_t toRead = 0;
bool is16 = (g_trackBits[g_currentTrack] == 16);
uint32_t bytesPerSample = is16 ? 2 : 1;
uint32_t toReadBytes = 0;
if (g_nextReadAddr < g_playEnd) {
toRead = min((uint32_t)AUDIO_BUF_SIZE, g_playEnd - g_nextReadAddr);
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(pcm, toRead);
g_pocFile.read(raw, toReadBytes);
#else
flashReadBytes(g_nextReadAddr, pcm, toRead);
flashReadBytes(g_nextReadAddr, raw, toReadBytes);
#endif
g_nextReadAddr += toRead;
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 = 0; i < toRead; 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 = toRead; i < AUDIO_BUF_SIZE; i++) {
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 (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)
}
}
@@ -796,13 +894,25 @@ void audioStart(uint8_t trackNum) {
Serial.print("audioStart: cannot open "); Serial.println(fname);
return;
}
g_nextReadAddr = 0;
g_pocFile.seek(g_trackDataOff[trackNum]);
g_nextReadAddr = g_trackDataOff[trackNum];
g_playEnd = g_trackLen[trackNum];
#else
g_nextReadAddr = g_trackStart[trackNum];
g_playEnd = g_nextReadAddr + g_trackLen[trackNum];
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;
@@ -1044,7 +1154,6 @@ 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() {
@@ -1062,7 +1171,6 @@ static void serUploadTick() {
g_serTrack = (uint8_t)utrk;
g_serBytesExpected = (uint32_t)ulen;
g_serBytesReceived = 0;
g_serSkipHeader = true;
char fname[16];
pocFilename(g_serTrack, fname);
@@ -1127,6 +1235,12 @@ static void serUploadTick() {
Serial.print("ERR no file "); Serial.println(fname);
}
loadTrackTable();
} else if (strcmp(g_serLineBuf, "FORMAT") == 0) {
audioStop();
Serial.println("Formatting LittleFS...");
InternalFS.format();
g_numTracks = 0;
Serial.println("FORMAT OK");
} else {
Serial.print("ERR bad cmd: ");
Serial.println(g_serLineBuf);
@@ -1149,20 +1263,8 @@ static void serUploadTick() {
(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) {
int32_t wr = g_serFile.write(chunk, (uint16_t)n);
if (wr != n) {
// Write failed (LittleFS full or error) — abort immediately.
// Do NOT loop printing errors; that fills USB CDC TX and hangs.
g_serFile.close();