// 日语 sherpa offline 路径的有界自适应分段器。输入是连续 16 kHz PCM；每个
// 输出 segment 最长 4 秒，优先在 2 秒后的低能量点截断，绝不产生 preview partial。
const defaults = {
    sampleRate: 16_000, onsetMs: 100, endSilenceMs: 600, minSpeechMs: 250,
    softTargetMs: 2_000, hardMaxMs: 4_000, lookbackMs: 800,
    // Tab capture can be considerably quieter than microphone input. Keep this
    // close to the capture watchdog's audible floor so low-volume speech is not
    // counted as "voiced" by one layer and "silence" by the next.
    threshold: 0.002,
};
const SOFT_CUT_SILENCE_MS = 80;
const VOICE_HANGOVER_MS = 10;
export class AdaptiveSegmenter {
    options;
    pending = [];
    speech = false;
    onsetSamples = 0;
    silenceSamples = 0;
    voicedHangoverSamples = 0;
    startMs = 0;
    constructor(options = {}) { this.options = { ...defaults, ...options }; }
    push(samples, startMs) {
        const outputs = [];
        const { sampleRate, threshold, onsetMs, endSilenceMs, minSpeechMs, softTargetMs, hardMaxMs, lookbackMs } = this.options;
        const hangoverSamples = Math.round((VOICE_HANGOVER_MS / 1000) * sampleRate);
        const minimumSamples = Math.round((minSpeechMs / 1000) * sampleRate);
        for (let index = 0; index < samples.length; index++) {
            const value = samples[index];
            // Raw speech crosses zero many times per millisecond. Requiring every
            // individual sample to exceed the threshold meant the 100ms onset counter
            // could never complete for real waveforms (it only passed synthetic flat
            // samples in tests). Hold a detected peak briefly to form an envelope.
            const peak = Math.abs(value) >= threshold;
            if (peak)
                this.voicedHangoverSamples = hangoverSamples;
            else if (this.voicedHangoverSamples > 0)
                this.voicedHangoverSamples -= 1;
            const voiced = peak || this.voicedHangoverSamples > 0;
            if (!this.speech) {
                this.onsetSamples = voiced ? this.onsetSamples + 1 : 0;
                if (this.onsetSamples >= Math.round((onsetMs / 1000) * sampleRate)) {
                    this.speech = true;
                    const onsetStart = Math.max(0, index - this.onsetSamples + 1);
                    this.startMs = startMs + (onsetStart / sampleRate) * 1000;
                    this.pending = Array.from(samples.subarray(onsetStart, index + 1));
                    this.silenceSamples = 0;
                }
                continue;
            }
            this.pending.push(value);
            this.silenceSamples = voiced ? 0 : this.silenceSamples + 1;
            const durationMs = (this.pending.length / sampleRate) * 1000;
            // This is only a cheap early gate.  A silence-based cut removes the
            // trailing silence below, so the final output must be checked again
            // against minimumSamples before it can reach the recognizer.
            const enoughSpeech = durationMs >= minSpeechMs;
            const naturalEnd = enoughSpeech && this.silenceSamples >= Math.round((endSilenceMs / 1000) * sampleRate);
            // At the two-second target prefer a real short pause. Waiting for the
            // 600 ms final-silence rule would turn every normal Japanese clause into
            // a four-second segment despite an earlier natural cut point.
            const softEnd = enoughSpeech && durationMs >= softTargetMs && this.silenceSamples >= Math.round((SOFT_CUT_SILENCE_MS / 1000) * sampleRate);
            const hardEnd = durationMs >= hardMaxMs;
            if (naturalEnd || softEnd || hardEnd) {
                // The envelope deliberately keeps a peak "voiced" for 10ms; remove
                // that hangover from a silence-based cut so stable cue timestamps still
                // end at the actual start of the trailing silence.
                const cut = naturalEnd || softEnd ? this.pending.length - this.silenceSamples - hangoverSamples : this.findCut(sampleRate, softTargetMs, lookbackMs);
                if (naturalEnd || softEnd) {
                    // A finished silence boundary is not an overlap boundary.  Keeping
                    // its trailing silence in `pending` leaves `speech` true and turns a
                    // long pause into repeated silence-only ASR segments.  Drop it and
                    // return to onset detection for the next utterance.
                    const segment = this.finishAtSilence(Math.max(0, cut), minimumSamples);
                    if (segment)
                        outputs.push(segment);
                }
                else {
                    outputs.push(this.emit(Math.max(1, cut)));
                }
            }
        }
        return outputs;
    }
    flush() {
        if (!this.speech || this.pending.length === 0)
            return undefined;
        const minimum = Math.round((this.options.minSpeechMs / 1000) * this.options.sampleRate);
        // Do not turn a short utterance followed by a little silence into a valid
        // segment merely because the silence makes `pending` long enough.
        const tail = this.silenceSamples > 0 ? this.silenceSamples + Math.min(this.voicedHangoverSamples, this.pending.length) : 0;
        return this.finishAtSilence(Math.max(0, this.pending.length - tail), minimum);
    }
    reset() { this.pending = []; this.speech = false; this.onsetSamples = 0; this.silenceSamples = 0; this.voicedHangoverSamples = 0; this.startMs = 0; }
    findCut(sampleRate, softTargetMs, lookbackMs) {
        const soft = Math.round((softTargetMs / 1000) * sampleRate);
        if (this.pending.length <= soft)
            return this.pending.length;
        const start = Math.max(soft, this.pending.length - Math.round((lookbackMs / 1000) * sampleRate));
        let best = this.pending.length - 1;
        let lowest = Number.POSITIVE_INFINITY;
        for (let index = start; index < this.pending.length; index++) {
            const energy = Math.abs(this.pending[index]);
            if (energy < lowest) {
                lowest = energy;
                best = index;
            }
        }
        return best + 1;
    }
    emit(count) {
        const audio = Float32Array.from(this.pending.slice(0, count));
        const endMs = this.startMs + (audio.length / this.options.sampleRate) * 1000;
        const rest = this.pending.slice(count);
        this.pending = rest;
        if (rest.length === 0) {
            this.speech = false;
            this.onsetSamples = 0;
            this.silenceSamples = 0;
        }
        else {
            this.startMs = endMs;
            this.silenceSamples = 0;
        }
        return { audio, startMs: endMs - (audio.length / this.options.sampleRate) * 1000, endMs };
    }
    finishAtSilence(count, minimumSamples) {
        const startMs = this.startMs;
        const audio = count > 0 ? Float32Array.from(this.pending.slice(0, count)) : new Float32Array(0);
        this.reset();
        if (audio.length < minimumSamples)
            return undefined;
        const endMs = startMs + (audio.length / this.options.sampleRate) * 1000;
        return { audio, startMs, endMs };
    }
}
