/** * Ti-Pote — Play a PCM/WAV file on the ESP32 speaker over USB. * * Usage: * pnpm esp:play * * Accepts either: * - raw S16 LE mono 16 kHz PCM * - WAV file with a 44-byte RIFF header (header is stripped) * * Default port: auto-detected, override with ESP_PORT=/dev/cu.usbserial-XXX */ import { execFileSync } from 'node:child_process'; import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, extname } from 'node:path'; import { SerialPort } from 'serialport'; const SAMPLE_RATE = 16000; function findDefaultPort(): string { const envPort = process.env.ESP_PORT; if (envPort) return envPort; const candidates = readdirSync('/dev').filter( (f) => f.startsWith('cu.usbserial') || f.startsWith('cu.SLAB_') || f.startsWith('cu.wchusbserial'), ); if (candidates.length === 0) { throw new Error( 'No ESP32 serial port detected. Plug the board in, or set ESP_PORT=/dev/cu.usbserial-XXX', ); } return `/dev/${candidates[0]}`; } function stripWav(buf: Buffer): Buffer { if ( buf.length > 44 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WAVE' ) { return buf.subarray(44); } return buf; } /** * Convert any audio file macOS can decode (m4a, mp3, ogg, aiff, …) to * S16 LE mono 16 kHz WAV using the built-in `afconvert` tool. Returns * the path to a new .wav file in a temp dir which the caller is * responsible for cleaning up. */ function convertToEsp32Wav(inputPath: string): { wavPath: string; cleanup: () => void } { const dir = mkdtempSync(join(tmpdir(), 'tipote-')); const wavPath = join(dir, 'converted.wav'); console.log(`→ converting ${inputPath} → 16 kHz mono S16LE WAV`); try { execFileSync( 'afconvert', [ '-f', 'WAVE', '-d', 'LEI16@16000', '-c', '1', inputPath, wavPath, ], { stdio: 'inherit' }, ); } catch (err) { rmSync(dir, { recursive: true, force: true }); throw new Error(`afconvert failed: ${(err as Error).message}`); } return { wavPath, cleanup: () => rmSync(dir, { recursive: true, force: true }), }; } async function main(): Promise { const inPath = process.argv[2]; if (!inPath) { console.error('Usage: esp-play.ts (wav, raw, m4a, mp3, …)'); process.exit(1); } if (!existsSync(inPath)) { throw new Error(`file not found: ${inPath}`); } // Convert anything that isn't already a .wav or raw PCM blob. This // covers m4a / mp3 / ogg / aiff / opus / flac via the built-in // macOS `afconvert` tool. const ext = extname(inPath).toLowerCase(); const needsConversion = ext !== '.wav' && ext !== '.raw' && ext !== '.pcm'; let cleanup: () => void = () => {}; let loadPath = inPath; if (needsConversion) { const converted = convertToEsp32Wav(inPath); loadPath = converted.wavPath; cleanup = converted.cleanup; } const raw = readFileSync(loadPath); const pcm = stripWav(raw); const samples = pcm.length / 2; const durationMs = (samples / SAMPLE_RATE) * 1000; console.log( `→ loaded ${loadPath}: ${pcm.length} bytes (${samples} samples, ${durationMs.toFixed(0)} ms)`, ); if (pcm.length === 0) { cleanup(); throw new Error('empty PCM buffer'); } if (pcm.length % 2 !== 0) { cleanup(); throw new Error( 'PCM size must be a multiple of 2 (S16 mono). The source file is probably not 16-bit or not mono. If you passed a raw file, convert it first.', ); } const path = findDefaultPort(); console.log(`→ opening ${path} @ 921600 baud`); const port = new SerialPort({ path, baudRate: 921600, autoOpen: false }); await new Promise((resolve, reject) => { port.open((err) => (err ? reject(err) : resolve())); }); const pongWaiters: Array<() => void> = []; const finished = new Promise((resolve, reject) => { const timeout = setTimeout( () => reject(new Error(`timeout waiting for OK after ${durationMs + 8000} ms`)), durationMs + 8000, ); let lineBuf = ''; port.on('data', (data: Buffer) => { lineBuf += data.toString('utf8'); let idx: number; while ((idx = lineBuf.indexOf('\n')) >= 0) { const line = lineBuf.slice(0, idx).replace(/\r$/, '').trim(); lineBuf = lineBuf.slice(idx + 1); if (!line) continue; if (line === 'OK') { clearTimeout(timeout); resolve(); return; } if (line === 'PONG') { while (pongWaiters.length) pongWaiters.shift()!(); continue; } if (line === 'READY') { // Firmware just booted (USB DTR reset). Ignore — the // PING/PONG handshake is our real readiness signal. continue; } if (line.startsWith('ERR ')) { clearTimeout(timeout); reject(new Error(`firmware error: ${line.slice(4)}`)); return; } if (line.startsWith('LOG ')) console.log(`[esp] ${line.slice(4)}`); else console.log(`[esp] ${line}`); } }); port.on('error', reject); }); // PING/PONG handshake — validates the link in both directions // regardless of whether the firmware was recently rebooted or not. console.log('→ PING'); port.write('PING\n'); await new Promise((resolve, reject) => { const timer = setTimeout( () => reject( new Error( 'no PONG from firmware — check TX/RX wiring (cross!), baud rate, and that the ESP32 is powered', ), ), 3000, ); pongWaiters.push(() => { clearTimeout(timer); resolve(); }); }); console.log('→ PONG received'); console.log(`→ PLAY ${pcm.length} bytes`); port.write(`PLAY ${pcm.length}\n`); // Stream the payload paced EXACTLY at the I2S consumption rate so // the ESP32 RX buffer stays roughly constant in size regardless of // file length. I2S consumes 16 kHz × 2 bytes/sample = 32 KB/s of // S16 mono. A 1024-byte burst is 32 ms of audio → sleeping 32 ms // between bursts matches playback exactly. // // We still pad lightly above 32 KB/s (30 ms instead of 32) so the // DMA never runs dry. The excess fills the ~16 KB RX buffer on the // firmware slowly; even for a 10 s file we stay well under it. const CHUNK = 1024; const PAUSE_MS = 30; for (let off = 0; off < pcm.length; off += CHUNK) { const slice = pcm.subarray(off, off + CHUNK); await new Promise((resolve, reject) => { port.write(slice, (err) => (err ? reject(err) : resolve())); }); await new Promise((resolve) => port.drain(() => resolve())); if (off + CHUNK < pcm.length) { await new Promise((r) => setTimeout(r, PAUSE_MS)); } } await finished; await new Promise((resolve) => port.close(() => resolve())); cleanup(); console.log('✅ playback done'); } main().catch((err) => { console.error(err); process.exit(1); });