"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
  for (var name in all)
    __defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
  if (from && typeof from === "object" || typeof from === "function") {
    for (let key of __getOwnPropNames(from))
      if (!__hasOwnProp.call(to, key) && key !== except)
        __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
  }
  return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
  // If the importer is in node compatibility mode or this is not an ESM
  // file that has been converted to a CommonJS file using a Babel-
  // compatible transform (i.e. "__esModule" has not been set), then set
  // "default" to the CommonJS "module.exports" for node compatibility.
  isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
  mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var recorder_io_exports = {};
__export(recorder_io_exports, {
  RecorderIO: () => RecorderIO
});
module.exports = __toCommonJS(recorder_io_exports);
var import_ffmpeg = __toESM(require("@ffmpeg-installer/ffmpeg"), 1);
var import_mutex = require("@livekit/mutex");
var import_rtc_node = require("@livekit/rtc-node");
var import_fluent_ffmpeg = __toESM(require("fluent-ffmpeg"), 1);
var import_node_fs = __toESM(require("node:fs"), 1);
var import_node_path = __toESM(require("node:path"), 1);
var import_node_stream = require("node:stream");
var import_web = require("node:stream/web");
var import_log = require("../../log.cjs");
var import_deferred_stream = require("../../stream/deferred_stream.cjs");
var import_stream_channel = require("../../stream/stream_channel.cjs");
var import_utils = require("../../utils.cjs");
var import_io = require("../io.cjs");
import_fluent_ffmpeg.default.setFfmpegPath(import_ffmpeg.default.path);
const WRITE_INTERVAL_MS = 2500;
const DEFAULT_SAMPLE_RATE = 48e3;
class RecorderIO {
  inRecord;
  outRecord;
  inChan = (0, import_stream_channel.createStreamChannel)();
  outChan = (0, import_stream_channel.createStreamChannel)();
  session;
  sampleRate;
  _outputPath;
  forwardTask;
  encodeTask;
  closeFuture = new import_utils.Future();
  lock = new import_mutex.Mutex();
  started = false;
  // FFmpeg streaming state
  pcmStream;
  ffmpegPromise;
  inResampler;
  outResampler;
  logger = (0, import_log.log)();
  constructor(opts) {
    const { agentSession, sampleRate = DEFAULT_SAMPLE_RATE } = opts;
    this.session = agentSession;
    this.sampleRate = sampleRate;
  }
  async start(outputPath) {
    const unlock = await this.lock.lock();
    try {
      if (this.started) return;
      if (!this.inRecord || !this.outRecord) {
        throw new Error(
          "RecorderIO not properly initialized: both `recordInput()` and `recordOutput()` must be called before starting the recorder."
        );
      }
      this._outputPath = outputPath;
      this.started = true;
      this.closeFuture = new import_utils.Future();
      const dir = import_node_path.default.dirname(outputPath);
      if (!import_node_fs.default.existsSync(dir)) {
        import_node_fs.default.mkdirSync(dir, { recursive: true });
      }
      this.forwardTask = import_utils.Task.from(({ signal }) => this.forward(signal));
      this.encodeTask = import_utils.Task.from(() => this.encode(), void 0, "recorder_io_encode_task");
    } finally {
      unlock();
    }
  }
  async close() {
    const unlock = await this.lock.lock();
    try {
      if (!this.started) return;
      await this.inChan.close();
      await this.outChan.close();
      await this.closeFuture.await;
      await (0, import_utils.cancelAndWait)([this.forwardTask, this.encodeTask]);
      this.started = false;
    } finally {
      unlock();
    }
  }
  recordInput(audioInput) {
    this.inRecord = new RecorderAudioInput(this, audioInput);
    return this.inRecord;
  }
  recordOutput(audioOutput) {
    this.outRecord = new RecorderAudioOutput(this, audioOutput, (buf) => this.writeCb(buf));
    return this.outRecord;
  }
  writeCb(buf) {
    var _a;
    const inputBuf = this.inRecord.takeBuf((_a = this.outRecord) == null ? void 0 : _a._lastSpeechEndTime);
    this.inChan.write(inputBuf);
    this.outChan.write(buf);
  }
  get recording() {
    return this.started;
  }
  get outputPath() {
    return this._outputPath;
  }
  get recordingStartedAt() {
    var _a, _b;
    const inT = (_a = this.inRecord) == null ? void 0 : _a.startedWallTime;
    const outT = (_b = this.outRecord) == null ? void 0 : _b.startedWallTime;
    if (inT === void 0) {
      return outT;
    }
    if (outT === void 0) {
      return inT;
    }
    return Math.min(inT, outT);
  }
  /**
   * Forward task: periodically flush input buffer to encoder
   */
  async forward(signal) {
    while (!signal.aborted) {
      try {
        await (0, import_utils.delay)(WRITE_INTERVAL_MS, { signal });
      } catch {
        break;
      }
      if (this.outRecord.hasPendingData) {
        continue;
      }
      const inputBuf = this.inRecord.takeBuf(this.outRecord._lastSpeechEndTime);
      this.inChan.write(inputBuf).catch((err) => this.logger.error({ err }, "Error writing RecorderIO input buffer"));
      this.outChan.write([]).catch((err) => this.logger.error({ err }, "Error writing RecorderIO output buffer"));
    }
  }
  /**
   * Start FFmpeg process for streaming encoding
   */
  startFFmpeg() {
    if (this.pcmStream) return;
    this.pcmStream = new import_node_stream.PassThrough();
    this.ffmpegPromise = new Promise((resolve, reject) => {
      (0, import_fluent_ffmpeg.default)(this.pcmStream).inputFormat("s16le").inputOptions([`-ar ${this.sampleRate}`, "-ac 2"]).audioCodec("libopus").audioChannels(2).audioFrequency(this.sampleRate).format("ogg").output(this._outputPath).on("end", () => {
        this.logger.debug("FFmpeg encoding finished");
        resolve();
      }).on("error", (err) => {
        var _a, _b, _c, _d;
        if (((_a = err.message) == null ? void 0 : _a.includes("Output stream closed")) || ((_b = err.message) == null ? void 0 : _b.includes("received signal 2")) || ((_c = err.message) == null ? void 0 : _c.includes("SIGKILL")) || ((_d = err.message) == null ? void 0 : _d.includes("SIGINT"))) {
          resolve();
        } else {
          this.logger.error({ err }, "FFmpeg encoding error");
          reject(err);
        }
      }).run();
    });
  }
  /**
   * Resample and mix frames to mono Float32
   */
  resampleAndMix(opts) {
    const INV_INT16 = 1 / 32768;
    const { frames, flush = false } = opts;
    let { resampler } = opts;
    if (frames.length === 0 && !flush) {
      return { samples: new Float32Array(0), resampler };
    }
    if (!resampler && frames.length > 0) {
      const firstFrame = frames[0];
      resampler = new import_rtc_node.AudioResampler(firstFrame.sampleRate, this.sampleRate, firstFrame.channels);
    }
    const resampledFrames = [];
    for (const frame of frames) {
      if (resampler) {
        resampledFrames.push(...resampler.push(frame));
      }
    }
    if (flush && resampler) {
      resampledFrames.push(...resampler.flush());
    }
    const totalSamples = resampledFrames.reduce((acc, frame) => acc + frame.samplesPerChannel, 0);
    const samples = new Float32Array(totalSamples);
    let pos = 0;
    for (const frame of resampledFrames) {
      const data = frame.data;
      const numChannels = frame.channels;
      for (let i = 0; i < frame.samplesPerChannel; i++) {
        let sum = 0;
        for (let ch = 0; ch < numChannels; ch++) {
          sum += data[i * numChannels + ch];
        }
        samples[pos++] = sum / numChannels * INV_INT16;
      }
    }
    return { samples, resampler };
  }
  /**
   * Write PCM chunk to FFmpeg stream
   */
  writePCM(leftSamples, rightSamples) {
    if (!this.pcmStream) {
      this.startFFmpeg();
    }
    if (leftSamples.length !== rightSamples.length) {
      const diff = Math.abs(leftSamples.length - rightSamples.length);
      if (leftSamples.length < rightSamples.length) {
        this.logger.warn(
          `Input is shorter by ${diff} samples; silence has been prepended to align the input channel.`
        );
        const padded = new Float32Array(rightSamples.length);
        padded.set(leftSamples, diff);
        leftSamples = padded;
      } else {
        const padded = new Float32Array(leftSamples.length);
        padded.set(rightSamples, diff);
        rightSamples = padded;
      }
    }
    const maxLen = Math.max(leftSamples.length, rightSamples.length);
    if (maxLen <= 0) return;
    const stereoData = new Int16Array(maxLen * 2);
    for (let i = 0; i < maxLen; i++) {
      stereoData[i * 2] = Math.max(
        -32768,
        Math.min(32767, Math.round((leftSamples[i] ?? 0) * 32768))
      );
      stereoData[i * 2 + 1] = Math.max(
        -32768,
        Math.min(32767, Math.round((rightSamples[i] ?? 0) * 32768))
      );
    }
    this.pcmStream.write(Buffer.from(stereoData.buffer));
  }
  /**
   * Encode task: read from channels, mix to stereo, stream to FFmpeg
   */
  async encode() {
    if (!this._outputPath) return;
    const inReader = this.inChan.stream().getReader();
    const outReader = this.outChan.stream().getReader();
    try {
      while (true) {
        const [inResult, outResult] = await Promise.all([inReader.read(), outReader.read()]);
        if (inResult.done || outResult.done) {
          break;
        }
        const inputBuf = inResult.value;
        const outputBuf = outResult.value;
        const inMixed = this.resampleAndMix({ frames: inputBuf, resampler: this.inResampler });
        this.inResampler = inMixed.resampler;
        const outMixed = this.resampleAndMix({
          frames: outputBuf,
          resampler: this.outResampler,
          flush: outputBuf.length > 0
        });
        this.outResampler = outMixed.resampler;
        this.writePCM(inMixed.samples, outMixed.samples);
      }
      if (this.pcmStream) {
        this.pcmStream.end();
        await this.ffmpegPromise;
      }
    } catch (err) {
      this.logger.error({ err }, "Error in encode task");
    } finally {
      inReader.releaseLock();
      outReader.releaseLock();
      if (!this.closeFuture.done) {
        this.closeFuture.resolve();
      }
    }
  }
}
class RecorderAudioInput extends import_io.AudioInput {
  source;
  recorderIO;
  accFrames = [];
  _startedWallTime;
  _padded = false;
  logger = (0, import_log.log)();
  constructor(recorderIO, source) {
    super();
    this.recorderIO = recorderIO;
    this.source = source;
    this.deferredStream.setSource(this.createInterceptingStream());
  }
  /**
   * Wall-clock time when the first frame was captured
   */
  get startedWallTime() {
    return this._startedWallTime;
  }
  /**
   * Take accumulated frames and clear the buffer
   * @param padSince - If provided and input started after this time, pad with silence
   */
  takeBuf(padSince) {
    let frames = this.accFrames;
    this.accFrames = [];
    if (padSince !== void 0 && this._startedWallTime !== void 0 && this._startedWallTime > padSince && !this._padded && frames.length > 0) {
      const padding = this._startedWallTime - padSince;
      this.logger.warn(
        {
          lastAgentSpeechTime: padSince,
          inputStartedTime: this._startedWallTime
        },
        "input speech started after last agent speech ended"
      );
      this._padded = true;
      const firstFrame = frames[0];
      frames = [
        createSilenceFrame(padding / 1e3, firstFrame.sampleRate, firstFrame.channels),
        ...frames
      ];
    } else if (padSince !== void 0 && this._startedWallTime === void 0 && !this._padded && frames.length === 0) {
      this.logger.warn(
        "input speech hasn't started yet, skipping silence padding, recording may be inaccurate until the speech starts"
      );
    }
    return frames;
  }
  /**
   * Creates a stream that intercepts frames from the source,
   * accumulates them when recording, and passes them through unchanged.
   */
  createInterceptingStream() {
    const sourceStream = this.source.stream;
    const reader = sourceStream.getReader();
    const transform = new import_web.TransformStream({
      transform: (frame, controller) => {
        if (this.recorderIO.recording) {
          if (this._startedWallTime === void 0) {
            this._startedWallTime = Date.now();
          }
          this.accFrames.push(frame);
        }
        controller.enqueue(frame);
      }
    });
    const pump = async () => {
      const writer = transform.writable.getWriter();
      let sourceError;
      try {
        while (true) {
          const { done, value } = await reader.read();
          if (done) break;
          await writer.write(value);
        }
      } catch (e) {
        if ((0, import_deferred_stream.isStreamReaderReleaseError)(e)) return;
        sourceError = e;
      } finally {
        if (sourceError) {
          writer.abort(sourceError);
          return;
        }
        writer.releaseLock();
        try {
          await transform.writable.close();
        } catch {
        }
      }
    };
    pump();
    return transform.readable;
  }
  onAttached() {
    this.source.onAttached();
  }
  onDetached() {
    this.source.onDetached();
  }
}
class RecorderAudioOutput extends import_io.AudioOutput {
  recorderIO;
  writeFn;
  accFrames = [];
  _startedWallTime;
  _logger = (0, import_log.log)();
  _lastSpeechEndTime;
  _lastSpeechStartTime;
  // Pause tracking
  currentPauseStart;
  pauseWallTimes = [];
  // [start, end] pairs
  constructor(recorderIO, audioOutput, writeFn) {
    super(audioOutput.sampleRate, audioOutput, { pause: true });
    this.recorderIO = recorderIO;
    this.writeFn = writeFn;
  }
  get startedWallTime() {
    return this._startedWallTime;
  }
  get hasPendingData() {
    return this.accFrames.length > 0;
  }
  pause() {
    if (this.currentPauseStart === void 0 && this.recorderIO.recording) {
      this.currentPauseStart = Date.now();
    }
    if (this.nextInChain) {
      this.nextInChain.pause();
    }
  }
  /**
   * Resume playback and record the pause interval
   */
  resume() {
    if (this.currentPauseStart !== void 0 && this.recorderIO.recording) {
      this.pauseWallTimes.push([this.currentPauseStart, Date.now()]);
      this.currentPauseStart = void 0;
    }
    if (this.nextInChain) {
      this.nextInChain.resume();
    }
  }
  resetPauseState() {
    this.currentPauseStart = void 0;
    this.pauseWallTimes = [];
  }
  onPlaybackFinished(options) {
    const finishTime = this.currentPauseStart ?? Date.now();
    const trailingSilenceDuration = Math.max(0, Date.now() - finishTime);
    let playbackPosition = options.playbackPosition * 1e3;
    if (this._lastSpeechStartTime === void 0) {
      this._logger.warn(
        {
          finishTime,
          playbackPosition,
          interrupted: options.interrupted
        },
        "playback finished before speech started"
      );
      playbackPosition = 0;
    }
    playbackPosition = Math.max(
      0,
      Math.min(finishTime - (this._lastSpeechStartTime ?? 0), playbackPosition)
    );
    super.onPlaybackFinished({ ...options, playbackPosition: playbackPosition / 1e3 });
    if (!this.recorderIO.recording) {
      return;
    }
    if (this.currentPauseStart !== void 0) {
      this.pauseWallTimes.push([this.currentPauseStart, finishTime]);
      this.currentPauseStart = void 0;
    }
    if (this.accFrames.length === 0) {
      this.resetPauseState();
      this._lastSpeechEndTime = Date.now();
      this._lastSpeechStartTime = void 0;
      return;
    }
    const pauseEvents = [];
    let playbackStartTime = finishTime - playbackPosition;
    if (this.pauseWallTimes.length > 0) {
      const totalPauseDuration = this.pauseWallTimes.reduce(
        (sum, [start, end]) => sum + (end - start),
        0
      );
      playbackStartTime = finishTime - playbackPosition - totalPauseDuration;
      let accumulatedPause = 0;
      for (const [pauseStart, pauseEnd] of this.pauseWallTimes) {
        let position = pauseStart - playbackStartTime - accumulatedPause;
        const duration = pauseEnd - pauseStart;
        position = Math.max(0, Math.min(position, playbackPosition));
        pauseEvents.push([position, duration]);
        accumulatedPause += duration;
      }
    }
    const buf = [];
    let accDur = 0;
    const sampleRate = this.accFrames[0].sampleRate;
    const numChannels = this.accFrames[0].channels;
    let pauseIdx = 0;
    let shouldBreak = false;
    for (const frame of this.accFrames) {
      let currentFrame = frame;
      const frameDuration = frame.samplesPerChannel / frame.sampleRate * 1e3;
      if (frameDuration + accDur > playbackPosition) {
        const [left] = splitFrame(currentFrame, (playbackPosition - accDur) / 1e3);
        currentFrame = left;
        shouldBreak = true;
      }
      while (pauseIdx < pauseEvents.length && pauseEvents[pauseIdx][0] <= accDur) {
        const [, pauseDur] = pauseEvents[pauseIdx];
        buf.push(createSilenceFrame(pauseDur / 1e3, sampleRate, numChannels));
        pauseIdx++;
      }
      const currentFrameDuration = currentFrame.samplesPerChannel / currentFrame.sampleRate * 1e3;
      while (pauseIdx < pauseEvents.length && pauseEvents[pauseIdx][0] < accDur + currentFrameDuration) {
        const [pausePos, pauseDur] = pauseEvents[pauseIdx];
        const [left, right] = splitFrame(currentFrame, (pausePos - accDur) / 1e3);
        buf.push(left);
        accDur += left.samplesPerChannel / left.sampleRate * 1e3;
        buf.push(createSilenceFrame(pauseDur / 1e3, sampleRate, numChannels));
        currentFrame = right;
        pauseIdx++;
      }
      buf.push(currentFrame);
      accDur += currentFrame.samplesPerChannel / currentFrame.sampleRate * 1e3;
      if (shouldBreak) {
        break;
      }
    }
    while (pauseIdx < pauseEvents.length) {
      const [pausePos, pauseDur] = pauseEvents[pauseIdx];
      if (pausePos <= playbackPosition) {
        buf.push(createSilenceFrame(pauseDur / 1e3, sampleRate, numChannels));
      }
      pauseIdx++;
    }
    if (buf.length > 0) {
      if (trailingSilenceDuration > 0) {
        buf.push(createSilenceFrame(trailingSilenceDuration / 1e3, sampleRate, numChannels));
      }
      this.writeFn(buf);
    }
    this.accFrames = [];
    this.resetPauseState();
    this._lastSpeechEndTime = Date.now();
    this._lastSpeechStartTime = void 0;
  }
  async captureFrame(frame) {
    if (this.nextInChain) {
      await this.nextInChain.captureFrame(frame);
    }
    await super.captureFrame(frame);
    if (this.recorderIO.recording) {
      this.accFrames.push(frame);
    }
    if (this._startedWallTime === void 0) {
      this._startedWallTime = Date.now();
    }
    if (this._lastSpeechStartTime === void 0) {
      this._lastSpeechStartTime = Date.now();
    }
  }
  flush() {
    super.flush();
    if (this.nextInChain) {
      this.nextInChain.flush();
    }
  }
  clearBuffer() {
    if (this.nextInChain) {
      this.nextInChain.clearBuffer();
    }
  }
}
function createSilenceFrame(durationInS, sampleRate, numChannels) {
  const samples = Math.floor(durationInS * sampleRate);
  const data = new Int16Array(samples * numChannels);
  return new import_rtc_node.AudioFrame(data, sampleRate, numChannels, samples);
}
function splitFrame(frame, position) {
  if (position <= 0) {
    const emptyFrame = new import_rtc_node.AudioFrame(new Int16Array(0), frame.sampleRate, frame.channels, 0);
    return [emptyFrame, frame];
  }
  const frameDuration = frame.samplesPerChannel / frame.sampleRate;
  if (position >= frameDuration) {
    const emptyFrame = new import_rtc_node.AudioFrame(new Int16Array(0), frame.sampleRate, frame.channels, 0);
    return [frame, emptyFrame];
  }
  const samplesNeeded = Math.floor(position * frame.sampleRate);
  const numChannels = frame.channels;
  const leftData = frame.data.slice(0, samplesNeeded * numChannels);
  const rightData = frame.data.slice(samplesNeeded * numChannels);
  const leftFrame = new import_rtc_node.AudioFrame(leftData, frame.sampleRate, frame.channels, samplesNeeded);
  const rightFrame = new import_rtc_node.AudioFrame(
    rightData,
    frame.sampleRate,
    frame.channels,
    frame.samplesPerChannel - samplesNeeded
  );
  return [leftFrame, rightFrame];
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
  RecorderIO
});
//# sourceMappingURL=recorder_io.cjs.map