Skip to content

feat: play one phrase at a time, repeatedly, with room to write (M11, 3/4) - #37

Merged
widgetii merged 3 commits into
mainfrom
feat/m11-phrase-transport
Oct 20, 2026
Merged

widgetii merged 3 commits into
mainfrom
feat/m11-phrase-transport

Conversation

@widgetii

Copy link
Copy Markdown
Member

Third of four for M11. The transport half of listening practice.

A phrase stops where the phrase stops

Through AVPlayerItem.forwardPlaybackEndTime, not by 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 exactly and reports it as the end of the item, which is the path a finished track already took.

The drill

Play a phrase N times with a gap after each. The gap is the point rather than a detail — it is where the learner says the phrase back or writes it down, and it is what separates a drill from a loop. WorkAudioBook spells the same two settings out as "repeat phrase" and "pause between phrases".

Advancing to the next phrase is deliberately not done here. Which phrase comes next depends on the text and on what the reader has already passed, and neither belongs in a player. onSegmentFinished reports; PR 4 decides.

Writing the tests found a real bug

pause() guarded on isPlaying. During the gap between repeats nothing is playing, so the guard returned before cancelling the pending repeat: press pause, hear silence, hear the phrase start again a moment later. Cancelling now happens before the guard, because pausing has to mean stop whether or not sound is coming out at that instant.

A correction to the plan

audioTimePitchAlgorithm was written up as a latent M7 defect — that a rate change would resample and deepen the voice at 0.75×. Measured rather than assumed: .timeDomain is already the default, so M7 never shipped a chipmunk voice and there was nothing to repair.

It is set explicitly anyway, and the comment now says honestly that this is pinning a behaviour rather than fixing one: the documented default has been .spectral in the past, and this app also runs on macOS 14 and iOS 17 where it may differ. The plan has been corrected too.

Testing note

One test was flaky as first written — a 500 ms sleep for a 300 ms gap, which passes on this machine and fails under parallel execution or on a slower runner. It polls to a generous deadline now: the claim is that the repeat happens, not that it happens within half a second. The one genuinely negative assertion (pausing stops it) still waits, because "nothing happened" can only be established by waiting.

465 core and 58 playback tests. Five mutants planted and killed. Both app targets built locally before pushing.

🤖 Generated with Claude Code

M11, third of four. The transport half of listening practice.

A phrase stops at its own end, set through `forwardPlaybackEndTime` rather
than watched for. 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 exactly and reports it as the end
of the item, which is the path a finished track already took.

The drill plays a phrase a given number of times with a gap after each. The
gap is the point rather than a detail: it is where the learner says the
phrase back or writes it down, and it is what separates a drill from a
loop. Advancing to the next phrase is deliberately not done here — which
phrase comes next depends on the text and on what the reader has already
passed, and neither belongs in a player.

Writing the tests found a real one. `pause()` guarded on `isPlaying`, and
during the gap between repeats nothing is playing, so the guard returned
before cancelling the pending repeat: press pause, hear silence, hear the
phrase start again. Cancelling now happens before the guard, because
pausing has to mean stop whether or not sound is coming out at that
instant.

**A correction.** `audioTimePitchAlgorithm` was written up in the plan as a
latent M7 defect — that a rate change would deepen the voice. Measured here
rather than assumed: `.timeDomain` is already the default on macOS 26, so
M7 never shipped a chipmunk voice and there was nothing to repair. It is
set explicitly anyway, and the comment now says why that is pinning a
behaviour rather than fixing one: the documented default has been
`.spectral` in the past, and this app also runs on macOS 14 and iOS 17.

One test was flaky as first written — a 500 ms sleep for a 300 ms gap,
which passes here and fails under parallel execution or on a slower
machine. It polls to a generous deadline now: the claim is that the repeat
happens, not that it happens within half a second.

Five mutants planted and killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@widgetii
widgetii enabled auto-merge (squash) October 20, 2026 05:34
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add phrase playback with timed repetition gaps

✨ Enhancement 🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Stop playback at phrase boundaries without leaking audio from the next phrase.
• Repeat phrases with configurable writing gaps, leaving phrase selection to the caller.
• Cancel pending repeats when paused and test phrase playback and transport interactions.
Diagram

graph TD
  A["Phrase request"] --> B["PlaybackModel"] --> C["AudioEngine"] --> D["AVPlayerItem"] --> E["End event"] --> F{"Repeats left?"}
  F -->|"After gap"| B
  F -->|"No"| G["Finish callback"]
Loading
High-Level Assessment

Keep the PR's separation: AVPlayerItem enforces the phrase boundary, the transport owns repetition timing, and the caller chooses the next phrase. Stopping from periodic time reports risks audible overshoot; moving phrase selection into the transport would couple playback to reading context.

Files changed (6) +490 / -83

Enhancement (3) +173 / -7
AudioEngine.swiftAdd item-level phrase stop times +33/-0

Add item-level phrase stop times

• Adds a stop-time operation to AudioEngine and implements it with AVPlayerItem.forwardPlaybackEndTime, allowing the existing finish event to signal a phrase boundary. Explicitly pins the speech time-pitch algorithm to .timeDomain.

Sources/HanReaderPlayback/AudioEngine.swift

PlaybackModel+Phrase.swiftImplement phrase passes and timed drills +74/-0

Implement phrase passes and timed drills

• Adds phrase playback, repeat counting, configurable gaps, cancellation, and a completion callback. Clearing phrase mode removes the item stop time without selecting a subsequent phrase.

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift

PlaybackModel.swiftIntegrate phrase state into the transport +66/-7

Integrate phrase state into the transport

• Adds segment and drill state, routes item-end events to phrase handling, and clears phrase state on unload. Moves repeat cancellation before the isPlaying guard so pausing during a gap prevents the pending restart.

Sources/HanReaderPlayback/PlaybackModel.swift

Tests (3) +317 / -76
PhrasePlaybackTests.swiftCover phrase boundaries, repeats, and cancellation +226/-0

Cover phrase boundaries, repeats, and cancellation

• Tests stop-time setup, repeat counts and seeking, gap behavior, completion, pause and replacement cancellation, and interaction with speech ducking. Polls for a delayed repeat rather than relying on a fixed positive timing assertion.

Tests/HanReaderPlaybackTests/PhrasePlaybackTests.swift

PlaybackModelTests.swiftExtract transport spies for reuse +0/-76

Extract transport spies for reuse

• Removes the file-private engine and session spies so the existing transport tests and new phrase tests can share them.

Tests/HanReaderPlaybackTests/PlaybackModelTests.swift

PlaybackSpies.swiftShare playback spies and record stop times +91/-0

Share playback spies and record stop times

• Moves the engine and session spies into a shared test file. The engine spy records requested stop times and can simulate the item-end event used by phrase playback.

Tests/HanReaderPlaybackTests/PlaybackSpies.swift

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Audio resumes after headphones come off ✓ Resolved
Description
finishSegmentPass() leaves a delayed repeat scheduled while isPlaying is false, so
handle(.outputGone) returns without cancelling it. If headphones are disconnected during the gap,
the task later calls playSegmentOnce() and can start narration through the remaining output.
Code

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[R64-65]

+            guard !Task.isCancelled else { return }
+            self?.playSegmentOnce()
Evidence
The repeat task starts playback unless cancelled, while the output-loss handler returns during a gap
because the model has marked itself not playing.

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[48-66]
Sources/HanReaderPlayback/PlaybackModel.swift[355-370]
Sources/HanReaderPlayback/PlaybackSession.swift[11-18]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A pending phrase repeat survives headphone disconnection during the silent gap.
## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel.swift[355-370]
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[59-66]
## Recommended Fix
Cancel the pending repeat when output disappears, including when no audio is currently playing. Preserve the existing rule that output loss must not automatically resume playback.

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



Remediation recommended

2. A new track plays the previous phrase ✓ Resolved
Description
load(_:) replaces the audio item without clearing segment or cancelling drillTask. Loading
another track during a drill gap lets the old task seek the new item to the previous phrase’s bounds
and start it there; a later finish is also handled as a phrase pass.
Code

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[R59-65]

+        drillTask = Task { [weak self] in
+            let gap = self?.drill.gap ?? 0
+            if gap > 0 {
+                try? await Task.sleep(for: .seconds(gap))
+            }
+            guard !Task.isCancelled else { return }
+            self?.playSegmentOnce()
Evidence
Loading resets ordinary transport state but not the new phrase state; the delayed task uses that
state to configure and play whichever item is current.

Sources/HanReaderPlayback/PlaybackModel.swift[188-218]
Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[34-44]
Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[59-66]
Sources/HanReaderPlayback/PlaybackModel.swift[387-394]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Loading a replacement track leaves an earlier phrase drill active.
## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel.swift[188-207]
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[59-66]
## Recommended Fix
Cancel any pending repeat and clear the segment and stop time when loading a track, before the new item can be played or receive a finish event.

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


3. The next phrase skips its writing gap ✓ Resolved
Description
finishSegmentPass() invokes onSegmentFinished immediately on the final repetition, bypassing the
branch that waits for drill.gap. When a caller advances to another phrase from that callback, the
learner gets no response or writing interval after the phrase’s last pass.
Code

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[R53-56]

+        guard repeatsPlayed < drill.repeats else {
+            onStateChange?()
+            onSegmentFinished?()
+            return
Evidence
The terminal branch reports completion before the task containing the gap can run, despite the drill
API describing a gap after each pass.

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[47-66]
Sources/HanReaderPlayback/PlaybackModel.swift[83-110]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The configured gap is omitted after a phrase's final repetition.
## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[47-66]
- Tests/HanReaderPlaybackTests/PhrasePlaybackTests.swift[146-160]
## Recommended Fix
Schedule the configured gap after the final pass before invoking onSegmentFinished. Keep that wait cancellable when playback is paused, cleared, or replaced, and test the final-gap behavior.

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


4. Play controls bypass the writing gap ✓ Resolved
Description
finishSegmentPass() marks the model not playing during the gap, but toggle() and the ordinary
play() path do not check or cancel the pending repeat. A play command from the audio bar or remote
controls during that interval calls engine.play() immediately while the scheduled repeat remains
active.
Code

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[R59-65]

+        drillTask = Task { [weak self] in
+            let gap = self?.drill.gap ?? 0
+            if gap > 0 {
+                try? await Task.sleep(for: .seconds(gap))
+            }
+            guard !Task.isCancelled else { return }
+            self?.playSegmentOnce()
Evidence
The new gap makes isPlaying false; existing play commands interpret that as permission to start the
engine without touching the new drill task.

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[47-66]
Sources/HanReaderPlayback/PlaybackModel.swift[231-272]
Sources/HanReaderPlayback/NowPlayingController.swift[110-124]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Ordinary play commands start the engine while a delayed phrase repeat is still pending.
## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel.swift[231-272]
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[47-66]
## Recommended Fix
Make ordinary play and toggle phrase-aware during a gap: either preserve the scheduled silence or cancel it and start one well-defined pass. Do not leave a second repeat task pending after handling the command.

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


View medium (3)
5. Seeking replays an outdated phrase ✓ Resolved
Description
playSegmentOnce() always seeks back to the stored segment start, while seek(to:) and its skip
and scrub callers leave a pending drill task untouched. If the learner moves to a different position
during the silent gap, the next repeat overwrites that position and starts the old phrase again.
Code

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[R64-65]

+            guard !Task.isCancelled else { return }
+            self?.playSegmentOnce()
Evidence
Seek updates the engine and playhead but does not cancel the task, which subsequently seeks to the
stored phrase start.

Sources/HanReaderPlayback/PlaybackModel.swift[274-311]
Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[34-44]
Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[59-66]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A pending repeat undoes a seek made during the phrase gap.
## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel.swift[274-311]
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[34-44]
## Recommended Fix
Choose and implement phrase-aware seek behavior. Cancel or update the pending drill when the learner seeks, and ensure the next playback uses the selected position rather than silently restoring the old phrase.

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


6. Pausing in a callback fails to stop ✓ Resolved
Description
finishSegmentPass() calls onStateChange before assigning the pending repeat to drillTask. If
that synchronous callback calls pause(), cancellation finds no new task, after which the finish
handler schedules a repeat that starts the phrase despite the pause.
Code

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[R58-59]

+        onStateChange?()
+        drillTask = Task { [weak self] in
Evidence
The callback precedes task assignment, and pause can only cancel the task currently stored in
drillTask.

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[47-73]
Sources/HanReaderPlayback/PlaybackModel.swift[248-258]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A state-change callback can cancel a drill before its next repeat task is installed.
## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[47-73]
- Sources/HanReaderPlayback/PlaybackModel.swift[248-258]
## Recommended Fix
Establish the pending-repeat state before invoking re-entrant callbacks, or check a drill generation after the callback before scheduling. Add a test that pauses synchronously from onStateChange at the end of a pass.

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


7. An old phrase reports completion late ✓ Resolved
Description
finishSegmentPass() calls onStateChange before onSegmentFinished on the final pass, without
checking whether that callback changed the segment. If the state callback clears the segment or
starts another phrase, listeners still receive a completion event for the abandoned phrase after
that change.
Code

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[R53-56]

+        guard repeatsPlayed < drill.repeats else {
+            onStateChange?()
+            onSegmentFinished?()
+            return
Evidence
The final-pass branch calls the two public callbacks in sequence, while the intervening callback can
invoke methods that replace or remove the segment.

Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[47-56]
Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[15-31]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A re-entrant state callback can replace a phrase before its completion event is delivered.
## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[47-56]
- Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[15-31]
## Recommended Fix
Track the phrase or drill generation being completed and verify it remains current after onStateChange before invoking onSegmentFinished. Test a callback that replaces or clears the segment.

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


Grey Divider

Tip of the day
💡 Did you know, you can open the rule or relationship behind a finding in one click

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread Sources/HanReaderPlayback/PlaybackModel+Phrase.swift Outdated
Comment thread Sources/HanReaderPlayback/PlaybackModel+Phrase.swift Outdated
Comment thread Sources/HanReaderPlayback/PlaybackModel+Phrase.swift Outdated
Comment thread Sources/HanReaderPlayback/PlaybackModel+Phrase.swift Outdated
Comment thread Sources/HanReaderPlayback/PlaybackModel+Phrase.swift Outdated
Comment thread Sources/HanReaderPlayback/PlaybackModel+Phrase.swift Outdated
Comment thread Sources/HanReaderPlayback/PlaybackModel+Phrase.swift Outdated
widgetii and others added 2 commits October 20, 2026 08:41
CI failed the gap test again, this time despite polling to a five-second
deadline — so it was never slack to be absorbed. A repeat is scheduled on a
task that suspends twice before it plays, and "wait a bit and look" is a
guess whether the waiting is a sleep or a poll.

The gap is now injected. `PlaybackModel` takes a `wait`, defaulting to
`Task.sleep`, so a test can let it return at once and assert what was asked
for and what followed it rather than how long it took. And a repeat
currently waiting one out can be awaited directly instead of yielded at.

The suite went from 0.85 seconds with two fixed sleeps in it to 0.001
seconds with no sleeping at all, and gained a test that nothing waits when
no gap was asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven review findings, all the same root cause: `cancelDrill()` was treated
as something called from three places rather than as an invariant. A repeat
waiting out its gap is a command still pending, and the reader can change
their mind in between — so every transport event that changes what should
be playing now cancels it.

The worst was the headphones. `handle(.outputGone)` returned early because
nothing was playing during the gap, so the repeat it should have stopped
started a moment later through whatever speaker was left. The same early
return let an interruption do it. Both cancel before their guard now, as
`pause()` already did.

Loading another recording left the old track's drill scheduled, so it would
wake against the new item, seek it to the previous phrase's bounds and
start it there — and the finish that followed would be handled as a phrase
pass of a phrase from another text.

Pressing play during the gap started the engine and left the repeat to
restart the phrase a moment later. Seeking during the gap was worse: the
repeat dragged the playhead back and played the old phrase over the reader.
Seeking outside the phrase now leaves phrase playback altogether, rather
than keeping a stop time that would halt them somewhere unrelated.

The gap is now taken after the last pass too. It is the interval the
learner writes in, and skipping it on the final pass meant a caller
advancing from `onSegmentFinished` moved on the instant the audio stopped.

And the repeat is scheduled before the state callback runs, not after, so a
`pause()` issued from inside that callback finds a task to cancel.

Writing the test for that last one found a hang of my own making: the early
return in `pause()` announced a state change, so a listener that pauses in
response to one called its own pause for ever. It announces nothing now —
nothing observable had changed.

One guard is kept although mutation testing shows it unreachable: a
completion is reported only if the phrase is still the current one. Every
path that changes the segment also cancels the task, so cancellation covers
it today — but `segment` has an internal setter, and the cost of being
wrong is a listener told "finished" about a phrase the reader has already
left. The comment says exactly that rather than implying it is load-bearing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@widgetii

Copy link
Copy Markdown
Member Author

All seven addressed in 95bdd90. They are one root cause: cancelDrill() was treated as something called from three places rather than as an invariant. A repeat waiting out its gap is a command still pending, and the reader can change their mind in between.

1 — headphones, the worst of them. handle(.outputGone) returned early because nothing was playing during the gap, so the repeat it should have stopped started a moment later through whatever output was left. Same early return let an interruption do it. Both cancel before their guard now, as pause() already did.

2 — loading another recording left the old drill scheduled: it would wake against the new item, seek it to the previous phrase's bounds and start it there, and the finish would be handled as a phrase pass from another text.

4, 5 — play and seek during the gap. Play started the engine and left the repeat to restart the phrase; seek was worse, the repeat dragging the playhead back and playing the old phrase over the reader. Seeking outside the phrase now leaves phrase playback entirely, rather than keeping a stop time that would halt them somewhere unrelated.

3 — the gap now follows the last pass too. It is the interval the learner writes in; skipping it on the final pass meant a caller advancing from onSegmentFinished moved on the instant the audio stopped.

6 — the repeat is scheduled before the state callback, not after, so a pause() from inside it finds a task to cancel.

Two things writing the tests found

A hang of my own making. The early return in pause() announced a state change — so a listener that pauses in response to one called its own pause for ever. The test for finding 6 segfaulted on it. It announces nothing on that path now; nothing observable had changed.

Two mutants survived and both were informative. The seek test seeked outside the phrase, which clears the segment, so the pending repeat was harmlessly skipped whether or not it was cancelled — the test proved nothing about cancellation. Seeking inside is the case that needs it, and that is what it does now.

The other survivor is finding 7's guard: completion is reported only if the phrase is still current. Every path that changes the segment also cancels the task, so cancellation already covers it and the guard is unreachable today. I have kept it, because segment has an internal setter and the cost of being wrong is a listener told "finished" about a phrase the reader has already left — but the comment now says it is unreachable rather than implying it is load-bearing.

465 core and 68 playback tests. PlaybackModel and its phrase test suite were both split as they crossed the length limit. Both app targets built locally before pushing.

🤖 Generated with Claude Code

@widgetii
widgetii merged commit 1d2cfbb into main Oct 20, 2026
8 checks passed
@widgetii
widgetii deleted the feat/m11-phrase-transport branch October 20, 2026 05:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant