// Whisper Base FP16 WebGPU high-precision engine (Phase 4).  This is deliberately
// separate from the legacy q8/WASM Whisper adapter: precision mode must never turn
// into an implicit CPU fallback when WebGPU or its verified capability is absent.
import { detectHallucination, isDegenerateTranscript, sanitizeTranscript, VoiceActivityDetector } from "./onnx-transcriber.js";
import { CapabilityModelStore } from "./capability-model-store.js";
import { WHISPER_BASE_WEBGPU_FP16 } from "./model-capabilities.js";
import { configureOnnxWasmRuntime, configureTransformersEnvironment } from "./onnx-transcriber.js";
import { emptyAsrMetrics } from "./asr-metrics.js";
const SAMPLE_RATE = 16_000;
const MAX_WAITING_UTTERANCES = 1;
/** A bounded VAD-segmented Whisper Base runner backed exclusively by WebGPU FP16. */
export class WhisperWebGpuEngine {
    models;
    loadPipeline;
    webGpuAvailable;
    prepareModel;
    backend = "transformers-webgpu";
    mode = "segmented";
    callbacks;
    pipeline;
    resolvedSourceLanguage;
    vad = new VoiceActivityDetector();
    pending = [];
    running = false;
    paused = false;
    generation = 0;
    values = { received: 0, processed: 0, inference: 0, dropped: 0, resets: 0 };
    constructor(models = new CapabilityModelStore(), loadPipeline = loadWhisperBaseWebGpuPipeline, webGpuAvailable = hasWebGpu, prepareModel = installCapabilityInTransformersCache) {
        this.models = models;
        this.loadPipeline = loadPipeline;
        this.webGpuAvailable = webGpuAvailable;
        this.prepareModel = prepareModel;
    }
    async init(config) {
        if (!this.webGpuAvailable())
            throw new Error("audio_unavailable");
        if (!(await this.models.isReady(WHISPER_BASE_WEBGPU_FP16)))
            throw new Error("audio_unconfigured");
        await this.prepareModel(this.models);
        this.pipeline = await this.loadPipeline();
        this.callbacks = config;
        this.resolvedSourceLanguage = config.sourceLanguage === "auto" ? undefined : config.sourceLanguage;
        this.pending = [];
        this.paused = false;
        this.vad.reset(false);
        this.generation += 1;
    }
    pushAudio(pcm16, startMs) {
        if (!this.callbacks || !this.pipeline || this.paused)
            return;
        const durationMs = (pcm16.length / SAMPLE_RATE) * 1000;
        this.values.received += durationMs;
        const audio = new Float32Array(pcm16.length);
        for (let index = 0; index < pcm16.length; index++)
            audio[index] = pcm16[index] / 0x7fff;
        this.values.processed += durationMs;
        const result = this.vad.process(audio, startMs);
        if (result.type === "utterance")
            this.enqueue(result.audio, result.startMs, result.endMs);
    }
    recordDroppedAudio(durationMs) { if (durationMs > 0 && Number.isFinite(durationMs))
        this.values.dropped += durationMs; }
    flush() { const result = this.vad.flush(); if (result?.type === "utterance")
        this.enqueue(result.audio, result.startMs, result.endMs); }
    reset(_reason) { this.generation += 1; this.values.resets += 1; this.pending = []; this.vad.reset(true); }
    setPaused(paused) { this.paused = paused; if (paused)
        this.reset("pause"); }
    idlePause() { this.setPaused(true); }
    resumeFromIdle() { this.paused = false; this.vad.reset(true); this.runNext(); }
    metrics() {
        const processed = this.values.processed;
        return { ...emptyAsrMetrics(), audioReceivedMs: Math.round(this.values.received), audioProcessedMs: Math.round(processed), inferenceMs: this.values.inference, rtf: processed ? this.values.inference / processed : 0, droppedAudioMs: Math.round(this.values.dropped), resetCount: this.values.resets };
    }
    dispose() { this.generation += 1; this.callbacks = undefined; this.pipeline = undefined; this.pending = []; this.running = false; this.vad.reset(false); }
    enqueue(audio, startMs, endMs) {
        if (this.pending.length >= MAX_WAITING_UTTERANCES) {
            const stale = this.pending.shift();
            this.values.dropped += stale ? (stale.audio.length / SAMPLE_RATE) * 1000 : 0;
        }
        this.pending.push({ audio, startMs, endMs });
        this.runNext();
    }
    runNext() {
        if (this.running || this.paused || !this.pipeline || !this.callbacks)
            return;
        const job = this.pending.shift();
        if (!job)
            return;
        const pipeline = this.pipeline, callbacks = this.callbacks, generation = this.generation;
        this.running = true;
        const started = Date.now();
        void this.resolveLanguage(pipeline, job.audio, callbacks, generation)
            .then((language) => language ? decodePrecisionTranscript(pipeline, job.audio, language) : "")
            .then((stable) => {
            if (stable && generation === this.generation && callbacks === this.callbacks && !this.paused)
                callbacks.onStable(stable, job.startMs, job.endMs);
        })
            .catch(() => { if (generation === this.generation && callbacks === this.callbacks)
            callbacks.onError({ code: "inference_failed" }); })
            .finally(() => { this.values.inference += Date.now() - started; this.running = false; this.runNext(); });
    }
    async resolveLanguage(pipeline, audio, callbacks, generation) {
        if (this.resolvedSourceLanguage)
            return this.resolvedSourceLanguage;
        if (callbacks.sourceLanguage !== "auto")
            return callbacks.sourceLanguage;
        const language = await pipeline.detectLanguage(audio);
        // A stop/reset may happen while the WebGPU language-token pass is running.
        // Never let that stale result select a translation path or publish a cue.
        if (generation !== this.generation || callbacks !== this.callbacks || this.paused)
            return undefined;
        this.resolvedSourceLanguage = language;
        callbacks.onLanguage?.(language);
        return language;
    }
}
/** Decode at most twice and never expose a degenerate Whisper loop as a cue. */
export async function decodePrecisionTranscript(pipeline, audio, sourceLanguage) {
    const base = {
        language: sourceLanguage === "ja" ? "japanese" : "english",
        task: "transcribe",
        max_new_tokens: Math.max(8, Math.min(96, Math.ceil(audio.length / SAMPLE_RATE * 6) + 4)),
        no_repeat_ngram_size: 3,
        repetition_penalty: 1.2,
        condition_on_prev_tokens: false,
    };
    const greedyRaw = (await pipeline(audio, { ...base, do_sample: false })).text;
    const greedy = sanitizeTranscript(greedyRaw);
    if (!needsHallucinationRetry(greedyRaw, greedy))
        return greedy;
    const sampledRaw = (await pipeline(audio, { ...base, do_sample: true, temperature: 0.6, top_k: 50, top_p: 0.95 })).text;
    const sampled = sanitizeTranscript(sampledRaw);
    return needsHallucinationRetry(sampledRaw, sampled) ? "" : sampled;
}
function needsHallucinationRetry(raw, sanitized) {
    // Check the raw result first: sanitization intentionally collapses repeated
    // phrases, and a long `ててて…` loop otherwise becomes `てて` and evades the
    // minimum-length condition in detectHallucination().
    return detectHallucination(raw.trim()) || isDegenerateTranscript(sanitized) || detectHallucination(sanitized);
}
export function hasWebGpu() { return typeof navigator !== "undefined" && Boolean(navigator.gpu); }
/** Select the supported language with Whisper's first decoder-token logits. */
export function selectWhisperLanguage(englishLogit, japaneseLogit) {
    if (!Number.isFinite(englishLogit) || !Number.isFinite(japaneseLogit) || englishLogit === japaneseLogit)
        throw new Error("language_detection_failed");
    return japaneseLogit > englishLogit ? "ja" : "en";
}
async function loadWhisperBaseWebGpuPipeline() {
    const ort = await import("onnxruntime-web/webgpu");
    configureOnnxWasmRuntime(ort.env);
    const { env, pipeline, LogitsProcessor } = await import("@huggingface/transformers");
    configureTransformersEnvironment(env);
    configureOnnxWasmRuntime(env.backends.onnx);
    const raw = await pipeline("automatic-speech-recognition", WHISPER_BASE_WEBGPU_FP16.modelId, { revision: WHISPER_BASE_WEBGPU_FP16.revision, device: "webgpu", dtype: "fp16", local_files_only: true });
    const featureCache = new WeakMap();
    const featuresFor = async (audio) => {
        const cached = featureCache.get(audio);
        if (cached)
            return cached;
        const features = (await raw.processor(audio)).input_features;
        featureCache.set(audio, features);
        return features;
    };
    const transcribe = async (audio, options) => {
        const output = await raw.model.generate({ inputs: await featuresFor(audio), ...options });
        featureCache.delete(audio);
        return { text: raw.tokenizer.decode(output[0].tolist().map(Number), { skip_special_tokens: true }) };
    };
    return Object.assign(transcribe, { detectLanguage: async (audio) => detectWhisperLanguage(raw, await featuresFor(audio), LogitsProcessor) });
}
async function detectWhisperLanguage(raw, inputFeatures, LogitsProcessor) {
    const startToken = raw.model.generation_config?.decoder_start_token_id;
    const languageIds = raw.model.generation_config?.lang_to_id;
    const englishId = languageIds?.["<|en|>"];
    const japaneseId = languageIds?.["<|ja|>"];
    if (!Number.isInteger(startToken) || !Number.isInteger(englishId) || !Number.isInteger(japaneseId))
        throw new Error("language_detection_unavailable");
    const decoderStartToken = startToken;
    const englishTokenId = englishId;
    const japaneseTokenId = japaneseId;
    let englishLogit;
    let japaneseLogit;
    class CaptureLanguageLogits extends LogitsProcessor {
        _call(_inputIds, logits) {
            englishLogit = Number(logits.data[englishTokenId]);
            japaneseLogit = Number(logits.data[japaneseTokenId]);
            return logits;
        }
    }
    await raw.model.generate({ inputs: inputFeatures, decoder_input_ids: [decoderStartToken], max_new_tokens: 1, do_sample: false, logits_processor: [new CaptureLanguageLogits()] });
    return selectWhisperLanguage(englishLogit ?? Number.NaN, japaneseLogit ?? Number.NaN);
}
// Transformers.js reads its own Cache API namespace. Copy only the verified,
// capability-pinned bytes into that namespace before local_files_only loading.
export async function installCapabilityInTransformersCache(models) {
    const { env } = await import("@huggingface/transformers");
    // This must happen before constructing cache keys. The default Transformers.js
    // host is huggingface.co, while this extension's verified capability cache uses
    // hf-mirror.com. Creating entries under the former makes local_files_only miss
    // them after the pipeline config switches to the latter.
    configureTransformersEnvironment(env);
    const cache = await caches.open(env.cacheKey);
    const host = String(env.remoteHost).replace(/\/$/u, "");
    await Promise.all(WHISPER_BASE_WEBGPU_FP16.files.map(async (file) => {
        const url = `${host}/${WHISPER_BASE_WEBGPU_FP16.modelId}/resolve/${WHISPER_BASE_WEBGPU_FP16.revision}/${file.path}`;
        const targets = [url, ...(file.path.startsWith("onnx/") ? [] : [`${host}/${WHISPER_BASE_WEBGPU_FP16.modelId}/resolve/main/${file.path}`])];
        const missing = (await Promise.all(targets.map(async (target) => (await cache.match(target)) ? undefined : target))).filter((target) => target !== undefined);
        if (!missing.length)
            return;
        const bytes = await models.fileBytes(WHISPER_BASE_WEBGPU_FP16, file.path);
        // Cache.put consumes the response body. Make a fresh body for every key so a
        // partial first attempt can never leave an unreadable alias or throw on clone.
        await Promise.all(missing.map((target) => cache.put(target, new Response(bytes.slice(), { headers: { "content-length": String(file.sizeBytes) } }))));
    }));
}
