2026-04-09 02:47:53 +02:00

220 lines
6.6 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()));
});
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);
});