Repository navigation
feat: practise by typing, not by pressing buttons #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
| /// | ||
| /// 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 8. Resuming a phrase restarts the drill's repeat count 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
|
||
| repeatsPlayed = 0 | ||
| playSegmentOnce() | ||
| } | ||
| } | ||
|
|
||
| /// Goes back to playing the whole recording. | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. Finished phrases replay only their tail 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
|
||
| } | ||
|
|
||
| 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() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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?() | ||
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 9. Scrubbing inside a phrase seeks exactly 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
|
||
| 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. | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 7. Fast typing can record one answer twice 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
|
||
| guard current == phrase, let result else { return } | ||
| if result.listeningAccuracy >= 1 { | ||
| await advanceAfterPass(from: phrase) | ||
|
Comment on lines
+50
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 3. Returning to a phrase can show stale marks 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
|
||
| } | ||
| } | ||
|
|
||
| /// 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 6. The first manually split phrase stays silent 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
|
||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
10. A doc comment line appears twice
🐞 Bug⚙ MaintainabilityAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools