Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions src/helpers/audioManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ import {
import { resolvePrompt } from "../config/prompts";
import { syncService } from "../services/SyncService.js";
import { evaluateFinishedRecording } from "./recordingValidation";
import { matchesDictionaryPrompt } from "../utils/dictionaryEchoFilter.js";
import {
matchesDictionaryPrompt,
truncateDictionaryPrompt,
} from "../utils/dictionaryEchoFilter.js";
import { getDictionaryHintWords } from "../utils/snippets";

const REASONING_CACHE_TTL = 30000; // 30 seconds
Expand Down Expand Up @@ -381,8 +384,9 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor);
return words.length > 0 ? words.join(", ") : null;
}

isDictionaryEcho(text) {
return matchesDictionaryPrompt(text, this.getCustomDictionaryPrompt());
isDictionaryEcho(text, dictionaryPrompt = this.getCustomDictionaryPrompt()) {
// Prefer the prompt actually sent to STT (may be truncated for Groq).
return matchesDictionaryPrompt(text, dictionaryPrompt);
}

setCallbacks({
Expand Down Expand Up @@ -2123,9 +2127,7 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor);
if (dictionaryPrompt) {
if (dictionaryPrompt.length > MAX_PROMPT_CHARS) {
const originalLength = dictionaryPrompt.length;
const truncated = dictionaryPrompt.slice(0, MAX_PROMPT_CHARS);
const lastComma = truncated.lastIndexOf(",");
dictionaryPrompt = lastComma > 0 ? truncated.slice(0, lastComma) : truncated;
dictionaryPrompt = truncateDictionaryPrompt(dictionaryPrompt, MAX_PROMPT_CHARS);
logger.debug(
"Custom dictionary prompt truncated",
{
Expand Down Expand Up @@ -2173,7 +2175,7 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor);
const proxyText = result?.text;

if (proxyText && proxyText.trim().length > 0) {
if (this.isDictionaryEcho(proxyText)) {
if (this.isDictionaryEcho(proxyText, dictionaryPrompt)) {
throw new Error("No audio detected");
}
timings.transcriptionProcessingDurationMs = Math.round(performance.now() - apiCallStart);
Expand Down Expand Up @@ -2387,7 +2389,8 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor);

// Check for text - handle both empty string and missing field
if (result.text && result.text.trim().length > 0) {
if (this.isDictionaryEcho(result.text)) {
// Match against the prompt actually sent (truncated for Groq), not the full dict.
if (this.isDictionaryEcho(result.text, dictionaryPrompt)) {
throw new Error("No audio detected");
}
timings.transcriptionProcessingDurationMs = Math.round(performance.now() - apiCallStart);
Expand Down
8 changes: 8 additions & 0 deletions src/utils/dictionaryEchoFilter.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ const normalize = (s) =>
.replace(/\s+/g, " ")
.trim();

/** Truncate a dictionary prompt to maxChars, preferring a comma boundary. */
export function truncateDictionaryPrompt(prompt, maxChars) {
if (!prompt || prompt.length <= maxChars) return prompt;
const truncated = prompt.slice(0, maxChars);
const lastComma = truncated.lastIndexOf(",");
return lastComma > 0 ? truncated.slice(0, lastComma) : truncated;
}

export function matchesDictionaryPrompt(text, dictionaryPrompt) {
if (!text || !dictionaryPrompt) return false;

Expand Down
26 changes: 26 additions & 0 deletions test/helpers/dictionaryEchoFilter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,29 @@ test("does not flag completely unrelated text", async () => {
false
);
});

test("truncateDictionaryPrompt prefers a comma boundary under maxChars", async () => {
const { truncateDictionaryPrompt } = await import("../../src/utils/dictionaryEchoFilter.js");
const full = "Alpha, Bravo, Charlie, Delta, Echo, Foxtrot";
assert.equal(truncateDictionaryPrompt(full, 20), "Alpha, Bravo");
assert.equal(truncateDictionaryPrompt(full, 1000), full);
assert.equal(truncateDictionaryPrompt(null, 10), null);
});

test("echo filter matches the truncated prompt that Groq actually receives", async () => {
const { matchesDictionaryPrompt, truncateDictionaryPrompt } = await import(
"../../src/utils/dictionaryEchoFilter.js"
);
// Large dict: enough unique terms that truncation drops a trailing chunk (>890 chars).
const words = Array.from({ length: 200 }, (_, i) => `DictionaryTerm${i}`);
const full = words.join(", ");
assert.ok(full.length > 890, `expected long dict, got length ${full.length}`);
const sent = truncateDictionaryPrompt(full, 890);
assert.ok(sent.length <= 890);
assert.notEqual(sent, full);

// Whisper often echoes the prompt that was sent, not the full dictionary.
assert.equal(matchesDictionaryPrompt(sent, sent), true);
// Comparing that echo against the full dict can miss (usage ratio drops).
assert.equal(matchesDictionaryPrompt(sent, full), false);
});