Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,29 @@ it needs to read like release notes rather than a commit log.

## [Unreleased]

### Changed

- Listening practice is driven by typing rather than by buttons. The sentence
plays as soon as you reach it; your answer is marked the moment it is as long
as the sentence; and a correct one moves to the next sentence by itself.
Check, Play and Next phrase are all still there for the times that is not
what you wanted — they are no longer the way through.
- Pausing a sentence and starting it again picks up a couple of seconds before
where it stopped rather than at the beginning, the way a podcast player does.
Starting a long sentence over every time you paused to think made one
impossible to finish.
- The writing field asks the system for a Chinese input source while it has
focus, so practising does not mean switching input source twice a sentence.
- Every character you got wrong now carries its pinyin, beside what you typed —
the reading is the thing worth taking away from a mistake.

### Fixed

- Sentences no longer begin mid-word. Phrase playback seeked with a tenth of a
second of tolerance, which is inaudible when dragging a scrubber through a
narration and very audible when the target is the first syllable of a
sentence. It is exact now; scrubbing keeps the tolerance it wants.

### Added

- Listening and dictation practice (听力 / 听写). A text with a narration opens
Expand Down
10 changes: 10 additions & 0 deletions Sources/HanReaderCore/Listening/DictationComparison.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,16 @@ public struct DictationResult: Hashable, Sendable {
/// for building this inside HanReader rather than using a general-purpose
/// dictation tool — the readings are already here.
public enum DictationComparison {
/// How many characters of this text count towards a score.
///
/// Punctuation and spacing are not the exercise, so they are not in the
/// count — which is what lets a screen notice that an answer is as long
/// as the sentence without caring whether the learner's input method
/// inserted a comma.
public static func scorableCount(of text: String) -> Int {
scorable(text).count
}

/// Compares an attempt against the expected text.
///
/// - Parameter readings: consulted only for characters that did not
Expand Down
47 changes: 47 additions & 0 deletions Sources/HanReaderPlatform/ChineseInput.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// HanReader — MIT licensed. See LICENSE.

#if os(macOS)
import AppKit
#endif

/// Asking the system for a Chinese input source while a field is being typed
/// into.
///
/// Listening practice is the one screen in the app where every keystroke is
/// meant to be Chinese. Leaving the learner to switch input source themselves
/// means doing it on the way in and again on the way out, every sentence, and
/// discovering they forgot only once the answer has been typed in Latin
/// letters and marked wrong.
///
/// ### Asking, not forcing
///
/// `allowedInputSourceLocales` is a *request*: the system honours it when the
/// learner has a matching input source installed, and does nothing when they
/// have not. That is the right shape — an app that could force an input
/// source could also strand somebody with no way to type their own language —
/// and it is why this reports nothing back. There is no failure here to
/// handle, only a preference the system may or may not act on.
public enum ChineseInput {
/// The locale to prefer. Simplified, matching the dictionary the app
/// bundles and the transcripts it is built for.
public static let locale = "zh-Hans"

/// Prefers a Chinese input source for whatever is being typed into now.
///
/// Call it as the writing field takes focus. The preference belongs to
/// the focused field's input context, so it lasts as long as that field
/// is first responder and the system restores the learner's own choice
/// afterwards.
public static func prefer() {
#if os(macOS)
NSTextInputContext.current?.allowedInputSourceLocales = [locale]
#endif
}

/// Gives the choice back, for a field that is no longer the exercise.
public static func clearPreference() {
#if os(macOS)
NSTextInputContext.current?.allowedInputSourceLocales = nil
#endif
}
}
20 changes: 13 additions & 7 deletions Sources/HanReaderPlayback/AudioEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ public protocol AudioEngine: AnyObject {
func load(_ url: URL)
func play()
func pause()
func seek(to time: Double)
/// - Parameter exactly: land on the requested time rather than wherever
/// is cheap. Scrubbing a narration does not care; starting a phrase
/// does, because the target *is* the first syllable.
func seek(to time: Double, exactly: Bool)
/// Applied immediately when playing, and remembered for the next `play()`
/// when not.
func setRate(_ rate: Double)
Expand Down Expand Up @@ -173,13 +176,16 @@ final class AVPlayerAudioEngine: AudioEngine {
player.pause()
}

func seek(to time: Double) {
func seek(to time: Double, exactly: Bool) {
let target = CMTime(seconds: max(0, time), preferredTimescale: 600)
// A tolerance, deliberately. The prototype seeked with
// `toleranceBefore: .zero, toleranceAfter: .zero`, which forces a
// frame-accurate seek — expensive, and pointless for spoken audio
// where a tenth of a second is inaudible.
let tolerance = CMTime(seconds: 0.1, preferredTimescale: 600)
// A tenth of a second is inaudible when the reader is dragging a
// scrubber through a narration, and a frame-accurate seek there is
// expensive for nothing. It is *not* inaudible at the start of a
// phrase: that target is the first syllable, AVPlayer resolves a
// tolerance by landing on whichever sync sample is cheapest — in
// practice an earlier one — and the reader hears the sentence
// begin mid-word.
let tolerance = exactly ? .zero : CMTime(seconds: 0.1, preferredTimescale: 600)
player.seek(to: target, toleranceBefore: tolerance, toleranceAfter: tolerance)
}

Expand Down
47 changes: 42 additions & 5 deletions Sources/HanReaderPlayback/PlaybackModel+Phrase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,26 @@ extension PlaybackModel {
// MARK: - One phrase at a time

/// Plays a single phrase, stopping exactly at its end.
/// Plays a single phrase, stopping exactly at its end.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational

10. A doc comment line appears twice 🐞 Bug ⚙ Maintainability

play(segment:drill:) now begins its doc comment with "Plays a single phrase, stopping exactly at
its end." on two consecutive lines. The original line was kept when the new doc block was added, so
the rendered documentation repeats its summary.
Agent Prompt
## Issue description
The doc comment summary line is duplicated.

## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[14-15]

## Recommended Fix
Remove one of the two identical lines.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

///
/// Asking for the phrase that is already loaded picks it up where it
/// stopped, a little way back, rather than starting it again — the
/// difference between pausing to think halfway through a long sentence
/// and never getting to the end of one.
public func play(segment range: Range<Double>, drill: Drill? = nil) {
guard state == .ready else { return }
let resuming = segment == range && !isPlaying
cancelDrill()
if let drill {
self.drill = drill
}
segment = range
repeatsPlayed = 0
playSegmentOnce()
if resuming {
resumeSegment()
} else {
Comment on lines +23 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

8. Resuming a phrase restarts the drill's repeat count 🐞 Bug ≡ Correctness

play(segment:drill:) calls cancelDrill(), which sets repeatsPlayed = 0, before the new
resuming branch calls resumeSegment(). Unlike the restart branch, the resume branch was meant to
keep the count. A learner who pauses part-way through repeat 3 of 3 and resumes is given a full set
of repeats again, so the drill no longer ends where it should.
Agent Prompt
## Issue description
Resuming a paused phrase resets the drill's repeat counter, because `cancelDrill()` zeroes it before `resumeSegment()` runs.

## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[21-35]

## Recommended Fix
Capture `repeatsPlayed` before `cancelDrill()` and restore it in the resuming branch, or cancel only `drillTask` when resuming. Add a test that pauses during the second repeat, resumes, and asserts the total number of passes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

repeatsPlayed = 0
playSegmentOnce()
}
}

/// Goes back to playing the whole recording.
Expand All @@ -31,13 +42,39 @@ extension PlaybackModel {
onStateChange?()
}

/// How far back a resumed phrase rewinds.
///
/// Picking up exactly where the sound stopped drops the listener into
/// the middle of a word with no run-up, and starting the phrase again
/// makes a long sentence impossible to finish — pause once near the end
/// and you are back at the beginning. Podcasts solves it with a short
/// rewind, and so does this.
public static let resumeRewind = 2.0

func playSegmentOnce() {
play(segment: segment, from: nil)
}

/// Picks the phrase up again from a little before where it stopped.
func resumeSegment() {
guard let segment else { return }
let from = max(segment.lowerBound, playhead - Self.resumeRewind)
// Close enough to the start that a rewind would land there anyway,
// or past the end after a finished pass: begin the phrase again.
let isNearlyOver = playhead >= segment.upperBound - 0.05
play(segment: segment, from: isNearlyOver ? nil : from)
Comment on lines +61 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Finished phrases replay only their tail 🐞 Bug ≡ Correctness

resumeSegment() treats a phrase as finished only when the last reported playhead is within 50 ms
of its endpoint. Phrase completion does not update that playhead, which is reported at 250 ms
intervals, so pressing Play after completion or during a drill gap commonly starts about two seconds
before the end instead of at the beginning.
Agent Prompt
## Issue description
A completed phrase is mistaken for a paused phrase because the finish callback leaves a periodically reported playhead short of its end.
## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[22-34]
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[59-65]
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[85-96]
## Recommended Fix
Record completion when a phrase pass finishes and restart from its beginning on a subsequent Play; reserve rewind for an actual mid-phrase pause. Test replay with a playhead more than 50 ms short of the endpoint.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}

private func play(segment: Range<Double>?, from: Double?) {
guard let segment, state == .ready else { return }
activateSession()
engine.setStopTime(segment.upperBound)
engine.seek(to: segment.lowerBound)
playhead = segment.lowerBound
observedTime = segment.lowerBound
let start = from ?? segment.lowerBound
// Exactly, because the target is the first syllable. A tolerance
// here is what makes a sentence begin mid-word.
engine.seek(to: start, exactly: true)
playhead = start
observedTime = start
engine.play()
isPlaying = true
clearPauseReasons()
Expand Down
4 changes: 2 additions & 2 deletions Sources/HanReaderPlayback/PlaybackModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ public final class PlaybackModel {
playhead = start
observedTime = start
if start > 0 {
engine.seek(to: start)
engine.seek(to: start, exactly: false)
}
engine.setRate(rate.rawValue)
onStateChange?()
Expand Down Expand Up @@ -322,7 +322,7 @@ public final class PlaybackModel {
self.segment = nil
engine.setStopTime(nil)
}
engine.seek(to: target)
engine.seek(to: target, exactly: segment != nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational

9. Scrubbing inside a phrase seeks exactly 🐞 Bug ➹ Performance

seek(to:) passes exactly: segment != nil, so every scrub or skip that stays inside the current
phrase makes a frame-accurate seek. The PR description and the AudioEngine comment both say
scrubbing keeps the tolerance. Dragging the scrubber within a phrase therefore pays the expensive
exact seek on every move, which the old code avoided on purpose.
Agent Prompt
## Issue description
Scrubs and skips inside the active phrase seek exactly, which contradicts the stated design and is expensive while dragging.

## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel.swift[325-325]

## Recommended Fix
Pass `exactly: false` here and keep exact seeks only for the start of phrase playback in `play(segment:from:)`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

playhead = target
// Published immediately rather than left to the next report, or the
// thumb springs back to where it was for a quarter of a second.
Expand Down
95 changes: 95 additions & 0 deletions Sources/HanReaderUI/Models/PracticeModel+Flow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// HanReader — MIT licensed. See LICENSE.

import Foundation
import HanReaderCore
import HanReaderPlayback

/// Practising without pressing anything.
///
/// The screen this grew out of asked for four deliberate actions per
/// sentence: play, check, grade, next. Three of them are inferable. A
/// learner has finished an answer when it is as long as the sentence; an
/// answer they heard correctly needs nothing said about it; and arriving at
/// a phrase means they want to hear it. What is left is typing, which is the
/// exercise.
///
/// Nothing here removes a control — the transport, Check and the grades are
/// all still there for the cases the inference gets wrong. They stop being
/// the way through.
extension PracticeModel {
/// How long a passed phrase stays on screen before the next one.
///
/// Long enough to read "8 of 8 correct" and see the marking, short
/// enough that it does not feel like waiting. Moving on instantly would
/// take the result away before it had been seen, which is the one piece
/// of feedback the exercise exists to give.
static let passPause = 1.2

/// Whether the answer is as long as the sentence.
///
/// The signal that a learner has finished. Counting the characters that
/// will be scored rather than every character typed, so an input method
/// inserting its own punctuation does not hold the check back — and a
/// half-finished Pinyin syllable sitting in the IME's composition
/// buffer is not in the field yet, so it cannot trigger one either.
var isAnswerComplete: Bool {
let expected = DictationComparison.scorableCount(of: text)
return expected > 0 && DictationComparison.scorableCount(of: typed) >= expected
}

/// Called whenever the writing field changes.
///
/// Checks as soon as the answer is long enough, and — if it was right —
/// moves on by itself. A learner who typed the sentence correctly has
/// said everything there is to say about it.
func answerChanged() async {
// `!isRevealed` covers an answer already marked as well as one the
// reader gave up on: checking always reveals, so a phrase with a
// result behind it is a revealed phrase.
guard phase == .ready, !isRevealed, isAnswerComplete else { return }
let phrase = current
await check()
Comment on lines +49 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

7. Fast typing can record one answer twice 🐞 Bug ☼ Reliability

Each change to typed starts a separate task, but answerChanged() uses !isRevealed as its only
guard and check() does not set isRevealed until its dictionary lookup returns. If another edit
arrives after the answer is complete but before that lookup finishes, both checks can record the
same phrase, schedule an advance, and race to save the answer.
Agent Prompt
## Issue description
Rapid edits to a complete answer can start overlapping auto-checks before either reveals the result, recording the same phrase twice and racing to save the answer.

## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel+Flow.swift[45-56]
- Sources/HanReaderUI/Models/PracticeModel.swift[267-304]

## Recommended Fix
Add an `isChecking` flag to the model. Set it synchronously before `await check()`, include `!isChecking` in the auto-check guard, clear the flag with `defer`, and reset it in `select`. Test two rapid complete-answer changes while the dictionary lookup is suspended to verify that only one check records the phrase.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

guard current == phrase, let result else { return }
if result.listeningAccuracy >= 1 {
await advanceAfterPass(from: phrase)
Comment on lines +50 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Returning to a phrase can show stale marks 🐞 Bug ≡ Correctness

answerChanged() accepts a completed check() when current again equals its original phrase
index, without knowing whether the reader left and returned while the lookup was pending. On that
navigation sequence, the old answer can replace the returned phrase’s result, record an attempt, and
schedule another automatic advance.
Agent Prompt
## Issue description
An automatic check started before navigation can finish after the reader leaves and returns to the same phrase index.
## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel+Flow.swift[45-55]
- Sources/HanReaderUI/Models/PracticeModel.swift[221-233]
- Sources/HanReaderUI/Models/PracticeModel.swift[267-292]
## Recommended Fix
Associate each check with a selection generation or cancellable task, invalidate it on selection, and reject its result before marking, recording, or advancing if that generation has changed.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}
}

/// Moves on from a phrase that was answered correctly.
private func advanceAfterPass(from phrase: Int) async {
advanceTask?.cancel()
advanceTask = Task { [weak self] in
await self?.wait(Self.passPause)
guard let self, !Task.isCancelled, current == phrase else { return }
// Only if they have not moved themselves in the meantime. The
// end of the list needs no guard of its own: `advance` clamps,
// so the last phrase stays where it is.
advance(by: 1)
advanceTask = nil
}
}

/// Waits for a pending move to land, so a test asserts where the reader
/// ended up rather than racing it.
func awaitPendingAdvance() async {
await advanceTask?.value
}

/// Stops a pending move, for a learner who did something else first.
func cancelAdvance() {
advanceTask?.cancel()
advanceTask = nil
}

/// Plays the phrase the reader has just arrived at.
///
/// The whole screen is "listen, then write", so arriving at a phrase and
/// having to ask for the sound is a step that is never not wanted. Held
/// back while the boundary editor is open, where arriving somewhere is
/// a drag rather than a decision to practise, and while tapping along,
/// which is already playing the whole recording.
func playOnArrival() {
guard phase == .ready, !isEditing, tapping == nil, result == nil else { return }
play()
Comment on lines +91 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

6. The first manually split phrase stays silent 🐞 Bug ≡ Correctness

playOnArrival() suppresses playback while isEditing is true, but manual splitting enters the
ready phase and opens the boundary editor before the writing view appears. Closing that editor does
not retry playback, so the first phrase requires an explicit Play or a move to another phrase.
Agent Prompt
## Issue description
The initial arrival after manual splitting occurs while the boundary editor is open, and its suppressed playback is never retried.
## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel+Flow.swift[84-94]
- Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[47-61]
- Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[116-140]
## Recommended Fix
Keep playback suppressed during editing, but trigger it when the initial editor closes if the reader is still on the unplayed phrase. Cover the manual-split path in a flow test.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}
}
18 changes: 18 additions & 0 deletions Sources/HanReaderUI/Models/PracticeModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ final class PracticeModel {
var current = 0
/// The last check, or nil if this phrase has not been answered yet.
private(set) var result: DictationResult?
/// What the characters in the last check sound like.
///
/// Kept from the check rather than looked up again by the view: the
/// lookup has already happened — marking a homophone is what it is for —
/// and a view that fetches is a view that cannot be rendered from a
/// value.
private(set) var readings: [Character: [CharacterReading]] = [:]
/// 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
Expand Down Expand Up @@ -100,6 +107,13 @@ final class PracticeModel {
/// files, and `private` in Swift reaches extensions in the *same* file
/// only.
var waveformTask: Task<Void, Never>?
/// A pending move to the next phrase after a correct answer.
var advanceTask: Task<Void, Never>?
/// How the flow waits, injectable so a test asserts order rather than
/// the clock — the lesson the drill's gap already taught.
var wait: @MainActor (Double) async -> Void = { seconds in
try? await Task.sleep(for: .seconds(seconds))
}

init(textID: TextID, services: AppServices, playback: PlaybackModel) {
self.textID = textID
Expand Down Expand Up @@ -148,6 +162,7 @@ final class PracticeModel {
alignTask = nil
waveformTask?.cancel()
waveformTask = nil
cancelAdvance()
// Through the same path the Stop button takes, rather than clearing
// the flag here: dismissing the sheet part-way through tapping means
// the same thing as stopping, and two ways to stop is two places to
Expand Down Expand Up @@ -205,9 +220,11 @@ final class PracticeModel {

func select(_ index: Int) {
guard phrases.contains(where: { $0.index == index }) else { return }
cancelAdvance()
current = index
typed = attempts[index]?.typed ?? ""
result = nil
readings = [:]
// Hidden again — unless the boundary editor is open, which shows
// the phrase's text because a boundary cannot be placed without
// knowing which words it is meant to contain. Leaving the flag
Expand Down Expand Up @@ -271,6 +288,7 @@ final class PracticeModel {
readings: { readings[$0] ?? [] },
)
result = outcome
self.readings = readings
isRevealed = true
// A reader who showed the answer and then copied it has not
// demonstrated anything about their listening, and recording a pass
Expand Down
Loading
Loading