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..fadbd96 --- /dev/null +++ b/Sources/HanReaderUI/Models/PracticeModel.swift @@ -0,0 +1,374 @@ +// 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? + /// 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 + 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 + } + // 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) + 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.pause() + playback.clearSegment() + if let resumeAt { + playback.seek(to: resumeAt) + } + } + + /// 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 + // 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() + } + + 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 } + // 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(expected) + Array(answer), + ) + guard current == phrase.index else { return } + let outcome = DictationComparison.check( + 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( + answer, + 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 } + alignGeneration += 1 + let generation = alignGeneration + alignTask = Task { [weak self] in + 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, generation: Int) 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() + guard generation == alignGeneration else { return } + 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, from: generation) } + }, + ) + 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) + // 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, from generation: Int) { + guard generation == alignGeneration, 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..c9c0091 --- /dev/null +++ b/Sources/HanReaderUI/Screens/PracticeComponents.swift @@ -0,0 +1,192 @@ +// 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() + // 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) } + .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..67d01f9 --- /dev/null +++ b/Sources/HanReaderUI/Screens/PracticeScreen.swift @@ -0,0 +1,238 @@ +// 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) + // 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) + .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..623f432 --- /dev/null +++ b/Sources/HanReaderUI/Screens/PracticeSheet.swift @@ -0,0 +1,70 @@ +// 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 + @Environment(\.horizontalSizeClass) private var sizeClass + + 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. + // + // 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: 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) + 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..151328a --- /dev/null +++ b/Tests/HanReaderUITests/PracticeCheckingTests.swift @@ -0,0 +1,148 @@ +// 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) + } + + /// 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") + 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..4eef450 --- /dev/null +++ b/Tests/HanReaderUITests/PracticeModelTests.swift @@ -0,0 +1,211 @@ +// 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: - 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") + 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..b13538e --- /dev/null +++ b/Tests/HanReaderUITests/PracticeTestSupport.swift @@ -0,0 +1,150 @@ +// 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. +/// +/// 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?) {} +} + +@MainActor +final class SilentSession: PlaybackSession { + var onInterruption: ((PlaybackInterruption) -> Void)? + func activate() throws {} + func deactivate() {} +}