Skip to content

Commit 6464cea

Browse files
Programcodeclaude
andcommitted
Fix review findings in voice rounds, dictation and the timer store
- Words heard after a Sparring Reset no longer start the reset turn's clock: dictated words append to the draft without the start path. - One capture uses iOS's recognizer at a time; starting another cancels the first, so two screens never end or hear each other's sessions. - An opponent clip that never reports its end gives up after its length plus slack, so the device voice reads the rest; clips fetched for a reply that stopped are deleted. - Sending a spoken turn with nothing heard returns quietly. - Generate's dictation keeps words that arrive between renders. - A custom format saved before the stored list loads no longer replaces it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3d6b51b commit 6464cea

6 files changed

Lines changed: 89 additions & 24 deletions

File tree

‎apps/mobile/src/app/generate.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,14 @@ export default function GenerateScreen() {
6464
const abort = useRef<AbortController | null>(null);
6565
const live = useRef(true);
6666
const liveRun = useLiveRun();
67-
// Dictation fills the motion, as the website's microphone beside it does.
67+
// Dictation fills the motion, as the website's microphone beside it does. The
68+
// ref holds words that arrived before the last ones were drawn.
69+
const motionRef = useRef(form.motion);
70+
motionRef.current = form.motion;
6871
const dictation = useSpeechCapture(language, (text) => {
69-
setForm({ motion: appendDictation(lastForm.get().motion, text, MAX_MOTION_LENGTH) });
72+
const motion = appendDictation(motionRef.current, text, MAX_MOTION_LENGTH);
73+
motionRef.current = motion;
74+
setForm({ motion });
7075
setMotionError(false);
7176
});
7277

‎apps/mobile/src/app/sparring/index.tsx‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,8 @@ export default function SparringScreen() {
342342
else void capture.stop();
343343
const latest = sessionRef.current;
344344
if (!latest || latest.id !== before.id || latest.currentTurnIndex !== before.currentTurnIndex) return;
345+
// Send pressed while listening, and nothing was heard: there is no speech to send yet.
346+
if (!latest.draft.trim()) return;
345347
const delivery = voiceActiveRef.current ? measureDelivery(capture.getTake(), outputLanguage) : null;
346348
const next = submitSparringSpeech(latest, latest.draft, { now: Date.now(), createId: newSparringId, delivery });
347349
if (!next) {
@@ -399,8 +401,14 @@ export default function SparringScreen() {
399401
});
400402
};
401403

402-
// Recognised words join the draft, so the student can see and fix them.
403-
const capture = useSpeechCapture(outputLanguage, (text) => updateDraft(appendDictation(sessionRef.current?.draft ?? '', text, MAX_SPARRING_SPEECH_CHARS)));
404+
// Recognised words join the draft, so the student can see and fix them. They
405+
// never start the clock: speaking starts it (onStart), and the last words
406+
// after a Reset must not set the reset turn running again.
407+
const capture = useSpeechCapture(outputLanguage, (text) => move((round, now) => ({
408+
...round,
409+
draft: appendDictation(round.draft, text, MAX_SPARRING_SPEECH_CHARS),
410+
updatedAt: now,
411+
})));
404412
// Each turn of each round is measured on its own, and a start still waiting for permission does not carry over.
405413
const turnKey = session ? `${session.id}:${currentSparringTurn(session)?.id ?? ''}` : '';
406414
const cancelCapture = capture.cancel;

‎apps/mobile/src/lib/opponent-voice.ts‎

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ async function requestSegmentAudio(feature: VoiceFeature, segment: VoiceSegment,
7171
}
7272

7373
let clipNumber = 0;
74+
/** How long a clip may play past its length, or at all when its length never loads, before the device voice goes on. */
75+
const CLIP_SLACK_MS = 5_000;
76+
const CLIP_WITHOUT_LENGTH_MS = 20_000;
77+
78+
function deleteClip(clip: File | null) {
79+
try {
80+
clip?.delete();
81+
} catch {
82+
// Already gone; iOS clears the cache anyway.
83+
}
84+
}
7485

7586
class OpponentVoice {
7687
private state: OpponentVoiceState = { status: 'idle', engine: null, notice: null };
@@ -157,14 +168,23 @@ class OpponentVoice {
157168
if (segments.length > 0 && !this.providerOff) {
158169
this.setState({ status: 'loading', engine: 'elevenlabs', notice: null });
159170
// Fetch one segment ahead so speech does not stall between segments.
160-
let pending = this.load(utterance.feature, segments[0], signal);
161-
for (; next < segments.length; next += 1) {
162-
const clip = await pending;
163-
if (run !== this.run) return;
164-
if (!clip) break;
165-
pending = next + 1 < segments.length ? this.load(utterance.feature, segments[next + 1], signal) : Promise.resolve(null);
166-
const played = await this.playClip(clip, run);
167-
if (!played) return;
171+
let pending: Promise<File | null> = this.load(utterance.feature, segments[0], signal);
172+
try {
173+
for (; next < segments.length; next += 1) {
174+
const clip = await pending;
175+
pending = Promise.resolve(null);
176+
if (run !== this.run) {
177+
deleteClip(clip);
178+
return;
179+
}
180+
if (!clip) break;
181+
pending = next + 1 < segments.length ? this.load(utterance.feature, segments[next + 1], signal) : Promise.resolve(null);
182+
const played = await this.playClip(clip, run);
183+
if (!played) return;
184+
}
185+
} finally {
186+
// A segment fetched ahead for a reply that stopped is not played; its file goes too.
187+
void pending.then(deleteClip);
168188
}
169189
}
170190
if (run !== this.run) return;
@@ -204,15 +224,19 @@ class OpponentVoice {
204224
this.setState({ status: 'speaking', engine: 'elevenlabs' });
205225
const player = createAudioPlayer(clip.uri);
206226
this.player = player;
227+
const startedAt = Date.now();
207228
await new Promise<void>((resolve) => {
208229
const subscription = player.addListener('playbackStatusUpdate', (status) => {
209230
if (!status.didJustFinish) return;
210231
subscription.remove();
211232
resolve();
212233
});
213-
// Stopping releases the player; the run check below ends the wait.
214234
const check = setInterval(() => {
215-
if (run === this.run && this.player === player) return;
235+
// Stopping releases the player. A clip that never reports its end (it
236+
// failed to load, or a call interrupted it) gives up after its length
237+
// and some slack, so the rest of the reply still gets read.
238+
const limit = (player.duration > 0 ? player.duration * 1_000 : CLIP_WITHOUT_LENGTH_MS) + CLIP_SLACK_MS;
239+
if (run === this.run && this.player === player && Date.now() - startedAt < limit) return;
216240
clearInterval(check);
217241
subscription.remove();
218242
resolve();
@@ -222,11 +246,7 @@ class OpponentVoice {
222246
if (this.player === player) this.releasePlayer();
223247
return run === this.run;
224248
} finally {
225-
try {
226-
clip.delete();
227-
} catch {
228-
// The cache is cleared by iOS anyway.
229-
}
249+
deleteClip(clip);
230250
}
231251
}
232252

‎apps/mobile/src/lib/speech-capture.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,20 @@ describe('speech capture', () => {
103103
expect(await starting).toBe(false);
104104
expect(fake.starts).toBe(0);
105105
});
106+
107+
it('lets one capture use the recognizer at a time: starting another cancels the first', async () => {
108+
const fake = fakeRecognizer();
109+
const heard: string[] = [];
110+
const first = new SpeechCapture(fake.recognizer, { lang: 'en-US', onFinalText: (text) => heard.push(`first: ${text}`) });
111+
const second = new SpeechCapture(fake.recognizer, { lang: 'en-US', onFinalText: (text) => heard.push(`second: ${text}`) });
112+
await first.start();
113+
await second.start();
114+
expect(first.getState().listening).toBe(false);
115+
// The first no longer hears the shared recognizer, nor restarts when the second's session ends.
116+
fake.heard('only for the second');
117+
fake.emit('end');
118+
expect(heard).toEqual(['second: only for the second']);
119+
expect(fake.starts).toBe(3);
120+
await second.stop();
121+
});
106122
});

‎apps/mobile/src/lib/speech-capture.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ export interface CaptureOptions {
3434

3535
const emptyTake = (): SpeechTake => ({ text: '', speakingMs: 0, pausesMs: [] });
3636

37+
// iOS has one recognizer for the app, and every capture listens to its events.
38+
// Only one capture may use it at a time: starting one cancels any other, so a
39+
// screen left listening under another never hears, or ends, the new one's session.
40+
const owner: { current: SpeechCapture | null } = { current: null };
41+
3742
export class SpeechCapture {
3843
private state: CaptureState = { listening: false, interim: '', error: null };
3944
private listeners = new Set<() => void>();
@@ -84,6 +89,7 @@ export class SpeechCapture {
8489
private detach() {
8590
this.subscriptions.forEach((subscription) => subscription.remove());
8691
this.subscriptions = [];
92+
if (owner.current === this) owner.current = null;
8793
}
8894

8995
private finishListening() {
@@ -124,7 +130,9 @@ export class SpeechCapture {
124130
}
125131

126132
start = async (): Promise<boolean> => {
127-
if (this.want) return false;
133+
if (this.want || this.state.listening) return false;
134+
if (owner.current && owner.current !== this) owner.current.cancel();
135+
owner.current = this;
128136
this.set({ error: null });
129137
this.want = true;
130138
const attempt = ++this.attempt;

‎apps/mobile/src/lib/timer-store.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ interface TimerStoreState {
1818
}
1919

2020
let state: TimerStoreState = { loaded: false, session: initialSession(), saved: [] };
21+
// What changed before the stored copy was read; that change wins over the copy.
22+
const changedBeforeLoad = { session: false, saved: false };
2123
const listeners = new Set<() => void>();
2224
let loading: Promise<void> | null = null;
2325

@@ -43,12 +45,15 @@ async function readJson(key: string): Promise<unknown> {
4345
export function loadTimer(): Promise<void> {
4446
loading ??= (async () => {
4547
const [session, saved] = await Promise.all([readJson(SESSION_KEY), readJson(FORMATS_KEY)]);
46-
// A change made before the read finished (a tap on a fresh screen) wins over the stored round.
48+
// A change made before the read finished (a tap on a fresh screen) wins over the stored copy.
49+
// Saved formats are merged, so one saved early does not drop the others.
50+
const stored = parseSavedFormats(saved);
4751
state = {
4852
loaded: true,
49-
session: state.loaded ? state.session : parseSession(session) ?? state.session,
50-
saved: state.loaded ? state.saved : parseSavedFormats(saved),
53+
session: changedBeforeLoad.session ? state.session : parseSession(session) ?? state.session,
54+
saved: changedBeforeLoad.saved ? state.saved.reduce((list, entry) => upsertSavedFormat(list, entry), stored) : stored,
5155
};
56+
if (changedBeforeLoad.saved) persist(FORMATS_KEY, state.saved);
5257
emit();
5358
})();
5459
return loading;
@@ -61,18 +66,21 @@ export function getTimer(): TimerStoreState {
6166
export function updateSession(change: (current: TimerSession) => TimerSession) {
6267
const next = change(state.session);
6368
if (next === state.session) return;
64-
state = { ...state, loaded: true, session: next };
69+
if (!state.loaded) changedBeforeLoad.session = true;
70+
state = { ...state, session: next };
6571
persist(SESSION_KEY, serializeSession(next));
6672
emit();
6773
}
6874

6975
export function saveFormat(entry: SavedFormat) {
76+
if (!state.loaded) changedBeforeLoad.saved = true;
7077
state = { ...state, saved: upsertSavedFormat(state.saved, entry) };
7178
persist(FORMATS_KEY, state.saved);
7279
emit();
7380
}
7481

7582
export function deleteSavedFormat(id: string) {
83+
if (!state.loaded) changedBeforeLoad.saved = true;
7684
state = { ...state, saved: state.saved.filter((item) => item.id !== id) };
7785
persist(FORMATS_KEY, state.saved);
7886
emit();

0 commit comments

Comments
 (0)