@agent-native/core 0.134.0 → 0.134.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +6 -0
- package/corpus/core/docs/content/template-content-developers.mdx +1 -1
- package/corpus/core/package.json +1 -1
- package/corpus/templates/clips/changelog/2026-07-29-meeting-notes-no-longer-transcribe-the-other-side-twice-when.md +6 -0
- package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +37 -48
- package/corpus/templates/clips/desktop/src/lib/transcription-capture.ts +7 -5
- package/corpus/templates/clips/desktop/src/lib/transcription-engine.ts +182 -106
- package/corpus/templates/clips/desktop/src/overlays/live-transcript.tsx +43 -14
- package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +3 -3
- package/corpus/templates/clips/desktop/src-tauri/src/echo_guard.rs +443 -0
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +1 -0
- package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +125 -16
- package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +6 -4
- package/corpus/templates/content/actions/_database-membership-lock.ts +25 -0
- package/corpus/templates/content/actions/_database-row-batch.ts +6 -2
- package/corpus/templates/content/actions/_database-source-utils.ts +289 -129
- package/corpus/templates/content/actions/_database-utils.ts +12 -3
- package/corpus/templates/content/actions/add-content-database-source-field-property.ts +39 -21
- package/corpus/templates/content/actions/attach-content-database-source.ts +4 -1
- package/corpus/templates/content/actions/change-content-database-source-role.ts +3 -5
- package/corpus/templates/content/actions/duplicate-document-property.ts +59 -46
- package/corpus/templates/content/actions/execute-builder-source-execution.ts +77 -71
- package/corpus/templates/content/actions/materialize-builder-required-fields.ts +7 -0
- package/corpus/templates/content/actions/remove-database-items.ts +183 -0
- package/corpus/templates/content/actions/set-document-property.ts +57 -39
- package/corpus/templates/content/actions/sync-local-folder-source.ts +7 -0
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +471 -329
- package/corpus/templates/content/app/components/editor/database/row-access.ts +45 -0
- package/corpus/templates/content/app/components/editor/database/shared.tsx +141 -38
- package/corpus/templates/content/app/hooks/use-content-database.ts +2 -2
- package/corpus/templates/content/app/i18n-data.ts +130 -0
- package/corpus/templates/content/changelog/2026-07-30-database-rows-can-now-be-removed-without-deleting-their-page.md +6 -0
- package/corpus/templates/content/parity/eval-scenarios.ts +2 -2
- package/corpus/templates/content/parity/matrix.md +1 -1
- package/corpus/templates/content/parity/matrix.ts +4 -5
- package/corpus/templates/content/shared/api.ts +4 -0
- package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +26 -11
- package/corpus/templates/design/actions/take-design-screenshot.ts +7 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +11 -3
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +4 -1
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +54 -17
- package/corpus/templates/design/app/components/design/design-canvas/local-runtime.ts +126 -0
- package/corpus/templates/design/app/components/templates/TemplatePreview.tsx +2 -0
- package/corpus/templates/design/app/pages/Index.tsx +3 -1
- package/corpus/templates/design/app/pages/Present.tsx +2 -1
- package/corpus/templates/design/changelog/2026-07-30-a-broken-inline-script-in-a-generated-screen-is-now-caught-o.md +6 -0
- package/corpus/templates/design/changelog/2026-07-30-design-html-is-now-validated-with-a-spec-grade-html-parser-i.md +6 -0
- package/corpus/templates/design/changelog/2026-07-30-designs-now-load-their-tailwind-and-alpine-runtimes-from-the.md +6 -0
- package/corpus/templates/design/changelog/2026-07-30-designs-with-a-broken-alpine-expression-are-now-caught-on-sa.md +6 -0
- package/corpus/templates/design/changelog/2026-07-30-editing-a-design-no-longer-strips-its-styling-the-canvas-kep.md +6 -0
- package/corpus/templates/design/package.json +4 -0
- package/corpus/templates/design/shared/html-integrity.ts +802 -556
- package/dist/notifications/routes.d.ts +3 -3
- package/docs/content/template-content-developers.mdx +1 -1
- package/package.json +1 -1
- package/corpus/templates/content/actions/delete-database-items.ts +0 -89
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
appendFinalTranscript,
|
|
5
|
+
isMicEcho,
|
|
4
6
|
onFinalTranscript,
|
|
5
7
|
onPartialTranscript,
|
|
8
|
+
type TranscriptLine,
|
|
6
9
|
} from "../lib/transcription-engine";
|
|
7
10
|
|
|
8
11
|
type Source = "mic" | "system";
|
|
@@ -13,6 +16,17 @@ export interface FinalLine {
|
|
|
13
16
|
startMs?: number;
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
/** Preloaded history arrives without its verbatim segments. */
|
|
20
|
+
function historyLine(line: FinalLine): TranscriptLine {
|
|
21
|
+
return {
|
|
22
|
+
text: line.text,
|
|
23
|
+
source: line.source,
|
|
24
|
+
startMs: line.startMs ?? null,
|
|
25
|
+
segments: [],
|
|
26
|
+
historical: true,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
/** Format ms since meeting start as m:ss. */
|
|
17
31
|
function formatTimestamp(ms: number): string {
|
|
18
32
|
const total = Math.floor(ms / 1000);
|
|
@@ -38,10 +52,12 @@ export function LiveTranscript({
|
|
|
38
52
|
onLinesChange,
|
|
39
53
|
initialLines,
|
|
40
54
|
}: {
|
|
41
|
-
onLinesChange?: (lines:
|
|
55
|
+
onLinesChange?: (lines: TranscriptLine[]) => void;
|
|
42
56
|
initialLines?: FinalLine[];
|
|
43
57
|
} = {}) {
|
|
44
|
-
const [finals, setFinals] = useState<
|
|
58
|
+
const [finals, setFinals] = useState<TranscriptLine[]>(
|
|
59
|
+
() => initialLines?.map(historyLine) ?? [],
|
|
60
|
+
);
|
|
45
61
|
const [micPartial, setMicPartial] = useState("");
|
|
46
62
|
const [sysPartial, setSysPartial] = useState("");
|
|
47
63
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -69,7 +85,7 @@ export function LiveTranscript({
|
|
|
69
85
|
// older history), instead of being overwritten.
|
|
70
86
|
if (preloadAppliedRef.current) return;
|
|
71
87
|
preloadAppliedRef.current = true;
|
|
72
|
-
setFinals((prev) => [...lines, ...prev]);
|
|
88
|
+
setFinals((prev) => [...lines.map(historyLine), ...prev]);
|
|
73
89
|
}, [initialLines]);
|
|
74
90
|
|
|
75
91
|
useEffect(() => {
|
|
@@ -97,12 +113,13 @@ export function LiveTranscript({
|
|
|
97
113
|
}),
|
|
98
114
|
);
|
|
99
115
|
trackListen(
|
|
100
|
-
onFinalTranscript((
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
116
|
+
onFinalTranscript((event) => {
|
|
117
|
+
if (!event.text.trim()) return;
|
|
118
|
+
setFinals((prev) => {
|
|
119
|
+
const next = [...prev];
|
|
120
|
+
return appendFinalTranscript(event, next) ? next : prev;
|
|
121
|
+
});
|
|
122
|
+
if (event.source === "system") setSysPartial("");
|
|
106
123
|
else setMicPartial("");
|
|
107
124
|
}),
|
|
108
125
|
);
|
|
@@ -126,9 +143,21 @@ export function LiveTranscript({
|
|
|
126
143
|
el.scrollTop = el.scrollHeight;
|
|
127
144
|
}, [finals, micPartial, sysPartial]);
|
|
128
145
|
|
|
146
|
+
// Partials never reach `appendFinalTranscript`, so speaker bleed shows up
|
|
147
|
+
// here as a live "You" bubble mirroring what the remote side is still
|
|
148
|
+
// saying. Judge the in-flight mic text against the in-flight system text too,
|
|
149
|
+
// since neither has been committed to a line yet.
|
|
150
|
+
const spokenMicPartial = useMemo(() => {
|
|
151
|
+
if (!micPartial) return "";
|
|
152
|
+
const inFlight: TranscriptLine[] = sysPartial
|
|
153
|
+
? [{ source: "system", text: sysPartial, startMs: null, segments: [] }]
|
|
154
|
+
: [];
|
|
155
|
+
return isMicEcho(micPartial, [...finals, ...inFlight]) ? "" : micPartial;
|
|
156
|
+
}, [micPartial, sysPartial, finals]);
|
|
157
|
+
|
|
129
158
|
return (
|
|
130
159
|
<div ref={scrollRef} className="lt-chat">
|
|
131
|
-
{finals.length === 0 && !
|
|
160
|
+
{finals.length === 0 && !spokenMicPartial && !sysPartial ? (
|
|
132
161
|
<div className="lt-empty">Listening…</div>
|
|
133
162
|
) : null}
|
|
134
163
|
{finals.map((line, i) => (
|
|
@@ -142,8 +171,8 @@ export function LiveTranscript({
|
|
|
142
171
|
{sysPartial ? (
|
|
143
172
|
<ChatBubble source="system" text={sysPartial} pending />
|
|
144
173
|
) : null}
|
|
145
|
-
{
|
|
146
|
-
<ChatBubble source="mic" text={
|
|
174
|
+
{spokenMicPartial ? (
|
|
175
|
+
<ChatBubble source="mic" text={spokenMicPartial} pending />
|
|
147
176
|
) : null}
|
|
148
177
|
</div>
|
|
149
178
|
);
|
|
@@ -164,7 +193,7 @@ function ChatBubble({
|
|
|
164
193
|
source: Source;
|
|
165
194
|
text: string;
|
|
166
195
|
pending?: boolean;
|
|
167
|
-
startMs?: number;
|
|
196
|
+
startMs?: number | null;
|
|
168
197
|
}) {
|
|
169
198
|
const isYou = source === "mic";
|
|
170
199
|
const label = isYou ? "You" : "Them";
|
|
@@ -17,7 +17,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
|
17
17
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
18
18
|
|
|
19
19
|
import { isDirectPillClick, type ScreenPoint } from "../lib/pill-interaction";
|
|
20
|
-
import { speakerFor } from "../lib/transcription-engine";
|
|
20
|
+
import { speakerFor, type TranscriptLine } from "../lib/transcription-engine";
|
|
21
21
|
import { LiveAudioBars } from "./live-audio-bars";
|
|
22
22
|
import { LiveTranscript, type FinalLine } from "./live-transcript";
|
|
23
23
|
import { PillLogo } from "./pill-logo";
|
|
@@ -52,7 +52,7 @@ export function RecordingPill() {
|
|
|
52
52
|
);
|
|
53
53
|
const finished = finishedMeetingId !== null;
|
|
54
54
|
const [error, setError] = useState<string | null>(null);
|
|
55
|
-
const transcriptLinesRef = useRef<
|
|
55
|
+
const transcriptLinesRef = useRef<TranscriptLine[]>([]);
|
|
56
56
|
const [hasTranscriptLines, setHasTranscriptLines] = useState(false);
|
|
57
57
|
const [transcriptCopied, setTranscriptCopied] = useState(false);
|
|
58
58
|
const [preloadedLines, setPreloadedLines] = useState<FinalLine[]>([]);
|
|
@@ -269,7 +269,7 @@ export function RecordingPill() {
|
|
|
269
269
|
|
|
270
270
|
// Stable callback for LiveTranscript to push locked-in lines up. Stable
|
|
271
271
|
// identity matters — it's a dep of an effect inside LiveTranscript.
|
|
272
|
-
const handleTranscriptLines = useCallback((lines:
|
|
272
|
+
const handleTranscriptLines = useCallback((lines: TranscriptLine[]) => {
|
|
273
273
|
transcriptLinesRef.current = lines;
|
|
274
274
|
setHasTranscriptLines(lines.length > 0);
|
|
275
275
|
}, []);
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
//! Speaker-bleed detection for the meeting microphone stream.
|
|
2
|
+
//!
|
|
3
|
+
//! Without headphones the microphone re-records whatever the speakers play, so
|
|
4
|
+
//! the remote side reaches Whisper twice: once cleanly on the system stream and
|
|
5
|
+
//! once, mangled, on the mic. Downstream text de-duplication can only catch the
|
|
6
|
+
//! copies that happen to transcribe alike, and echo is exactly the audio
|
|
7
|
+
//! Whisper transcribes worst — so the leak is cut here instead. Mic audio whose
|
|
8
|
+
//! loudness envelope tracks the system-audio envelope at a constant delay is
|
|
9
|
+
//! playback bleed, not speech, and never reaches inference.
|
|
10
|
+
//!
|
|
11
|
+
//! Dropping real speech is far worse here than letting echo through, so the
|
|
12
|
+
//! gate is built to fail open:
|
|
13
|
+
//! - With headphones the reference is just as loud but uncorrelated with the
|
|
14
|
+
//! mic, so it stays open without any output-device detection.
|
|
15
|
+
//! - During double-talk the user's own voice is energy the reference cannot
|
|
16
|
+
//! explain, which breaks the correlation and keeps the utterance.
|
|
17
|
+
//! - The verdict is taken per one-second window and every window has to
|
|
18
|
+
//! agree, so a single sentence of the user's cannot be outvoted by the
|
|
19
|
+
//! minute of remote speech it interrupted.
|
|
20
|
+
//!
|
|
21
|
+
//! Envelopes, not waveforms: the speaker→mic path adds room reverb, clipping,
|
|
22
|
+
//! and device resampling that destroy sample-level correlation but leave the
|
|
23
|
+
//! loudness contour intact.
|
|
24
|
+
|
|
25
|
+
use std::collections::VecDeque;
|
|
26
|
+
use std::sync::Mutex;
|
|
27
|
+
use std::time::{Duration, Instant};
|
|
28
|
+
|
|
29
|
+
/// Envelope resolution. Short enough to follow syllables, long enough that a
|
|
30
|
+
/// capture buffer lands in one or two frames.
|
|
31
|
+
const FRAME_MS: u64 = 20;
|
|
32
|
+
/// Longest speaker→microphone round trip we search for. Covers output device
|
|
33
|
+
/// buffering plus room propagation.
|
|
34
|
+
const MAX_ECHO_DELAY_MS: u64 = 400;
|
|
35
|
+
/// Playback older than this can never explain a mic utterance we are about to
|
|
36
|
+
/// finalize, so the reference ring never needs to grow past it.
|
|
37
|
+
const REFERENCE_RETENTION: Duration = Duration::from_secs(30);
|
|
38
|
+
/// An utterance is judged one window at a time rather than as a whole, and is
|
|
39
|
+
/// only suppressed when every window is echo. Whisper keeps buffering until it
|
|
40
|
+
/// hears a pause, so an utterance can run for tens of seconds — long enough
|
|
41
|
+
/// that a whole-buffer verdict would let a wall of remote speech outvote the
|
|
42
|
+
/// second in which the user cut in. One second is the shortest window whose
|
|
43
|
+
/// envelope still carries enough syllables to correlate.
|
|
44
|
+
const WINDOW_FRAMES: usize = 1000 / FRAME_MS as usize;
|
|
45
|
+
/// A window with less speech than this has nothing to explain, so it neither
|
|
46
|
+
/// confirms nor denies echo.
|
|
47
|
+
const MIN_VOICED_FRAMES: usize = 100 / FRAME_MS as usize;
|
|
48
|
+
/// Mic frames quieter than this are not speech and do not need explaining.
|
|
49
|
+
/// Matches the whisper worker's own voice-activity threshold.
|
|
50
|
+
const VOICED_RMS: f32 = 0.006;
|
|
51
|
+
/// Reference frames quieter than this count as "nothing was playing".
|
|
52
|
+
const PLAYBACK_RMS: f32 = 0.002;
|
|
53
|
+
/// Share of voiced mic frames that must coincide with playback. A single
|
|
54
|
+
/// stretch of the user talking into silence drops the utterance below this.
|
|
55
|
+
const MIN_COVERAGE: f32 = 0.9;
|
|
56
|
+
/// Pearson correlation of the two dB envelopes at the best delay.
|
|
57
|
+
const MIN_CORRELATION: f32 = 0.7;
|
|
58
|
+
/// Both envelopes must actually vary, otherwise correlation is measuring noise
|
|
59
|
+
/// between two near-constant lines. Steady background playback fails this and
|
|
60
|
+
/// the utterance is kept.
|
|
61
|
+
const MIN_DB_DEVIATION: f32 = 3.0;
|
|
62
|
+
|
|
63
|
+
const MAX_LAG_FRAMES: usize = MAX_ECHO_DELAY_MS as usize / FRAME_MS as usize;
|
|
64
|
+
|
|
65
|
+
/// Loudness of one capture buffer, kept with the wall-clock window it covers so
|
|
66
|
+
/// mic and system streams can be aligned without a shared sample clock.
|
|
67
|
+
#[derive(Clone, Copy)]
|
|
68
|
+
struct ReferenceSpan {
|
|
69
|
+
start: Instant,
|
|
70
|
+
end: Instant,
|
|
71
|
+
rms: f32,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Rolling record of what the speakers have been playing.
|
|
75
|
+
pub(crate) struct EchoGuard {
|
|
76
|
+
spans: Mutex<VecDeque<ReferenceSpan>>,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
impl EchoGuard {
|
|
80
|
+
pub(crate) fn new() -> Self {
|
|
81
|
+
Self {
|
|
82
|
+
spans: Mutex::new(VecDeque::new()),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// Record one system-audio capture buffer. Called from the realtime audio
|
|
87
|
+
/// callback: one pass over the samples, one push, no allocation beyond the
|
|
88
|
+
/// ring's amortized growth.
|
|
89
|
+
pub(crate) fn note_playback(&self, samples: &[f32], src_rate: f64) {
|
|
90
|
+
if samples.is_empty() || src_rate <= 0.0 {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
let end = Instant::now();
|
|
94
|
+
let duration = Duration::from_secs_f64(samples.len() as f64 / src_rate);
|
|
95
|
+
let rms = (samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32).sqrt();
|
|
96
|
+
let mut spans = self
|
|
97
|
+
.spans
|
|
98
|
+
.lock()
|
|
99
|
+
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
100
|
+
spans.push_back(ReferenceSpan {
|
|
101
|
+
start: end.checked_sub(duration).unwrap_or(end),
|
|
102
|
+
end,
|
|
103
|
+
rms,
|
|
104
|
+
});
|
|
105
|
+
while spans
|
|
106
|
+
.front()
|
|
107
|
+
.is_some_and(|span| end.saturating_duration_since(span.end) > REFERENCE_RETENTION)
|
|
108
|
+
{
|
|
109
|
+
spans.pop_front();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// Whether `samples` (16 kHz mono, captured starting at `buffer_start`) is
|
|
114
|
+
/// the speakers bleeding back into the microphone rather than speech.
|
|
115
|
+
pub(crate) fn is_playback_echo(&self, samples: &[f32], buffer_start: Instant) -> bool {
|
|
116
|
+
let mic = envelope_16k(samples);
|
|
117
|
+
if mic.len() < WINDOW_FRAMES {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
// The reference has to start MAX_LAG_FRAMES early so every candidate
|
|
121
|
+
// delay has real playback to line up against.
|
|
122
|
+
let lag = Duration::from_millis(MAX_LAG_FRAMES as u64 * FRAME_MS);
|
|
123
|
+
let reference_start = buffer_start.checked_sub(lag).unwrap_or(buffer_start);
|
|
124
|
+
let reference = self.reference_envelope(reference_start, mic.len() + MAX_LAG_FRAMES);
|
|
125
|
+
is_echo(&mic, &reference)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// Sample the playback envelope onto the same `FRAME_MS` grid the mic uses,
|
|
129
|
+
/// taking the loudest overlapping span for each frame.
|
|
130
|
+
fn reference_envelope(&self, from: Instant, frames: usize) -> Vec<f32> {
|
|
131
|
+
let mut envelope = vec![0.0f32; frames];
|
|
132
|
+
let spans: Vec<ReferenceSpan> = {
|
|
133
|
+
let spans = self
|
|
134
|
+
.spans
|
|
135
|
+
.lock()
|
|
136
|
+
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
137
|
+
spans.iter().copied().collect()
|
|
138
|
+
};
|
|
139
|
+
let frame = Duration::from_millis(FRAME_MS);
|
|
140
|
+
let until = from + frame * frames as u32;
|
|
141
|
+
for span in spans {
|
|
142
|
+
if span.end <= from || span.start >= until {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
let first = span.start.saturating_duration_since(from).as_nanos() / frame.as_nanos();
|
|
146
|
+
let end = span.end.saturating_duration_since(from).as_nanos();
|
|
147
|
+
let end_exclusive = end.div_ceil(frame.as_nanos());
|
|
148
|
+
for slot in envelope
|
|
149
|
+
.iter_mut()
|
|
150
|
+
.take((end_exclusive as usize).min(frames))
|
|
151
|
+
.skip((first as usize).min(frames))
|
|
152
|
+
{
|
|
153
|
+
*slot = slot.max(span.rms);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
envelope
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/// Per-frame RMS of 16 kHz mono samples. A trailing partial frame is dropped —
|
|
161
|
+
/// its loudness is not comparable to a full one.
|
|
162
|
+
fn envelope_16k(samples: &[f32]) -> Vec<f32> {
|
|
163
|
+
let frame = (16_000 * FRAME_MS as usize) / 1000;
|
|
164
|
+
samples
|
|
165
|
+
.chunks_exact(frame)
|
|
166
|
+
.map(|chunk| (chunk.iter().map(|s| s * s).sum::<f32>() / chunk.len() as f32).sqrt())
|
|
167
|
+
.collect()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// Decide whether `mic` is playback bleed. `reference` is the playback envelope
|
|
171
|
+
/// on the same grid but starting `MAX_LAG_FRAMES` earlier, so a window of `mic`
|
|
172
|
+
/// at offset `o` is judged against `reference[o..o + WINDOW_FRAMES + lag]`.
|
|
173
|
+
///
|
|
174
|
+
/// Every speech-carrying window has to look like echo. One window that does not
|
|
175
|
+
/// keeps the whole utterance, because that window is the user talking.
|
|
176
|
+
///
|
|
177
|
+
/// Split out from `EchoGuard` so the decision is testable without audio devices
|
|
178
|
+
/// or wall-clock timing.
|
|
179
|
+
fn is_echo(mic: &[f32], reference: &[f32]) -> bool {
|
|
180
|
+
if mic.len() < WINDOW_FRAMES || reference.len() < mic.len() + MAX_LAG_FRAMES {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
let mut judged = 0u32;
|
|
184
|
+
for offset in window_offsets(mic.len()) {
|
|
185
|
+
let window = &mic[offset..offset + WINDOW_FRAMES];
|
|
186
|
+
if window.iter().filter(|&&level| level > VOICED_RMS).count() < MIN_VOICED_FRAMES {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
judged += 1;
|
|
190
|
+
let reference = &reference[offset..offset + WINDOW_FRAMES + MAX_LAG_FRAMES];
|
|
191
|
+
if !meets_echo_thresholds(
|
|
192
|
+
playback_coverage(window, reference),
|
|
193
|
+
best_delay_correlation(window, reference),
|
|
194
|
+
) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
judged > 0
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/// Window start frames covering all of `frames`. The last window is anchored to
|
|
202
|
+
/// the end rather than dropped, so speech in a trailing part-window — a "hang
|
|
203
|
+
/// on, actually" right before the pause that ended the utterance — is still
|
|
204
|
+
/// judged on its own instead of inheriting the verdict of the echo before it.
|
|
205
|
+
fn window_offsets(frames: usize) -> Vec<usize> {
|
|
206
|
+
let Some(last) = frames.checked_sub(WINDOW_FRAMES) else {
|
|
207
|
+
return Vec::new();
|
|
208
|
+
};
|
|
209
|
+
let mut offsets: Vec<usize> = (0..=last).step_by(WINDOW_FRAMES).collect();
|
|
210
|
+
if offsets.last() != Some(&last) {
|
|
211
|
+
offsets.push(last);
|
|
212
|
+
}
|
|
213
|
+
offsets
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
fn meets_echo_thresholds(coverage: f32, correlation: f32) -> bool {
|
|
217
|
+
coverage >= MIN_COVERAGE && correlation >= MIN_CORRELATION
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/// Share of the mic's voiced frames that had playback somewhere inside the echo
|
|
221
|
+
/// delay window. Voice arriving while the speakers were silent cannot be echo.
|
|
222
|
+
fn playback_coverage(mic: &[f32], reference: &[f32]) -> f32 {
|
|
223
|
+
let mut voiced = 0u32;
|
|
224
|
+
let mut covered = 0u32;
|
|
225
|
+
for (i, &level) in mic.iter().enumerate() {
|
|
226
|
+
if level <= VOICED_RMS {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
voiced += 1;
|
|
230
|
+
if reference[i..=i + MAX_LAG_FRAMES]
|
|
231
|
+
.iter()
|
|
232
|
+
.any(|&r| r > PLAYBACK_RMS)
|
|
233
|
+
{
|
|
234
|
+
covered += 1;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if voiced == 0 {
|
|
238
|
+
return 0.0;
|
|
239
|
+
}
|
|
240
|
+
covered as f32 / voiced as f32
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/// Best Pearson correlation between the mic and reference dB envelopes across
|
|
244
|
+
/// every candidate echo delay.
|
|
245
|
+
fn best_delay_correlation(mic: &[f32], reference: &[f32]) -> f32 {
|
|
246
|
+
let mic_db: Vec<f32> = mic.iter().copied().map(decibels).collect();
|
|
247
|
+
let reference_db: Vec<f32> = reference.iter().copied().map(decibels).collect();
|
|
248
|
+
(0..=MAX_LAG_FRAMES)
|
|
249
|
+
.map(|delay| {
|
|
250
|
+
let offset = MAX_LAG_FRAMES - delay;
|
|
251
|
+
correlation(&mic_db, &reference_db[offset..offset + mic_db.len()])
|
|
252
|
+
})
|
|
253
|
+
.fold(0.0f32, f32::max)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
fn decibels(rms: f32) -> f32 {
|
|
257
|
+
20.0 * rms.max(1e-6).log10()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/// Pearson correlation, or 0 when either series is too flat to correlate
|
|
261
|
+
/// meaningfully.
|
|
262
|
+
fn correlation(left: &[f32], right: &[f32]) -> f32 {
|
|
263
|
+
let n = left.len() as f32;
|
|
264
|
+
let left_mean = left.iter().sum::<f32>() / n;
|
|
265
|
+
let right_mean = right.iter().sum::<f32>() / n;
|
|
266
|
+
let mut covariance = 0.0f32;
|
|
267
|
+
let mut left_variance = 0.0f32;
|
|
268
|
+
let mut right_variance = 0.0f32;
|
|
269
|
+
for (&l, &r) in left.iter().zip(right) {
|
|
270
|
+
let l = l - left_mean;
|
|
271
|
+
let r = r - right_mean;
|
|
272
|
+
covariance += l * r;
|
|
273
|
+
left_variance += l * l;
|
|
274
|
+
right_variance += r * r;
|
|
275
|
+
}
|
|
276
|
+
let left_deviation = (left_variance / n).sqrt();
|
|
277
|
+
let right_deviation = (right_variance / n).sqrt();
|
|
278
|
+
if left_deviation < MIN_DB_DEVIATION || right_deviation < MIN_DB_DEVIATION {
|
|
279
|
+
return 0.0;
|
|
280
|
+
}
|
|
281
|
+
covariance / (n * left_deviation * right_deviation)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
#[cfg(test)]
|
|
285
|
+
mod tests {
|
|
286
|
+
use super::*;
|
|
287
|
+
|
|
288
|
+
const FRAMES: usize = 150;
|
|
289
|
+
const DELAY: usize = 5;
|
|
290
|
+
|
|
291
|
+
/// Speech-like envelope: alternating loud and quiet stretches.
|
|
292
|
+
fn speech(frames: usize, seed: usize) -> Vec<f32> {
|
|
293
|
+
(0..frames)
|
|
294
|
+
.map(|i| {
|
|
295
|
+
let phase = (i + seed * 7) % 40;
|
|
296
|
+
if phase < 24 {
|
|
297
|
+
0.02 + 0.02 * ((i * (seed + 3)) % 5) as f32 / 5.0
|
|
298
|
+
} else {
|
|
299
|
+
0.0005
|
|
300
|
+
}
|
|
301
|
+
})
|
|
302
|
+
.collect()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/// Place `mic` inside a reference track delayed by `DELAY` frames and
|
|
306
|
+
/// attenuated the way a speaker→mic path attenuates.
|
|
307
|
+
fn reference_echoing(mic: &[f32], gain: f32) -> Vec<f32> {
|
|
308
|
+
let mut reference = vec![0.0f32; mic.len() + MAX_LAG_FRAMES];
|
|
309
|
+
for (i, &level) in mic.iter().enumerate() {
|
|
310
|
+
reference[i + MAX_LAG_FRAMES - DELAY] = level / gain;
|
|
311
|
+
}
|
|
312
|
+
reference
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#[test]
|
|
316
|
+
fn speaker_bleed_is_detected_as_echo() {
|
|
317
|
+
let mic = speech(FRAMES, 1);
|
|
318
|
+
let reference = reference_echoing(&mic, 8.0);
|
|
319
|
+
assert!(is_echo(&mic, &reference));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
#[test]
|
|
323
|
+
fn headphones_keep_the_utterance_even_though_playback_is_loud() {
|
|
324
|
+
// Reference is continuously loud (remote side talking into the user's
|
|
325
|
+
// headphones) but its contour is unrelated to the mic.
|
|
326
|
+
let mic = speech(FRAMES, 1);
|
|
327
|
+
let mut reference = vec![0.0f32; mic.len() + MAX_LAG_FRAMES];
|
|
328
|
+
for (i, level) in speech(reference.len(), 9).into_iter().enumerate() {
|
|
329
|
+
reference[i] = level;
|
|
330
|
+
}
|
|
331
|
+
assert!(!is_echo(&mic, &reference));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#[test]
|
|
335
|
+
fn double_talk_keeps_the_utterance() {
|
|
336
|
+
// Echo plus the user speaking through the reference's quiet stretches.
|
|
337
|
+
let remote = speech(FRAMES, 1);
|
|
338
|
+
let reference = reference_echoing(&remote, 8.0);
|
|
339
|
+
let mic: Vec<f32> = remote
|
|
340
|
+
.iter()
|
|
341
|
+
.enumerate()
|
|
342
|
+
.map(|(i, &level)| if i % 40 >= 24 { 0.05 } else { level })
|
|
343
|
+
.collect();
|
|
344
|
+
assert!(!is_echo(&mic, &reference));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
#[test]
|
|
348
|
+
fn silent_playback_keeps_the_utterance() {
|
|
349
|
+
let mic = speech(FRAMES, 1);
|
|
350
|
+
let reference = vec![0.0f32; mic.len() + MAX_LAG_FRAMES];
|
|
351
|
+
assert!(!is_echo(&mic, &reference));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
#[test]
|
|
355
|
+
fn steady_playback_is_never_mistaken_for_echo() {
|
|
356
|
+
// Constant tone under a constant mic level: coverage is total, but
|
|
357
|
+
// neither envelope varies so there is nothing to correlate.
|
|
358
|
+
let mic = vec![0.03f32; FRAMES];
|
|
359
|
+
let reference = vec![0.004f32; FRAMES + MAX_LAG_FRAMES];
|
|
360
|
+
assert!(!is_echo(&mic, &reference));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
#[test]
|
|
364
|
+
fn short_utterances_are_always_kept() {
|
|
365
|
+
let mic = speech(WINDOW_FRAMES - 1, 1);
|
|
366
|
+
let reference = reference_echoing(&mic, 8.0);
|
|
367
|
+
assert!(!is_echo(&mic, &reference));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
#[test]
|
|
371
|
+
fn a_late_interruption_saves_the_whole_utterance() {
|
|
372
|
+
// Eight seconds of the remote side echoing off the speakers, then the
|
|
373
|
+
// user cuts in for the last second. A whole-buffer verdict would let
|
|
374
|
+
// the echo outvote the interruption and discard both.
|
|
375
|
+
let remote = speech(400, 1);
|
|
376
|
+
let reference = reference_echoing(&remote, 8.0);
|
|
377
|
+
let mut mic = remote;
|
|
378
|
+
for frame in mic.iter_mut().skip(350) {
|
|
379
|
+
*frame = 0.05;
|
|
380
|
+
}
|
|
381
|
+
assert!(!is_echo(&mic, &reference));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#[test]
|
|
385
|
+
fn window_offsets_always_reach_the_end_of_the_utterance() {
|
|
386
|
+
let offsets = window_offsets(WINDOW_FRAMES * 2 + 7);
|
|
387
|
+
assert_eq!(offsets.first(), Some(&0));
|
|
388
|
+
assert_eq!(offsets.last(), Some(&(WINDOW_FRAMES + 7)));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
#[test]
|
|
392
|
+
fn window_offsets_are_empty_for_short_input() {
|
|
393
|
+
assert!(window_offsets(WINDOW_FRAMES - 1).is_empty());
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
#[test]
|
|
397
|
+
fn poisoned_reference_lock_does_not_disable_the_guard() {
|
|
398
|
+
let guard = std::sync::Arc::new(EchoGuard::new());
|
|
399
|
+
let poisoning_guard = guard.clone();
|
|
400
|
+
assert!(std::thread::spawn(move || {
|
|
401
|
+
let _spans = poisoning_guard.spans.lock().unwrap();
|
|
402
|
+
panic!("poison reference lock");
|
|
403
|
+
})
|
|
404
|
+
.join()
|
|
405
|
+
.is_err());
|
|
406
|
+
|
|
407
|
+
guard.note_playback(&[0.5; 480], 48_000.0);
|
|
408
|
+
assert_eq!(
|
|
409
|
+
guard
|
|
410
|
+
.spans
|
|
411
|
+
.lock()
|
|
412
|
+
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
413
|
+
.len(),
|
|
414
|
+
1
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
#[test]
|
|
419
|
+
fn reference_envelope_aligns_playback_onto_the_mic_frame_grid() {
|
|
420
|
+
let guard = EchoGuard::new();
|
|
421
|
+
let from = Instant::now();
|
|
422
|
+
guard.spans.lock().unwrap().push_back(ReferenceSpan {
|
|
423
|
+
start: from + Duration::from_millis(5),
|
|
424
|
+
end: from + Duration::from_millis(25),
|
|
425
|
+
rms: 0.5,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
assert_eq!(guard.reference_envelope(from, 3), vec![0.5, 0.5, 0.0]);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
#[test]
|
|
432
|
+
fn echo_thresholds_reject_values_just_below_the_boundaries() {
|
|
433
|
+
assert!(!meets_echo_thresholds(
|
|
434
|
+
f32::from_bits(MIN_COVERAGE.to_bits() - 1),
|
|
435
|
+
MIN_CORRELATION,
|
|
436
|
+
));
|
|
437
|
+
assert!(!meets_echo_thresholds(
|
|
438
|
+
MIN_COVERAGE,
|
|
439
|
+
f32::from_bits(MIN_CORRELATION.to_bits() - 1),
|
|
440
|
+
));
|
|
441
|
+
assert!(meets_echo_thresholds(MIN_COVERAGE, MIN_CORRELATION));
|
|
442
|
+
}
|
|
443
|
+
}
|