fix(transcript): collapse rolling segment echoes
Build & Push Docker Image / test (push) Successful in 12s
Build & Push Docker Image / build-and-push (push) Successful in 45s

Normalize incremental backend hypothesis chains before persistence and ignore stale or replayed webhook callbacks so duplicate transcript text does not survive ingest.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-11 22:46:38 +02:00
co-authored by Copilot
parent 35a2d86dbb
commit 672b161cda
7 changed files with 246 additions and 33 deletions
+21 -18
View File
@@ -16,10 +16,10 @@
type RecordState = 'idle' | 'requesting' | 'recording' | 'stopping';
let state = $state<RecordState>('idle');
let error = $state('');
let elapsed = $state(0); // seconds
let liveData = $state<Float32Array | null>(null);
let recordState: RecordState = $state('idle');
let error: string = $state('');
let elapsed: number = $state(0); // seconds
let liveData: Float32Array | null = $state(null);
let mediaRecorder: MediaRecorder | null = null;
let chunks: Blob[] = [];
@@ -60,12 +60,12 @@
async function startRecording() {
error = '';
state = 'requesting';
recordState = 'requesting';
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
error = 'Microphone access denied';
state = 'idle';
recordState = 'idle';
return;
}
@@ -81,11 +81,11 @@
elapsed = 0;
timerInterval = setInterval(() => elapsed++, 1000);
state = 'recording';
recordState = 'recording';
}
function stopRecording() {
state = 'stopping';
recordState = 'stopping';
mediaRecorder?.stop();
if (timerInterval) clearInterval(timerInterval);
if (animFrame) cancelAnimationFrame(animFrame);
@@ -99,7 +99,7 @@
const ext = mime.includes('ogg') ? 'ogg' : mime.includes('mp4') ? 'mp4' : 'webm';
const blob = new Blob(chunks, { type: mime });
const filename = `recording-${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')}.${ext}`;
state = 'idle';
recordState = 'idle';
ondone?.(blob, filename);
}
@@ -116,15 +116,18 @@
{ length: IDLE_BARS },
(_, i) => 3 + Math.abs(Math.sin(i * 0.7) + Math.cos(i * 0.31)) * 20
);
const liveBars = $derived.by<number[]>(() =>
liveData ? Array.from(liveData.slice(0, IDLE_BARS), (value) => Number(value)) : []
);
</script>
<div class="recorder">
<!-- Waveform display -->
<div class="waveform-area" aria-hidden="true">
{#if state === 'recording' && liveData}
{#if recordState === 'recording' && liveData}
<!-- Live waveform from AnalyserNode -->
<svg viewBox="0 0 {IDLE_BARS * 5} 28" preserveAspectRatio="none" class="waveform-svg">
{#each Array.from(liveData).slice(0, IDLE_BARS) as v, i}
{#each liveBars as v, i}
{@const h = 2 + v * 24}
<rect
x={i * 5}
@@ -147,8 +150,8 @@
width="3"
height={h}
rx="1.5"
fill={state === 'idle' ? 'rgba(255,255,255,0.15)' : accent}
opacity={state === 'idle' ? 1 : 0.3}
fill={recordState === 'idle' ? 'rgba(255,255,255,0.15)' : accent}
opacity={recordState === 'idle' ? 1 : 0.3}
/>
{/each}
</svg>
@@ -156,7 +159,7 @@
</div>
<!-- Timer (recording only) -->
{#if state === 'recording'}
{#if recordState === 'recording'}
<div class="timer" style="color: {accent}">
<span class="rec-dot" style="background: {accent}"></span>
{formatTime(elapsed)}
@@ -170,15 +173,15 @@
<!-- Buttons -->
<div class="btn-row">
{#if state === 'idle' || state === 'requesting'}
{#if recordState === 'idle' || recordState === 'requesting'}
<button
class="btn-record"
style="background: {accent}; color: #0c0d10;"
onclick={startRecording}
disabled={state === 'requesting'}
disabled={recordState === 'requesting'}
aria-label="Start recording"
>
{#if state === 'requesting'}
{#if recordState === 'requesting'}
<svg width="13" height="13" viewBox="0 0 13 13" style="animation: spin 1s linear infinite">
<circle cx="6.5" cy="6.5" r="5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-dasharray="20 12"/>
</svg>
@@ -190,7 +193,7 @@
Record
{/if}
</button>
{:else if state === 'recording'}
{:else if recordState === 'recording'}
<button
class="btn-stop"
onclick={stopRecording}
+123 -4
View File
@@ -37,6 +37,119 @@ function mergeConsecutive(segments: Segment[]): Segment[] {
return out;
}
// ── Collapse rolling prefix/suffix chains from backend segment hypotheses ──────
const MAX_CHAIN_GAP_SECS = 0.15;
const MIN_MEANINGFUL_WORDS = 2;
const MIN_MEANINGFUL_CHARS = 8;
const MIN_OVERLAP_WORDS = 3;
function splitWords(text: string): string[] {
return text.trim().split(/\s+/).filter(Boolean);
}
function normaliseWords(text: string): string[] {
return splitWords(text)
.map((word) => word.toLowerCase().replace(/[^\w]/g, ''))
.filter(Boolean);
}
function arraysEqual(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index]);
}
function startsWithWords(full: string[], prefix: string[]): boolean {
return prefix.length <= full.length && arraysEqual(full.slice(0, prefix.length), prefix);
}
function endsWithWords(full: string[], suffix: string[]): boolean {
return suffix.length <= full.length && arraysEqual(full.slice(full.length - suffix.length), suffix);
}
function suffixPrefixOverlap(left: string[], right: string[]): number {
const max = Math.min(left.length, right.length);
for (let size = max; size >= 1; size--) {
if (arraysEqual(left.slice(left.length - size), right.slice(0, size))) return size;
}
return 0;
}
function isMeaningfulPhrase(words: string[]): boolean {
return words.length >= MIN_MEANINGFUL_WORDS && words.join(' ').length >= MIN_MEANINGFUL_CHARS;
}
function trimLeadingWords(text: string, count: number): string {
return splitWords(text).slice(count).join(' ').trim();
}
function collapseIncrementalSegments(segments: Segment[]): Segment[] {
const out: Segment[] = [];
for (const seg of segments) {
let current: Segment = {
...seg,
text: seg.text.trim()
};
if (!current.text) continue;
const last = out[out.length - 1];
if (!last) {
out.push(current);
continue;
}
const gap = current.start - last.end;
if (gap > MAX_CHAIN_GAP_SECS) {
out.push(current);
continue;
}
const lastWords = normaliseWords(last.text);
const currentWords = normaliseWords(current.text);
if (lastWords.length === 0 || currentWords.length === 0) {
out.push(current);
continue;
}
if (
currentWords.length > lastWords.length &&
startsWithWords(currentWords, lastWords) &&
isMeaningfulPhrase(lastWords)
) {
last.text = current.text;
last.end = current.end;
last.words = current.words;
continue;
}
if (endsWithWords(lastWords, currentWords) && isMeaningfulPhrase(currentWords)) {
last.end = Math.max(last.end, current.end);
continue;
}
const overlapWords = suffixPrefixOverlap(lastWords, currentWords);
if (overlapWords >= MIN_OVERLAP_WORDS) {
const trimmedText = trimLeadingWords(current.text, overlapWords);
if (!trimmedText) {
last.end = Math.max(last.end, current.end);
continue;
}
current = {
...current,
start: Math.max(current.start, last.end),
text: trimmedText,
words: []
};
}
out.push(current);
}
return out;
}
// ── N-gram deduplication ─────────────────────────────────────────────────────
const NGRAM_N = 6;
@@ -93,16 +206,22 @@ export function deduplicateSegments(segments: Segment[]): Segment[] {
// 2. Remove empty segments
result = result.filter((s) => s.text.length > 0);
// 3. First merge pass
// 3. Collapse rolling backend hypotheses before generic dedup
result = collapseIncrementalSegments(result);
// 4. First merge pass
result = mergeConsecutive(result);
// 4. N-gram dedup
// 5. N-gram dedup
result = ngramDedup(result);
// 5. Second merge pass (catches new adjacencies after dedup)
// 6. Re-run rolling collapse after removals create new adjacencies
result = collapseIncrementalSegments(result);
// 7. Second merge pass (catches new adjacencies after dedup)
result = mergeConsecutive(result);
// 6. Re-index
// 8. Re-index
result.forEach((s, i) => (s.index = i));
return result;