Skip to content

feat: play a text's narration, and bring the old library across (M7) - #34

Merged
widgetii merged 3 commits into
mainfrom
feat/m7-audio-playback
Oct 19, 2026
Merged

widgetii merged 3 commits into
mainfrom
feat/m7-audio-playback

Conversation

@widgetii

Copy link
Copy Markdown
Member

Milestone M7 — audio playback, plus the prototype library.db migration the plan files under the same milestone.

The transport

Attach a narration to any text and it plays in a bar below the reader: play/pause, ten-second skips, scrubber, five speeds, elapsed and total time, and lock screen / Control Centre controls. Fidelity invariant 6.

Four behaviours are the point, each one a prototype defect:

The scrubber commits once. The prototype bound its slider straight to player.seek with toleranceBefore/After: .zero, so every drag delta issued a frame-accurate seek; they cancelled each other and fought the 10 Hz observer writing the old value back under the finger. Here a drag moves scrubTime and nothing else, engine reports are ignored while it is non-nil, and the finger coming up issues exactly one seek.

That requirement is about calls, not about sound, which is why AudioEngine is a protocol — against AVPlayer it can only be checked by ear; against a spy it is an assertion:

@Test("A drag issues no seeks at all")
@Test("Committing issues exactly one seek")

The playhead publishes at most once a second. The display shows m:ss, and the prototype woke the actor laying out CJK text ten times a second to re-render a string that had not changed. The engine observes at 4 Hz, the model forwards only when the whole second changes, and the exact playhead is kept unpublished for persistence — so stopping mid-sentence resumes mid-sentence.

Now-playing info is pushed on transitions only. The system extrapolates position from the last elapsed time and rate it was given, so telling it less often is telling it better; each update restarts the extrapolation.

The audio session is activated on first play, never at launch. Activating a session you are not using interrupts whatever the reader was listening to, for an app that might never make a sound. Interruptions pause and resume; audio the reader paused themselves is never resumed for them; unplugging headphones pauses and does not resume, because plugging them back in must not start their audio out loud in the room.

Speaking a tapped word ducks the narration and resumes it after — the attached audio is usually the same narration, so hearing both is confusing rather than merely noisy — and only if it was the speech that paused it.

Relative paths

Narration is copied into the container and recorded by a path relative to the audio directory. The prototype stored an absolute path containing the app container's UUID, which changes across an iOS reinstall or restore, so every attachment broke at once, silently, long after the mistake. LibraryRepository.attachAudio already rejected an absolute path; this is the code that gives it a relative one.

Saving the playhead

Written while the reader is using the app, not only on teardown. macOS delivers neither a scene-phase change nor onDisappear reliably on quit, so a transport that saved only at the end saved nothing at all — I found this by quitting and querying the database. It is now written when playback stops, once a minute while playing, when another text takes the transport, and on scene-phase change. Changing text in the sidebar was the commonest of those and was losing it outright, because by the time .task(id:) runs textID is already the new one.

Prototype library migration

library.db is migrated on first launch: texts, revealed words split out of the newline-joined string they were packed into, and last_opened_at. scroll_offset is discarded deliberately — a pixel offset is meaningless after a font change, a resize or a move between platforms, and the prototype never restored it either. An audio_path that still resolves is copied inside; one that does not is reported rather than failing the migration. The file is renamed rather than deleted, so a migration that went wrong has cost nothing — and renaming rather than setting a flag keeps the state with the thing it describes, unlike the prototype's UserDefaults.bool(forKey: "dictionaryParsed_bkrs_v1"), which still said "parsed" after the database was deleted.

Run against the real prototype database: 3 texts, 152 revealed words, counts matching the source exactly.

Also

ReaderMetrics.minimumHitTarget had gone missing at some point and left its documentation attached to tokenCornerRadius — the third instance in two PRs of a declaration being inserted between a comment and what it documented. That is why the transport's first buttons measured 9×11 points; they are now 44×24.

Verification

On the running app: attach a narration, play, scrub, pause, quit, relaunch — the transport returns parked at 0:04 where it was left, and does not start playing by itself. All five controls are in the accessibility tree with labels (checked with an AX walker, since System Events' traversal truncates on the ~250 token buttons in the reading surface).

384 + 93 + 35 tests pass. Thirteen mutants planted against the new code and all thirteen killed — three after a first attempt exposed weak tests, including one where a hard link passed a "the file is copied" assertion until the test mutated the original in place through a file handle.

Not in this milestone

iOS is built by CI but still unverified on a simulator — no runtime is installed on this machine, so make run-ios has never launched anything. The transport's 44×24 hit targets are a Mac compromise; touch wants the full 44 vertically, which M8 will take.

🤖 Generated with Claude Code

M7. Per-text audio, with the transport the prototype had and the
behaviours it got wrong.

The scrubber commits once. The prototype bound its slider straight to
`player.seek` with zero tolerance, so every drag delta was a frame-accurate
seek; they cancelled each other and fought the 10 Hz observer writing the
old value back under the finger. Here a drag moves `scrubTime` and nothing
else, engine reports are ignored while it is non-nil, and the finger coming
up issues exactly one seek. That requirement is about calls rather than
about sound, which is why `AudioEngine` is a protocol: against `AVPlayer` it
can only be checked by ear, against a spy it is an assertion.

The playhead is published at most once a second. The display shows `m:ss`,
and the prototype woke the actor laying out CJK text ten times a second to
render a string that had not changed. The engine observes at 4 Hz and the
model forwards only when the second changes; the exact playhead is kept
unpublished for persistence, so stopping mid-sentence resumes mid-sentence.

Now-playing info is pushed on transitions only. The system extrapolates the
position from the last elapsed time and rate it was given, so telling it
less often is also telling it better.

The audio session is activated on first play, never at launch — activating
one you are not using interrupts whatever the reader was listening to, for
an app that might never make a sound. Interruptions pause and resume;
audio the reader paused themselves is never resumed for them; unplugging
headphones pauses and does not resume, because plugging them back in must
not start their audio in the open.

Narration is copied in and recorded by a path **relative** to the audio
directory. The prototype stored an absolute path containing the container
UUID, which changes across an iOS reinstall or restore, so every attachment
broke at once long after the mistake.

The playhead is written while the reader is using the app rather than only
on teardown: macOS delivers neither a scene-phase change nor `onDisappear`
reliably on quit, so saving only at the end saved nothing. It is written
when playback stops, once a minute while playing, when another text takes
the transport, and on scene-phase change.

Also here, because it is the other half of the same milestone: the
prototype's `library.db` is migrated on first launch. Texts, revealed words
split out of the newline-joined string they were packed into, and
`last_opened_at`. `scroll_offset` is discarded deliberately — a pixel
offset means nothing after a font change and the prototype never restored
it either. An `audio_path` that still resolves is copied inside; one that
does not is reported rather than failing the migration. The file is renamed
rather than deleted, so a migration that went wrong has not cost anything.

And a defect found on the way: `ReaderMetrics.minimumHitTarget` had gone
missing and left its documentation attached to `tokenCornerRadius`, which
is why the transport's first buttons were 9x11 targets.

Verified on the running app: attach, play, scrub, pause, quit, relaunch —
the transport comes back parked at 0:04 where it was left. The migration
was run against the real prototype database: 3 texts and 152 revealed words
recovered from the blob, counts matching the source exactly.

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

Copy link
Copy Markdown

PR Summary by Qodo

Add per-text narration playback and migrate prototype libraries

✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Add per-text narration controls, remote playback commands, and interruption-safe audio handling.
• Preserve playback positions and store imported audio using container-relative paths.
• Migrate prototype texts and reading history on launch, retaining the original database.
Diagram

graph TD
  UI["Reader UI"] --> Model["Playback model"] --> Engine["AVPlayer engine"]
  Model --> Session["Audio session"]
  Model --> Now["Now Playing"]
  UI --> Attachment["Audio attachment"] --> Repository["Library repository"]
  Legacy[("Prototype DB")] -->|migration| Repository
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Import the prototype library in one database transaction
  • ➕ Avoids partially imported database records if migration fails midway.
  • ➖ Requires a migration-specific database path around existing repository operations; copied audio still needs separate filesystem recovery.

Recommendation: Keep the repository-based, idempotent migration for this small one-time library: it reuses established import rules and retains the source for retry. Review partial-failure recovery carefully, since database writes and audio copies are not one transaction.

Files changed (19) +2581 / -44

Enhancement (10) +1533 / -42
LibraryRepository.swiftPersist audio playheads independently of reading positions +68/-37

Persist audio playheads independently of reading positions

• Adds a playhead upsert that creates a position row if needed without overwriting the text position. Moves the existing preview helper ahead of a separate audio repository extension.

Sources/HanReaderPersistence/LibraryRepository.swift

PrototypeLibrary.swiftImport and retain prototype library databases +264/-0

Import and retain prototype library databases

• Reads prototype texts and progress, deduplicates imported content, restores revealed words and last-opened dates, and copies audio files that still exist. Reports missing audio and renames the source database only after migration succeeds.

Sources/HanReaderPersistence/PrototypeLibrary.swift

AudioEngine.swiftIntroduce a testable AVPlayer playback engine +176/-0

Introduce a testable AVPlayer playback engine

• Defines an engine protocol and an AVPlayer implementation with periodic time reports, asynchronous duration loading, finish notification, rate control, and tolerant seeks.

Sources/HanReaderPlayback/AudioEngine.swift

NowPlayingController.swiftIntegrate system now-playing controls +139/-0

Integrate system now-playing controls

• Publishes track information on playback transitions and routes play, pause, skip, toggle, and position commands to the transport. Clears system information when the text is detached.

Sources/HanReaderPlayback/NowPlayingController.swift

PlaybackModel.swiftManage scene-scoped narration transport state +344/-0

Manage scene-scoped narration transport state

• Adds loading, playback, skipping, five rates, interruptions, and speech-aware pausing. Scrubbing seeks only on commit, while the exact playhead remains available for persistence without publishing every engine tick.

Sources/HanReaderPlayback/PlaybackModel.swift

PlaybackSession.swiftHandle audio-session activation and interruptions +125/-0

Handle audio-session activation and interruptions

• Introduces a session protocol and an iOS implementation that activates on first play, handles permitted interruption resumes, and pauses when the current output disappears. The macOS implementation requires no session setup.

Sources/HanReaderPlayback/PlaybackSession.swift

HanReaderRootView.swiftOwn playback and now-playing controls per window +9/-0

Own playback and now-playing controls per window

• Creates scene-scoped playback and now-playing objects and passes them, along with the selected text title, to the reader screen.

Sources/HanReaderUI/HanReaderRootView.swift

AudioBar.swiftDisplay accessible narration controls below the reader +171/-0

Display accessible narration controls below the reader

• Adds play/pause, ten-second skips, a commit-on-release scrubber, elapsed and total clocks, and a speed menu. Provides accessibility labels and larger button targets.

Sources/HanReaderUI/Screens/AudioBar.swift

ReaderScreen.swiftConnect narration to the reading lifecycle +202/-5

Connect narration to the reading lifecycle

• Adds audio import, track loading, the bottom transport, and speech ducking. Saves the exact playhead on stops, periodically during playback, on scene changes, and before switching texts.

Sources/HanReaderUI/Screens/ReaderScreen.swift

AppServices.swiftRun prototype migration during service launch +35/-0

Run prototype migration during service launch

• Attempts library migration before opening dictionaries, logs the outcome and missing-file notes, and keeps migration failures nonfatal so the source can be retried.

Sources/HanReaderUI/Services/AppServices.swift

Bug fix (2) +100 / -0
DesignTokens.swiftRestore the minimum transport hit-target width +5/-0

Restore the minimum transport hit-target width

• Restores the missing 44-point minimum-hit-target metric and separates its documentation from the token corner-radius declaration.

Sources/HanReaderUI/Design/DesignTokens.swift

AudioAttachment.swiftCopy selected narration into app-owned audio storage +95/-0

Copy selected narration into app-owned audio storage

• Copies security-scoped audio into a per-text file and stores a path relative to the app’s audio directory. Resolves that path against current storage locations when loading the track.

Sources/HanReaderUI/Services/AudioAttachment.swift

Tests (4) +932 / -0
PrototypeLibraryTests.swiftTest migration against the prototype schema +282/-0

Test migration against the prototype schema

• Covers texts, revealed words, idempotency, absent sources, missing and existing audio, and retention of the renamed source database.

Tests/HanReaderPersistenceTests/PrototypeLibraryTests.swift

PlaybackModelTests.swiftVerify transport behavior with engine and session spies +437/-0

Verify transport behavior with engine and session spies

• Counts scrub seeks and transition notifications, and tests playhead throttling, lazy session activation, interruptions, speech pausing, playback controls, and rate selection.

Tests/HanReaderPlaybackTests/PlaybackModelTests.swift

AudioAttachmentTests.swiftVerify copied audio and relative attachment paths +156/-0

Verify copied audio and relative attachment paths

• Checks container-relative paths, copy independence from in-place source edits, per-text filename isolation, and replacement behavior.

Tests/HanReaderUITests/AudioAttachmentTests.swift

AudioBarTests.swiftTest transport clock and spoken-time formatting +57/-0

Test transport clock and spoken-time formatting

• Covers short and hour-long recordings, invalid time values, and VoiceOver-friendly duration wording.

Tests/HanReaderUITests/AudioBarTests.swift

Documentation (3) +16 / -2
CHANGELOG.mdDocument narration and prototype-library migration +15/-0

Document narration and prototype-library migration

• Adds release notes for audio controls, remembered playheads, speech and headphone behavior, and preservation of prototype library data.

CHANGELOG.md

README.mdMark audio playback milestone complete +1/-1

Mark audio playback milestone complete

• Marks M7 as complete in the project roadmap.

README.md

HanReaderPlayback.swiftRemove obsolete playback scaffolding note +0/-1

Remove obsolete playback scaffolding note

• Removes the comment stating that playback has not yet been implemented.

Sources/HanReaderPlayback/HanReaderPlayback.swift

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

qodo-free-for-open-source-projects Bot commented Oct 19, 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. Unplugging headphones can resume audio ✓ Resolved
Description
PlaybackModel.handle(.outputGone) returns when playback is already paused without clearing
wasInterrupted. If a route-loss event follows an interruption-start event, the subsequent
interruption-end event resumes narration despite the lost output route.
Code

Sources/HanReaderPlayback/PlaybackModel.swift[R284-288]

+            guard isPlaying else { return }
+            engine.pause()
+            isPlaying = false
+            wasInterrupted = false
+            onStateChange?()
Evidence
Interruption start sets the flag and pauses; route loss returns before clearing it; interruption end
invokes a method that plays whenever the flag remains set. The session subscribes to both
notifications.

Sources/HanReaderPlayback/PlaybackModel.swift[260-288]
Sources/HanReaderPlayback/PlaybackSession.swift[81-115]

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

## Issue description
An output-loss notification leaves an earlier interruption's automatic-resume flag intact when playback is already paused.
## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel.swift[268-289]
## Recommended Fix
Clear automatic-resume intent for every output-loss event, including when playback is already paused; test the began, outputGone, ended sequence.

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


2. Remote controls act on multiple windows ✓ Resolved
Description
NowPlayingController.subscribe() registers a separate set of handlers against the process-wide
remote-command center for every window. With two narrated texts open, a lock-screen play, pause, or
skip command reaches both windows’ playback models rather than one selected narration.
Code

Sources/HanReaderPlayback/NowPlayingController.swift[R95-101]

+    private func subscribe() {
+        guard handlers.isEmpty else { return }
+        add(commands.playCommand) { $0.play() }
+        add(commands.pauseCommand) { $0.pause() }
+        add(commands.togglePlayPauseCommand) { $0.toggle() }
+        add(commands.skipForwardCommand) { $0.skip(by: Self.skipInterval) }
+        add(commands.skipBackwardCommand) { $0.skip(by: -Self.skipInterval) }
Evidence
The root creates playback and now-playing controllers per window, while each controller registers
targets on MPRemoteCommandCenter.shared() and its handlers act on its own model.

Sources/HanReaderUI/HanReaderRootView.swift[138-143]
Sources/HanReaderPlayback/NowPlayingController.swift[46-60]
Sources/HanReaderPlayback/NowPlayingController.swift[95-132]

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

## Issue description
Each window independently subscribes to process-wide remote commands, so one command controls multiple narrations.
## Fix Focus Areas
- Sources/HanReaderPlayback/NowPlayingController.swift[55-70]
- Sources/HanReaderPlayback/NowPlayingController.swift[93-132]
- Sources/HanReaderUI/HanReaderRootView.swift[138-143]
## Recommended Fix
Give the process-wide command center one coordinator and route commands to only its active playback model; coordinate now-playing ownership when windows attach or detach.

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


3. Changing texts can show the wrong audio ✓ Resolved
Description
loadAudio(for:) and attachAudio(_:) mutate the shared transport after asynchronous work without
checking whether their originating text is still selected. Switching from one text to another while
a lookup or attachment is in flight can load the first text’s narration into the second text’s
reader.
Code

Sources/HanReaderUI/Screens/ReaderScreen.swift[R384-386]

+            hasAudio = true
+            playback.load(file)
+            nowPlaying.attach(to: playback, title: title)
Evidence
The root passes the same playback object to each selection. Audio lookup awaits a repository read,
and importer completion starts an unstructured task; both paths subsequently load into that object
without a selection check.

Sources/HanReaderUI/HanReaderRootView.swift[192-206]
Sources/HanReaderUI/Screens/ReaderScreen.swift[149-159]
Sources/HanReaderUI/Screens/ReaderScreen.swift[319-339]
Sources/HanReaderUI/Screens/ReaderScreen.swift[376-386]

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

## Issue description
Late audio lookups and attachments can replace the shared transport after their text is no longer current.
## Fix Focus Areas
- Sources/HanReaderUI/Screens/ReaderScreen.swift[149-159]
- Sources/HanReaderUI/Screens/ReaderScreen.swift[319-344]
- Sources/HanReaderUI/Screens/ReaderScreen.swift[376-391]
## Recommended Fix
Associate each operation with its originating text and a current-selection generation; after every await, update the transport only if that generation is still active. Allow a completed attachment to remain recorded against its original text without displaying it in another.

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


View high (4)
4. A late save can corrupt another playhead ✓ Resolved
Description
ReaderScreen schedules save tasks with a text ID but reads pendingPlayhead() only when each task
runs. If selection changes and the shared transport loads another track first, a delayed task can
write that new track’s position under the old text’s ID.
Code

Sources/HanReaderUI/Screens/ReaderScreen.swift[R125-127]

+            .onChange(of: playback.isPlaying) { _, playing in
+                guard !playing else { return }
+                Task { await savePlayhead(for: textID) }
Evidence
The change handlers create delayed tasks; savePlayhead reads the shared model at execution time,
and the repository stores the supplied position under the supplied ID.

Sources/HanReaderUI/Screens/ReaderScreen.swift[125-139]
Sources/HanReaderUI/Screens/ReaderScreen.swift[346-370]
Sources/HanReaderUI/HanReaderRootView.swift[192-206]
Sources/HanReaderPersistence/LibraryRepository.swift[164-177]

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

## Issue description
Asynchronous saves pair an event-time text ID with a later playhead read from a reusable transport.
## Fix Focus Areas
- Sources/HanReaderUI/Screens/ReaderScreen.swift[110-139]
- Sources/HanReaderUI/Screens/ReaderScreen.swift[346-374]
## Recommended Fix
Capture both the originating text ID and exact playhead synchronously in each change handler, then pass those immutable values to the asynchronous write.

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


5. Migration can replace a reader’s audio ✓ Resolved
Description
Migration.importTexts carries prototype audio even when content deduplication mapped the old text
to an existing library text. If that text already has an attachment, carryAudio removes a
same-named file and upserts the audio row to the prototype recording; a missing old file is handled,
but a present old file displaces the reader’s current choice.
Code

Sources/HanReaderPersistence/PrototypeLibrary.swift[R181-182]

+            if let path = text.audioPath, let id = identifiers[oldID] {
+                try await carryAudio(from: path, for: id, title: title, into: &outcome)
Evidence
The migration uses the existing ID returned by content deduplication and proceeds to audio copying.
Its copy path removes a matching destination, while the repository upsert replaces the text’s sole
audio row.

Sources/HanReaderPersistence/LibraryRepository.swift[103-115]
Sources/HanReaderPersistence/PrototypeLibrary.swift[168-183]
Sources/HanReaderPersistence/PrototypeLibrary.swift[231-242]
Sources/HanReaderPersistence/LibraryRepository.swift[337-347]

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

## Issue description
Content deduplication lets prototype audio replace an existing text's attachment.
## Fix Focus Areas
- Sources/HanReaderPersistence/PrototypeLibrary.swift[168-183]
- Sources/HanReaderPersistence/PrototypeLibrary.swift[210-243]
## Recommended Fix
Before carrying audio onto a deduplicated text, check whether it already has an attachment and preserve that attachment. Handle multiple prototype rows mapping to one new text without overwriting an earlier accepted track.

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


6. A failed replacement can erase narration ✓ Resolved
Description
AudioAttachment.attach deletes an existing destination before copying the replacement and updating
the database. If the copy or database write then throws, the existing audio row can still point to
the file that was deleted, and the reader’s previously playable narration is lost.
Code

Sources/HanReaderUI/Services/AudioAttachment.swift[R66-69]

+        if FileManager.default.fileExists(atPath: destination.path) {
+            try FileManager.default.removeItem(at: destination)
+        }
+        try FileManager.default.copyItem(at: url, to: destination)
Evidence
The new attachment path removes the destination before either throwing operation. The repository
update occurs only afterward, and the reader’s error handler does not restore the previous
attachment.

Sources/HanReaderUI/Services/AudioAttachment.swift[63-80]
Sources/HanReaderPersistence/LibraryRepository.swift[330-347]
Sources/HanReaderUI/Screens/ReaderScreen.swift[376-390]

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

## Issue description
Replacement destroys the old audio file before fallible copy and database operations complete.
## Fix Focus Areas
- Sources/HanReaderUI/Services/AudioAttachment.swift[63-80]
## Recommended Fix
Copy to a distinct temporary file first, preserve the existing file and row through failures, and coordinate the final file swap with the database update so a failed replacement leaves the old attachment usable.

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


7. Narration stays paused when a word is not spoken ✓ Resolved
Description
ReaderScreen.speak(_:) calls playback.pauseForSpeech() before SpeechModel.speak, which returns
without starting an utterance when VoiceOver suppresses speech, the text is empty, or no Chinese
voice is installed. In those cases isSpeaking never changes, so the reader’s onChange resume
path does not run and narration that was playing remains paused until the reader presses play.
Code

Sources/HanReaderUI/Screens/ReaderScreen.swift[R397-401]

+    private func speak(_ word: String) {
+        if settings.pausesAudioWhileSpeaking {
+            playback.pauseForSpeech()
+        }
+        speech.speak(word, suppressed: voiceOverEnabled)
Evidence
The caller pauses playback before invoking SpeechModel.speak, whose guards for suppressed speech,
empty text, and a missing voice return before the synthesizer starts or isSpeaking changes. The
reader resumes playback only when its onChange(of: speech.isSpeaking) observes a transition to
false, which these early returns cannot produce.

Sources/HanReaderPlayback/SpeechModel.swift[76-100]
Sources/HanReaderUI/Screens/ReaderScreen.swift[140-148]
Sources/HanReaderUI/Screens/ReaderScreen.swift[140-147]
Sources/HanReaderUI/Screens/ReaderScreen.swift[394-402]
Sources/HanReaderPlayback/SpeechModel.swift[76-101]

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

## Issue description
ReaderScreen pauses narration before calling SpeechModel.speak, but VoiceOver suppression, empty text, or an unavailable Chinese voice can prevent an utterance from starting. With no `isSpeaking` transition, narration is not resumed.
## Fix Focus Areas
- Sources/HanReaderUI/Screens/ReaderScreen.swift[394-402]
- Sources/HanReaderPlayback/SpeechModel.swift[76-101]
## Recommended Fix
Have `SpeechModel.speak` report whether it started an utterance, such as by returning a `Bool`, or expose a `canSpeak(suppressed:)` check. Pause narration only when speech will start; if playback is paused before calling `speak` and it reports that nothing started, call `playback.resumeAfterSpeech()` immediately.

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



Remediation recommended

8. Closing one window can stop another’s audio ✓ Resolved
Description
SystemPlaybackSession.deactivate() deactivates the shared iOS audio session without accounting for
other playback models. When one of two playing windows unloads or finishes its track, it can
deactivate the session still being used by the other window.
Code

Sources/HanReaderPlayback/PlaybackSession.swift[R65-73]

+    public func deactivate() {
+        #if os(iOS)
+            // Notifying others is what lets the music the reader interrupted
+            // start itself again. Failing here is not worth surfacing: the
+            // session is already idle and the reader has nothing to do about it.
+            try? AVAudioSession.sharedInstance().setActive(
+                false,
+                options: .notifyOthersOnDeactivation,
+            )
Evidence
The root makes playback window-scoped, but each session uses AVAudioSession.sharedInstance() for
activation and unconditional deactivation; unload and finish release the session.

Sources/HanReaderUI/HanReaderRootView.swift[138-143]
Sources/HanReaderPlayback/PlaybackSession.swift[53-74]
Sources/HanReaderPlayback/PlaybackModel.swift[150-160]
Sources/HanReaderPlayback/PlaybackModel.swift[306-315]

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

## Issue description
Window-scoped models independently deactivate one process-wide iOS audio session.
## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackSession.swift[53-74]
- Sources/HanReaderPlayback/PlaybackModel.swift[318-334]
## Recommended Fix
Coordinate session use across playback models, deactivating the system session only after the last active model releases it.

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


9. A shorter track shows an invalid position ✓ Resolved
Description
PlaybackModel.load(_:startingAt:) clamps a saved position only to zero, and its later duration
callback never revisits that position. If a narration has been replaced by a shorter file, the
parked slider and now-playing elapsed time can exceed the track’s duration until playback is
started.
Code

Sources/HanReaderPlayback/PlaybackModel.swift[R138-142]

+        let start = max(0, time ?? 0)
+        playhead = start
+        observedTime = start
+        if start > 0 {
+            engine.seek(to: start)
Evidence
The load path accepts any nonnegative saved time while duration may be zero; the duration callback
changes only duration. The slider uses the saved position with a duration-bounded range.

Sources/HanReaderPlayback/PlaybackModel.swift[111-117]
Sources/HanReaderPlayback/PlaybackModel.swift[131-145]
Sources/HanReaderUI/Screens/AudioBar.swift[80-101]

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 saved position can exceed an asynchronously discovered track duration.
## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel.swift[111-117]
- Sources/HanReaderPlayback/PlaybackModel.swift[131-145]
## Recommended Fix
When duration becomes known, clamp the parked playhead and observed position to the valid range and seek the engine to the corrected position.

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


10. Migration can move a recent text backward ✓ Resolved
Description
Migration.applyProgress calls markOpened with the prototype timestamp even for a text that was
already present in the destination. When that destination text was opened more recently, the
unconditional update replaces its newer timestamp and changes library recency ordering.
Code

Sources/HanReaderPersistence/PrototypeLibrary.swift[R202-204]

+            if let opened = row.lastOpenedAt {
+                try await repository.markOpened(id, at: Date(timeIntervalSince1970: opened))
+            }
Evidence
A deduplicated row retains the existing text ID, and progress applies the old date to that ID.
markOpened unconditionally updates the column used for library ordering.

Sources/HanReaderPersistence/PrototypeLibrary.swift[174-204]
Sources/HanReaderPersistence/LibraryRepository.swift[187-194]
Sources/HanReaderPersistence/LibraryRepository.swift[48-75]

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

## Issue description
Applying old progress to deduplicated texts can overwrite a more recent destination opening time.
## Fix Focus Areas
- Sources/HanReaderPersistence/PrototypeLibrary.swift[188-205]
- Sources/HanReaderPersistence/LibraryRepository.swift[187-194]
## Recommended Fix
Merge last-opened timestamps by retaining the later of the existing and prototype values, rather than unconditionally marking the text opened at the prototype date.

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


View medium (5)
11. Migrated texts lose their import dates ✓ Resolved
Description
PrototypeLibrary.readTexts omits the prototype’s imported_at, so Migration.importTexts gives
importText no original date. Newly migrated, never-opened texts receive the migration time
instead, changing their recorded import dates and their relative order in the library.
Code

Sources/HanReaderPersistence/PrototypeLibrary.swift[R117-119]

+            try Row.fetchAll(db, sql: """
+                SELECT id, title, content, audio_path FROM texts ORDER BY id
+            """).map {
Evidence
The prototype schema contains imported_at, but the new projection and row type omit it.
importText defaults to the current date, and library ordering uses importedAt for texts without
lastOpenedAt.

Tests/HanReaderPersistenceTests/PrototypeLibraryTests.swift[17-27]
Sources/HanReaderPersistence/PrototypeLibrary.swift[115-125]
Sources/HanReaderPersistence/PrototypeLibrary.swift[168-172]
Sources/HanReaderPersistence/LibraryRepository.swift[48-75]
Sources/HanReaderPersistence/LibraryRepository.swift[103-130]

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 migration drops existing import dates and makes migrated texts appear newly imported.
## Fix Focus Areas
- Sources/HanReaderPersistence/PrototypeLibrary.swift[115-126]
- Sources/HanReaderPersistence/PrototypeLibrary.swift[163-173]
- Sources/HanReaderPersistence/PrototypeLibrary.swift[253-258]
## Recommended Fix
Read `imported_at` from each prototype row and pass its converted date as `now` when importing that text.

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


12. Replacing audio can leave unused files ✓ Resolved
Description
AudioAttachment.attach derives the replacement filename from the new file’s extension and removes
only a destination with that new name. Replacing an .m4a narration with an .mp3 updates the sole
database row to the .mp3 but leaves the old .m4a in storage with no row through which to remove
it.
Code

Sources/HanReaderUI/Services/AudioAttachment.swift[R63-67]

+        let name = "text-\(text.rawValue).\(url.pathExtension.lowercased())"
+        let destination = locations.audio.appendingPathComponent(name)
+
+        if FileManager.default.fileExists(atPath: destination.path) {
+            try FileManager.default.removeItem(at: destination)
Evidence
File naming includes the extension and cleanup checks only that new filename. The repository holds
one path per text and changes it on attachment; its deletion cleanup can only find the currently
recorded path.

Sources/HanReaderUI/Services/AudioAttachment.swift[60-79]
Sources/HanReaderPersistence/LibraryRepository.swift[133-151]
Sources/HanReaderPersistence/LibraryRepository.swift[330-347]

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 changed file extension leaves the former attachment unreferenced on disk.
## Fix Focus Areas
- Sources/HanReaderUI/Services/AudioAttachment.swift[60-80]
## Recommended Fix
Read and retain the prior attachment path before replacement, and remove that former file once the new copy and database association succeed; leave it untouched if replacement fails.

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


13. Tapping a word can restart audio after a call ✓ Resolved
Description
handle(.began) sets wasInterrupted = true, and SystemPlaybackSession only sends .ended when
.shouldResume is present, so an interruption without that option leaves wasInterrupted true.
pauseForSpeech returns early when nothing is playing, but resumeAfterSpeech checks only
wasInterrupted, so the next word the reader taps calls engine.play() when the word finishes and
starts narration the system never invited back.
Code

Sources/HanReaderPlayback/PlaybackModel.swift[R260-263]

+    public func resumeAfterSpeech() {
+        guard wasInterrupted, state == .ready else { return }
+        engine.play()
+        isPlaying = true
Evidence
Interruptions and speech share the single wasInterrupted flag. The session deliberately drops
.ended without shouldResume, so the flag is never cleared. Afterwards, speech ending triggers
resumeAfterSpeech from ReaderScreen's isSpeaking onChange.

Sources/HanReaderPlayback/PlaybackModel.swift[252-290]
Sources/HanReaderPlayback/PlaybackSession.swift[89-104]
Sources/HanReaderUI/Screens/ReaderScreen.swift[140-148]

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

## Issue description
PlaybackModel uses one `wasInterrupted` flag for both system interruptions and speech ducking, so a system interruption that never ends with shouldResume makes the next speech completion resume the narration.
## Fix Focus Areas
- Sources/HanReaderPlayback/PlaybackModel.swift[252-290]
## Recommended Fix
Split the flag into `pausedForSpeech` and `pausedForInterruption`. `resumeAfterSpeech` should act only on `pausedForSpeech`, and `.ended` only on `pausedForInterruption`. Add a test covering began, no ended, then pauseForSpeech and resumeAfterSpeech, asserting that no play happens.

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


14. One unreadable old audio file blocks migration ✓ Resolved
Description
carryAudio turns a missing file into a note, but an existing file that copyItem (or
createDirectory / attachAudio) cannot handle, for example because of a permission or sandbox
denial on the old container path, throws through importTexts and migrate. migrateIfPresent
then never renames library.db, so the migration fails and is retried on every launch, and audio
for the texts after the failing row is never carried.
Code

Sources/HanReaderPersistence/PrototypeLibrary.swift[R233-242]

+        if FileManager.default.fileExists(atPath: destination.path) {
+            try FileManager.default.removeItem(at: destination)
+        }
+        try FileManager.default.copyItem(at: original, to: destination)
+        try await repository.attachAudio(AudioTrack(
+            textID: id,
+            relativePath: name,
+            duration: nil,
+            importedAt: Date(),
+        ))
Evidence
The try calls inside carryAudio propagate out. migrateIfPresent moves the file only after migrate
succeeds, and AppServices logs the error and continues, so the same failure repeats on every launch.
The PR description promises that an unresolvable audio_path is reported rather than failing the
migration.

Sources/HanReaderPersistence/PrototypeLibrary.swift[69-84]
Sources/HanReaderPersistence/PrototypeLibrary.swift[208-244]
Sources/HanReaderUI/Services/AppServices.swift[90-114]

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

## Issue description
Any error while copying a prototype audio file aborts the whole migration, so library.db is never renamed and the failure repeats on every launch.
## Fix Focus Areas
- Sources/HanReaderPersistence/PrototypeLibrary.swift[208-244]
## Recommended Fix
Wrap the createDirectory, copyItem and attachAudio steps in do/catch. On error, append a note such as "The audio for “title” could not be copied: …" and return, so the rest of the migration completes and the file is renamed.

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


15. Replaced narration resumes at the old track's spot ✓ Resolved
Description
attachAudio in ReaderScreen loads the new file at 0 but never clears the stored audioTime
(clearAudioTime(of:) exists) and does not reset lastSavedPlayhead, and the repository's
attachAudio upsert leaves readingPosition.audioTime alone. A reader who replaces a narration and
leaves before a later save starts the new recording at the previous track's position on next launch,
possibly past its end.
Code

Sources/HanReaderUI/Screens/ReaderScreen.swift[R384-386]

+            hasAudio = true
+            playback.load(file)
+            nowPlaying.attach(to: playback, title: title)
Evidence
The repository upsert only touches audioTrack columns. loadAudio on next launch passes
model.storedAudioTime to playback.load, which is still the old track's playhead.

Sources/HanReaderPersistence/LibraryRepository.swift[330-349]
Sources/HanReaderUI/Screens/ReaderScreen.swift[321-344]
Sources/HanReaderPersistence/LibraryRepository.swift[181-189]

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

## Issue description
Replacing a text's narration keeps the previous track's stored playhead, so the new recording later resumes at an unrelated position.
## Fix Focus Areas
- Sources/HanReaderUI/Screens/ReaderScreen.swift[376-392]
## Recommended Fix
After a successful AudioAttachment.attach, call `try await services.library.clearAudioTime(of: textID)` and set `lastSavedPlayhead = 0` before `playback.load(file)`.

ⓘ 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 import rules and skills only from the repositories your team follows

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread Sources/HanReaderPlayback/PlaybackModel.swift
Comment thread Sources/HanReaderPlayback/NowPlayingController.swift
Comment thread Sources/HanReaderPlayback/PlaybackSession.swift
Comment thread Sources/HanReaderUI/Screens/ReaderScreen.swift
Comment thread Sources/HanReaderUI/Screens/ReaderScreen.swift Outdated
Comment thread Sources/HanReaderUI/Services/AudioAttachment.swift Outdated
Comment thread Sources/HanReaderUI/Screens/ReaderScreen.swift
Comment thread Sources/HanReaderPlayback/PlaybackModel.swift
Comment thread Sources/HanReaderPersistence/PrototypeLibrary.swift Outdated
Comment thread Sources/HanReaderUI/Screens/ReaderScreen.swift
All fifteen were real. They cluster into five.

**Who may resume narration.** One flag was doing two jobs, and the two
cases contradict each other. A system interruption that never invites us
back left its claim standing, so the next word the reader tapped ended by
resuming audio the system had taken away; and losing the output while
already paused by an interruption left that claim set, so the interruption
ending played into a room with no headphones in it. Now `pausedForSpeech`
and `pausedByInterruption` are separate, each undone only by its own
counterpart, and `.outputGone` clears both before its guard rather than
after it.

**Speech that never happens.** `SpeechModel.speak` declines in three
ordinary situations — VoiceOver on, an empty word, no Chinese voice — and
says so by never starting. The narration ducked for an utterance that never
came and stayed paused until the reader pressed play, which on a Mac with
no Chinese voice is every single tap.

**Process-wide singletons used per window.** `MPRemoteCommandCenter` and
`AVAudioSession` are one per process, so registering per window meant a
lock-screen play reaching every window's transport, and one window
finishing a track deactivating the session another was still playing
through. Commands now route to the controller that most recently claimed
the lock screen, and the session is reference counted.

**Shared transport, unguarded `await`.** `loadAudio` and `attachAudio` wrote
to the shared transport after asynchronous work without checking the text
was still open, so changing text mid-lookup loaded one text's narration
under another's name. Save tasks read the playhead when they ran rather
than when they were scheduled, which could write a new track's position
under the old text's id. Both now check, and the playhead is read at
schedule time.

**Destructive ordering.** Attaching deleted the existing narration before
copying the replacement, so a failed copy left a row pointing at a file
that was gone; it now stages the copy and moves it into place. Replacing an
`.m4a` with an `.mp3` left the old file with nothing pointing at it. A
replaced narration kept the previous one's stored playhead, so it resumed
partway through a recording it had never played. A parked position was
clamped only to zero, so a shorter replacement showed a slider past its own
end until the duration arrived.

**Migration overreach.** A reader who has used both versions has a library
that is partly theirs already. Carrying prototype audio over a text that
was matched by content replaced a narration they had chosen; stamping
`last_opened_at` on it moved it backwards in the sidebar. Both now apply
only to texts this migration created. `imported_at` is carried, so a
migrated library keeps the order it was built in. And an audio file the old
container will not give up is a note rather than a thrown error — it used
to abort the migration before the rename that marks it done, so it would
fail again on every launch for ever.

Nine mutants planted against the fixes; all nine killed.

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

Copy link
Copy Markdown
Member Author

All fifteen findings addressed in 9991829. Every one was real. They cluster into five groups.

Who may resume narration (1, 13) — one flag was doing two jobs, and the two cases contradict each other. A system interruption that never invites us back left its claim standing, so the next word the reader tapped ended by resuming audio the system had taken away. And losing the output while already paused by an interruption left that claim set, so the interruption ending played into a room with no headphones in it. pausedForSpeech and pausedByInterruption are now separate, each undone only by its own counterpart, and .outputGone clears both before its guard rather than after it.

Speech that never happens (7) — SpeechModel.speak declines in three ordinary situations (VoiceOver on, empty word, no Chinese voice) and says so by never starting. The narration ducked for an utterance that never came and stayed paused until the reader pressed play — which on a Mac with no Chinese voice is every tap.

Process-wide singletons used per window (2, 8) — MPRemoteCommandCenter and AVAudioSession are one per process. Commands now route to the controller that most recently claimed the lock screen, and the session is reference counted so the last window out releases it.

Shared transport, unguarded await (3, 4) — loadAudio and attachAudio wrote to the shared transport after async work without checking the text was still open; save tasks read the playhead when they ran rather than when they were scheduled. Both now check, and the playhead is read at schedule time.

Destructive ordering (6, 9, 12, 15) — attaching deleted the existing narration before copying the replacement, so a failed copy left a row pointing at nothing; it now stages the copy and moves it into place. Replacing .m4a with .mp3 orphaned the old file. A replaced narration kept the previous one's playhead. A parked position was clamped only to zero, so a shorter replacement showed a slider past its own end.

Migration overreach (5, 10, 11, 14) — a reader who has used both versions has a library that is partly theirs already. Carrying prototype audio over a content-matched text replaced a narration they chose; stamping last_opened_at on it moved it backwards in the sidebar. Both now apply only to texts this migration created. imported_at is carried, so a migrated library keeps the order it was built in. And an audio file the old container will not give up is now a note rather than a thrown error — it used to abort the migration before the rename that marks it done, so it would fail again on every launch for ever.

Nine mutants planted against the fixes, all nine killed. 384 + 97 + 40 + 30 tests pass; two suites were split as they crossed the length limit, along the seams the new tests created (Playback interruptions, Prototype migration leaves existing work alone).

Re-verified on the running app after the changes: resumes from the stored 0:04, plays through, and parks correctly at the end.

🤖 Generated with Claude Code

Three things CI caught that this machine could not.

`SystemPlaybackSession.deinit` reached main-actor state from a nonisolated
`deinit`. The same problem as `AVPlayerAudioEngine`'s, and the same fix — a
teardown closure — but the property is inside `#if os(iOS)`, so the macOS
build compiled happily and only the iOS one failed. Both app targets are
now built locally before pushing, which is what should have happened.

The reference-counted audio session was never applied. The edit that was
supposed to add it silently matched nothing and reported success, and
because it changed no code the build and the tests agreed. It is here now:
`AVAudioSession` is one per process, so the last transport out is the one
that puts it down, and an `isActive` flag keeps the count balanced against
a repeated activate or a deactivate without one.

`Migration`'s synthesised memberwise initializer was private, because a
`private` stored property drags it down. Xcode 26.6, which CI pins, enforces
that; the newer toolchain here does not, so it built locally and failed
there. Spelled out explicitly, which is correct on both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@widgetii
widgetii merged commit fc613f8 into main Oct 19, 2026
8 checks passed
@widgetii
widgetii deleted the feat/m7-audio-playback branch October 19, 2026 17:09
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