























@@ -762,6 +762,7 @@ describe("DiscordVoiceManager", () => {
762762audioSink?: {
763763sendAudio: (audio: Buffer) => void;
764764};
765+onEvent?: (event: { direction: "server"; type: string }) => void;
765766}
766767| undefined;
767768player.state.status = "playing";
@@ -781,6 +782,7 @@ describe("DiscordVoiceManager", () => {
781782);
782783expect(subscribeCall?.[0]).toBe("u1");
783784expect(requireRecord(subscribeCall?.[1], "subscribe options").end).toBeTypeOf("object");
785+bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
784786});
785787786788it("interrupts realtime playback when an already-active speaker keeps talking", async () => {
@@ -819,6 +821,7 @@ describe("DiscordVoiceManager", () => {
819821audioSink?: {
820822sendAudio: (audio: Buffer) => void;
821823};
824+onEvent?: (event: { direction: "server"; type: string }) => void;
822825}
823826| undefined;
824827const player = getLastAudioPlayer();
@@ -838,6 +841,7 @@ describe("DiscordVoiceManager", () => {
838841expect(lastTimestampCall).toBeLessThan(firstBargeInCall);
839842expect(player.stop).not.toHaveBeenCalled();
840843expect(realtimeSessionMock.sendAudio).toHaveBeenCalled();
844+bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
841845});
842846843847it("does not interrupt realtime provider state when local playback is already idle", async () => {
@@ -882,6 +886,95 @@ describe("DiscordVoiceManager", () => {
882886expect(realtimeSessionMock.sendAudio).toHaveBeenCalled();
883887});
884888889+it("sends trailing realtime silence when a speaker turn closes", async () => {
890+const manager = createManager({
891+groupPolicy: "open",
892+allowFrom: ["discord:u1"],
893+voice: {
894+enabled: true,
895+mode: "bidi",
896+realtime: {
897+provider: "openai",
898+providers: {
899+openai: {
900+silenceDurationMs: 450,
901+},
902+},
903+},
904+},
905+});
906+907+await manager.join({ guildId: "g1", channelId: "1001" });
908+909+const entry = getSessionEntry(manager) as {
910+realtime?: {
911+beginSpeakerTurn: (
912+context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
913+userId: string,
914+) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
915+};
916+};
917+const turn = entry.realtime?.beginSpeakerTurn(
918+{ extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
919+"u1",
920+);
921+922+turn?.sendInputAudio(Buffer.alloc(3840));
923+turn?.close();
924+925+expect(realtimeSessionMock.sendAudio).toHaveBeenCalledTimes(2);
926+const trailingSilence = realtimeSessionMock.sendAudio.mock.calls.at(-1)?.[0] as
927+| Buffer
928+| undefined;
929+expect(trailingSilence).toBeInstanceOf(Buffer);
930+expect(trailingSilence?.length).toBe(33_600);
931+expect(trailingSilence?.equals(Buffer.alloc(33_600))).toBe(true);
932+});
933+934+it("clamps configured realtime trailing silence before allocating audio", async () => {
935+const manager = createManager({
936+groupPolicy: "open",
937+allowFrom: ["discord:u1"],
938+voice: {
939+enabled: true,
940+mode: "bidi",
941+realtime: {
942+provider: "openai",
943+providers: {
944+openai: {
945+silenceDurationMs: 60_000,
946+},
947+},
948+},
949+},
950+});
951+952+await manager.join({ guildId: "g1", channelId: "1001" });
953+954+const entry = getSessionEntry(manager) as {
955+realtime?: {
956+beginSpeakerTurn: (
957+context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
958+userId: string,
959+) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
960+};
961+};
962+const turn = entry.realtime?.beginSpeakerTurn(
963+{ extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
964+"u1",
965+);
966+967+turn?.sendInputAudio(Buffer.alloc(3840));
968+turn?.close();
969+970+const trailingSilence = realtimeSessionMock.sendAudio.mock.calls.at(-1)?.[0] as
971+| Buffer
972+| undefined;
973+expect(trailingSilence).toBeInstanceOf(Buffer);
974+expect(trailingSilence?.length).toBe(144_000);
975+expect(trailingSilence?.equals(Buffer.alloc(144_000))).toBe(true);
976+});
977+885978it("ignores realtime capture during playback when barge-in is disabled", async () => {
886979const connection = createConnectionMock();
887980joinVoiceChannelMock.mockReturnValueOnce(connection);
@@ -1229,11 +1322,12 @@ describe("DiscordVoiceManager", () => {
12291322| undefined;
1230132312311324bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
1325+expect(createAudioResourceMock).not.toHaveBeenCalled();
1326+expect(player.play).not.toHaveBeenCalled();
1327+bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
12321328expect(createAudioResourceMock).toHaveBeenCalledTimes(1);
12331329expect(player.play).toHaveBeenCalledTimes(1);
12341330const firstStream = lastAudioResourceInput() as { writableEnded?: boolean } | undefined;
1235-expect(firstStream?.writableEnded).toBe(false);
1236-bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
12371331expect(firstStream?.writableEnded).toBe(true);
1238133212391333const idleHandler = player.on.mock.calls.find(([event]) => event === "idle")?.[1] as
@@ -1243,10 +1337,90 @@ describe("DiscordVoiceManager", () => {
12431337idleHandler?.();
1244133812451339bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
1340+expect(createAudioResourceMock).toHaveBeenCalledTimes(1);
1341+expect(player.play).toHaveBeenCalledTimes(1);
1342+bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
12461343expect(createAudioResourceMock).toHaveBeenCalledTimes(2);
12471344expect(player.play).toHaveBeenCalledTimes(2);
12481345});
124913461347+it("prebuffers realtime output before starting Discord playback", async () => {
1348+const manager = createManager({
1349+groupPolicy: "open",
1350+voice: {
1351+enabled: true,
1352+mode: "agent-proxy",
1353+realtime: { provider: "openai" },
1354+},
1355+});
1356+1357+await manager.join({ guildId: "g1", channelId: "1001" });
1358+1359+const player = getLastAudioPlayer();
1360+const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as
1361+| {
1362+audioSink?: {
1363+sendAudio: (audio: Buffer) => void;
1364+};
1365+onEvent?: (event: { direction: "server"; type: string }) => void;
1366+}
1367+| undefined;
1368+1369+for (let index = 0; index < 49; index += 1) {
1370+bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
1371+}
1372+1373+expect(createAudioResourceMock).not.toHaveBeenCalled();
1374+expect(player.play).not.toHaveBeenCalled();
1375+1376+bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
1377+1378+expect(createAudioResourceMock).toHaveBeenCalledTimes(1);
1379+expect(player.play).toHaveBeenCalledTimes(1);
1380+bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
1381+});
1382+1383+it("discards prebuffered realtime output when the response is cancelled", async () => {
1384+const manager = createManager({
1385+groupPolicy: "open",
1386+voice: {
1387+enabled: true,
1388+mode: "agent-proxy",
1389+realtime: { provider: "openai" },
1390+},
1391+});
1392+1393+await manager.join({ guildId: "g1", channelId: "1001" });
1394+1395+const player = getLastAudioPlayer();
1396+const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as
1397+| {
1398+audioSink?: {
1399+sendAudio: (audio: Buffer) => void;
1400+};
1401+onEvent?: (event: { detail?: string; direction: "server"; type: string }) => void;
1402+}
1403+| undefined;
1404+1405+bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
1406+bridgeParams?.onEvent?.({ direction: "server", type: "response.cancelled" });
1407+1408+expect(createAudioResourceMock).not.toHaveBeenCalled();
1409+expect(player.play).not.toHaveBeenCalled();
1410+expect(player.stop).toHaveBeenCalledWith(true);
1411+1412+bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
1413+bridgeParams?.onEvent?.({
1414+detail: "response completed with status=cancelled",
1415+direction: "server",
1416+type: "response.done",
1417+});
1418+1419+expect(createAudioResourceMock).not.toHaveBeenCalled();
1420+expect(player.play).not.toHaveBeenCalled();
1421+expect(player.stop).toHaveBeenCalledTimes(2);
1422+});
1423+12501424it("applies Discord realtime model and voice overrides during provider auto-selection", async () => {
12511425const manager = createManager({
12521426groupPolicy: "open",
@@ -1622,6 +1796,66 @@ describe("DiscordVoiceManager", () => {
16221796expectUserMessageIncludes("second answer");
16231797});
162417981799+it("drains queued exact speech after cancelled prebuffered output is discarded", async () => {
1800+agentCommandMock
1801+.mockResolvedValueOnce({ payloads: [{ text: "first answer" }] })
1802+.mockResolvedValueOnce({ payloads: [{ text: "second answer" }] });
1803+const manager = createManager({
1804+groupPolicy: "open",
1805+voice: {
1806+enabled: true,
1807+mode: "agent-proxy",
1808+realtime: { provider: "openai" },
1809+},
1810+});
1811+1812+await manager.join({ guildId: "g1", channelId: "1001" });
1813+const entry = getSessionEntry(manager) as {
1814+realtime?: {
1815+beginSpeakerTurn: (
1816+context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
1817+userId: string,
1818+) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
1819+};
1820+};
1821+const player = getLastAudioPlayer();
1822+const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as
1823+| {
1824+audioSink?: { sendAudio: (audio: Buffer) => void };
1825+onEvent?: (event: { detail?: string; direction: "server"; type: string }) => void;
1826+onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
1827+}
1828+| undefined;
1829+1830+const firstTurn = entry.realtime?.beginSpeakerTurn(
1831+{ extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
1832+"u-owner",
1833+);
1834+firstTurn?.sendInputAudio(Buffer.alloc(8));
1835+bridgeParams?.onTranscript?.("user", "first question", true);
1836+1837+await new Promise((resolve) => setTimeout(resolve, 260));
1838+await vi.waitFor(() => expectUserMessageIncludes("first answer"));
1839+bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
1840+1841+const secondTurn = entry.realtime?.beginSpeakerTurn(
1842+{ extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
1843+"u-owner",
1844+);
1845+secondTurn?.sendInputAudio(Buffer.alloc(8));
1846+bridgeParams?.onTranscript?.("user", "second question", true);
1847+1848+await new Promise((resolve) => setTimeout(resolve, 260));
1849+expectUserMessageNotIncludes("second answer");
1850+1851+bridgeParams?.onEvent?.({ direction: "server", type: "response.cancelled" });
1852+1853+expect(createAudioResourceMock).not.toHaveBeenCalled();
1854+expect(player.play).not.toHaveBeenCalled();
1855+expect(player.stop).toHaveBeenCalledWith(true);
1856+expectUserMessageIncludes("second answer");
1857+});
1858+16251859it("matches agent-proxy consult tool calls to the pending transcript", async () => {
16261860agentCommandMock
16271861.mockResolvedValueOnce({ payloads: [{ text: "owner answer" }] })
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。