import { AudioResampler, AudioResamplerQuality } from "@livekit/rtc-node";
import { EventEmitter } from "node:events";
import { log } from "../../log.js";
import { createStreamChannel } from "../../stream/stream_channel.js";
import { Future, Task, cancelAndWait, shortuuid } from "../../utils.js";
const DEFAULT_SAMPLE_RATE = 16e3;
const MIN_SILENCE_DURATION_MS = 200;
function isFlushSentinel(value) {
  return typeof value === "object" && value !== null && value.kind === "flush";
}
class BaseStreamingTurnDetector extends EventEmitter {
  _opts;
  /**
   * Active streams the detector tracks for bulk teardown via `aclose()`.
   * `Set` rather than `WeakSet` because we need iteration; each stream
   * removes itself on its own `aclose` (see `BaseStreamingTurnDetectorStream.aclose`)
   * so the strong refs are released without requiring the caller to call
   * `detector.aclose()`.
   */
  _streams = /* @__PURE__ */ new Set();
  constructor(opts) {
    super();
    this._opts = opts;
  }
  /** @internal Stream lifecycle hook — called by the stream itself on close. */
  _unregisterStream(stream) {
    this._streams.delete(stream);
  }
  get provider() {
    return "livekit";
  }
  /** Most-recent materialized threshold map (after any cloud→local fallback
   * rescale or server-default adoption). */
  get thresholds() {
    return this._opts.thresholds.thresholds;
  }
  /** Threshold below which the detector treats the prediction as "unlikely
   * to be end-of-turn". Returns `undefined` when the language isn't covered. */
  async unlikelyThreshold(language) {
    return this._opts.thresholds.lookup(language);
  }
  /** Threshold above which a pause is a backchannel opportunity, or `undefined`
   * when backchannel is disabled (server sent none, or the local mini model). */
  async backchannelThreshold(language) {
    return this._opts.thresholds.lookupBackchannel(language);
  }
  async supportsLanguage(language) {
    return this._opts.thresholds.supports(language);
  }
  async aclose() {
    const streams = Array.from(this._streams);
    this._streams.clear();
    await Promise.allSettled(streams.map((s) => s.aclose()));
  }
}
class SwapAbortError extends Error {
  constructor() {
    super("__swap__");
    this.name = "SwapAbortError";
  }
}
class BaseStreamingTurnDetectorStream {
  _detector;
  _opts;
  _transport;
  _audioInputSampleRate;
  _audioInputNumChannels;
  _audioResampler;
  _audioChannel = createStreamChannel();
  /** Id of the in-flight inference request, or `undefined` when idle. */
  _requestId;
  /** Future for the in-flight request; resolves to the prediction event (or
   * a default event when the request is cancelled / flushed). */
  _requestFut;
  _mainTask;
  _logger = log();
  /**
   * Aborted whenever the main loop needs to retry on a new transport (e.g.
   * fallback). The base FSM also aborts it from `aclose()` so idle
   * transports that are awaiting forever can be unstuck. Listeners check
   * `signal.aborted` and surface a sentinel rejection so the `_run` loop
   * can decide whether to continue or exit.
   */
  _swapController = new AbortController();
  constructor(args) {
    this._detector = args.detector;
    this._opts = args.opts;
    this._transport = args.transport;
    this._transport.attach(this);
    this._mainTask = Task.from((controller) => this._mainTaskBody(controller));
  }
  // region: _TurnDetector protocol proxies
  get model() {
    return this._detector.model;
  }
  get provider() {
    return this._detector.provider;
  }
  /** @internal Shared threshold resolver — the cloud transport reads it to
   * adopt the server-sent defaults from `SessionCreated`. */
  get thresholdsOptions() {
    return this._opts.thresholds;
  }
  async unlikelyThreshold(language) {
    return this._opts.thresholds.lookup(language);
  }
  async backchannelThreshold(language) {
    return this._opts.thresholds.lookupBackchannel(language);
  }
  async supportsLanguage(language) {
    return this._opts.thresholds.supports(language);
  }
  // endregion
  // region: inference requests
  /** Start a new inference request and return its future, superseding any
   * previous request. */
  predict() {
    if (this._audioChannel.closed) {
      const fut2 = new Future();
      fut2.resolve(BaseStreamingTurnDetectorStream._defaultEvent(1));
      return fut2;
    }
    this.cancelInference();
    const fut = new Future();
    this._requestId = shortuuid("turn_request_");
    this._requestFut = fut;
    this._transport.runInference(this._requestId);
    return fut;
  }
  /** Close the current inference request (new speech, turn boundary,
   * prediction timeout, mode change) and fall back if needed. */
  cancelInference(opts = {}) {
    if (this._requestId !== void 0) {
      const fut = this._requestFut;
      this._requestId = void 0;
      this._requestFut = void 0;
      if (fut !== void 0 && !fut.done) {
        fut.resolve(BaseStreamingTurnDetectorStream._defaultEvent(0));
      }
    }
    if (opts.timedOut) {
      this._onPredictTimeout();
    }
  }
  flush(reason) {
    if (this._audioChannel.closed) {
      return;
    }
    for (const resampled of this._flushAudioResampler()) {
      void this._audioChannel.write(resampled);
    }
    const sentinel = {
      kind: "flush",
      reason
    };
    void this._audioChannel.write(sentinel);
    this.cancelInference();
  }
  static _defaultEvent(probability) {
    return {
      type: "eot_prediction",
      endOfTurnProbability: probability,
      lastSpeakingTimeMs: Date.now()
    };
  }
  // endregion
  // region: audio ingress
  pushAudio(frame) {
    if (this._audioChannel.closed) {
      return;
    }
    for (const resampled of this._resampleAudioFrame(frame)) {
      void this._audioChannel.write(resampled);
    }
  }
  endInput() {
    this.flush();
    void this._audioChannel.close();
  }
  _resampleAudioFrame(frame) {
    if (this._audioInputSampleRate === void 0 || this._audioInputNumChannels === void 0) {
      this._audioInputSampleRate = frame.sampleRate;
      this._audioInputNumChannels = frame.channels;
      if (this._audioInputSampleRate !== this._opts.sampleRate) {
        this._audioResampler = new AudioResampler(
          this._audioInputSampleRate,
          this._opts.sampleRate,
          this._audioInputNumChannels,
          AudioResamplerQuality.QUICK
        );
      }
    } else if (frame.sampleRate !== this._audioInputSampleRate || frame.channels !== this._audioInputNumChannels) {
      this._logger.error(
        {
          sampleRate: frame.sampleRate,
          expectedSampleRate: this._audioInputSampleRate,
          numChannels: frame.channels,
          expectedNumChannels: this._audioInputNumChannels
        },
        "a frame with different audio format was already pushed"
      );
      return [];
    }
    if (this._audioResampler === void 0) {
      return [frame];
    }
    return this._audioResampler.push(frame);
  }
  _flushAudioResampler() {
    var _a;
    const frames = ((_a = this._audioResampler) == null ? void 0 : _a.flush()) ?? [];
    this._resetAudioResampler();
    return frames;
  }
  _resetAudioResampler() {
    this._audioResampler = void 0;
    this._audioInputSampleRate = void 0;
    this._audioInputNumChannels = void 0;
  }
  // endregion
  // region: results
  /**
   * Accept a prediction from a transport. A stale response (request id
   * mismatch) is ignored; otherwise the in-flight future resolves with the
   * full `TurnDetectionEvent` and the request completes.
   */
  _resolvePrediction(requestId, probability, opts = {}) {
    if (this._closing) {
      return;
    }
    if (requestId !== this._requestId) {
      return;
    }
    const fut = this._requestFut;
    this._requestId = void 0;
    this._requestFut = void 0;
    if (fut !== void 0 && !fut.done) {
      fut.resolve({
        type: "eot_prediction",
        endOfTurnProbability: probability,
        lastSpeakingTimeMs: Date.now(),
        detectionDelay: opts.detectionDelay,
        inferenceDuration: opts.inferenceDuration,
        backchannelProbability: opts.backchannelProbability
      });
    }
  }
  // endregion
  // region: teardown
  /**
   * Synchronously release this stream's registration on its owning detector,
   * so a replacement stream can be created before this one's async teardown
   * finishes. Base is a no-op; detectors that enforce single-stream ownership
   * override it. Idempotent.
   */
  detach() {
    return;
  }
  async aclose() {
    this.endInput();
    this._closing = true;
    this._swapController.abort();
    await cancelAndWait([this._mainTask]);
    this.cancelInference();
    this._detector._unregisterStream(this);
  }
  /** True once `aclose()` has been called. The `_run` loop uses this to
   * distinguish swap-aborts (continue with new transport) from teardown
   * aborts (exit). */
  _closing = false;
  // endregion
  // region: main task scaffolding
  async _mainTaskBody(_controller) {
    await this._run();
  }
  /**
   * Drain the shared audio channel into the current transport.
   *
   * The audio channel exposes a single `ReadableStream` (one underlying
   * `transform.readable`), so only one reader may hold its lock at a time.
   * When `signal` aborts (a transport being swapped out — e.g. cloud→local
   * fallback — fires it via `detach()`), we release the reader lock right
   * away: on a pending `read()` this rejects that read and frees the lock so
   * the swapped-in transport's `_drainAudioChannel` can re-acquire it.
   * Without this an orphaned drain would hold the lock forever and the next
   * `getReader()` would throw "ReadableStream is locked".
   */
  async _drainAudioChannel(signal) {
    const stream = this._audioChannel.stream();
    const reader = stream.getReader();
    const release = () => {
      try {
        reader.releaseLock();
      } catch {
      }
    };
    if (signal == null ? void 0 : signal.aborted) {
      release();
      return;
    }
    signal == null ? void 0 : signal.addEventListener("abort", release, { once: true });
    try {
      while (true) {
        const { done, value } = await reader.read();
        if (done) return;
        if (isFlushSentinel(value)) {
          await this._transport.flush(value);
        } else {
          await this._transport.pushFrame(value);
        }
      }
    } catch (err) {
      if (signal == null ? void 0 : signal.aborted) return;
      throw err;
    } finally {
      signal == null ? void 0 : signal.removeEventListener("abort", release);
      release();
    }
  }
  // endregion
  // region: subclass hooks
  /** Default: hand control to the transport. Subclasses override for
   * cross-transport orchestration (e.g. cloud→local fallback). */
  async _run() {
    await this._raceWithSwap(this._transport.run());
  }
  /**
   * Race `inner` against `_swapController.signal`. If the signal aborts
   * while `inner` is still pending, throw a `SwapAbortError` so the
   * subclass loop can decide whether to continue or exit. Resets the
   * controller after a swap-abort so subsequent races have a fresh signal.
   *
   * `aclose()` aborts during teardown — subclasses observe `_closing` to
   * exit cleanly instead of looping.
   */
  async _raceWithSwap(inner) {
    const signal = this._swapController.signal;
    let onAbort;
    const abortPromise = new Promise((_, reject) => {
      if (signal.aborted) {
        reject(new SwapAbortError());
        return;
      }
      onAbort = () => reject(new SwapAbortError());
      signal.addEventListener("abort", onAbort, { once: true });
    });
    abortPromise.catch(() => {
    });
    try {
      return await Promise.race([inner, abortPromise]);
    } finally {
      if (onAbort !== void 0) {
        signal.removeEventListener("abort", onAbort);
      }
      if (signal.aborted) {
        this._swapController = new AbortController();
      }
    }
  }
  /** @internal Wake up an idle transport so the main loop can pick up a
   * new one after fallback. Subclasses call this from their swap logic. */
  _signalSwap() {
    this._swapController.abort();
  }
  /** `predictEndOfTurn` timed out. Subclasses may override to react (e.g.
   * promote local on cloud timeout). */
  _onPredictTimeout() {
    return;
  }
  // endregion
}
export {
  BaseStreamingTurnDetector,
  BaseStreamingTurnDetectorStream,
  DEFAULT_SAMPLE_RATE,
  MIN_SILENCE_DURATION_MS,
  SwapAbortError,
  isFlushSentinel
};
//# sourceMappingURL=base.js.map