/**
 * Audio I/O via ffmpeg subprocess.
 *
 * We deliberately don't depend on native node modules (`speaker`, `mic`, etc.)
 * because they need rebuilding per Node version and are flaky across Windows /
 * macOS / Linux. ffmpeg is a single statically-linked binary the user can
 * install once and forget.
 *
 * All exports degrade to a no-op (or null) when ffmpeg is missing — callers
 * should treat ffmpeg-absence as "voice features off", not as an error.
 *
 * Five named audio cues:
 *   - 'ready'           short rising chirp (REPL ready for input)
 *   - 'recording-start' two-tone tick (mic opened)
 *   - 'recording-stop'  single low tick (mic closed)
 *   - 'processing'      sustained mid tone (TTS/STT in flight)
 *   - 'done'            short falling chirp (operation succeeded)
 *   - 'error'           low buzz (operation failed)
 *
 * Cues are generated on-the-fly by ffmpeg's `sine` filter so we don't have to
 * ship binary asset files in the npm tarball.
 */
import { spawn } from 'node:child_process';
import { Readable } from 'node:stream';
// ── Detection ─────────────────────────────────────────────
let ffmpegCheckCache = null;
let ffmpegPathCache = 'ffmpeg';
/**
 * Detect ffmpeg on the user's PATH. Result is cached; call resetFfmpegCache()
 * if you want to re-probe (e.g. after the user installs ffmpeg mid-session).
 */
export async function isFfmpegAvailable() {
    if (ffmpegCheckCache !== null)
        return ffmpegCheckCache;
    ffmpegCheckCache = await new Promise((resolve) => {
        const child = spawn(ffmpegPathCache, ['-version'], { stdio: 'ignore' });
        child.on('error', () => resolve(false));
        child.on('close', (code) => resolve(code === 0));
    });
    return ffmpegCheckCache;
}
export function resetFfmpegCache() {
    ffmpegCheckCache = null;
}
export function setFfmpegPath(p) {
    ffmpegPathCache = p;
    ffmpegCheckCache = null;
}
// ── Platform-specific mic input args ──────────────────────
/**
 * Build the `-f <driver> -i <device>` arguments for capturing from the
 * default mic on each OS.
 *
 *   Windows : -f dshow -i audio="<first input device>"
 *             We can't enumerate without listing — but ffmpeg's "default"
 *             alias works on most systems via dshow. If it doesn't, the user
 *             can override via env var GRAWKUS_AUDIO_INPUT.
 *   macOS   : -f avfoundation -i ":0"         (":0" = default audio input)
 *   Linux   : -f pulse -i default              (PulseAudio default sink)
 *
 * Env override: GRAWKUS_AUDIO_INPUT="-f dshow -i audio=\"My Mic\""
 *               (raw argv, split by spaces — anything in quotes preserved)
 */
function getMicInputArgs() {
    const override = process.env.GRAWKUS_AUDIO_INPUT;
    if (override) {
        // Quote-aware split so `audio="My Mic"` survives as one token
        return tokenize(override);
    }
    switch (process.platform) {
        case 'win32':
            return ['-f', 'dshow', '-i', 'audio=default'];
        case 'darwin':
            return ['-f', 'avfoundation', '-i', ':0'];
        default:
            return ['-f', 'pulse', '-i', 'default'];
    }
}
function tokenize(s) {
    const out = [];
    let buf = '';
    let inQuote = false;
    for (const ch of s) {
        if (ch === '"') {
            inQuote = !inQuote;
            continue;
        }
        if (ch === ' ' && !inQuote) {
            if (buf) {
                out.push(buf);
                buf = '';
            }
            continue;
        }
        buf += ch;
    }
    if (buf)
        out.push(buf);
    return out;
}
let micProbeCache = null;
const MIC_PROBE_TTL_MS = 10_000;
export async function probeMic(force = false) {
    if (!force && micProbeCache && (Date.now() - micProbeCache.at) < MIC_PROBE_TTL_MS) {
        return micProbeCache.result;
    }
    if (!(await isFfmpegAvailable())) {
        micProbeCache = { result: 'no-ffmpeg', at: Date.now() };
        return 'no-ffmpeg';
    }
    const result = await new Promise((resolve) => {
        const args = [
            ...getMicInputArgs(),
            '-t', '0.2', // 200ms is enough to confirm the device opens
            '-ac', '1',
            '-ar', '16000',
            '-f', 'wav',
            '-loglevel', 'error',
            'pipe:1',
        ];
        let child;
        try {
            child = spawn(ffmpegPathCache, args, { stdio: ['ignore', 'pipe', 'pipe'] });
        }
        catch {
            resolve('error');
            return;
        }
        let gotBytes = false;
        let errBuf = '';
        child.stdout.on('data', (c) => { if (c.length > 0)
            gotBytes = true; });
        child.stderr.on('data', (c) => { errBuf += c.toString(); });
        child.on('error', () => resolve('error'));
        // Safety timeout — some configurations hang opening the device
        const killer = setTimeout(() => { try {
            child.kill('SIGTERM');
        }
        catch { /* noop */ } }, 1500);
        child.on('close', (code) => {
            clearTimeout(killer);
            if (gotBytes) {
                resolve('ok');
                return;
            }
            // ffmpeg's "no such device" / "could not find audio only device" /
            // "I/O error" / "No such file or directory" all indicate the mic
            // isn't reachable. Match loosely — different platforms phrase it
            // differently.
            const lower = errBuf.toLowerCase();
            if (lower.includes('could not') ||
                lower.includes('no such') ||
                lower.includes('not found') ||
                lower.includes('cannot open') ||
                lower.includes('device or resource busy') ||
                lower.includes('input/output error') ||
                code !== 0) {
                resolve('no-mic');
            }
            else {
                resolve('error');
            }
        });
    });
    micProbeCache = { result, at: Date.now() };
    return result;
}
/**
 * Friendly one-line message explaining a probe result. Caller logs +
 * announces via TTS. Returned phrasing is screen-reader-friendly.
 */
export function micProbeMessage(r) {
    switch (r) {
        case 'ok': return 'Microphone ready.';
        case 'no-mic': return 'No microphone detected. Plug one in or set GRAWKUS_AUDIO_INPUT to a specific device.';
        case 'no-ffmpeg': return 'ffmpeg is not installed. Install ffmpeg to enable dictation.';
        case 'error': return 'Microphone probe failed.';
    }
}
// ── Recording ─────────────────────────────────────────────
/**
 * Record from the default mic for up to `maxSeconds`, return WAV-encoded
 * bytes. ffmpeg encodes to 16kHz mono PCM-16 (Whisper's preferred format).
 *
 * Returns null on failure — including ffmpeg missing or mic unavailable.
 * Caller usually wraps with `dictateOnce()` which handles the UX.
 */
export async function recordAudio(maxSeconds) {
    if (!(await isFfmpegAvailable()))
        return null;
    return new Promise((resolve) => {
        const args = [
            ...getMicInputArgs(),
            '-t', String(Math.max(1, Math.min(300, maxSeconds))),
            '-ac', '1', // mono
            '-ar', '16000', // 16 kHz (Whisper)
            '-f', 'wav',
            '-loglevel', 'error',
            'pipe:1', // write WAV to stdout
        ];
        const child = spawn(ffmpegPathCache, args, { stdio: ['ignore', 'pipe', 'pipe'] });
        const chunks = [];
        let errBuf = '';
        child.stdout.on('data', (c) => chunks.push(c));
        child.stderr.on('data', (c) => { errBuf += c.toString(); });
        child.on('error', () => resolve(null));
        child.on('close', (code) => {
            if (code !== 0 && chunks.length === 0) {
                if (process.env.GRAWKUS_AUDIO_DEBUG) {
                    // eslint-disable-next-line no-console
                    console.error('[audio] record failed:', errBuf.slice(0, 400));
                }
                resolve(null);
                return;
            }
            resolve(Buffer.concat(chunks));
        });
    });
}
// ── Playback ──────────────────────────────────────────────
/**
 * Pipe an audio buffer (MP3 from ElevenLabs, or WAV from us) through ffplay
 * if available, falling back to ffmpeg with the platform's default audio
 * output. Honors an AbortSignal so a Ctrl+C or new-message arrival can cut
 * playback immediately.
 *
 * Returns true if playback completed without error, false otherwise.
 */
export async function playAudioBuffer(buf, signal) {
    if (!(await isFfmpegAvailable()))
        return false;
    if (!buf || buf.length === 0)
        return false;
    if (signal?.aborted)
        return false;
    // Try ffplay first (smaller, dedicated to playback). If it's not on PATH
    // we'll fall back to `ffmpeg -f <platform-audio-out>`.
    const ffplayOk = await tryFfplay(buf, signal);
    if (ffplayOk)
        return true;
    return await tryFfmpegPlay(buf, signal);
}
async function tryFfplay(buf, signal) {
    return new Promise((resolve) => {
        const args = ['-nodisp', '-autoexit', '-loglevel', 'error', '-i', 'pipe:0'];
        let child;
        try {
            child = spawn('ffplay', args, { stdio: ['pipe', 'ignore', 'pipe'] });
        }
        catch {
            resolve(false);
            return;
        }
        let resolved = false;
        const onAbort = () => {
            if (!resolved) {
                resolved = true;
                try {
                    child.kill('SIGTERM');
                }
                catch { /* noop */ }
                resolve(false);
            }
        };
        if (signal)
            signal.addEventListener('abort', onAbort, { once: true });
        child.on('error', () => { if (!resolved) {
            resolved = true;
            resolve(false);
        } });
        child.on('close', (code) => {
            if (signal)
                signal.removeEventListener('abort', onAbort);
            if (!resolved) {
                resolved = true;
                resolve(code === 0);
            }
        });
        // Stream the buffer into stdin
        const stream = Readable.from([buf]);
        stream.pipe(child.stdin).on('error', () => { });
    });
}
function getPlayOutputArgs() {
    switch (process.platform) {
        case 'win32':
            // DirectSound is the most universally-available output on Windows
            return ['-f', 'dshow']; // Unfortunately dshow is input-only; use sdl2 if linked
        case 'darwin':
            return ['-f', 'audiotoolbox'];
        default:
            return ['-f', 'pulse'];
    }
}
async function tryFfmpegPlay(buf, signal) {
    return new Promise((resolve) => {
        // ffmpeg-as-player: decode from stdin and route to platform audio out.
        // This is a fallback, ffplay is the preferred path. We try sdl2 first
        // (works on all platforms if ffmpeg was built with --enable-sdl2), then
        // a platform-specific output module.
        const args = [
            '-loglevel', 'error',
            '-i', 'pipe:0',
            '-f', 'sdl', '-',
        ];
        let child;
        try {
            child = spawn(ffmpegPathCache, args, { stdio: ['pipe', 'ignore', 'ignore'] });
        }
        catch {
            resolve(false);
            return;
        }
        let resolved = false;
        const onAbort = () => {
            if (!resolved) {
                resolved = true;
                try {
                    child.kill('SIGTERM');
                }
                catch { /* noop */ }
                resolve(false);
            }
        };
        if (signal)
            signal.addEventListener('abort', onAbort, { once: true });
        child.on('error', () => { if (!resolved) {
            resolved = true;
            resolve(false);
        } });
        child.on('close', (code) => {
            if (signal)
                signal.removeEventListener('abort', onAbort);
            if (!resolved) {
                resolved = true;
                resolve(code === 0);
            }
        });
        const stream = Readable.from([buf]);
        stream.pipe(child.stdin).on('error', () => { });
    });
}
const CUE_TONES = {
    'ready': { freqHz: 880, durationSec: 0.08, follow: { freqHz: 1320, durationSec: 0.08 } },
    'recording-start': { freqHz: 660, durationSec: 0.07, follow: { freqHz: 880, durationSec: 0.07 } },
    'recording-stop': { freqHz: 440, durationSec: 0.10 },
    'processing': { freqHz: 660, durationSec: 0.18 },
    'done': { freqHz: 1320, durationSec: 0.08, follow: { freqHz: 880, durationSec: 0.08 } },
    'error': { freqHz: 220, durationSec: 0.22 },
};
/**
 * Play a named audio cue. Best-effort; returns true on apparent success.
 * Failures are silent — a missing beep should never break the REPL.
 */
export async function audioCue(name) {
    if (!(await isFfmpegAvailable()))
        return false;
    const spec = CUE_TONES[name];
    if (!spec)
        return false;
    const buf = await generateTone(spec);
    if (!buf)
        return false;
    return await playAudioBuffer(buf);
}
async function generateTone(spec) {
    // We compose a single ffmpeg pipeline using anullsrc + sine + concat. Easier:
    // synthesize each tone to a WAV stream, concatenate buffers.
    const part1 = await synthSine(spec.freqHz, spec.durationSec);
    if (!part1)
        return null;
    if (!spec.follow)
        return part1;
    const part2 = await synthSine(spec.follow.freqHz, spec.follow.durationSec);
    if (!part2)
        return part1;
    // Simple concat: both WAV bodies; ffplay handles back-to-back WAV headers
    // OK in practice, but to be safe we just emit two short tones as separate
    // playback calls if needed. Here we just concatenate the raw buffers; ffplay
    // ignores the second header.
    return Buffer.concat([part1, part2]);
}
function synthSine(freq, durationSec) {
    return new Promise((resolve) => {
        const args = [
            '-f', 'lavfi',
            '-i', `sine=frequency=${freq}:duration=${durationSec}`,
            '-ac', '1',
            '-ar', '22050',
            '-f', 'wav',
            '-loglevel', 'error',
            'pipe:1',
        ];
        const child = spawn(ffmpegPathCache, args, { stdio: ['ignore', 'pipe', 'ignore'] });
        const chunks = [];
        child.stdout.on('data', (c) => chunks.push(c));
        child.on('error', () => resolve(null));
        child.on('close', (code) => resolve(code === 0 ? Buffer.concat(chunks) : null));
    });
}
export async function startRecording(maxSeconds = 60) {
    if (!(await isFfmpegAvailable()))
        return null;
    const args = [
        ...getMicInputArgs(),
        '-t', String(Math.max(1, Math.min(300, maxSeconds))),
        '-ac', '1',
        '-ar', '16000',
        '-f', 'wav',
        '-loglevel', 'error',
        'pipe:1',
    ];
    const child = spawn(ffmpegPathCache, args, { stdio: ['pipe', 'pipe', 'pipe'] });
    const chunks = [];
    child.stdout.on('data', (c) => chunks.push(c));
    // Drain stderr to avoid back-pressure
    child.stderr.on('data', () => { });
    let resolved = false;
    let finalResolve = null;
    const donePromise = new Promise((resolve) => { finalResolve = resolve; });
    child.on('close', () => {
        if (!resolved && finalResolve) {
            resolved = true;
            finalResolve(chunks.length > 0 ? Buffer.concat(chunks) : null);
        }
    });
    child.on('error', () => {
        if (!resolved && finalResolve) {
            resolved = true;
            finalResolve(null);
        }
    });
    return {
        stop: async () => {
            // ffmpeg flushes a clean WAV when it gets 'q' on stdin
            try {
                child.stdin.write('q');
            }
            catch { /* noop */ }
            try {
                child.stdin.end();
            }
            catch { /* noop */ }
            // Safety: if it doesn't exit within 1500ms, SIGTERM it
            const killer = setTimeout(() => { try {
                child.kill('SIGTERM');
            }
            catch { /* noop */ } }, 1500);
            const buf = await donePromise;
            clearTimeout(killer);
            return buf;
        },
        abort: () => {
            try {
                child.kill('SIGTERM');
            }
            catch { /* noop */ }
        },
    };
}
//# sourceMappingURL=audio.js.map