220 lines
6.6 KiB
TypeScript
220 lines
6.6 KiB
TypeScript
/**
|
||
* Ti-Pote — Play a PCM/WAV file on the ESP32 speaker over USB.
|
||
*
|
||
* Usage:
|
||
* pnpm esp:play <file.wav|file.raw>
|
||
*
|
||
* 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<void> {
|
||
const inPath = process.argv[2];
|
||
if (!inPath) {
|
||
console.error('Usage: esp-play.ts <file> (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<void>((resolve, reject) => {
|
||
port.open((err) => (err ? reject(err) : resolve()));
|
||
});
|
||
|
||
let ready = false;
|
||
const readyWaiters: Array<() => void> = [];
|
||
|
||
const finished = new Promise<void>((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 === 'READY') {
|
||
ready = true;
|
||
while (readyWaiters.length) readyWaiters.shift()!();
|
||
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);
|
||
});
|
||
|
||
// Wait for READY so we don't send PLAY into the bootloader.
|
||
await new Promise<void>((resolve, reject) => {
|
||
if (ready) return resolve();
|
||
const timer = setTimeout(
|
||
() => reject(new Error('timeout waiting for READY from firmware')),
|
||
5000,
|
||
);
|
||
readyWaiters.push(() => {
|
||
clearTimeout(timer);
|
||
resolve();
|
||
});
|
||
});
|
||
await new Promise((r) => setTimeout(r, 50));
|
||
|
||
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<void>((resolve, reject) => {
|
||
port.write(slice, (err) => (err ? reject(err) : resolve()));
|
||
});
|
||
await new Promise<void>((resolve) => port.drain(() => resolve()));
|
||
if (off + CHUNK < pcm.length) {
|
||
await new Promise((r) => setTimeout(r, PAUSE_MS));
|
||
}
|
||
}
|
||
|
||
await finished;
|
||
await new Promise<void>((resolve) => port.close(() => resolve()));
|
||
cleanup();
|
||
console.log('✅ playback done');
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error(err);
|
||
process.exit(1);
|
||
});
|