From 9a487f1dc47dc8b9e02fb3d851233254e3f9941c Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:10:13 +0300 Subject: [PATCH 1/3] feat: hear one sentence, write it down, be told what you missed (M11, 4/4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 19 ++ README.md | 9 +- Sources/HanReaderUI/Design/DesignTokens.swift | 29 ++ .../HanReaderUI/Models/PracticeModel.swift | 321 ++++++++++++++++++ .../Screens/DictationMarkedView.swift | 221 ++++++++++++ .../Screens/PracticeComponents.swift | 189 +++++++++++ .../HanReaderUI/Screens/PracticeScreen.swift | 231 +++++++++++++ .../HanReaderUI/Screens/PracticeSheet.swift | 65 ++++ .../HanReaderUI/Screens/ReaderScreen.swift | 23 ++ .../Services/DictionaryService.swift | 35 ++ .../PracticeCheckingTests.swift | 124 +++++++ .../HanReaderUITests/PracticeModelTests.swift | 164 +++++++++ .../PracticeTestSupport.swift | 142 ++++++++ 13 files changed, 1571 insertions(+), 1 deletion(-) create mode 100644 Sources/HanReaderUI/Models/PracticeModel.swift create mode 100644 Sources/HanReaderUI/Screens/DictationMarkedView.swift create mode 100644 Sources/HanReaderUI/Screens/PracticeComponents.swift create mode 100644 Sources/HanReaderUI/Screens/PracticeScreen.swift create mode 100644 Sources/HanReaderUI/Screens/PracticeSheet.swift create mode 100644 Tests/HanReaderUITests/PracticeCheckingTests.swift create mode 100644 Tests/HanReaderUITests/PracticeModelTests.swift create mode 100644 Tests/HanReaderUITests/PracticeTestSupport.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index fa7fd75..7b75001 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,25 @@ it needs to read like release notes rather than a commit log. ### Added +- Listening and dictation practice (听力 / 听写). A text with a narration opens + a practice screen that plays one sentence at a time, repeated as many times + as you ask with a gap to write in, and marks what you typed against what was + said. Sentence boundaries are found by listening to the recording once, on + this device — the audio is not sent anywhere — and aligning what was heard + against the transcript already in your library, so a recogniser's mistakes + are bridged by the words either side of them rather than becoming yours. + The transcript stays hidden until you check, which is the whole point. +- Dictation marking tells the three kinds of wrong apart. A character that + sounds exactly like the right one — 真 for 针, both `zhen1` — is marked as a + homophone, which is an input-method slip rather than a listening mistake and + does not keep the sentence in the drill. A character with the right syllable + and the wrong tone — 是 for 时 — is marked separately, because tones are what + an ear loses first. Everything else is a mishearing. No other tool this + project was measured against can make that distinction; HanReader can + because the readings are already open. +- Sentences can be graded easy, middling or hard, and a filter drills only the + ones still worth drilling: graded hard, never attempted, or last attempted + below the pass mark. - Per-text audio. Attach a narration to any text and it plays in a transport below the reader, with a scrubber, ten-second skips, five speeds and lock screen and Control Centre controls. The playhead is remembered, so the next diff --git a/README.md b/README.md index 7c55ab9..dc0dbab 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,13 @@ separate window: it never jumps as you move from word to word. - **Hear it.** Tapping a word speaks it. - **Follow along with audio.** Attach a narration file to a text and scrub it at - 0.75× to 1.5×. *(Arrives in M7.)* + 0.75× to 1.5×. +- **Practise listening and dictation (听力 / 听写).** HanReader listens to the + narration once, on this device, to work out where each sentence is said. Then + it plays one sentence at a time — repeated as many times as you like, with a + gap to write in — and marks what you typed against what was said, character + by character. A wrong character that *sounds* the same is told apart from one + you misheard, and from one whose tone you missed. - **Two reading modes** — visibly word-segmented, or continuous as it would actually be printed. - **It remembers.** Your position and the words you have revealed survive @@ -107,6 +113,7 @@ framework imports. Details in [`Docs/ARCHITECTURE.md`](Docs/ARCHITECTURE.md). - [x] **M5** — **the macOS reader: first usable version** - [x] **M6** — **BKRS DSL import** - [x] **M7** — **audio playback** +- [ ] **M11** — **listening and dictation practice (听力 / 听写)** — practice screen done, phrase-boundary editor to come - [ ] **M8** — iOS and iPadOS - [ ] **M9** — accessibility and Russian localization - [ ] **M10** — 1.0 diff --git a/Sources/HanReaderUI/Design/DesignTokens.swift b/Sources/HanReaderUI/Design/DesignTokens.swift index 4cf6bec..df255c1 100644 --- a/Sources/HanReaderUI/Design/DesignTokens.swift +++ b/Sources/HanReaderUI/Design/DesignTokens.swift @@ -1,5 +1,6 @@ // HanReader — MIT licensed. See LICENSE. +import HanReaderCore import SwiftUI /// Colour roles for the reading surface. @@ -26,6 +27,34 @@ nonisolated enum ReaderColor { /// Punctuation and whitespace, which are never looked up. static let inertText = Color.primary.opacity(0.75) + /// A phrase nobody has tried yet, one they have passed, one that still + /// needs work. + static let untried = Color.secondary.opacity(0.3) + static let passed = Color.green.opacity(0.7) + static let needsWork = Color.orange.opacity(0.8) + + /// How a marked character is coloured. + /// + /// Three distinct colours for the three kinds of wrong, because that + /// distinction is the point of the screen: a homophone is the learner's + /// input method, a tone slip is their ear for tones, and a mishearing + /// is their ear. Flattening them to one red would throw away the only + /// thing this tells them that a plain diff could not. + static func dictation(_ outcome: DictationOutcome) -> Color { + switch outcome { + case .correct: .primary + case .homophone: .blue + case .toneSlip: .purple + case .misheard: .red + case .missing: .orange + case .extra: .secondary + } + } + + static func dictationFill(_ outcome: DictationOutcome) -> Color { + outcome == .correct ? .clear : dictation(outcome).opacity(0.14) + } + /// The fill behind a highlighted token. /// /// Takes the resolved `TokenHighlight` rather than an opacity, so that the diff --git a/Sources/HanReaderUI/Models/PracticeModel.swift b/Sources/HanReaderUI/Models/PracticeModel.swift new file mode 100644 index 0000000..0bc2d82 --- /dev/null +++ b/Sources/HanReaderUI/Models/PracticeModel.swift @@ -0,0 +1,321 @@ +// HanReader — MIT licensed. See LICENSE. + +import Foundation +import HanReaderCore +import HanReaderPersistence +import HanReaderPlayback + +/// One text's listening and dictation practice. +/// +/// Document-scoped, like `ReaderModel`: created by `.task(id:)` and +/// discarded with the text, so there is no reset code and no stale state to +/// clear. +@Observable +@MainActor +final class PracticeModel { + /// What the screen can be doing. + enum Phase: Equatable { + /// No recording attached, so there is nothing to practise against. + case noAudio + /// A recording, but nobody has worked out where its phrases are. + case needsAlignment + case aligning(SpeechAlignmentProgress) + /// Alignment cannot be done here, with the reason to show. + case unavailable(AlignmentObstacle) + case ready + } + + /// Why automatic alignment is not possible, in terms the reader can act + /// on — or be told plainly that they cannot. + enum AlignmentObstacle: Equatable { + /// The system is older than `SpeechTranscriber`. + case systemTooOld + /// The Chinese model is not installed. This is the one worth + /// offering a button for. + case modelMissing + /// Speech recognition does not know this language at all. + case languageUnsupported + case failed(String) + + var canInstall: Bool { + self == .modelMissing + } + } + + /// Which phrases the reader is working through. + enum Filter: String, CaseIterable, Hashable { + case all + /// Graded hard, or last attempted below the pass mark. The replay + /// filter WorkAudioBook spells as "play only bookmarks", and the + /// closest this gets to a scheduler. + case needsWork + } + + private(set) var phase: Phase = .noAudio + private(set) var phrases: [Phrase] = [] + private(set) var attempts: [Int: DictationAttempt] = [:] + private(set) var current = 0 + /// The last check, or nil if this phrase has not been answered yet. + private(set) var result: DictationResult? + /// Whether the transcript is on screen. Off until checked, because the + /// answer being visible is the one thing that makes dictation worthless. + private(set) var isRevealed = false + + var typed = "" + var filter: Filter = .all + var drill = PlaybackModel.Drill.once + + let textID: TextID + private let services: AppServices + private let playback: PlaybackModel + private var content = "" + private var alignTask: Task? + + init(textID: TextID, services: AppServices, playback: PlaybackModel) { + self.textID = textID + self.services = services + self.playback = playback + } + + // MARK: - Opening + + func load() async { + do { + guard let content = try await services.library.content(of: textID) else { + phase = .noAudio + return + } + self.content = content + guard let track = try await services.library.audioTrack(for: textID) else { + phase = .noAudio + return + } + let file = AudioAttachment.file(for: track, in: services.locations) + guard FileManager.default.fileExists(atPath: file.path) else { + phase = .noAudio + return + } + playback.load(file) + + phrases = try await services.library.phrases(for: textID) + attempts = try await services.library.attempts(for: textID) + phase = phrases.isEmpty ? .needsAlignment : .ready + current = firstUnfinished + } catch { + Log.error("practice", "could not open practice for \(textID.rawValue): \(error)") + phase = .unavailable(.failed(error.localizedDescription)) + } + } + + func close() async { + alignTask?.cancel() + alignTask = nil + playback.clearSegment() + } + + /// The first phrase not yet passed, so reopening a text resumes rather + /// than starting the reader at the top of something half done. + private var firstUnfinished: Int { + phrases.first { attempts[$0.index]?.isPassing() != true }?.index ?? 0 + } + + // MARK: - Working through phrases + + /// The phrases the reader is actually stepping through, after the + /// filter. + /// + /// The phrase they are on is always in the list, even once it stops + /// needing work. Dropping it the instant it passes loses their place: + /// the strip would show nothing as current, and moving on would count + /// from the top of the list rather than from where they are — so + /// passing a phrase would skip the one after it. It leaves the list the + /// moment they move off it, which is the point at which removing it + /// costs them nothing. + var visible: [Phrase] { + switch filter { + case .all: + phrases + case .needsWork: + phrases.filter { needsWork($0) || $0.index == current } + } + } + + func needsWork(_ phrase: Phrase) -> Bool { + if phrase.grade == .hard { + return true + } + guard let attempt = attempts[phrase.index] else { return true } + return !attempt.isPassing() + } + + var phrase: Phrase? { + phrases.first { $0.index == current } + } + + var text: String { + phrase?.text(in: content) ?? "" + } + + func select(_ index: Int) { + guard phrases.contains(where: { $0.index == index }) else { return } + current = index + typed = attempts[index]?.typed ?? "" + result = nil + isRevealed = false + playback.clearSegment() + } + + func advance(by offset: Int) { + let order = visible.map(\.index) + guard !order.isEmpty else { return } + let position = order.firstIndex(of: current) ?? 0 + let next = (position + offset).clamped(to: 0 ... (order.count - 1)) + select(order[next]) + } + + /// Plays the current phrase, as many times as the drill asks. + func play() { + guard let phrase, phase == .ready else { return } + playback.play(segment: phrase.audioRange, drill: drill) + } + + func stop() { + playback.pause() + } + + // MARK: - Checking + + /// Marks the attempt and shows the transcript. + /// + /// Revealing is part of checking rather than a separate step: the reader + /// has committed an answer, and the only useful next thing is seeing + /// where it differed. + func check() async { + guard let phrase else { return } + // Fetched before marking rather than during it. `check` is a pure + // function over a synchronous lookup, which is what makes it + // testable without a database — so the asynchronous part happens + // here, once, for every character either side might need. + let readings = await services.dictionary.readings( + forCharacters: Array(text) + Array(typed), + ) + let outcome = DictationComparison.check( + typed: typed, + against: text, + readings: { readings[$0] ?? [] }, + ) + result = outcome + isRevealed = true + do { + try await services.library.recordAttempt( + typed, + accuracy: outcome.listeningAccuracy, + at: phrase.index, + in: textID, + ) + attempts = try await services.library.attempts(for: textID) + } catch { + Log.error("practice", "could not record an attempt: \(error)") + } + } + + /// Shows the transcript without marking anything — for a reader who + /// wants to give up on one phrase rather than guess. + func reveal() { + isRevealed = true + } + + func grade(_ grade: PhraseGrade?) async { + guard let phrase else { return } + do { + try await services.library.gradePhrase(phrase.index, in: textID, as: grade) + phrases = try await services.library.phrases(for: textID) + } catch { + Log.error("practice", "could not grade a phrase: \(error)") + } + } + + // MARK: - Alignment + + /// Works out where each phrase is said, which is what makes any of the + /// rest possible. + func align(installingModel: Bool = false) { + guard alignTask == nil else { return } + alignTask = Task { [weak self] in + await self?.runAlignment(installingModel: installingModel) + self?.alignTask = nil + } + } + + func cancelAlignment() { + alignTask?.cancel() + alignTask = nil + phase = phrases.isEmpty ? .needsAlignment : .ready + } + + private func runAlignment(installingModel: Bool) async { + guard #available(macOS 26.0, iOS 26.0, *) else { + phase = .unavailable(.systemTooOld) + return + } + guard let track = try? await services.library.audioTrack(for: textID) else { + phase = .noAudio + return + } + let file = AudioAttachment.file(for: track, in: services.locations) + let aligner = SpeechAligner() + phase = .aligning(SpeechAlignmentProgress(fraction: nil)) + + do { + let heard = try await aligner.listen( + to: file, + installIfNeeded: installingModel, + progress: { [weak self] progress in + Task { @MainActor in self?.report(progress) } + }, + ) + try Task.checkCancellation() + + let splitting = PhraseSplitting.forAudio( + characters: content.count, + duration: heard.duration, + ) + let placed = ForcedAlignment.align( + phrases: PhraseSplitter.phrases(in: content, splitting: splitting), + in: content, + to: heard, + ) + try await services.library.savePhrases(placed, for: textID) + phrases = try await services.library.phrases(for: textID) + attempts = try await services.library.attempts(for: textID) + current = firstUnfinished + phase = .ready + } catch is CancellationError { + phase = phrases.isEmpty ? .needsAlignment : .ready + } catch let problem as SpeechAlignmentProblem { + phase = .unavailable(obstacle(for: problem)) + } catch { + phase = .unavailable(.failed(error.localizedDescription)) + } + } + + private func report(_ progress: SpeechAlignmentProgress) { + guard case .aligning = phase else { return } + phase = .aligning(progress) + } + + private func obstacle(for problem: SpeechAlignmentProblem) -> AlignmentObstacle { + switch problem { + case .systemTooOld: .systemTooOld + case .languageUnsupported: .languageUnsupported + case .modelUnavailable: .modelMissing + case let .unreadable(reason): .failed(reason) + } + } +} + +extension Int { + fileprivate func clamped(to range: ClosedRange) -> Int { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} diff --git a/Sources/HanReaderUI/Screens/DictationMarkedView.swift b/Sources/HanReaderUI/Screens/DictationMarkedView.swift new file mode 100644 index 0000000..24da514 --- /dev/null +++ b/Sources/HanReaderUI/Screens/DictationMarkedView.swift @@ -0,0 +1,221 @@ +// HanReader — MIT licensed. See LICENSE. + +import HanReaderCore +import SwiftUI + +/// What the learner wrote, against what was said, character by character. +/// +/// The three kinds of wrong are the point of the screen. A learner who typed +/// 真 for 针 heard the phrase perfectly and picked the wrong candidate from +/// their input method; one who typed 是 for 时 heard the syllable and missed +/// the tone; one who typed 猫 for 狗 did not hear it. Telling them apart is +/// the difference between "practise listening more" and "your ears are +/// fine, watch your IME". +struct DictationMarkedView: View { + let result: DictationResult + let expected: String + let style: ReaderStyle + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + marks + summary + legend + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// The transcript with each character marked. + /// + /// Laid out as a wrapping row of characters rather than one attributed + /// string, because a wrong character needs the right one *above* it — + /// the same reason the reader draws ruby itself. + private var marks: some View { + FlowRow(spacing: 2) { + ForEach(Array(result.marks.enumerated()), id: \.offset) { _, mark in + MarkView(mark: mark, style: style) + } + } + } + + private var summary: some View { + HStack(spacing: 14) { + Text("\(result.correct) of \(result.expected) correct") + .font(.callout.weight(.medium)) + ForEach(Self.reportable, id: \.self) { outcome in + let count = result.count(of: outcome) + if count > 0 { + Text(Self.describe(outcome, count: count)) + .font(.caption) + .foregroundStyle(ReaderColor.dictation(outcome)) + } + } + Spacer() + // The score that decides whether this phrase is done, which is + // the listening one: a homophone slip is not a listening + // mistake, so it does not keep a phrase in the drill. + Text("\(Int((result.listeningAccuracy * 100).rounded()))% heard") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var legend: some View { + let kinds = Self.reportable.filter { result.count(of: $0) > 0 } + if !kinds.isEmpty { + HStack(spacing: 12) { + ForEach(kinds, id: \.self) { outcome in + Label { + Text(Self.explain(outcome)) + } icon: { + Circle() + .fill(ReaderColor.dictation(outcome)) + .frame(width: 7, height: 7) + } + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + + static let reportable: [DictationOutcome] = [ + .homophone, .toneSlip, .misheard, .missing, .extra, + ] + + static func describe(_ outcome: DictationOutcome, count: Int) -> String { + switch outcome { + case .homophone: "\(count) homophone" + case .toneSlip: "\(count) tone" + case .misheard: "\(count) misheard" + case .missing: "\(count) missing" + case .extra: "\(count) extra" + case .correct: "" + } + } + + static func explain(_ outcome: DictationOutcome) -> String { + switch outcome { + case .homophone: "heard right, wrong character" + case .toneSlip: "right syllable, wrong tone" + case .misheard: "not heard" + case .missing: "left out" + case .extra: "not said" + case .correct: "" + } + } +} + +/// One marked character: what was said, and what was typed instead. +private struct MarkView: View { + let mark: DictationMark + let style: ReaderStyle + + var body: some View { + VStack(spacing: 1) { + // What they typed, above, only when it differs — the same place + // the reader puts pinyin, for the same reason: it annotates + // without moving the line it annotates. + Text(verbatim: typedAbove) + .font(ReaderFont.ruby(style)) + .foregroundStyle(ReaderColor.dictation(mark.outcome)) + .frame(minHeight: style.rubyReservation) + Text(verbatim: displayed) + .font(ReaderFont.base(style)) + .foregroundStyle(mark.outcome == .extra ? .secondary : .primary) + .strikethrough(mark.outcome == .extra) + .padding(.horizontal, 1) + .background { + RoundedRectangle(cornerRadius: ReaderMetrics.tokenCornerRadius) + .fill(ReaderColor.dictationFill(mark.outcome)) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibility) + } + + /// A missing character is shown as an underscore rather than omitted: + /// the gap is the information. + private var displayed: String { + if let expected = mark.expected { + return String(expected) + } + if let typed = mark.typed { + return String(typed) + } + return "_" + } + + private var typedAbove: String { + guard mark.outcome != .correct, mark.outcome != .extra, + let typed = mark.typed, mark.expected != nil + else { return " " } + return String(typed) + } + + private var accessibility: Text { + let expected = mark.expected.map(String.init) ?? "" + let typed = mark.typed.map(String.init) ?? "" + return switch mark.outcome { + case .correct: Text("\(expected), correct") + case .homophone: Text("\(expected), you wrote \(typed), same sound") + case .toneSlip: Text("\(expected), you wrote \(typed), wrong tone") + case .misheard: Text("\(expected), you wrote \(typed)") + case .missing: Text("\(expected), missing") + case .extra: Text("\(typed), not said") + } + } +} + +/// A row that wraps, for laying out marked characters. +/// +/// The reading surface's `TokenFlowLayout` is not reused here: it exists to +/// reserve ruby space and apply kinsoku across a paragraph of tokens, and +/// this is a single phrase of single characters. Borrowing it would mean +/// taking its whole line-breaking model for a row of boxes. +private struct FlowRow: Layout { + var spacing: CGFloat = 2 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let width = proposal.width ?? .infinity + var rows = 1 + var x: CGFloat = 0 + var height: CGFloat = 0 + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x > 0, x + size.width > width { + rows += 1 + x = 0 + } + x += size.width + spacing + height = max(height, size.height) + } + return CGSize( + width: proposal.width ?? x, + height: height * CGFloat(rows) + spacing * CGFloat(rows - 1), + ) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout (), + ) { + var x = bounds.minX + var y = bounds.minY + var lineHeight: CGFloat = 0 + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x > bounds.minX, x + size.width > bounds.maxX { + x = bounds.minX + y += lineHeight + spacing + lineHeight = 0 + } + subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing + lineHeight = max(lineHeight, size.height) + } + } +} diff --git a/Sources/HanReaderUI/Screens/PracticeComponents.swift b/Sources/HanReaderUI/Screens/PracticeComponents.swift new file mode 100644 index 0000000..132029a --- /dev/null +++ b/Sources/HanReaderUI/Screens/PracticeComponents.swift @@ -0,0 +1,189 @@ +// HanReader — MIT licensed. See LICENSE. + +import HanReaderCore +import HanReaderPlayback +import SwiftUI + +/// Where the reader is in the text, and what they have already passed. +/// +/// A strip of marks rather than a list: a two-minute exercise is twenty-odd +/// phrases, and the useful question is "how much is left and which ones am +/// I failing", not "what is phrase fourteen called". +struct PhraseProgressStrip: View { + let model: PracticeModel + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 4) { + ForEach(model.visible, id: \.index) { phrase in + Button { + model.select(phrase.index) + } label: { + Capsule() + .fill(colour(for: phrase)) + .frame(width: phrase.index == model.current ? 22 : 12, height: 6) + } + .buttonStyle(.plain) + .accessibilityLabel(label(for: phrase)) + } + } + .padding(.horizontal, ReaderMetrics.readingColumnPadding) + .padding(.vertical, 10) + } + .scrollIndicators(.never) + } + + private func colour(for phrase: Phrase) -> Color { + if phrase.index == model.current { + return ReaderColor.highlight + } + guard let attempt = model.attempts[phrase.index] else { + return ReaderColor.untried + } + return attempt.isPassing() ? ReaderColor.passed : ReaderColor.needsWork + } + + private func label(for phrase: Phrase) -> Text { + guard let attempt = model.attempts[phrase.index] else { + return Text("Phrase \(phrase.index + 1), not yet attempted") + } + return Text("Phrase \(phrase.index + 1), \(Int(attempt.accuracy * 100)) percent") + } +} + +/// Play this phrase, again, slower, or move to another one. +struct PhraseTransport: View { + let model: PracticeModel + let playback: PlaybackModel + + var body: some View { + HStack(spacing: 14) { + Button { + playback.isPlaying ? model.stop() : model.play() + } label: { + Image(systemName: playback.isPlaying ? "pause.fill" : "play.fill") + .font(.title2) + } + .buttonStyle(.borderless) + .keyboardShortcut("p", modifiers: .command) + .accessibilityLabel(playback.isPlaying ? Text("Stop") : Text("Play this phrase")) + + Button { + model.advance(by: -1) + } label: { + Image(systemName: "chevron.left") + } + .buttonStyle(.borderless) + .keyboardShortcut(.leftArrow, modifiers: .command) + .accessibilityLabel(Text("Previous phrase")) + + Button { + model.advance(by: 1) + } label: { + Image(systemName: "chevron.right") + } + .buttonStyle(.borderless) + .keyboardShortcut(.rightArrow, modifiers: .command) + .accessibilityLabel(Text("Next phrase")) + + if let phrase = model.phrase { + Text(verbatim: "\(phrase.index + 1) / \(model.phrases.count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + Text(verbatim: AudioBar.clock(phrase.duration)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) + // Low confidence means the recogniser inferred most of this + // phrase's timing rather than matching it, so the reader is + // told before they blame their ears for a boundary. + if phrase.confidence < 0.5 { + Label("Boundary uncertain", systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.orange) + .labelStyle(.iconOnly) + .help("The recording was hard to follow here.") + } + } + + Spacer() + + Picker(selection: Bindable(playback).rate) { + ForEach(PlaybackRate.allCases, id: \.self) { rate in + Text(verbatim: rate.label).tag(rate) + } + } label: { + Text("Speed") + } + .pickerStyle(.menu) + .fixedSize() + .accessibilityLabel(Text("Playback speed")) + } + } +} + +/// How many times each phrase plays, and how long a gap it leaves. +struct DrillMenu: View { + let model: PracticeModel + + var body: some View { + Menu { + Picker("Repeats", selection: repeats) { + ForEach([1, 2, 3, 5], id: \.self) { count in + Text("\(count)×").tag(count) + } + } + Picker("Gap", selection: gap) { + Text("None").tag(0.0) + Text("2 seconds").tag(2.0) + Text("5 seconds").tag(5.0) + Text("10 seconds").tag(10.0) + } + } label: { + Label("Repeat settings", systemImage: "repeat") + } + } + + private var repeats: Binding { + Binding( + get: { model.drill.repeats }, + set: { model.drill = .init(repeats: $0, gap: model.drill.gap) }, + ) + } + + private var gap: Binding { + Binding( + get: { model.drill.gap }, + set: { model.drill = .init(repeats: model.drill.repeats, gap: $0) }, + ) + } +} + +/// Easy, medium, hard — and what to drill again. +struct GradeRow: View { + let model: PracticeModel + + var body: some View { + HStack(spacing: 10) { + ForEach(PhraseGrade.allCases, id: \.self) { grade in + Button(title(grade)) { + Task { await model.grade(model.phrase?.grade == grade ? nil : grade) } + } + .buttonStyle(.bordered) + .tint(model.phrase?.grade == grade ? ReaderColor.highlight : nil) + .font(.caption) + } + Spacer() + Button("Next phrase") { model.advance(by: 1) } + .keyboardShortcut(.rightArrow, modifiers: .command) + .font(.caption) + } + } + + private func title(_ grade: PhraseGrade) -> LocalizedStringKey { + switch grade { + case .easy: "Easy" + case .medium: "Middling" + case .hard: "Hard" + } + } +} diff --git a/Sources/HanReaderUI/Screens/PracticeScreen.swift b/Sources/HanReaderUI/Screens/PracticeScreen.swift new file mode 100644 index 0000000..759f277 --- /dev/null +++ b/Sources/HanReaderUI/Screens/PracticeScreen.swift @@ -0,0 +1,231 @@ +// HanReader — MIT licensed. See LICENSE. + +import HanReaderCore +import HanReaderPlayback +import SwiftUI + +/// Listening practice: hear one phrase, type what you heard, be told where +/// you were wrong. +/// +/// ### Every shortcut is a ⌘ chord +/// +/// WorkAudioBook puts these on bare Space, arrows and R, which it can +/// because its text box is a separate panel that must be focused +/// deliberately. Here the field holds focus the whole time and the reader is +/// typing Chinese through an input method, where a bare Space commits a +/// candidate and a bare arrow moves through them. Taking those keys would +/// make the app fight the IME on every character. +/// +/// ### The transcript is hidden until it is checked +/// +/// 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. +struct PracticeScreen: View { + let model: PracticeModel + let playback: PlaybackModel + let style: ReaderStyle + + @FocusState private var isWriting: Bool + + var body: some View { + VStack(spacing: 0) { + switch model.phase { + case .noAudio: + ContentUnavailableView( + "This text has no recording", + systemImage: "waveform.slash", + description: Text("Add audio from the reader to practise listening."), + ) + case .needsAlignment: + AlignmentInvitation(model: model) + case let .aligning(progress): + AlignmentProgressView(progress: progress) { model.cancelAlignment() } + case let .unavailable(obstacle): + AlignmentObstacleView(obstacle: obstacle, model: model) + case .ready: + practice + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Practising + + private var practice: some View { + VStack(spacing: 0) { + PhraseProgressStrip(model: model) + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 16) { + PhraseTransport(model: model, playback: playback) + writing + if let result = model.result { + DictationMarkedView( + result: result, + expected: model.text, + style: style, + ) + GradeRow(model: model) + } else if model.isRevealed { + revealed + } + } + .padding(ReaderMetrics.readingColumnPadding) + .frame(maxWidth: style.maximumColumnWidth, alignment: .leading) + } + } + .toolbar { toolbar } + } + + private var writing: some View { + VStack(alignment: .leading, spacing: 6) { + TextField("Type what you hear…", text: Bindable(model).typed, axis: .vertical) + .textFieldStyle(.plain) + .font(ReaderFont.base(style)) + .lineLimit(2 ... 4) + .focused($isWriting) + .onSubmit { Task { await model.check() } } + HStack(spacing: 12) { + Button("Check") { Task { await model.check() } } + .keyboardShortcut(.return, modifiers: .command) + .disabled(model.typed.isEmpty) + Button("Show the answer") { model.reveal() } + .buttonStyle(.borderless) + .font(.caption) + Spacer() + Text("⌘↩ check · ⌘P play · ⌘→ next") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .onAppear { isWriting = true } + .onChange(of: model.current) { _, _ in isWriting = true } + } + + private var revealed: some View { + Text(verbatim: model.text) + .font(ReaderFont.base(style)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + ToolbarItem { + Picker(selection: Bindable(model).filter) { + Text("All phrases").tag(PracticeModel.Filter.all) + Text("Needs work").tag(PracticeModel.Filter.needsWork) + } label: { + Text("Which phrases") + } + .pickerStyle(.segmented) + } + ToolbarItem { + DrillMenu(model: model) + } + } +} + +// MARK: - Before there are phrases + +/// Offered rather than done. Listening to a recording takes a moment and, +/// the first time, a model download measured in hundreds of megabytes — +/// neither of which should happen because somebody opened a tab. +private struct AlignmentInvitation: View { + let model: PracticeModel + + var body: some View { + ContentUnavailableView { + Label("Find the phrases", systemImage: "waveform.badge.magnifyingglass") + } description: { + Text(""" + HanReader listens to the recording once to work out where each \ + sentence is said. It happens on this device and the audio is \ + not sent anywhere. + """) + } actions: { + Button("Listen to the recording") { model.align() } + .buttonStyle(.borderedProminent) + } + } +} + +private struct AlignmentProgressView: View { + let progress: SpeechAlignmentProgress + let onCancel: () -> Void + + var body: some View { + VStack(spacing: 12) { + if progress.isInstallingModel { + ProgressView(value: progress.fraction ?? 0) { + Text("Downloading the Chinese speech model…") + } + Text("This happens once. It is several hundred megabytes.") + .font(.caption) + .foregroundStyle(.secondary) + } else if let fraction = progress.fraction { + ProgressView(value: fraction) { + Text("Listening to the recording…") + } + } else { + ProgressView("Listening to the recording…") + } + Button("Stop", role: .cancel, action: onCancel) + .font(.caption) + } + .frame(maxWidth: 420) + .padding() + } +} + +/// What to say when alignment cannot be done, which on a managed Mac is a +/// real and un-obvious situation: `allowDictation = false` closes the usual +/// route to a Chinese speech model, and the reader deserves to know the app +/// is not simply broken. +private struct AlignmentObstacleView: View { + let obstacle: PracticeModel.AlignmentObstacle + let model: PracticeModel + + var body: some View { + ContentUnavailableView { + Label(title, systemImage: "waveform.badge.exclamationmark") + } description: { + Text(verbatim: explanation) + } actions: { + if obstacle.canInstall { + Button("Download the model") { model.align(installingModel: true) } + .buttonStyle(.borderedProminent) + } + } + } + + private var title: LocalizedStringKey { + switch obstacle { + case .modelMissing: "The Chinese speech model is not installed" + case .systemTooOld: "This Mac cannot find phrases automatically" + case .languageUnsupported: "Chinese speech recognition is unavailable" + case .failed: "The recording could not be read" + } + } + + private var explanation: String { + switch obstacle { + case .modelMissing: + """ + HanReader needs it to work out where each sentence is said. It \ + downloads once, works offline afterwards, and does not require \ + Dictation to be switched on. + """ + case .systemTooOld: + """ + Finding phrases automatically needs macOS 26 or later. Placing \ + them by hand is not possible yet. + """ + case .languageUnsupported: + "This system has no Chinese speech recognition to offer." + case let .failed(reason): + reason + } + } +} diff --git a/Sources/HanReaderUI/Screens/PracticeSheet.swift b/Sources/HanReaderUI/Screens/PracticeSheet.swift new file mode 100644 index 0000000..73e1144 --- /dev/null +++ b/Sources/HanReaderUI/Screens/PracticeSheet.swift @@ -0,0 +1,65 @@ +// HanReader — MIT licensed. See LICENSE. + +import HanReaderCore +import HanReaderPlayback +import SwiftUI + +/// Holds a `PracticeModel` for as long as the sheet is open. +/// +/// Document-scoped the same way `ReaderScreen` holds its own model: built by +/// `.task`, closed on the way out. Separate from the reader because practice +/// *hides* the text, which is the opposite of what the reader exists to do — +/// and because the transport is shared, so leaving has to hand it back. +struct PracticeSheet: View { + let textID: TextID + let title: String + let services: AppServices + let playback: PlaybackModel + let style: ReaderStyle + + @State private var model: PracticeModel? + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Group { + if let model { + PracticeScreen(model: model, playback: playback, style: style) + } else { + Color.clear + } + } + .navigationTitle(Text(verbatim: title)) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + // A sheet sizes itself to its content, and the content here is a + // short invitation before it is a practice screen — so without a + // floor it opens narrow and then has to grow under the reader. + .frame( + minWidth: 620, + idealWidth: 760, + minHeight: 520, + idealHeight: 660, + ) + .task { + let next = PracticeModel(textID: textID, services: services, playback: playback) + model = next + await next.load() + } + .onDisappear { + // The transport belongs to the window, not to this sheet, so it + // is handed back rather than left stopped on a phrase the + // reader can no longer see. + let model = model + Task { + await model?.close() + playback.clearSegment() + playback.pause() + } + } + } +} diff --git a/Sources/HanReaderUI/Screens/ReaderScreen.swift b/Sources/HanReaderUI/Screens/ReaderScreen.swift index eec276a..05b672a 100644 --- a/Sources/HanReaderUI/Screens/ReaderScreen.swift +++ b/Sources/HanReaderUI/Screens/ReaderScreen.swift @@ -32,6 +32,7 @@ struct ReaderScreen: View { @State private var model: ReaderModel? @State private var isInspectorPresented = false @State private var isAudioImporterPresented = false + @State private var isPractising = false /// Whether this text has a narration, which decides whether the /// transport is on screen at all. @State private var hasAudio = false @@ -168,6 +169,19 @@ struct ReaderScreen: View { } } .toolbar { toolbar } + // A sheet rather than a mode of the reader. Practice hides the + // text, which is the opposite of what the reader is for, and + // the two sharing a surface would mean every change to one + // reasoning about the other. + .sheet(isPresented: $isPractising) { + PracticeSheet( + textID: textID, + title: title, + services: services, + playback: playback, + style: style, + ) + } .alert( "No Chinese voice is installed", isPresented: Binding( @@ -274,6 +288,15 @@ struct ReaderScreen: View { @ToolbarContentBuilder private var toolbar: some ToolbarContent { + ToolbarItem { + Button { + isPractising = true + } label: { + Label("Listening practice", systemImage: "headphones") + } + .disabled(!hasAudio) + .help("Hear one sentence at a time and type what you heard.") + } ToolbarItem { Button { isAudioImporterPresented = true diff --git a/Sources/HanReaderUI/Services/DictionaryService.swift b/Sources/HanReaderUI/Services/DictionaryService.swift index 7786eaa..d9509c5 100644 --- a/Sources/HanReaderUI/Services/DictionaryService.swift +++ b/Sources/HanReaderUI/Services/DictionaryService.swift @@ -38,6 +38,9 @@ actor DictionaryService { /// occurrence in the book. private var readingCache: BoundedCache private var entryCache: BoundedCache + /// Small: there are only so many characters, and a dictation marks a + /// few dozen at a time. + private var characterCache = BoundedCache(capacity: 2048) /// Whether a database read has already failed. /// @@ -137,6 +140,38 @@ actor DictionaryService { return reading } + /// What a character sounds like, for marking a dictation. + /// + /// Cached like everything else here, and for the same reason: checking + /// one attempt asks about every character the reader got wrong, and the + /// ones they get wrong are the ones they will get wrong again. + func readings(forCharacter character: Character) -> [CharacterReading] { + if let cached = characterCache.value(forKey: character) { + return cached + } + let readings = attempt("readings for \(character)", default: []) { + try lookup.readings(forCharacter: character) + } + characterCache.insert(readings, forKey: character) + return readings + } + + /// What each of these characters sounds like, in one go. + /// + /// A batch because this is an actor and marking a dictation asks about + /// every character the reader got wrong: one hop for the lot rather + /// than one per character, which is the same reason `readings(for:)` + /// takes a window of words rather than a word. + func readings(forCharacters characters: some Sequence) + -> [Character: [CharacterReading]] + { + var result: [Character: [CharacterReading]] = [:] + for character in characters where result[character] == nil { + result[character] = readings(forCharacter: character) + } + return result + } + // MARK: - Entries /// Every entry for a word, for the detail panel. diff --git a/Tests/HanReaderUITests/PracticeCheckingTests.swift b/Tests/HanReaderUITests/PracticeCheckingTests.swift new file mode 100644 index 0000000..d61388f --- /dev/null +++ b/Tests/HanReaderUITests/PracticeCheckingTests.swift @@ -0,0 +1,124 @@ +// HanReader — MIT licensed. See LICENSE. + +import Foundation +import HanReaderCore +import HanReaderPersistence +import HanReaderPlayback +import Testing +@testable import HanReaderUI + +/// Marking an attempt: when the answer appears, what gets written down, and +/// which score decides whether a phrase is finished. +@MainActor +@Suite("Dictation marking") +struct PracticeCheckingTests { + private typealias Support = PracticeTestSupport + + // MARK: - Hiding the answer + + /// 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. + @Test("The transcript stays hidden until it is checked") + func transcriptIsHiddenUntilChecked() async throws { + let model = try await Support.practising() + await model.load() + #expect(!model.isRevealed) + + model.typed = "第一句" + await model.check() + #expect(model.isRevealed) + } + + @Test("Moving to another phrase hides the answer again") + func movingHidesTheAnswerAgain() async throws { + let model = try await Support.practising() + await model.load() + // Checked rather than revealed, so that the marking exists and its + // disappearance is something the test can actually see. + model.typed = "第一句" + await model.check() + #expect(model.isRevealed) + #expect(model.result != nil) + + model.advance(by: 1) + #expect(!model.isRevealed) + #expect(model.result == nil) + } + + /// Giving up shows the answer without recording an attempt — a reader + /// who looked should not be marked as having answered. + @Test("Giving up reveals without marking") + func revealingDoesNotRecordAnAttempt() async throws { + let model = try await Support.practising() + await model.load() + model.reveal() + #expect(model.isRevealed) + #expect(model.result == nil) + #expect(model.attempts.isEmpty) + } + + // MARK: - Checking + + @Test("Checking records the attempt and keeps the marking") + func checkingRecords() async throws { + let model = try await Support.practising() + await model.load() + model.typed = "第一句话" + await model.check() + + #expect(model.result != nil) + #expect(model.attempts[model.current]?.typed == "第一句话") + } + + /// The stored score is the listening one, so an input-method slip does + /// not keep a phrase in the drill. That is the contract `isPassing` + /// documents, and it is only true if the right number is written down. + @Test("The recorded score is the listening score") + func recordsTheListeningScore() async throws { + let model = try await Support.practising() + await model.load() + model.typed = model.text + await model.check() + #expect(model.attempts[model.current]?.accuracy == 1) + } + + /// The two scores only differ when a homophone is involved, so this is + /// the test that can tell them apart — and the one that fails if the + /// strict score is written down instead. + @Test("A homophone slip is recorded as heard, not as a mistake") + func homophoneCountsAsHeard() async throws { + let (services, id) = try await Support.services( + content: "我有针。", + lookup: Support.HomophoneDictionary(), + ) + try await services.library.savePhrases( + [Phrase(index: 0, start: 0, end: 4, textRange: 0 ..< 3)], + for: id, + ) + let model = Support.model(textID: id, services: services) + await model.load() + model.typed = "我有真" + await model.check() + + #expect(model.result?.count(of: .homophone) == 1) + // Strictly, one character in three is wrong. + #expect(model.result?.accuracy != 1) + // What gets stored is what was heard, which was all of it. + #expect(model.attempts[0]?.accuracy == 1) + #expect(model.attempts[0]?.isPassing() == true) + } + + // MARK: - Grading + + @Test("A grade is recorded and tapping it again clears it") + func grading() async throws { + let model = try await Support.practising() + await model.load() + await model.grade(.hard) + #expect(model.phrase?.grade == .hard) + + await model.grade(nil) + #expect(model.phrase?.grade == nil) + } +} diff --git a/Tests/HanReaderUITests/PracticeModelTests.swift b/Tests/HanReaderUITests/PracticeModelTests.swift new file mode 100644 index 0000000..70efd35 --- /dev/null +++ b/Tests/HanReaderUITests/PracticeModelTests.swift @@ -0,0 +1,164 @@ +// HanReader — MIT licensed. See LICENSE. + +import Foundation +import HanReaderCore +import HanReaderPersistence +import HanReaderPlayback +import Testing +@testable import HanReaderUI + +/// Where the reader is in the recording: which phrase opens, which ones the +/// filter keeps, and what moving between them does. +@MainActor +@Suite("Listening practice") +struct PracticeModelTests { + private typealias Support = PracticeTestSupport + + // MARK: - Which phrase to start on + + /// Reopening a text resumes rather than putting the reader back at the + /// top of something half done. + @Test("Opening starts at the first phrase not yet passed") + func resumesWhereLeftOff() async throws { + let model = try await Support.practising(attempts: [0: 1, 1: 1]) + await model.load() + #expect(model.current == 2) + } + + @Test("A text finished to the end opens at its first phrase") + func finishedTextOpensAtTheStart() async throws { + let model = try await Support.practising(attempts: [0: 1, 1: 1, 2: 1]) + await model.load() + #expect(model.current == 0) + } + + // MARK: - The replay filter + + /// A phrase is still work if it was graded hard, if it was never + /// attempted, or if the last attempt did not pass — the replay filter + /// WorkAudioBook spells as "play only bookmarks". + @Test("Needing work means graded hard, unattempted, or failed") + func needsWork() async throws { + let model = try await Support.practising(grades: [0: .hard], attempts: [0: 1, 1: 0.4]) + await model.load() + + model.filter = .needsWork + let indices = model.visible.map(\.index) + #expect(indices.contains(0)) // passed, but graded hard + #expect(indices.contains(1)) // attempted and failed + #expect(indices.contains(2)) // never attempted + } + + @Test("A passed phrase with no hard grade drops out of the filter") + func passedPhrasesLeaveTheFilter() async throws { + let model = try await Support.practising(attempts: [0: 1]) + await model.load() + model.filter = .needsWork + #expect(!model.visible.map(\.index).contains(0)) + } + + // MARK: - Moving between phrases + + /// Navigation follows the filter, or a reader drilling the hard ones is + /// marched through the easy ones between them. + @Test("Moving on skips phrases the filter has excluded") + func navigationFollowsTheFilter() async throws { + let model = try await Support.practising(attempts: [1: 1]) + await model.load() + model.filter = .needsWork + model.select(0) + model.advance(by: 1) + #expect(model.current == 2) + } + + /// Found on the running app: passing a phrase under the filter dropped + /// it from the list at once, so moving on counted from the top of what + /// was left and skipped the phrase that came next. + @Test("Passing a phrase does not skip the one after it") + func passingDoesNotSkipAhead() async throws { + let model = try await Support.practising() + await model.load() + model.filter = .needsWork + model.select(0) + model.typed = model.text + await model.check() + + // Still on the list, so the reader can see what they just did and + // the strip still shows where they are. + #expect(model.visible.map(\.index).contains(0)) + + model.advance(by: 1) + #expect(model.current == 1) + // And now that they have moved off it, it is gone. + #expect(!model.visible.map(\.index).contains(0)) + } + + @Test("Selecting a phrase that is not there changes nothing") + func selectIgnoresAnUnknownIndex() async throws { + let model = try await Support.practising() + await model.load() + model.typed = "第一句" + model.select(99) + #expect(model.current == 0) + // And it did not clear the field on the way, which is what makes an + // ignored tap different from a tap on the phrase already open. + #expect(model.typed == "第一句") + } + + @Test("Moving past the last phrase stays on it") + func navigationStopsAtTheEnd() async throws { + let model = try await Support.practising() + await model.load() + model.select(2) + model.advance(by: 1) + #expect(model.current == 2) + model.select(0) + model.advance(by: -1) + #expect(model.current == 0) + } + + @Test("Returning to a phrase shows what was typed before") + func previousAnswerComesBack() async throws { + let model = try await Support.practising() + await model.load() + model.typed = "第一句话" + await model.check() + model.advance(by: 1) + #expect(model.typed.isEmpty) + + model.advance(by: -1) + #expect(model.typed == "第一句话") + } + + // MARK: - Before there are phrases + + @Test("A text with no audio says so rather than offering to listen") + func noAudio() async throws { + let services = try AppServices.inMemory() + guard case let .imported(id) = try await services.library.importText( + title: "无声", + content: "没有录音。", + ) else { + throw Support.FixtureError.importFailed + } + let model = Support.model(textID: id, services: services) + await model.load() + #expect(model.phase == .noAudio) + } + + @Test("A recording nobody has listened to offers to be listened to") + func audioWithoutPhrasesNeedsAlignment() async throws { + let (services, id) = try await Support.services(content: Support.content) + let model = Support.model(textID: id, services: services) + await model.load() + #expect(model.phase == .needsAlignment) + } + + @Test("Only a missing model is offered as something to fix") + func obstacleRecoverability() { + #expect(PracticeModel.AlignmentObstacle.modelMissing.canInstall) + #expect(!PracticeModel.AlignmentObstacle.systemTooOld.canInstall) + #expect(!PracticeModel.AlignmentObstacle.languageUnsupported.canInstall) + #expect(!PracticeModel.AlignmentObstacle.failed("…").canInstall) + } +} diff --git a/Tests/HanReaderUITests/PracticeTestSupport.swift b/Tests/HanReaderUITests/PracticeTestSupport.swift new file mode 100644 index 0000000..53306e9 --- /dev/null +++ b/Tests/HanReaderUITests/PracticeTestSupport.swift @@ -0,0 +1,142 @@ +// HanReader — MIT licensed. See LICENSE. + +import Foundation +import HanReaderCore +import HanReaderPersistence +import HanReaderPlayback +@testable import HanReaderUI + +/// Shared fixtures for the listening-practice suites. +/// +/// A namespace rather than file-scoped `private` declarations, for the same +/// reason as `ModelTestSupport`: `private` in Swift reaches extensions in +/// the same file only, and these fixtures are wanted by two suites. +nonisolated enum PracticeTestSupport { + /// Three sentences of five UTF-16 units each; a phrase covers the four + /// characters and leaves the full stop out, because what a learner types + /// is the sentence rather than its punctuation. + static let content = "第一句话。第二句话。第三句话。" + + static func phrase(_ index: Int, grade: PhraseGrade? = nil) -> Phrase { + Phrase( + index: index, + start: Double(index) * 5, + end: Double(index) * 5 + 4, + textRange: (index * 5) ..< (index * 5 + 4), + grade: grade, + ) + } + + enum FixtureError: Error { case importFailed } + + /// Services over an in-memory library with a recording attached. + /// + /// The file has to exist on disk — `load()` checks, because a row + /// pointing at a missing file is exactly what a restored backup used to + /// produce — but nothing ever reads it: the engine is silent. + @MainActor + static func services( + content: String, + lookup: some DictionaryLookup = EmptyDictionary(), + ) async throws + -> (services: AppServices, id: TextID) + { + let services = try AppServices.inMemory(lookup: lookup) + guard case let .imported(id) = try await services.library.importText( + title: "听力", + content: content, + ) else { + throw FixtureError.importFailed + } + let name = "text-\(id.rawValue).m4a" + try FileManager.default.createDirectory( + at: services.locations.audio, + withIntermediateDirectories: true, + ) + try Data().write(to: services.locations.audio.appendingPathComponent(name)) + try await services.library.attachAudio(AudioTrack( + textID: id, + relativePath: name, + duration: 15, + importedAt: Date(), + )) + return (services, id) + } + + /// A model over `services`, with a transport that makes no sound and + /// never waits. + @MainActor + static func model(textID: TextID, services: AppServices) -> PracticeModel { + PracticeModel( + textID: textID, + services: services, + playback: PlaybackModel( + engine: SilentEngine(), + session: SilentSession(), + wait: { _ in }, + ), + ) + } + + /// A text with three phrases already aligned, so the tests are about + /// practising rather than about alignment — which needs a speech model + /// and cannot run on a fresh clone. + @MainActor + static func practising( + grades: [Int: PhraseGrade] = [:], + attempts: [Int: Double] = [:], + ) async throws + -> PracticeModel + { + let (services, id) = try await services(content: content) + try await services.library.savePhrases( + (0 ..< 3).map { phrase($0, grade: grades[$0]) }, + for: id, + ) + for (index, accuracy) in attempts { + try await services.library.recordAttempt("答", accuracy: accuracy, at: index, in: id) + } + return model(textID: id, services: services) + } + + /// Knows that 真 and 针 are both `zhen1`, which is the only way a test + /// can tell the strict score from the listening one. + struct HomophoneDictionary: DictionaryLookup { + func entries(for _: String) throws -> [DictionaryEntry] { + [] + } + + func readings(forCharacter character: Character) throws -> [CharacterReading] { + guard let numeric = ["真": "zhen1", "针": "zhen1"][String(character)] else { return [] } + return [CharacterReading( + character: character, + numeric: numeric, + display: Pinyin.display(Pinyin.parse(numeric: numeric)), + rank: 0, + )] + } + } + + /// A transport that makes no sound, for tests about practising rather + /// than about playing. + final class SilentEngine: AudioEngine { + var duration = 60.0 + var currentTime = 0.0 + var onTime: ((Double) -> Void)? + var onDuration: ((Double) -> Void)? + var onFinish: (() -> Void)? + + func load(_: URL) {} + func play() {} + func pause() {} + func seek(to _: Double) {} + func setRate(_: Double) {} + func setStopTime(_: Double?) {} + } + + final class SilentSession: PlaybackSession { + var onInterruption: ((PlaybackInterruption) -> Void)? + func activate() throws {} + func deactivate() {} + } +} From 6f81e1de9d8848830ac1d7508a68fe3bceb64bbf Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:21:54 +0300 Subject: [PATCH 2/3] fix: keep the silent transport out of the nonisolated namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../PracticeTestSupport.swift | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/Tests/HanReaderUITests/PracticeTestSupport.swift b/Tests/HanReaderUITests/PracticeTestSupport.swift index 53306e9..b13538e 100644 --- a/Tests/HanReaderUITests/PracticeTestSupport.swift +++ b/Tests/HanReaderUITests/PracticeTestSupport.swift @@ -116,27 +116,35 @@ nonisolated enum PracticeTestSupport { )] } } +} - /// A transport that makes no sound, for tests about practising rather - /// than about playing. - final class SilentEngine: AudioEngine { - var duration = 60.0 - var currentTime = 0.0 - var onTime: ((Double) -> Void)? - var onDuration: ((Double) -> Void)? - var onFinish: (() -> Void)? +/// A transport that makes no sound, for tests about practising rather than +/// about playing. +/// +/// At file scope and `@MainActor`, like `SpyEngine` in the playback tests, +/// rather than nested in the namespace above. Nested inside a `nonisolated` +/// type its members would still take this target's `MainActor` default +/// isolation while its implicit initialiser stayed nonisolated — which +/// Xcode 26.6 rejects outright and a later compiler quietly accepts. +@MainActor +final class SilentEngine: AudioEngine { + var duration = 60.0 + var currentTime = 0.0 + var onTime: ((Double) -> Void)? + var onDuration: ((Double) -> Void)? + var onFinish: (() -> Void)? - func load(_: URL) {} - func play() {} - func pause() {} - func seek(to _: Double) {} - func setRate(_: Double) {} - func setStopTime(_: Double?) {} - } + func load(_: URL) {} + func play() {} + func pause() {} + func seek(to _: Double) {} + func setRate(_: Double) {} + func setStopTime(_: Double?) {} +} - final class SilentSession: PlaybackSession { - var onInterruption: ((PlaybackInterruption) -> Void)? - func activate() throws {} - func deactivate() {} - } +@MainActor +final class SilentSession: PlaybackSession { + var onInterruption: ((PlaybackInterruption) -> Void)? + func activate() throws {} + func deactivate() {} } From 87268d863b33a0c6487500cc3d5e0f9f60701f1f Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:37:52 +0300 Subject: [PATCH 3/3] fix: answer the review on the practice screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../HanReaderUI/Models/PracticeModel.swift | 73 ++++++++++++++++--- .../Screens/PracticeComponents.swift | 5 +- .../HanReaderUI/Screens/PracticeScreen.swift | 9 ++- .../HanReaderUI/Screens/PracticeSheet.swift | 13 +++- .../PracticeCheckingTests.swift | 24 ++++++ .../HanReaderUITests/PracticeModelTests.swift | 47 ++++++++++++ 6 files changed, 155 insertions(+), 16 deletions(-) diff --git a/Sources/HanReaderUI/Models/PracticeModel.swift b/Sources/HanReaderUI/Models/PracticeModel.swift index 0bc2d82..fadbd96 100644 --- a/Sources/HanReaderUI/Models/PracticeModel.swift +++ b/Sources/HanReaderUI/Models/PracticeModel.swift @@ -70,6 +70,16 @@ final class PracticeModel { private let playback: PlaybackModel private var content = "" private var alignTask: Task? + /// Where the narration was before practice took the transport over. + private var resumeAt: Double? + /// Which alignment run is the current one. + /// + /// A cancelled task keeps running until its next suspension point, and + /// its callbacks can still fire. Without a way to tell runs apart, a + /// stopped run finishing late can clear the handle of the run that + /// replaced it — leaving a second alignment going that nothing can + /// stop. + private var alignGeneration = 0 init(textID: TextID, services: AppServices, playback: PlaybackModel) { self.textID = textID @@ -95,6 +105,11 @@ final class PracticeModel { phase = .noAudio return } + // Where the reader was in the narration, so closing practice + // can put it back. Loading parks the playhead at zero, and the + // reader's own position is a resume point they did not ask to + // lose because they opened a different screen. + resumeAt = playback.playhead playback.load(file) phrases = try await services.library.phrases(for: textID) @@ -110,7 +125,11 @@ final class PracticeModel { func close() async { alignTask?.cancel() alignTask = nil + playback.pause() playback.clearSegment() + if let resumeAt { + playback.seek(to: resumeAt) + } } /// The first phrase not yet passed, so reopening a text resumes rather @@ -162,6 +181,11 @@ final class PracticeModel { typed = attempts[index]?.typed ?? "" result = nil isRevealed = false + // Paused before the segment is cleared, not after. Clearing it + // removes the stop time, so a transport left running would sail + // past the phrase it was playing and on into the next one, while + // the screen showed a different phrase entirely. + playback.pause() playback.clearSegment() } @@ -192,23 +216,37 @@ final class PracticeModel { /// where it differed. func check() async { guard let phrase else { return } + // Read before the await, not after it. The reader can keep typing, + // or move to another phrase, while the dictionary is being asked — + // and marking the answer they have now against the phrase they had + // then would save a score under the wrong sentence. + let expected = text + let answer = typed + // Whether they had already looked. Taken here for the same reason. + let hadLooked = isRevealed // Fetched before marking rather than during it. `check` is a pure // function over a synchronous lookup, which is what makes it // testable without a database — so the asynchronous part happens // here, once, for every character either side might need. let readings = await services.dictionary.readings( - forCharacters: Array(text) + Array(typed), + forCharacters: Array(expected) + Array(answer), ) + guard current == phrase.index else { return } let outcome = DictationComparison.check( - typed: typed, - against: text, + typed: answer, + against: expected, readings: { readings[$0] ?? [] }, ) result = outcome isRevealed = true + // A reader who showed the answer and then copied it has not + // demonstrated anything about their listening, and recording a pass + // would drop the phrase out of the drill on the strength of it. + // They still see the marking — that is what they asked for. + guard !hadLooked else { return } do { try await services.library.recordAttempt( - typed, + answer, accuracy: outcome.listeningAccuracy, at: phrase.index, in: textID, @@ -241,19 +279,25 @@ final class PracticeModel { /// rest possible. func align(installingModel: Bool = false) { guard alignTask == nil else { return } + alignGeneration += 1 + let generation = alignGeneration alignTask = Task { [weak self] in - await self?.runAlignment(installingModel: installingModel) - self?.alignTask = nil + await self?.runAlignment(installingModel: installingModel, generation: generation) + guard let self, generation == alignGeneration else { return } + alignTask = nil } } func cancelAlignment() { alignTask?.cancel() alignTask = nil + // Bumped so that whatever the stopped run does on its way out + // belongs to a generation nobody is listening to any more. + alignGeneration += 1 phase = phrases.isEmpty ? .needsAlignment : .ready } - private func runAlignment(installingModel: Bool) async { + private func runAlignment(installingModel: Bool, generation: Int) async { guard #available(macOS 26.0, iOS 26.0, *) else { phase = .unavailable(.systemTooOld) return @@ -264,6 +308,7 @@ final class PracticeModel { } let file = AudioAttachment.file(for: track, in: services.locations) let aligner = SpeechAligner() + guard generation == alignGeneration else { return } phase = .aligning(SpeechAlignmentProgress(fraction: nil)) do { @@ -271,7 +316,7 @@ final class PracticeModel { to: file, installIfNeeded: installingModel, progress: { [weak self] progress in - Task { @MainActor in self?.report(progress) } + Task { @MainActor in self?.report(progress, from: generation) } }, ) try Task.checkCancellation() @@ -286,21 +331,29 @@ final class PracticeModel { to: heard, ) try await services.library.savePhrases(placed, for: textID) + // Checked again after the write, not only after the listening. + // Stop pressed while the database was busy would otherwise let + // the run finish and announce itself ready. + try Task.checkCancellation() + guard generation == alignGeneration else { return } phrases = try await services.library.phrases(for: textID) attempts = try await services.library.attempts(for: textID) current = firstUnfinished phase = .ready } catch is CancellationError { + guard generation == alignGeneration else { return } phase = phrases.isEmpty ? .needsAlignment : .ready } catch let problem as SpeechAlignmentProblem { + guard generation == alignGeneration else { return } phase = .unavailable(obstacle(for: problem)) } catch { + guard generation == alignGeneration else { return } phase = .unavailable(.failed(error.localizedDescription)) } } - private func report(_ progress: SpeechAlignmentProgress) { - guard case .aligning = phase else { return } + private func report(_ progress: SpeechAlignmentProgress, from generation: Int) { + guard generation == alignGeneration, case .aligning = phase else { return } phase = .aligning(progress) } diff --git a/Sources/HanReaderUI/Screens/PracticeComponents.swift b/Sources/HanReaderUI/Screens/PracticeComponents.swift index 132029a..c9c0091 100644 --- a/Sources/HanReaderUI/Screens/PracticeComponents.swift +++ b/Sources/HanReaderUI/Screens/PracticeComponents.swift @@ -173,8 +173,11 @@ struct GradeRow: View { .font(.caption) } Spacer() + // No shortcut here. ⌘→ belongs to the transport's own next + // button, which is on screen at the same time; binding both + // leaves SwiftUI to pick one, and a later change to either + // action would silently alter the other. Button("Next phrase") { model.advance(by: 1) } - .keyboardShortcut(.rightArrow, modifiers: .command) .font(.caption) } } diff --git a/Sources/HanReaderUI/Screens/PracticeScreen.swift b/Sources/HanReaderUI/Screens/PracticeScreen.swift index 759f277..67d01f9 100644 --- a/Sources/HanReaderUI/Screens/PracticeScreen.swift +++ b/Sources/HanReaderUI/Screens/PracticeScreen.swift @@ -85,7 +85,14 @@ struct PracticeScreen: View { .font(ReaderFont.base(style)) .lineLimit(2 ... 4) .focused($isWriting) - .onSubmit { Task { await model.check() } } + // Guarded the same way the Check button is. Return + // otherwise checks an empty field, which reveals the answer + // and writes down a zero — through a control the screen is + // showing as disabled. + .onSubmit { + guard !model.typed.isEmpty else { return } + Task { await model.check() } + } HStack(spacing: 12) { Button("Check") { Task { await model.check() } } .keyboardShortcut(.return, modifiers: .command) diff --git a/Sources/HanReaderUI/Screens/PracticeSheet.swift b/Sources/HanReaderUI/Screens/PracticeSheet.swift index 73e1144..623f432 100644 --- a/Sources/HanReaderUI/Screens/PracticeSheet.swift +++ b/Sources/HanReaderUI/Screens/PracticeSheet.swift @@ -19,6 +19,7 @@ struct PracticeSheet: View { @State private var model: PracticeModel? @Environment(\.dismiss) private var dismiss + @Environment(\.horizontalSizeClass) private var sizeClass var body: some View { NavigationStack { @@ -39,11 +40,15 @@ struct PracticeSheet: View { // A sheet sizes itself to its content, and the content here is a // short invitation before it is a practice screen — so without a // floor it opens narrow and then has to grow under the reader. + // + // Only where there is room for one. A phone is narrower than this + // floor, and a sheet that will not fit its window clips its own + // controls off the side. .frame( - minWidth: 620, - idealWidth: 760, - minHeight: 520, - idealHeight: 660, + minWidth: sizeClass == .compact ? nil : 620, + idealWidth: sizeClass == .compact ? nil : 760, + minHeight: sizeClass == .compact ? nil : 520, + idealHeight: sizeClass == .compact ? nil : 660, ) .task { let next = PracticeModel(textID: textID, services: services, playback: playback) diff --git a/Tests/HanReaderUITests/PracticeCheckingTests.swift b/Tests/HanReaderUITests/PracticeCheckingTests.swift index d61388f..151328a 100644 --- a/Tests/HanReaderUITests/PracticeCheckingTests.swift +++ b/Tests/HanReaderUITests/PracticeCheckingTests.swift @@ -58,6 +58,30 @@ struct PracticeCheckingTests { #expect(model.attempts.isEmpty) } + /// Showing the answer and then copying it demonstrates nothing about + /// listening, so it must not drop the phrase out of the drill. The + /// marking still appears — that is what was asked for. + @Test("Checking after showing the answer marks but does not record") + func checkingAfterRevealingDoesNotRecord() async throws { + let model = try await Support.practising() + await model.load() + model.reveal() + model.typed = model.text + await model.check() + + #expect(model.result?.accuracy == 1) + #expect(model.attempts.isEmpty) + } + + @Test("Checking without having looked still records") + func checkingWithoutRevealingRecords() async throws { + let model = try await Support.practising() + await model.load() + model.typed = model.text + await model.check() + #expect(model.attempts[model.current]?.accuracy == 1) + } + // MARK: - Checking @Test("Checking records the attempt and keeps the marking") diff --git a/Tests/HanReaderUITests/PracticeModelTests.swift b/Tests/HanReaderUITests/PracticeModelTests.swift index 70efd35..4eef450 100644 --- a/Tests/HanReaderUITests/PracticeModelTests.swift +++ b/Tests/HanReaderUITests/PracticeModelTests.swift @@ -130,6 +130,53 @@ struct PracticeModelTests { #expect(model.typed == "第一句话") } + // MARK: - Sharing the transport with the reader + + /// The transport belongs to the window. Practice borrows it, so it has + /// to give it back where it found it rather than parked at zero. + @Test("Closing practice puts the narration back where it was") + func closingRestoresTheNarrationPosition() async throws { + let (services, id) = try await Support.services(content: Support.content) + try await services.library.savePhrases( + (0 ..< 3).map { Support.phrase($0) }, + for: id, + ) + let engine = SilentEngine() + let playback = PlaybackModel(engine: engine, session: SilentSession(), wait: { _ in }) + playback.load(URL(fileURLWithPath: "/dev/null"), startingAt: 42) + #expect(playback.playhead == 42) + + let model = PracticeModel(textID: id, services: services, playback: playback) + await model.load() + engine.onDuration?(90) + #expect(playback.playhead == 0) + + await model.close() + #expect(playback.playhead == 42) + } + + /// Clearing a segment removes its stop time, so a transport left + /// running would sail past the phrase it was playing. + @Test("Moving to another phrase stops the sound") + func movingStopsPlayback() async throws { + let (services, id) = try await Support.services(content: Support.content) + try await services.library.savePhrases( + (0 ..< 3).map { Support.phrase($0) }, + for: id, + ) + let engine = SilentEngine() + let playback = PlaybackModel(engine: engine, session: SilentSession(), wait: { _ in }) + let model = PracticeModel(textID: id, services: services, playback: playback) + await model.load() + engine.onDuration?(30) + + model.play() + #expect(playback.isPlaying) + + model.advance(by: 1) + #expect(!playback.isPlaying) + } + // MARK: - Before there are phrases @Test("A text with no audio says so rather than offering to listen")