// 英语实时 sherpa Online Recognizer 适配器。运行时的 Emscripten 装载由独立
// SherpaRuntime 提供；此处只负责 100ms 有界喂音频、endpoint 和状态边界。
import { AudioRingBuffer } from "./audio-ring-buffer.js";
import { emptyAsrMetrics } from "./asr-metrics.js";
import { PartialStabilizer } from "./partial-stabilizer.js";
import { AdaptiveSpeechGate } from "./adaptive-speech-gate.js";
const SAMPLE_RATE = 16_000;
const FRAME_SAMPLES = 1_600;
const RING_SAMPLES = SAMPLE_RATE * 4;
const PARTIAL_INTERVAL_MS = 200;
const GATE_FRAME_SAMPLES = 320;
const PRE_ROLL_SAMPLES = SAMPLE_RATE / 4;
const MAX_DECODE_STEPS_PER_FRAME = 64;
export class SherpaStreamingEngine {
    createRecognizer;
    now;
    emit;
    backend = "sherpa-wasm";
    mode = "streaming";
    recognizer;
    stream;
    config;
    ring = new AudioRingBuffer(RING_SAMPLES);
    gatePending = new Int16Array(0);
    pending = new Int16Array(0);
    segmentStartMs = 0;
    gateActive = false;
    lastPartial = "";
    lastPartialAt = -Infinity;
    paused = false;
    metric = emptyAsrMetrics();
    stabilizer;
    lastAudioEndMs = 0;
    speechGate = new AdaptiveSpeechGate();
    constructor(createRecognizer, now = () => Date.now(), emit = () => undefined) {
        this.createRecognizer = createRecognizer;
        this.now = now;
        this.emit = emit;
        this.stabilizer = new PartialStabilizer({ now: this.now });
    }
    async init(config) {
        if (config.sourceLanguage === "auto")
            throw new Error("language_unsupported");
        this.dispose();
        this.metric = emptyAsrMetrics();
        this.stabilizer.reset();
        this.lastAudioEndMs = 0;
        this.lastPartial = "";
        this.lastPartialAt = -Infinity;
        this.speechGate.reset();
        this.segmentStartMs = 0;
        this.config = config;
        this.recognizer = await this.createRecognizer();
        this.stream = this.recognizer.createStream();
        this.paused = false;
        this.emit({ type: "diagnostic", line: "sherpa streaming initialized sampleRate=16000 frameMs=100" });
    }
    pushAudio(pcm16, startMs) {
        if (!this.stream || !this.recognizer || !this.config || this.paused || pcm16.length === 0)
            return;
        this.metric.audioReceivedMs += (pcm16.length / SAMPLE_RATE) * 1000;
        this.lastAudioEndMs = startMs + (pcm16.length / SAMPLE_RATE) * 1000;
        this.ring.push(pcm16);
        this.gatePending = append(this.gatePending, pcm16);
        let consumed = 0;
        while (this.gatePending.length >= GATE_FRAME_SAMPLES) {
            const frame = this.gatePending.subarray(0, GATE_FRAME_SAMPLES);
            this.gatePending = this.gatePending.slice(GATE_FRAME_SAMPLES);
            consumed += GATE_FRAME_SAMPLES;
            const endMs = startMs + (consumed / SAMPLE_RATE) * 1000;
            if (!this.gateActive) {
                if (!this.speechGate.observe(frame, SAMPLE_RATE))
                    continue;
                // The ring already contains this onset frame. Replaying exactly the latest
                // 250 ms restores initial consonants without feeding the onset twice.
                this.gateActive = true;
                const preRoll = this.ring.latest(PRE_ROLL_SAMPLES);
                this.segmentStartMs = endMs - (preRoll.length / SAMPLE_RATE) * 1000;
                this.feed(preRoll, endMs);
                continue;
            }
            this.feed(frame, endMs);
        }
        // VAD-gated silence is intentionally not sent to sherpa, but it has still
        // been processed by the gate.  Exclude only the tiny frame tails waiting
        // for a 100ms boundary from audio lag; otherwise a quiet/paused video would
        // look like an ever-growing inference backlog and trigger overload resets.
        const bufferedSamples = this.gatePending.length + this.pending.length;
        this.metric.audioProcessedMs = Math.max(this.metric.audioProcessedMs, this.metric.audioReceivedMs - (bufferedSamples / SAMPLE_RATE) * 1000);
    }
    flush(endMs = this.now()) {
        if (!this.stream || !this.recognizer)
            return;
        if (this.gatePending.length && this.gateActive) {
            const frame = new Int16Array(FRAME_SAMPLES);
            frame.set(this.gatePending);
            this.gatePending = new Int16Array(0);
            this.feed(frame, endMs);
        }
        if (this.pending.length && this.gateActive) {
            const frame = new Int16Array(FRAME_SAMPLES);
            frame.set(this.pending);
            this.pending = new Int16Array(0);
            this.feed(frame, endMs);
        }
        this.stream.inputFinished();
        this.decodeReady();
        this.publishStable(endMs);
        this.recognizer.reset(this.stream);
    }
    reset(reason) {
        if (!this.stream || !this.recognizer)
            return;
        const replay = reason === "overload" ? this.ring.latest(PRE_ROLL_SAMPLES) : new Int16Array(0);
        const replayMs = (replay.length / SAMPLE_RATE) * 1000;
        this.gatePending = replay;
        this.pending = new Int16Array(0);
        this.gateActive = false;
        this.speechGate.reset();
        this.lastPartial = "";
        this.segmentStartMs = Math.max(0, this.lastAudioEndMs - replayMs);
        this.stabilizer.reset();
        this.recognizer.reset(this.stream);
        this.metric.resetCount += 1;
        // A reset intentionally discards stale work.  Keep only the most recent
        // pre-roll in the lag accounting so recovery starts near the live edge.
        this.metric.audioProcessedMs = Math.max(0, this.metric.audioReceivedMs - replayMs);
        this.emit({ type: "diagnostic", line: `sherpa streaming reset reason=${reason}` });
    }
    setPaused(paused) { this.paused = paused; if (paused)
        this.reset("pause"); }
    idlePause() { this.setPaused(true); }
    resumeFromIdle() { this.paused = false; }
    recordDroppedAudio(durationMs) { if (Number.isFinite(durationMs) && durationMs > 0)
        this.metric.droppedAudioMs += durationMs; }
    metrics() {
        const processed = this.metric.audioProcessedMs;
        return { ...this.metric, rtf: processed > 0 ? this.metric.inferenceMs / processed : 0, audioLagMs: Math.max(0, this.metric.audioReceivedMs - processed) };
    }
    dispose() { this.stream?.free(); this.recognizer?.free(); this.stream = undefined; this.recognizer = undefined; this.config = undefined; this.gatePending = new Int16Array(0); this.pending = new Int16Array(0); this.gateActive = false; this.speechGate.reset(); this.lastPartial = ""; this.lastPartialAt = -Infinity; this.segmentStartMs = 0; this.lastAudioEndMs = 0; this.stabilizer.reset(); this.ring.clear(); }
    feed(samples, endMs) {
        this.pending = append(this.pending, samples);
        while (this.pending.length >= FRAME_SAMPLES) {
            const frame = this.pending.subarray(0, FRAME_SAMPLES);
            this.pending = this.pending.slice(FRAME_SAMPLES);
            const float = new Float32Array(frame.length);
            for (let i = 0; i < frame.length; i++)
                float[i] = frame[i] / 0x7fff;
            const started = this.now();
            this.stream?.acceptWaveform(SAMPLE_RATE, float);
            this.decodeReady();
            this.metric.inferenceMs += Math.max(0, this.now() - started);
            // Pre-roll may replay audio that was already received (and possibly
            // counted as VAD-processed).  Never let replay make processed time
            // exceed the live received timeline.
            this.metric.audioProcessedMs = Math.min(this.metric.audioReceivedMs, this.metric.audioProcessedMs + 100);
            this.publishResult(endMs - (this.pending.length / SAMPLE_RATE) * 1000);
        }
    }
    publishResult(endMs) {
        if (!this.stream || !this.recognizer)
            return;
        const text = this.recognizer.getResult(this.stream).text?.trim() ?? "";
        if (this.recognizer.isEndpoint(this.stream)) {
            this.publishStable(endMs, text);
            this.recognizer.reset(this.stream);
            this.gateActive = false;
            this.pending = new Int16Array(0);
            return;
        }
        const now = this.now();
        const stabilized = this.stabilizer.observe(text, this.segmentStartMs, endMs, now);
        if (stabilized.stableText) {
            this.metric.partialLatencyMs = Math.max(0, now - endMs);
            this.config?.onStable(stabilized.stableText, this.segmentStartMs, endMs);
        }
        if (stabilized.partialText && stabilized.partialText !== this.lastPartial && now - this.lastPartialAt >= PARTIAL_INTERVAL_MS) {
            this.lastPartial = stabilized.partialText;
            this.lastPartialAt = now;
            this.metric.partialLatencyMs = Math.max(0, now - endMs);
            this.config?.onPartial(stabilized.partialText, this.segmentStartMs, endMs);
        }
    }
    decodeReady() {
        if (!this.stream || !this.recognizer)
            return;
        let decodeSteps = 0;
        while (this.recognizer.isReady(this.stream) && decodeSteps < MAX_DECODE_STEPS_PER_FRAME) {
            this.recognizer.decode(this.stream);
            decodeSteps += 1;
        }
        if (decodeSteps === MAX_DECODE_STEPS_PER_FRAME)
            this.emit({ type: "diagnostic", line: "sherpa streaming decode budget reached" });
    }
    publishStable(endMs, value) {
        const text = value ?? (this.stream && this.recognizer ? this.recognizer.getResult(this.stream).text?.trim() : "") ?? "";
        const now = this.now();
        const stabilized = this.stabilizer.endpoint(text, this.segmentStartMs, endMs);
        if (stabilized.stableText) {
            this.metric.finalLatencyMs = Math.max(0, now - endMs);
            this.config?.onStable(stabilized.stableText, this.segmentStartMs, endMs);
        }
        this.lastPartial = "";
        this.segmentStartMs = endMs;
    }
}
function append(left, right) {
    const joined = new Int16Array(left.length + right.length);
    joined.set(left);
    joined.set(right, left.length);
    return joined;
}
