Repository navigation
feat: the pure core for listening and dictation practice (M11, 1/4) - #35
Conversation
M11, first of four. No UI and nothing user-visible yet: the types the rest of the milestone is built from, and the schema to keep their results. `SequenceAlignment` is why the two halves of this milestone are one job. Scoring a dictation lines up what the learner typed against the transcript; forced alignment lines up what the speech recogniser heard against the same transcript. Both are character-level alignment of two Chinese strings, so there is one implementation. It is banded. The backtrace needs the matrix, and two minutes of practice is 600 characters against 600 — a third of a megabyte, nothing — but an hour is over a gigabyte. The two sequences are near-identical by construction, one being a recognition of the speech the other transcribes, so the true path never strays far from the diagonal and only a corridor around it is computed. A path that would leave the corridor is not found; what comes back is the best one inside it, which degrades the alignment rather than failing it. `PhraseSplitter` cuts sentences first and clauses only where a sentence overruns its budget, then packs clauses back up to it. Measured on 听力64: 609 characters over about 147 seconds is 4.1 a second, so a seven-second cap is 29 characters, and its 22 sentences become 29 phrases with the longest exactly at the cap. Splitting at every comma instead would give 61 fragments of three or four characters, which is not a listening test. Half-width punctuation is first-class rather than an edge case: that transcript has 39 half-width commas and no full-width ones, so a splitter that knew only , would leave its longest sentence whole and the cap would silently not apply. `DictationComparison` reports five outcomes, not right and wrong. A wrong character that shares a reading with the right one is a homophone — heard correctly, wrong candidate from the input method. One that shares only the syllable is a tone slip, which is what a learner's ear loses first. Only a character with no reading in common means they did not hear it. The readings come from the dictionaries already open, which is the whole reason to build this here rather than run a general-purpose dictation tool. Punctuation and spacing are dropped from both sides before scoring. They are a transcription convention rather than evidence about anyone's ears, and a Chinese input method inserts them for the learner anyway, so scoring them would mark some people down for their keyboard. `LibraryRepository.dbQueue` becomes internal. Swift's `private` reaches extensions in the same file only, so the repository could not be split at all, and it was over the file-length limit. GRDB still cannot leave the module: `internal import` and the lint boundary rule are what guarantee that, not the access level of one stored property. Nineteen mutants planted and all killed. Four survived a first attempt and each showed a test proving less than it claimed: a zero-slack case using identical sequences, where a band of nought and a band of one agree; a half-width test that did not isolate terminators from separators; a merge-budget guard no input ever reached; and a case-folding fixture whose capitalised reading sat beside a lowercase one, making the folding redundant. A fifth survivor was a genuinely equivalent mutant — a guard `isDisjoint` had already made unreachable — so the guard is gone rather than kept as a line that reads as load-bearing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR Summary by QodoAdd the listening and dictation practice core
AI Description
Diagram
High-Level Assessment
Files changed (12)
|
Code Review by Qodo
1.
|
All eight were real. Two of them were comments promising what the code did not do, which is worse than a missing feature because it reads as done. `.manual` was decorative. The doc said the origin exists so a re-alignment can leave a corrected boundary alone; `savePhrases` then deleted everything and reinserted the recogniser's times, so a reader would have fixed the same boundary after every run for ever. Corrections now survive, matched by text range rather than by index — re-aligning can insert or drop phrases, and an index means nothing across that. `isPassing` could never pass a homophone. Its contract said such an attempt is acceptable because the learner heard the phrase; the accuracy it tested counted only exact matches, so it could not reach the threshold. There are two scores now: `accuracy`, which is strict, and `listeningAccuracy`, which counts a homophone as heard and a tone slip as not. The gate uses the second, which is the thesis of the feature rather than a concession to it. Attempts were inherited across a re-alignment. They are keyed by index and phrases are positional, so re-splitting handed an earlier answer and score to a phrase the reader had never seen, marking it passed. An attempt now survives only where its phrase still covers the same text. Invented characters were free: typing the phrase correctly and appending nonsense scored one and passed. They are in the denominator now. 真的吗?! split into a question and a phrase consisting of !, because only closing marks extended a pending sentence end and a second terminator did not. Dividing an over-long sentence could likewise leave a closing quote as a practice phrase of its own, which neither packing nor the minimum-length merge would take back once the clause before it was over budget. Splitting was quadratic. `characters(of:in:)` copied the whole document's UTF-16 storage on every measurement, and the splitter measures every sentence, then every packing step, then every merge. A prefix sum built once makes measuring a range a subtraction. And the band could take the app with it. It widens by the difference in length, so a long transcript against a near-empty recognition — speech recognition that gave up, which is exactly what the next milestone will meet — made it as wide as the transcript: about 367 MB for 6,000 characters against three. Clamping the band was the wrong fix and the first one tried: the band has to span the difference in length or a row past the end of the shorter sequence has a lower bound above its upper bound, which trapped. The right fix was to stop paying for it. The cost matrix is needed for two rows only — a cell depends on the row above and the cell to its left — so only the moves need the full matrix, and a move is one byte. That is five times less memory, and an hour-long chapter against its own transcript now fits in single-digit megabytes. A pair too mismatched to band is declined rather than bought: the trivial alignment says almost nothing lines up, which is what a real one would have said at four hundred megabytes. Fourteen mutants planted and killed. One further survivor was equivalent — a per-row array clear whose cells nothing could read — so it is gone rather than kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
All eight addressed in 2 — 5 — 3 — attempts were inherited across a re-alignment. Keyed by index, phrases positional, so re-splitting handed an earlier answer and score to a phrase the reader had never seen, marking it passed. An attempt now survives only where its phrase still covers the same text. 6 — invented characters were free. Typing the phrase correctly and appending nonsense scored one and passed. They are in the denominator now. 8 and 7 — punctuation became practice phrases. 4 — splitting was quadratic. 1 — the band could take the app with it, and this one is worth spelling out because my first fix was wrong. The band widens by the difference in length, so a long transcript against a near-empty recognition — recognition that gave up, which is exactly what PR 2 will meet — made it as wide as the transcript: ~367 MB for 6,000 characters against 3. Clamping the band broke it: the band has to span the difference in length or a row past the end of the shorter sequence has a lower bound above its upper bound, and the range traps. I had documented that invariant myself earlier in the same file and then violated it. The right fix was to stop paying. The cost matrix is needed for two rows only — a cell depends on the row above and the cell to its left — so only the moves need the full matrix, and a move is one byte. Five times less memory: an hour-long chapter against its own transcript now fits in single-digit megabytes, where before it would have been tens. A pair genuinely too mismatched to band is declined rather than bought, returning the trivial alignment — which says almost nothing lines up, which is what a real one would have said at four hundred megabytes. 441 core tests, 111 persistence. Fourteen more mutants planted and killed; one further survivor was equivalent — a per-row array clear whose cells nothing could read — so it is deleted rather than kept. Both app targets built locally before pushing. 🤖 Generated with Claude Code |
First of four for M11 — listening and dictation (听力 / 听写). No UI; nothing user-visible yet. This is the pure core the rest is built on, plus the schema to keep its results.
The reuse that makes this one milestone
SequenceAlignmentserves both halves:Both are character-level alignment of two Chinese strings, so there is one implementation of it.
It is banded. The backtrace needs the matrix: two minutes of practice is 600×600, a third of a megabyte; an hour is over a gigabyte. The two sequences are near-identical by construction, so only a corridor around the diagonal is computed — O(n × band). A path that would leave the corridor is not found; the best one inside it comes back instead, degrading the alignment rather than failing it.
Measured, not estimated
PhraseSplitterwas built against 听力64, the transcript already in the library:Splitting at every comma instead gives 61 fragments of three or four characters — not a listening test, just a slow way to read. Sentences within budget stay whole; only the over-long ones are cut, then clauses are packed back up to the budget.
Half-width punctuation is first-class, not an edge case. 听力64 contains 39 half-width commas and zero full-width ones — it was typed by someone whose input method produced
,. A splitter that knew only,would leave its 42-character sentence whole and the cap would silently not apply to the one text this was built for.Five outcomes, not right and wrong
DictationComparisondistinguishes three kinds of wrong, usingreadings(forCharacter:)from the dictionaries already open:zhen1shi4for 时shi2Plus
missingandextra. This is the feature WorkAudioBook structurally cannot have, and the reason to build this inside HanReader rather than keep using it.Punctuation and spacing are dropped from both sides before scoring: a transcription convention, not evidence about anyone's ears — and a Chinese IME inserts punctuation for the learner, so scoring it would mark some people down for their keyboard.
One structural change
LibraryRepository.dbQueueis nowinternal. Swift'sprivatereaches extensions in the same file only, so the repository could not be split across files at all — and it had gone past the 400-line limit. GRDB still cannot leave the module;internal import GRDBand the SwiftLint boundary rule are what guarantee that, not the access level of one stored property.Tests
49 new (433 core, 108 persistence). Both app targets built locally before pushing, macOS and
generic/platform=iOS Simulator.Nineteen mutants planted, all killed — but four survived a first attempt, and each one exposed a test proving less than it claimed: a zero-slack case using identical sequences, where a band of nought and a band of one agree; a half-width test that did not isolate terminators from separators; a merge-budget guard no input ever reached; and a case-folding fixture whose capitalised reading sat beside a lowercase one, making the folding redundant. A fifth survivor was a genuinely equivalent mutant — a guard
isDisjointhad already made unreachable — so the guard is deleted rather than kept as a line that reads as load-bearing.Known
The plan predicted 32 phrases for 听力64; the implementation gives 29, because it packs clauses back up to the budget rather than emitting one per comma. The tests carry the real figure.
Next: PR 2 is the alignment itself —
SFSpeechURLRecognitionRequestwithrequiresOnDeviceRecognition = true, never the network. zh-CN offline dictation is offered on macOS (High Quality = true) but is not installed on the dev machine, so PR 2 also brings the notice that explains how to install it, modelled on the existing "no Chinese voice" alert.🤖 Generated with Claude Code