2026-04-09 02:58:29 +02:00

230 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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()));
});
const pongWaiters: 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 === '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<void>((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<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);
});