@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.
Files changed (57) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/docs/content/template-content-developers.mdx +1 -1
  4. package/corpus/core/package.json +1 -1
  5. package/corpus/templates/clips/changelog/2026-07-29-meeting-notes-no-longer-transcribe-the-other-side-twice-when.md +6 -0
  6. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +37 -48
  7. package/corpus/templates/clips/desktop/src/lib/transcription-capture.ts +7 -5
  8. package/corpus/templates/clips/desktop/src/lib/transcription-engine.ts +182 -106
  9. package/corpus/templates/clips/desktop/src/overlays/live-transcript.tsx +43 -14
  10. package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +3 -3
  11. package/corpus/templates/clips/desktop/src-tauri/src/echo_guard.rs +443 -0
  12. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +1 -0
  13. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +125 -16
  14. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +6 -4
  15. package/corpus/templates/content/actions/_database-membership-lock.ts +25 -0
  16. package/corpus/templates/content/actions/_database-row-batch.ts +6 -2
  17. package/corpus/templates/content/actions/_database-source-utils.ts +289 -129
  18. package/corpus/templates/content/actions/_database-utils.ts +12 -3
  19. package/corpus/templates/content/actions/add-content-database-source-field-property.ts +39 -21
  20. package/corpus/templates/content/actions/attach-content-database-source.ts +4 -1
  21. package/corpus/templates/content/actions/change-content-database-source-role.ts +3 -5
  22. package/corpus/templates/content/actions/duplicate-document-property.ts +59 -46
  23. package/corpus/templates/content/actions/execute-builder-source-execution.ts +77 -71
  24. package/corpus/templates/content/actions/materialize-builder-required-fields.ts +7 -0
  25. package/corpus/templates/content/actions/remove-database-items.ts +183 -0
  26. package/corpus/templates/content/actions/set-document-property.ts +57 -39
  27. package/corpus/templates/content/actions/sync-local-folder-source.ts +7 -0
  28. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +471 -329
  29. package/corpus/templates/content/app/components/editor/database/row-access.ts +45 -0
  30. package/corpus/templates/content/app/components/editor/database/shared.tsx +141 -38
  31. package/corpus/templates/content/app/hooks/use-content-database.ts +2 -2
  32. package/corpus/templates/content/app/i18n-data.ts +130 -0
  33. package/corpus/templates/content/changelog/2026-07-30-database-rows-can-now-be-removed-without-deleting-their-page.md +6 -0
  34. package/corpus/templates/content/parity/eval-scenarios.ts +2 -2
  35. package/corpus/templates/content/parity/matrix.md +1 -1
  36. package/corpus/templates/content/parity/matrix.ts +4 -5
  37. package/corpus/templates/content/shared/api.ts +4 -0
  38. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +26 -11
  39. package/corpus/templates/design/actions/take-design-screenshot.ts +7 -0
  40. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +11 -3
  41. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +4 -1
  42. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +54 -17
  43. package/corpus/templates/design/app/components/design/design-canvas/local-runtime.ts +126 -0
  44. package/corpus/templates/design/app/components/templates/TemplatePreview.tsx +2 -0
  45. package/corpus/templates/design/app/pages/Index.tsx +3 -1
  46. package/corpus/templates/design/app/pages/Present.tsx +2 -1
  47. package/corpus/templates/design/changelog/2026-07-30-a-broken-inline-script-in-a-generated-screen-is-now-caught-o.md +6 -0
  48. package/corpus/templates/design/changelog/2026-07-30-design-html-is-now-validated-with-a-spec-grade-html-parser-i.md +6 -0
  49. package/corpus/templates/design/changelog/2026-07-30-designs-now-load-their-tailwind-and-alpine-runtimes-from-the.md +6 -0
  50. package/corpus/templates/design/changelog/2026-07-30-designs-with-a-broken-alpine-expression-are-now-caught-on-sa.md +6 -0
  51. package/corpus/templates/design/changelog/2026-07-30-editing-a-design-no-longer-strips-its-styling-the-canvas-kep.md +6 -0
  52. package/corpus/templates/design/package.json +4 -0
  53. package/corpus/templates/design/shared/html-integrity.ts +802 -556
  54. package/dist/notifications/routes.d.ts +3 -3
  55. package/docs/content/template-content-developers.mdx +1 -1
  56. package/package.json +1 -1
  57. 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: FinalLine[]) => void;
55
+ onLinesChange?: (lines: TranscriptLine[]) => void;
42
56
  initialLines?: FinalLine[];
43
57
  } = {}) {
44
- const [finals, setFinals] = useState<FinalLine[]>(initialLines ?? []);
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(({ text, source, segments }) => {
101
- const txt = text.trim();
102
- if (!txt) return;
103
- const startMs = segments[0]?.startMs;
104
- setFinals((prev) => [...prev, { text: txt, source, startMs }]);
105
- if (source === "system") setSysPartial("");
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 && !micPartial && !sysPartial ? (
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
- {micPartial ? (
146
- <ChatBubble source="mic" text={micPartial} pending />
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<FinalLine[]>([]);
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: FinalLine[]) => {
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
+ }
@@ -11,6 +11,7 @@ mod capture_graph;
11
11
  mod clips;
12
12
  mod config;
13
13
  mod debug;
14
+ mod echo_guard;
14
15
  mod eventkit;
15
16
  mod logfile;
16
17
  mod meetings_watcher;