Skip to content

Commit e35c899

Browse files
committed
fix: stop discarding finished signs while the classifier is busy
1 parent f8a03c9 commit e35c899

2 files changed

Lines changed: 161 additions & 2 deletions

File tree

src/application/use-cases/RecognizeSignsUseCase.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ export class RecognizeSignsUseCase {
3939
private busy = false;
4040
/** Set while teaching: the next completed sign is handed over instead of transcribed. */
4141
private capture: ((window: readonly LandmarkFrame[]) => void) | null = null;
42+
/**
43+
* A finished sign waiting for the classifier to free up.
44+
*
45+
* A window closes on exactly one frame. Dropping it because a previous classification was
46+
* still running loses the whole sign, permanently and silently — and with three MediaPipe
47+
* models plus a GRU running per frame on a phone, that is the common case, not the rare
48+
* one. Holding it here means a slow device recognises late instead of not at all.
49+
*/
50+
private pendingWindow: readonly LandmarkFrame[] | null = null;
4251

4352
constructor(
4453
private readonly source: ILandmarkSource,
@@ -52,6 +61,7 @@ export class RecognizeSignsUseCase {
5261

5362
stop(): void {
5463
this.source.stop();
64+
this.pendingWindow = null;
5565
this.segmenter.reset();
5666
this.frameStabilizer.release();
5767
this.windowStabilizer.release();
@@ -105,13 +115,15 @@ export class RecognizeSignsUseCase {
105115
}
106116

107117
const closedWindow = this.segmenter.push(frame);
118+
if (closedWindow) this.pendingWindow = closedWindow;
108119

109120
if (this.capture) {
110121
// Teaching: hand the finished sign over, and transcribe nothing meanwhile — the user
111122
// is demonstrating a sign, not dictating.
112123
if (closedWindow) {
113124
const deliver = this.capture;
114125
this.capture = null;
126+
this.pendingWindow = null;
115127
deliver(closedWindow);
116128
}
117129
this.emit([], frame);
@@ -125,8 +137,10 @@ export class RecognizeSignsUseCase {
125137
const accepted = this.frameStabilizer.accept(live[0] ?? null);
126138
if (accepted) this.append(accepted, frame.timestampMs);
127139

128-
if (closedWindow) {
129-
const words = await this.classifyWindow(closedWindow);
140+
const pending = this.pendingWindow;
141+
this.pendingWindow = null;
142+
if (pending) {
143+
const words = await this.classifyWindow(pending);
130144
const word = this.windowStabilizer.accept(words[0] ?? null);
131145
if (word) this.append(word, frame.timestampMs);
132146
this.windowStabilizer.release();
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import type { ILandmarkSource, LandmarkListener } from '@domain/landmarks/services/ILandmarkSource';
2+
import type { LandmarkFrame } from '@domain/landmarks/value-objects/LandmarkFrame';
3+
import type { ISignClassifier } from '@domain/recognition/services/ISignClassifier';
4+
import { createGloss, type SignCandidate } from '@domain/recognition/value-objects/Gloss';
5+
import { beforeEach, describe, expect, it } from 'vitest';
6+
import { buildFrame, buildHand } from '@/test/handFixtures';
7+
import { RecognizeSignsUseCase } from '../RecognizeSignsUseCase';
8+
9+
/** Drives frames by hand instead of waiting on a camera. */
10+
class ScriptedSource implements ILandmarkSource {
11+
private listener: LandmarkListener | null = null;
12+
13+
async start(listener: LandmarkListener): Promise<void> {
14+
this.listener = listener;
15+
}
16+
stop(): void {
17+
this.listener = null;
18+
}
19+
isRunning(): boolean {
20+
return this.listener !== null;
21+
}
22+
push(frame: LandmarkFrame): void {
23+
this.listener?.(frame);
24+
}
25+
}
26+
27+
/**
28+
* The per-frame engine, held open on demand.
29+
*
30+
* This is what actually blocks in the app: it runs on every frame, and on a phone the
31+
* MediaPipe passes behind it are slow enough that a sign routinely finishes mid-call.
32+
*/
33+
class SlowFrameClassifier implements ISignClassifier {
34+
readonly id = 'slow-frame';
35+
readonly granularity = 'frame' as const;
36+
blocking = false;
37+
private release: (() => void) | null = null;
38+
39+
isReady(): boolean {
40+
return true;
41+
}
42+
async load(): Promise<void> {}
43+
44+
async classify(): Promise<readonly SignCandidate[]> {
45+
if (this.blocking) {
46+
await new Promise<void>((resolve) => {
47+
this.release = resolve;
48+
});
49+
}
50+
return [];
51+
}
52+
53+
finish(): void {
54+
this.release?.();
55+
this.release = null;
56+
}
57+
}
58+
59+
/** The vocabulary engine, counting how often it is actually consulted. */
60+
class CountingWindowClassifier implements ISignClassifier {
61+
readonly id = 'window';
62+
readonly granularity = 'window' as const;
63+
calls = 0;
64+
65+
isReady(): boolean {
66+
return true;
67+
}
68+
async load(): Promise<void> {}
69+
70+
async classify(): Promise<readonly SignCandidate[]> {
71+
this.calls += 1;
72+
return [{ gloss: createGloss('DOLOR'), confidence: 0.9, source: 'vocabulary' }];
73+
}
74+
}
75+
76+
function movingFrames(count: number, from = 0) {
77+
return Array.from({ length: count }, (_, i) =>
78+
buildFrame((from + i) * 33, buildHand({ offset: { x: (from + i) * 0.06, y: 0 } })),
79+
);
80+
}
81+
82+
function stillFrames(count: number, at: number) {
83+
return Array.from({ length: count }, () => buildFrame(0, buildHand({ offset: { x: at, y: 0 } })));
84+
}
85+
86+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
87+
88+
describe('RecognizeSignsUseCase', () => {
89+
let source: ScriptedSource;
90+
let slow: SlowFrameClassifier;
91+
let vocabulary: CountingWindowClassifier;
92+
let recognize: RecognizeSignsUseCase;
93+
94+
beforeEach(() => {
95+
source = new ScriptedSource();
96+
slow = new SlowFrameClassifier();
97+
vocabulary = new CountingWindowClassifier();
98+
recognize = new RecognizeSignsUseCase(source, [slow, vocabulary]);
99+
});
100+
101+
it('does not lose a sign that finishes while the per-frame engine is busy', async () => {
102+
// The bug this exists for: a window closes on exactly one frame, and that frame arriving
103+
// mid-classification used to hit `if (busy) return` and be discarded outright. On a
104+
// phone running three MediaPipe models per frame that is the common case, not the rare
105+
// one — and the whole sign was lost silently. The app looked simply dead.
106+
await recognize.start(() => {});
107+
108+
slow.blocking = true;
109+
source.push(buildFrame(0, buildHand()));
110+
await tick();
111+
112+
// The sign now completes entirely while that first call is still in flight.
113+
for (const frame of [...movingFrames(20), ...stillFrames(14, 1.2)]) source.push(frame);
114+
expect(vocabulary.calls).toBe(0);
115+
116+
slow.blocking = false;
117+
slow.finish();
118+
await tick();
119+
120+
expect(vocabulary.calls).toBe(1);
121+
});
122+
123+
it('transcribes a recognised sign as a word', async () => {
124+
await recognize.start(() => {});
125+
for (const frame of [...movingFrames(20), ...stillFrames(14, 1.2)]) source.push(frame);
126+
await tick();
127+
128+
expect(recognize.current.toText().toLowerCase()).toContain('dolor');
129+
});
130+
131+
it('drops the queued sign on stop, so it cannot surface in the next session', async () => {
132+
await recognize.start(() => {});
133+
slow.blocking = true;
134+
source.push(buildFrame(0, buildHand()));
135+
await tick();
136+
for (const frame of [...movingFrames(20), ...stillFrames(14, 1.2)]) source.push(frame);
137+
138+
recognize.stop();
139+
slow.blocking = false;
140+
slow.finish();
141+
await tick();
142+
143+
expect(vocabulary.calls).toBe(0);
144+
});
145+
});

0 commit comments

Comments
 (0)