Skip to content
Merged
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
33 changes: 33 additions & 0 deletions Sources/HanReaderPlayback/AudioEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ public protocol AudioEngine: AnyObject {
/// when not.
func setRate(_ rate: Double)

/// Stop here rather than at the end of the track, or nil to play on.
///
/// Needed because listening practice plays one phrase, and a phrase that
/// runs a quarter-second into the next one has given the answer away.
func setStopTime(_ time: Double?)

/// Called about four times a second while playing. What reaches the UI is
/// throttled further — see `PlaybackModel.report(_:)`.
var onTime: ((Double) -> Void)? { get set }
Expand Down Expand Up @@ -119,6 +125,16 @@ final class AVPlayerAudioEngine: AudioEngine {

let asset = AVURLAsset(url: url)
let item = AVPlayerItem(asset: asset)
// Pinned, not fixed. Measured on macOS 26 this is already the
// default, so M7 was never shipping a chipmunk voice at 0.75 — a
// claim made in the plan and checked only now. What it does buy is
// certainty: the documented default has been `.spectral` in the
// past, this app also runs on macOS 14 and iOS 17, and a recording
// slowed down for a learner must be a slower voice rather than a
// deeper one on every one of them. `.timeDomain` is Apple's
// recommendation for speech and is what WorkAudioBook bundles
// SoundTouch to do.
item.audioTimePitchAlgorithm = .timeDomain
player.replaceCurrentItem(with: item)

finishObserver = NotificationCenter.default.addObserver(
Expand Down Expand Up @@ -173,4 +189,21 @@ final class AVPlayerAudioEngine: AudioEngine {
player.rate = Float(rate)
}
}

/// `forwardPlaybackEndTime` rather than watching the clock.
///
/// The time observer reports four times a second, so stopping by hand
/// overshoots by up to 250 ms — audible, and enough to leak the first
/// syllable of the next phrase to a learner who is about to be asked to
/// transcribe it. The player stops itself at exactly this point and
/// reports it as the end of the item, which is the same path a finished
/// track already takes.
func setStopTime(_ time: Double?) {
guard let item = player.currentItem else { return }
guard let time else {
item.forwardPlaybackEndTime = .invalid
return
}
item.forwardPlaybackEndTime = CMTime(seconds: max(0, time), preferredTimescale: 600)
}
}
52 changes: 52 additions & 0 deletions Sources/HanReaderPlayback/PlaybackModel+Interruptions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// HanReader — MIT licensed. See LICENSE.

import Foundation

/// Who is allowed to stop the narration, and who is allowed to start it
/// again.
///
/// Its own file because the rules are not obvious and there are several:
/// the system may take the audio away and give it back, a spoken word may
/// duck it, the reader may have pressed pause, and a drill may be waiting
/// out a gap. Only one of those is allowed to undo each of the others.
extension PlaybackModel {
func handle(_ interruption: PlaybackInterruption) {
switch interruption {
case .began:
// Before the guard: during a gap nothing is playing, and a
// pending repeat would otherwise start during the interruption.
cancelDrill()
guard isPlaying else { return }
engine.pause()
isPlaying = false
pausedByInterruption = true
onStateChange?()
case .ended:
guard pausedByInterruption, state == .ready else { return }
pausedByInterruption = false
engine.play()
isPlaying = true
onStateChange?()
case .outputGone:
// Headphones pulled out. Pausing is not politeness, it is the
// difference between a quiet room and the reader's audio playing
// through the speaker to everyone around them — and it must not
// be resumable automatically, or plugging back in starts it in
// the open.
//
// The reasons are cleared *before* the guard, not after it: the
// output can be lost while already paused by an interruption,
// and leaving that reason set means the interruption ending
// resumes narration into a room with no headphones in it.
clearPauseReasons()
// Likewise, and this is the one that matters most: headphones
// pulled out during the gap would otherwise be followed by the
// phrase starting through whatever speaker is left.
cancelDrill()
guard isPlaying else { return }
engine.pause()
isPlaying = false
onStateChange?()
}
}
}
104 changes: 104 additions & 0 deletions Sources/HanReaderPlayback/PlaybackModel+Phrase.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// HanReader — MIT licensed. See LICENSE.

import Foundation

/// Playing one phrase at a time, which is what listening practice is.
///
/// Separate from the transport's own body because it is a different mode of
/// use: the rest of `PlaybackModel` plays a recording, and this plays a
/// span of one, repeatedly, with silence in between for the learner to
/// write in.
extension PlaybackModel {
// MARK: - One phrase at a time

/// Plays a single phrase, stopping exactly at its end.
public func play(segment range: Range<Double>, drill: Drill? = nil) {
guard state == .ready else { return }
cancelDrill()
if let drill {
self.drill = drill
}
segment = range
repeatsPlayed = 0
playSegmentOnce()
}

/// Goes back to playing the whole recording.
public func clearSegment() {
cancelDrill()
segment = nil
engine.setStopTime(nil)
onStateChange?()
}

func playSegmentOnce() {
guard let segment, state == .ready else { return }
activateSession()
engine.setStopTime(segment.upperBound)
engine.seek(to: segment.lowerBound)
playhead = segment.lowerBound
observedTime = segment.lowerBound
engine.play()
isPlaying = true
clearPauseReasons()
onStateChange?()
}

/// One pass through the phrase ended, at exactly its end time.
///
/// The gap is taken **after every pass, including the last**. It is the
/// interval the learner writes in, so skipping it on the final pass
/// means a caller that advances from `onSegmentFinished` moves on the
/// instant the audio stops — no time to answer, which is the one thing
/// the drill exists to provide.
func finishSegmentPass() {
engine.pause()
isPlaying = false
clearPauseReasons()
repeatsPlayed += 1
let wasLast = repeatsPlayed >= drill.repeats
let phrase = segment

// Scheduled *before* the state callback runs, so that a `pause()`
// issued from inside it finds a task to cancel. Assigning afterwards
// meant cancellation found nothing and the repeat started anyway,
// despite the reader having paused.
drillTask = Task { [weak self] in
guard let self else { return }
if drill.gap > 0 {
await wait(drill.gap)
}
guard !Task.isCancelled else { return }
guard wasLast else {
playSegmentOnce()
return
}
// Belt and braces, and said plainly: every path that changes
// the segment today also cancels this task, so the check above
// already covers it and mutation testing shows this line is
// unreachable. It is kept because `segment` has an internal
// setter and the cost of being wrong here is a listener told
// "finished" about a phrase the reader has already left —
// which would advance them past the one they just chose.
guard segment == phrase else { return }
onSegmentFinished?()
}
onStateChange?()
}

/// Awaits a repeat that is currently waiting out its gap.
///
/// A test seam, and the reason there is no sleeping left in these
/// tests: a repeat is scheduled on a task that suspends twice before it
/// plays, so "yield a couple of times and hope" is as much a guess as
/// sleeping was. Awaiting the task itself is exact.
func awaitPendingRepeat() async {
await drillTask?.value
}

func cancelDrill() {
drillTask?.cancel()
drillTask = nil
repeatsPlayed = 0
}
}
Loading
Loading