feat: per-call LLM timing, speaker ID, cancel captures on leave

- llm.py: log each Ollama call's caller + total/load/prompt/gen durations
  so a slow voice turn is attributable to a specific internal call
  (router/enrichment/digest/main); a RELOAD marker flags cold reloads.
- voice.ts: track in-flight Opus captures and abort them on session
  destroy(); drop any utterance that finishes after the user left, so no
  trailing post-leave VAD turns are reported.
- userbot.ts: show the speaker's Discord user ID on each transcript line
  (answered and dropped) so it's clear whose audio produced the turn.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
javis-bot
2026-06-14 00:38:26 +09:00
parent 5c11c5f7e8
commit 44ebfeafa8
4 changed files with 83 additions and 3 deletions

View File

@@ -53,6 +53,14 @@ test("formatTurnMessage reports a dropped turn with only the listening stage", (
expect(msg).not.toContain("🔊 TTS");
});
test("formatTurnMessage shows the speaker user ID when provided", () => {
const answered = formatTurnMessage({ user: "12345", transcript: "안녕", reply: "하이" });
expect(answered).toContain("👤 12345");
const dropped = formatTurnMessage({ user: "67890", transcript: "", reply: "", note: "음성 아님(VAD 차단)" });
expect(dropped).toContain("👤 67890");
expect(dropped).toContain("❌ 음성 아님(VAD 차단)");
});
test("formatTurnMessage falls back to the plain line when no timing is present", () => {
const msg = formatTurnMessage({ transcript: "안녕", reply: "하이" });
expect(msg).toBe('🎤 들음 → 🗣️ "안녕"\n🤖 답변: 하이');

View File

@@ -35,6 +35,9 @@ async function loadSelfbot(): Promise<any> {
}
export interface TurnInfo {
/** Discord user ID of the speaker, so the transcript shows whose audio
* produced each turn (and which user a dropped/VAD turn belongs to). */
user?: string;
transcript: string;
reply: string;
note?: string;
@@ -69,9 +72,10 @@ function durSec(a?: number, b?: number): string | null {
* timing breakdown (listening / LLM / TTS) with start→end wall-clock times and
* durations, so it's obvious what took long. Pure + exported for testing. */
export function formatTurnMessage(info: TurnInfo): string {
const who = info.user ? `👤 ${info.user} ` : "";
const head = info.transcript
? `🎤 들음 → 🗣️ "${info.transcript}"\n🤖 답변: ${(info.reply || "").trim() || "(무응답)"}`
: `🎤 들음 → ❌ ${info.note || "무시됨"}`;
? `${who}🎤 들음 → 🗣️ "${info.transcript}"\n🤖 답변: ${(info.reply || "").trim() || "(무응답)"}`
: `${who}🎤 들음 → ❌ ${info.note || "무시됨"}`;
const lines: string[] = [];
const listen = durSec(info.listenStartMs, info.listenEndMs);
@@ -120,7 +124,7 @@ async function joinAndListen(client: AnyClient, channelId: string): Promise<void
// joinVoiceChannel (it exposes id, guild.id and guild.voiceAdapterCreator).
const session = await joinChannel(channel as unknown as VoiceBasedChannel);
session.onTurn = (info) => {
console.log(`🗣️ ${info.transcript || "(" + (info.note || "empty") + ")"}\n🤖 ${info.reply}`);
console.log(`👤 ${info.user || "?"} 🗣️ ${info.transcript || "(" + (info.note || "empty") + ")"}\n🤖 ${info.reply}`);
// Mirror every heard utterance (and the reply / drop reason) to a text
// channel so you can see what the bot understood even when it doesn't answer.
void postTranscript(client, info);

View File

@@ -66,6 +66,13 @@ export class VoiceSession {
private connection: VoiceConnection;
private player: AudioPlayer;
private listening = new Set<string>();
/** Set once the session is torn down (user left / leave command). In-flight
* captures check this so we don't run STT/reply or post a transcript for
* audio that arrived after the user already left the channel. */
private destroyed = false;
/** Opus subscriptions currently capturing, so leave() can end them
* immediately instead of waiting out the silence timeout. */
private activeStreams = new Set<{ destroy: () => void }>();
/** Pending reply clips. Played one at a time so concurrent speakers don't
* cut each other's replies off. */
private playQueue: Buffer[] = [];
@@ -158,11 +165,14 @@ export class VoiceSession {
}
private async captureUtterance(userId: string): Promise<void> {
// Don't start a new capture once we're tearing down (user left).
if (this.destroyed) return;
// "듣기 시작": the moment we begin capturing this speaker's utterance.
const listenStartMs = Date.now();
const opusStream = this.connection.receiver.subscribe(userId, {
end: { behavior: EndBehaviorType.AfterSilence, duration: config.silenceMs },
});
this.activeStreams.add(opusStream);
const decoder = new prism.opus.Decoder({
frameSize: 960,
channels: DISCORD_CHANNELS,
@@ -173,6 +183,11 @@ export class VoiceSession {
pcmStream.on("data", (c: Buffer) => chunks.push(c));
await new Promise<void>((resolve) => pcmStream.once("end", () => resolve()));
this.activeStreams.delete(opusStream);
// If the user left while we were capturing, drop this utterance entirely —
// don't run STT/reply or report a (usually empty/VAD-blocked) turn for
// audio that trailed in after they left.
if (this.destroyed) return;
// "듣기 종료": end of speech (silence detected). Anything after this is
// STT + reply + TTS on the brain side.
const listenEndMs = Date.now();
@@ -264,6 +279,18 @@ export class VoiceSession {
}
destroy() {
this.destroyed = true;
// End any in-flight captures now so their pcmStream resolves immediately
// and the post-capture `destroyed` check drops them (no trailing
// post-leave VAD turns).
for (const s of this.activeStreams) {
try {
s.destroy();
} catch {
/* already ended */
}
}
this.activeStreams.clear();
try {
this.connection.destroy();
} catch {