Repository navigation
feat: hear one sentence, write it down, be told what you missed (M11, 4/4) - #38
Conversation
… 4/4) M11, fourth of four. The screen the rest of the milestone existed to make possible: play one phrase, type what you heard, and see where you were wrong — 听力 and 听写 in the same place as the text. The transcript is hidden until the attempt is checked. That is not a preference: the answer being on screen is the one thing that makes dictation worthless, so it is revealed by checking or by giving up, and by nothing else. Marking tells the three kinds of wrong apart, which is the whole reason to build this inside HanReader rather than run a general-purpose dictation tool beside it. The readings are already open, so a character that sounds exactly like the right one — 真 for 针, both zhen1 — is an input-method slip rather than a listening mistake, and the score written down is the listening one, so it does not keep the sentence in the drill. A character with the right syllable and the wrong tone is marked apart from one that was simply not heard; tones are what an ear loses first. Every shortcut is a ⌘ chord. WorkAudioBook puts these on bare Space, arrows and R, which it can because its writing box is a separate panel that must be focused deliberately. Here the field holds focus throughout and the learner is typing through a Chinese input method, where a bare Space commits a candidate and a bare arrow moves between them. A sheet rather than a mode of the reader: practice hides the text, which is the opposite of what the reader is for, and sharing one surface would mean every change to either reasoning about the other. Found on the running app and fixed here: under the replay filter, passing a phrase dropped it from the list the instant it passed, so moving on counted from the top of what was left and skipped the phrase that came next. The phrase being worked on now stays in the list until the reader moves off it. Verified against 听力64 and its narration: 32 phrases aligned at full confidence, phrase 6 landing on 「卖主是位六十多岁的老太太,她叫我给个价, 我就说五角钱一个。」; typed with a deliberate 事 for 是 and 朵 for 多, the screen reports 24 of 26 correct, one homophone, one tone, 96% heard; a grade persists, the filter holds, and a drill of two repeats with a two-second gap plays twice and stops. M11 is not ticked yet. The phrase-boundary editor the plan puts in this stage is not here, and until it is, a Mac too old for SpeechTranscriber has no way to place boundaries by hand — so that screen now says so rather than promising it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR Summary by QodoAdd sentence-by-sentence listening and dictation practice
AI Description
Diagram
High-Level Assessment
Files changed (13)
|
Code Review by Qodo
1.
|
CI only. Nested inside `nonisolated enum PracticeTestSupport`, the spy classes took this target's `MainActor` default isolation for their members while their implicit initialiser stayed nonisolated. Xcode 26.6 rejects that outright — "default initializer cannot be both nonisolated and main actor-isolated" — and Swift 6.4 locally accepts it, so it was green here and red there. Moved to file scope and marked `@MainActor`, which is what `SpyEngine` in the playback tests already does and what the only caller, an already MainActor-isolated helper, wants anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nine findings, all real. **The two that change behaviour a learner would notice.** Showing the answer and then typing it recorded a pass. `reveal()` set the flag and `check()` never looked at it, so a reader who gave up, read the transcript and copied it scored 100% and the phrase left the drill. Checking after looking now marks the attempt — that is what was asked for — and records nothing. Moving to another phrase cleared the segment without stopping the sound. Clearing removes the stop time, so the transport sailed past the phrase it was playing and on into the next one while the screen showed a different phrase entirely. **Two races, both real.** `check()` read `text` and `typed` after awaiting the dictionary, so a reader who kept typing or moved on mid-lookup could have the new answer marked against the old phrase and saved under its index. Both are read before the await now, and the result is dropped if the phrase changed underneath. A cancelled alignment kept running to its next suspension point and its callbacks still wrote to shared state, so a stopped run finishing late could clear the handle of the run that replaced it — leaving a second alignment going that nothing could stop. Runs now carry a generation and only the current one may write. The same check after `savePhrases` stops a run that was cancelled during the write from announcing itself ready. **And four smaller ones.** Return in an empty writing field checked anyway, revealing the answer and writing down a zero through a control the screen was showing as disabled. Opening practice reset the reader's position in the narration to zero and never put it back. The sheet's 620-point minimum width does not fit a phone, so it now applies only where there is room. `GradeRow` bound ⌘→ to a second button already carrying it, leaving SwiftUI to choose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The screen the rest of M11 existed to make possible: play one phrase, type what
you heard, and see where you were wrong — 听力 and 听写 in the same place as the
text. Fourth of four.
What it does
A text with a narration grows a headphones button in the reader's toolbar. It
opens a sheet that offers to listen to the recording once, on this device, and
then steps through the text one sentence at a time:
menu — how many times each phrase plays and how long a gap it leaves.
hidden until the attempt is checked. That is not a preference: the answer
being on screen is the one thing that makes dictation worthless, so it is
revealed by checking or by giving up, and by nothing else.
it.
The three kinds of wrong
This is the reason to build it inside HanReader rather than run a
general-purpose dictation tool beside it. The readings are already open, so:
zhen1— heard right, wrong candidate from the IMEduo3againstduo1— the syllable was heard, the tone was notThe score written down is the listening one, counting a homophone as heard,
so an input-method slip does not keep a sentence in the drill. A tone slip
counts against it, because tones are what an ear loses first.
Shape
PracticeModel— phases (no audio / needs alignment / aligning / unavailable/ ready), the replay filter, navigation, checking, grading, and running
alignment with progress and cancellation.
PracticeScreen,PracticeComponents,DictationMarkedView,PracticeSheet— the views. A sheet rather than a mode of the reader:practice hides the text, which is the opposite of what the reader is for, and
sharing one surface would mean every change to either reasoning about the
other.
DictionaryService.readings(forCharacters:)— one actor hop for everycharacter a marking needs, rather than one per character.
ReaderColor.dictation(_:)/.dictationFill(_:)— three distinct coloursfor the three kinds of wrong, because flattening them to one red throws away
the only thing this says that a plain diff could not.
Every shortcut is a ⌘ chord. WorkAudioBook puts these on bare Space, arrows and
R, which it can because its writing box is a separate panel that must be focused
deliberately. Here the field holds focus throughout and the learner is typing
through a Chinese input method, where a bare Space commits a candidate and a
bare arrow moves between them.
A bug the tests did not find
Under the replay filter, passing a phrase dropped it from the list the instant
it passed. The strip then showed nothing as current, and moving on counted from
the top of what was left — so passing phrase 1 and pressing ⌘→ landed on phrase
3. The phrase being worked on now stays in the list until the reader moves off
it. Caught on the running app, fixed, and pinned by a test.
Verification
19 tests across two suites, and every one of 20 planted mutants dies —
including the one that records the strict score instead of the listening score,
which only a homophone can tell apart, so there is a test with a dictionary that
knows 真 and 针 are both
zhen1.On the running app, against 听力64 and its narration:
「卖主是位六十多岁的老太太,她叫我给个价,我就说五角钱一个。」
homophone, 1 tone, 96% heard, each marked in its own colour with the typed
character above the expected one.
and a drill of two repeats with a two-second gap plays twice and stops.
swift testgreen (465 + the UI target),make lintclean, and both apptargets built — macOS and
generic/platform=iOS Simulator.What is not here
M11 is not ticked. The plan puts a phrase-boundary editor in this stage and
it is not built: no waveform strip with draggable handles, no tap-along mode.
Until it exists a Mac too old for
SpeechTranscriberhas no way to placeboundaries by hand, so that screen now says so plainly rather than promising
it. The editor is the next PR.
🤖 Generated with Claude Code