import { AudioFrame } from "@livekit/rtc-node";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as logModule from "../../log.js";
import { AudioOutput, TextOutput } from "../io.js";
import { SpeakingRateData, TranscriptionSynchronizer } from "./synchronizer.js";
describe("SpeakingRateData", () => {
  describe("constructor", () => {
    it("should initialize with empty arrays", () => {
      const data = new SpeakingRateData();
      expect(data.timestamps).toEqual([]);
      expect(data.speakingRate).toEqual([]);
      expect(data.speakIntegrals).toEqual([]);
      expect(data.pushedDuration).toBe(0);
    });
  });
  describe("addByRate", () => {
    it("should add a single rate entry", () => {
      const data = new SpeakingRateData();
      data.addByRate(1, 5);
      expect(data.timestamps).toEqual([1]);
      expect(data.speakingRate).toEqual([5]);
      expect(data.speakIntegrals).toEqual([5]);
      expect(data.pushedDuration).toBe(1);
    });
    it("should accumulate integrals across multiple entries", () => {
      const data = new SpeakingRateData();
      data.addByRate(1, 4);
      data.addByRate(2, 6);
      data.addByRate(3.5, 2);
      expect(data.timestamps).toEqual([1, 2, 3.5]);
      expect(data.speakingRate).toEqual([4, 6, 2]);
      expect(data.speakIntegrals).toEqual([4, 10, 13]);
      expect(data.pushedDuration).toBe(3.5);
    });
    it("should handle zero rate", () => {
      const data = new SpeakingRateData();
      data.addByRate(1, 0);
      expect(data.timestamps).toEqual([1]);
      expect(data.speakingRate).toEqual([0]);
      expect(data.speakIntegrals).toEqual([0]);
    });
  });
  describe("addByAnnotation", () => {
    it("should buffer text without startTime", () => {
      const data = new SpeakingRateData();
      data.addByAnnotation("hello", void 0, void 0);
      expect(data.timestamps).toEqual([]);
      expect(data.pushedDuration).toBe(0);
    });
    it("should add entry when startTime is provided", () => {
      const data = new SpeakingRateData();
      data.addByAnnotation("hello", void 0, void 0);
      data.addByAnnotation("world", 1, void 0);
      expect(data.timestamps).toEqual([1]);
      expect(data.speakingRate).toEqual([5]);
      expect(data.speakIntegrals).toEqual([5]);
    });
    it("should handle startTime and endTime together", () => {
      const data = new SpeakingRateData();
      data.addByAnnotation("hello ", 0, 0.5);
      data.addByAnnotation("world", 0.5, 1);
      expect(data.timestamps.length).toBeGreaterThanOrEqual(2);
      expect(data.pushedDuration).toBe(1);
    });
    it("should calculate rate based on buffered text length", () => {
      const data = new SpeakingRateData();
      data.addByAnnotation("ab", void 0, void 0);
      data.addByAnnotation("cde", void 0, void 0);
      data.addByAnnotation("", 2, void 0);
      expect(data.timestamps).toEqual([2]);
      expect(data.speakingRate).toEqual([2.5]);
      expect(data.speakIntegrals).toEqual([5]);
    });
    it("should handle zero time delta gracefully", () => {
      const data = new SpeakingRateData();
      data.addByAnnotation("hello", 0, void 0);
      expect(data.timestamps).toEqual([0]);
      expect(data.speakingRate).toEqual([0]);
      expect(data.speakIntegrals).toEqual([0]);
    });
  });
  describe("accumulateTo", () => {
    it("should return 0 for empty data", () => {
      const data = new SpeakingRateData();
      expect(data.accumulateTo(1)).toBe(0);
    });
    it("should return 0 for timestamp before first entry", () => {
      const data = new SpeakingRateData();
      data.addByRate(1, 5);
      expect(data.accumulateTo(0.5)).toBe(0);
    });
    it("should return exact integral at timestamp", () => {
      const data = new SpeakingRateData();
      data.addByRate(1, 4);
      data.addByRate(2, 6);
      expect(data.accumulateTo(1)).toBe(4);
      expect(data.accumulateTo(2)).toBe(10);
    });
    it("should interpolate between timestamps", () => {
      const data = new SpeakingRateData();
      data.addByRate(1, 4);
      data.addByRate(2, 6);
      expect(data.accumulateTo(1.5)).toBe(7);
    });
    it("should extrapolate beyond last timestamp", () => {
      const data = new SpeakingRateData();
      data.addByRate(1, 4);
      data.addByRate(2, 6);
      expect(data.accumulateTo(3)).toBe(16);
    });
    it("should not exceed next integral when interpolating", () => {
      const data = new SpeakingRateData();
      data.addByRate(1, 100);
      data.addByRate(2, 1);
      expect(data.accumulateTo(1.5)).toBe(100.5);
    });
  });
  describe("pushedDuration", () => {
    it("should return 0 when empty", () => {
      const data = new SpeakingRateData();
      expect(data.pushedDuration).toBe(0);
    });
    it("should return last timestamp", () => {
      const data = new SpeakingRateData();
      data.addByRate(1, 5);
      data.addByRate(2.5, 3);
      data.addByRate(4, 7);
      expect(data.pushedDuration).toBe(4);
    });
  });
  describe("integration scenarios", () => {
    it("should handle typical TTS word timing scenario", () => {
      const data = new SpeakingRateData();
      data.addByAnnotation("Hello ", 0, 0.3);
      data.addByAnnotation("world", 0.3, 0.6);
      expect(data.pushedDuration).toBe(0.6);
      const mid1 = data.accumulateTo(0.15);
      expect(mid1).toBeGreaterThan(0);
      expect(mid1).toBeLessThan(6);
      const mid2 = data.accumulateTo(0.45);
      expect(mid2).toBeGreaterThan(6);
    });
    it("should handle mixed rate and annotation data", () => {
      const data = new SpeakingRateData();
      data.addByRate(0.5, 4);
      data.addByAnnotation("test", void 0, void 0);
      data.addByAnnotation("", 1, void 0);
      expect(data.timestamps).toEqual([0.5, 1]);
      expect(data.speakIntegrals).toEqual([2, 6]);
    });
  });
});
class MockAudioOutput extends AudioOutput {
  constructor() {
    super(8e3);
  }
  async captureFrame(frame) {
    await super.captureFrame(frame);
  }
  clearBuffer() {
  }
}
class MockTextOutput extends TextOutput {
  captured = [];
  async captureText(text) {
    this.captured.push(text);
  }
  flush() {
  }
}
describe("TranscriptionSynchronizer attachment warnings", () => {
  const textDetachedWarning = "TranscriptSynchronizer text output was detached while audio output is still active; transcription sync is disabled. This usually means session.output.transcription was replaced after AgentSession.start().";
  const audioDetachedWarning = "TranscriptSynchronizer audio output was detached while text output is still active; transcription sync is disabled. This usually means session.output.audio was replaced after AgentSession.start().";
  afterEach(() => {
    vi.restoreAllMocks();
  });
  it("warns once per enabled cycle when text output is detached while audio is still attached", async () => {
    const warn = vi.fn();
    vi.spyOn(logModule, "log").mockReturnValue({
      warn,
      debug: vi.fn(),
      info: vi.fn(),
      error: vi.fn()
    });
    const synchronizer = new TranscriptionSynchronizer(new MockAudioOutput(), new MockTextOutput());
    const frame = new AudioFrame(new Int16Array(160), 8e3, 1, 160);
    synchronizer.textOutput.onDetached();
    await synchronizer.audioOutput.captureFrame(frame);
    await synchronizer.audioOutput.captureFrame(frame);
    expect(warn.mock.calls.filter((c) => c[0] === textDetachedWarning)).toHaveLength(1);
    synchronizer.textOutput.onAttached();
    synchronizer.textOutput.onDetached();
    await synchronizer.audioOutput.captureFrame(frame);
    expect(warn.mock.calls.filter((c) => c[0] === textDetachedWarning)).toHaveLength(2);
    await synchronizer.close();
  });
  it("warns once per enabled cycle when audio output is detached while text is still attached", async () => {
    const warn = vi.fn();
    vi.spyOn(logModule, "log").mockReturnValue({
      warn,
      debug: vi.fn(),
      info: vi.fn(),
      error: vi.fn()
    });
    const synchronizer = new TranscriptionSynchronizer(new MockAudioOutput(), new MockTextOutput());
    synchronizer.audioOutput.onDetached();
    await synchronizer.textOutput.captureText("hello");
    await synchronizer.textOutput.captureText("again");
    expect(warn.mock.calls.filter((c) => c[0] === audioDetachedWarning)).toHaveLength(1);
    synchronizer.audioOutput.onAttached();
    synchronizer.audioOutput.onDetached();
    await synchronizer.textOutput.captureText("third");
    expect(warn.mock.calls.filter((c) => c[0] === audioDetachedWarning)).toHaveLength(2);
    await synchronizer.close();
  });
});
class DroppingAudioOutput extends AudioOutput {
  constructor() {
    super(8e3);
  }
  async captureFrame(_frame) {
  }
  clearBuffer() {
  }
}
describe("TranscriptionSynchronizer playback-counter drift on a dropped frame", () => {
  it("does not strand waitForPlayout() when the downstream sink drops a captured frame", async () => {
    const synchronizer = new TranscriptionSynchronizer(
      new DroppingAudioOutput(),
      new MockTextOutput()
    );
    const frame = new AudioFrame(new Int16Array(160), 8e3, 1, 160);
    await synchronizer.audioOutput.captureFrame(frame);
    const result = await Promise.race([
      synchronizer.audioOutput.waitForPlayout().then(() => "resolved"),
      new Promise((resolve) => setTimeout(() => resolve("timeout"), 1e3))
    ]);
    expect(result).toBe("resolved");
    await synchronizer.close();
  });
  it("routes the synthetic finish through the synchronizer like a real playback finish", async () => {
    const synchronizer = new TranscriptionSynchronizer(
      new DroppingAudioOutput(),
      new MockTextOutput()
    );
    const frame = new AudioFrame(new Int16Array(160), 8e3, 1, 160);
    const implBefore = synchronizer._impl;
    await synchronizer.audioOutput.captureFrame(frame);
    const ev = await synchronizer.audioOutput.waitForPlayout();
    expect(ev.interrupted).toBe(true);
    expect(typeof ev.synchronizedTranscript).toBe("string");
    await synchronizer.barrier();
    expect(synchronizer._impl).not.toBe(implBefore);
    await synchronizer.close();
  });
});
//# sourceMappingURL=synchronizer.test.js.map