Skip to content

feat: place phrase boundaries by hand, by dragging or by tapping along (M11, 5/4) - #39

Merged
widgetii merged 2 commits into
mainfrom
feat/m11-boundary-editor
Oct 20, 2026
Merged

widgetii merged 2 commits into
mainfrom
feat/m11-boundary-editor

Conversation

@widgetii

Copy link
Copy Markdown
Member

Stacked on #38 — based on feat/m11-practice-screen, so this diff is only the new work. Merge #38 first.

The piece the plan folded into PR 4 and I split out. M11 is ticked here.

Why

Forced alignment is right most of the time and wrong in a way the reader can
hear: a boundary lands half a syllable late and the next phrase opens with the
tail of the last word. Correcting that is a drag — so the editor draws the
recording's shape around the phrase, the only waveform in the app. Everywhere
else the question is "how far through am I"; here it is "does that syllable
belong inside", and that is a question about shape.

Two operations, deliberately different

What it means What it does
Drag a handle "this phrase should start/end here" Corrects one phrase, stops at its neighbour, leaves excluded silence alone. Where two phrases already meet, moves both — the line between them is one thing, not two.
Tap while playing "the last one ended here and this one starts here" Closes whatever gap was there. A reader tapping sentences in has no way to say "leave 300 ms between them", and no reason to want to.

The path for a system that cannot listen

A Mac older than macOS 26, a system with no Chinese speech model, or a reader
who will not download several hundred megabytes. The text is split into
sentences with no times worth having, the recording plays once, and ⌘↩ places
each boundary. A few dozen lines, always exactly right — which is why it is
here rather than a third alignment path guessing from silence.

Now offered from the invitation screen as well as after a failure: a reader
who already knows the recogniser cannot follow their recording should not have
to watch it fail first.

What survives re-alignment

What a reader places is manual and is kept. What was merely shuffled out of
their way is not, so a later listen is free to replace it — which is why
movePhrase now takes an origin instead of assuming one.

Shape

  • HanReaderCore/Listening/BoundaryEditing.swift — move, place(boundaryBefore:),
    respread, window. Pure, Foundation only.
  • HanReaderPlayback/WaveformPeaks.swift — AVAssetReader down to one peak per
    bucket, 12,000 over a file; resampled(over:count:) takes the maximum per
    output bucket so a short transient stays visible when zoomed out.
  • HanReaderUI/Screens/BoundaryEditor.swift — a Canvas strip, the phrase's own
    bars in the accent colour, two draggable edges with grab areas far wider than
    the lines they draw.
  • PracticeModel+Boundaries.swift — opening the editor, dragging, splitting
    without listening, tapping along.

Three things the tests did not find

Found on the running app:

  1. Splitting without listening opened the editor on a flat line — it set the
    flag instead of going through startEditing, which is what reads the shape.
  2. Stopping part-way through tapping left every phrase ahead collapsed to the
    minimum
    , stacked up behind the playhead. They are now spread over the
    recording that is left — not right, but evenly wrong, which is what a
    placeholder split is.
  3. Mark was on ⌘M, which is the system's Minimize. A view's shortcut
    competing with a menu command is a fight to lose. It is ⌘↩ now: free while the
    writing field is off screen, and already "commit this" elsewhere on the screen.

Verification

  • 28 core tests, 12 waveform tests, 15 model tests. Every planted mutant dies
    — 31 on the core, 15 on the model, 10 on the waveform.
  • Mutation testing also turned up a real crash: a span wholly outside the
    recording built a range whose lower bound was above its upper one, and Range
    traps on that. The editor asks for exactly that the moment a phrase's times
    outlive the file they were measured against.
  • Two surviving mutants were equivalent — clamps guarding states the guards above
    them already make unreachable. Those clamps were removed rather than papered
    over with tests that cannot fail.
  • On the running app, against 听力64 and its narration: a drag moved both sides
    of a shared boundary and wrote them as manual; splitting without listening
    gave 27 placeholders over the full 133 s at zero confidence; tapping placed
    boundaries at the playhead and advanced the counter; stopping after one tap left
    the remaining twenty-five sharing 4.75 s each rather than a quarter-second
    each, ending exactly at the end of the recording.
  • swift test green (493), make lint clean, both app targets built.

🤖 Generated with Claude Code

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

Copy link
Copy Markdown

PR Summary by Qodo

Add waveform-based phrase boundary editing and manual tap-along

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Add waveform-guided dragging to correct phrase timings while preserving manually placed
 boundaries.
• Let readers split and mark phrases during playback without speech recognition.
• Cover boundary rules, waveform decoding, and practice workflows with tests.
Diagram

graph TD
  Screen["Practice screen"] --> Editor["Boundary editor"] --> Model["Practice model"] --> Rules["Boundary rules"]
  Model --> Reader["Waveform reader"] --> Audio["Recording file"]
  Model --> Repository["Phrase repository"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Transactional batch persistence
  • ➕ Would save both sides of a shared boundary atomically and reduce writes when respreading.
  • ➖ Requires an additional repository API for this workflow.
2. Automatic silence-based fallback
  • ➕ Could reduce the number of boundaries a reader places.
  • ➖ Would substitute another timing guess where the manual path is intended to avoid one.

Recommendation: Keep the distinct drag and tap rules and the manual fallback: they match different reader intentions without adding another unreliable alignment path. Consider transactional batch writes if multi-phrase edits need atomic persistence.

Files changed (15) +1870 / -18

Enhancement (10) +1106 / -17
BoundaryEditing.swiftDefine phrase-boundary timing rules +237/-0

Define phrase-boundary timing rules

• Adds pure rules for dragging, tapping, respreading untapped phrases, and selecting a waveform window. Placed boundaries become manual while respread placeholders retain their origin.

Sources/HanReaderCore/Listening/BoundaryEditing.swift

ForcedAlignment.swiftExpose placeholder phrase spreading +1/-1

Expose placeholder phrase spreading

• Makes equal-duration, zero-confidence phrase spreading available to the manual splitting workflow.

Sources/HanReaderCore/Listening/ForcedAlignment.swift

LibraryRepository+Listening.swiftPersist the appropriate origin for moved phrases +7/-1

Persist the appropriate origin for moved phrases

• Allows callers to specify a phrase's origin when saving new times, so untapped placeholders remain eligible for later re-alignment.

Sources/HanReaderPersistence/LibraryRepository+Listening.swift

WaveformPeaks.swiftExtract and resample recording peaks +217/-0

Extract and resample recording peaks

• Adds on-demand audio decoding into bounded peak buckets and maximum-based resampling for the editor. Handles spans outside the recording without constructing an invalid range.

Sources/HanReaderPlayback/WaveformPeaks.swift

DesignTokens.swiftAdd boundary-editor visual tokens +23/-0

Add boundary-editor visual tokens

• Defines waveform colours, a highlighted span fill, and waveform and handle dimensions.

Sources/HanReaderUI/Design/DesignTokens.swift

PracticeModel+Boundaries.swiftCoordinate dragging and tap-along editing +242/-0

Coordinate dragging and tap-along editing

• Loads peaks when editing opens, creates placeholder phrases without recognition, and manages dragging and playback-time marks. Saves edits and respreads phrases left untapped.

Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift

PracticeModel.swiftHold boundary-editor state in the practice model +31/-7

Hold boundary-editor state in the practice model

• Adds editor, waveform, tap-along, and audio-track state; exposes state to the new model extension and cancels waveform work on close.

Sources/HanReaderUI/Models/PracticeModel.swift

BoundaryEditor.swiftAdd the waveform boundary editor +240/-0

Add the waveform boundary editor

• Draws phrase and surrounding audio peaks with draggable start and end handles. Adds phrase playback, fixed-step adjustment, accessibility actions, and a tap-along entry point.

Sources/HanReaderUI/Screens/BoundaryEditor.swift

PracticeComponents.swiftAdd playback-time marking controls +57/-0

Add playback-time marking controls

• Introduces a tap-along bar showing the next phrase, progress, Mark and Stop actions, and the ⌘↩ shortcut.

Sources/HanReaderUI/Screens/PracticeComponents.swift

PracticeScreen.swiftOffer editing and manual placement in practice +51/-8

Offer editing and manual placement in practice

• Shows the editor or tap-along controls in the practice screen and adds an editing toolbar action. Offers manual phrase placement before alignment and when recognition is unavailable.

Sources/HanReaderUI/Screens/PracticeScreen.swift

Tests (3) +752 / -0
BoundaryEditingTests.swiftTest phrase-boundary rules +312/-0

Test phrase-boundary rules

• Covers shared and separated boundaries, duration limits, tap placement, origin preservation, respreading, and waveform-window bounds.

Tests/HanReaderCoreTests/BoundaryEditingTests.swift

WaveformPeaksTests.swiftTest waveform extraction and resampling +177/-0

Test waveform extraction and resampling

• Uses generated audio to check peak extraction and normalization. Tests zooming, silence, degenerate spans, and spans outside the recording.

Tests/HanReaderPlaybackTests/WaveformPeaksTests.swift

PracticeBoundaryTests.swiftTest practice-model boundary workflows +263/-0

Test practice-model boundary workflows

• Verifies immediate drag persistence, recognition-free splitting, tap-along progression, early-stop respreading, and editor lifecycle behavior.

Tests/HanReaderUITests/PracticeBoundaryTests.swift

Documentation (2) +12 / -1
CHANGELOG.mdDocument manual phrase-boundary editing +11/-0

Document manual phrase-boundary editing

• Adds release notes for waveform-guided corrections and the recognition-free tap-along workflow.

CHANGELOG.md

README.mdMark M11 listening practice complete +1/-1

Mark M11 listening practice complete

• Marks the listening and dictation milestone complete now that boundary editing is included.

README.md

@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 (8) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Taps get pinned to placeholder ends, not the playhead 🐞 Bug ≡ Correctness
Description
BoundaryEditing.place clamps the tapped playhead time to the opened phrase’s equal-slice
placeholder end minus minimumDuration, rather than allowing the boundary to reach the tap. When a
spoken phrase runs past that placeholder, tapBoundary saves an early boundary as manual with
confidence 1, and later taps do not correct the displaced boundaries.
Code

Sources/HanReaderCore/Listening/BoundaryEditing.swift[R115-118]

+        let floor = previous.start + minimumDuration
+        let ceiling = phrase.end - minimumDuration
+        guard floor <= ceiling else { return [] }
+        let boundary = time.clamped(to: floor ... ceiling)
Evidence
splitWithoutListening creates equal-time placeholders, while beginTapAlong and tapBoundary
pass playback.playhead to place. Because place caps that time at the opened phrase’s
placeholder end and returns only the two affected phrases without pushing later phrases forward, a
boundary tapped at 12 seconds in a 15-second recording split into three five-second phrases is saved
at 9.75 seconds. This also conflicts with the tapBoundary comment saying later phrases are pushed
out of the way as needed.

Sources/HanReaderCore/Listening/BoundaryEditing.swift[103-124]
Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[154-178]
Sources/HanReaderCore/Listening/ForcedAlignment.swift[253-270]
Sources/HanReaderCore/Listening/ForcedAlignment.swift[256-269]
Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[116-127]
Sources/HanReaderCore/Listening/BoundaryEditing.swift[113-123]
Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[161-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
`BoundaryEditing.place` saves a tap at the opened phrase’s placeholder limit when the playhead is later than that limit. Fix tap-along so the saved boundary can reflect the tapped time without violating minimum phrase durations.
## Fix Focus Areas
- Sources/HanReaderCore/Listening/BoundaryEditing.swift[103-124]
- Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[161-178]
## Recommended Fix
Pass the recording duration into `place`. Bound the tapped boundary below by `previous.start + minimumDuration` and above by the recording duration, rather than by the opened phrase’s placeholder end. When the tap passes `phrase.end - minimumDuration`, move the opened phrase’s end far enough to accommodate the boundary and minimum duration, moving or respreading unplaced phrases ahead as needed while preserving minimum durations for phrases whose boundaries have already been placed. Return the changed phrase so it is saved, and add a test with a tap later than the placeholder end.

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


2. Stopping tap-along wipes good boundaries 🐞 Bug ≡ Correctness
Description
settleUntapped respreads phrases from the last opened phrase onward into equal slices without
distinguishing placeholders from recognised or manually placed times, while respread retains their
origin and confidence. Stopping tap-along immediately or after a few taps can therefore replace
existing boundaries—including the newly opened phrase’s end—and save guessed times as manual
corrections that survive re-alignment without a low-confidence warning.
Code

Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[R188-196]

+    func settleUntapped() async {
+        guard let tapping, tapping.next <= phrases.count, tapping.next > 0 else { return }
+        let opened = phrases[tapping.next - 1]
+        await save(BoundaryEditing.respread(
+            from: opened.index,
+            after: opened.start,
+            in: phrases,
+            duration: max(playback.duration, phrases.last?.end ?? 0),
+        ))
Evidence
“Tap them all in” is available for existing phrase sets without checking whether their times are
placeholders, and starting tap-along does not inspect origins. A tap marks both adjacent phrases
manual, including the newly opened phrase; Stop calls settleUntapped, which respreads from that
phrase onward. respread assigns replacement times while copying origin and confidence, and saving
writes those values over the existing rows. Repository replacement then preserves the replacement
times for phrases still marked manual during re-alignment.

Sources/HanReaderUI/Screens/BoundaryEditor.swift[200-202]
Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[223-228]
Sources/HanReaderCore/Listening/BoundaryEditing.swift[142-170]
Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[200-221]
Sources/HanReaderUI/Screens/BoundaryEditor.swift[199-203]
Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[145-152]
Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[188-197]
Sources/HanReaderCore/Listening/BoundaryEditing.swift[150-169]
Sources/HanReaderPersistence/LibraryRepository+Listening.swift[139-145]
Sources/HanReaderCore/Listening/BoundaryEditing.swift[113-123]
Sources/HanReaderCore/Listening/BoundaryEditing.swift[158-169]
Sources/HanReaderPersistence/LibraryRepository+Listening.swift[33-71]

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

## Issue description
Stopping tap-along, even without marking a boundary, can replace recognised or hand-set phrase times. After a tap, it can also give the newly opened phrase a guessed end while retaining a manual origin and its previous confidence, causing the guess to survive re-alignment without a low-confidence warning.
## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[145-152]
- Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[180-197]
- Sources/HanReaderCore/Listening/BoundaryEditing.swift[142-170]
- Sources/HanReaderPersistence/LibraryRepository+Listening.swift[33-71]
## Recommended Fix
Restrict respreading to times that are safe to replace, such as confidence-0 placeholders or phrases that now start before the last tap; leave manually placed times and phrases still ahead of the playhead alone. Track placed boundaries separately from automatically assigned times so re-alignment preserves only actual corrections, and set confidence to 0 on any respread guesses. Alternatively, require an explicit reset before retapping corrected recordings or offer “Tap them all in” only for placeholders. Test stopping immediately after starting with manual phrases present, and stopping after one tap followed by re-alignment.

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



Remediation recommended

3. Closing practice leaves placeholders behind 🐞 Bug ≡ Correctness
Description
PracticeModel.close clears tapping directly instead of running the settling operation used by
Stop. If the reader dismisses the practice sheet after a partial tap-along, the untapped
placeholders are not respread before the model is discarded.
Code

Sources/HanReaderUI/Models/PracticeModel.swift[R134-136]

+        waveformTask?.cancel()
+        waveformTask = nil
+        tapping = nil
Evidence
Stop calls settleUntapped before clearing state, whereas sheet dismissal calls close, which only
clears that state. The sheet’s disappearance handler performs no separate settling operation.

Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[188-197]
Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[223-228]
Sources/HanReaderUI/Models/PracticeModel.swift[131-139]
Sources/HanReaderUI/Screens/PracticeSheet.swift[48-62]

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

## Issue description
Dismissing the sheet during tap-along skips the operation that spreads the remaining placeholders.
## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel.swift[131-139]
- Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[188-197]
- Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[223-228]
## Recommended Fix
Run the same settling path on close as on Stop before clearing tap-along state. Test dismissal after a partial sequence and inspect the persisted phrase times on reopening.

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


4. Quick marks can lose a phrase boundary 🐞 Bug ≡ Correctness
Description
tapBoundary reads the upcoming phrase, then awaits persistence before advancing tapping.next,
while each Mark press starts a separate task. Two presses during that await can both act on the same
upcoming phrase and each write the same next counter value, leaving one intended boundary unplaced.
Code

Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[R165-168]

+        guard var tapping, let next = upcoming else { return }
+        await save(BoundaryEditing.place(
+            boundaryBefore: next.index,
+            at: playback.playhead,
Evidence
The button creates an independent task on every press. save applies local times but awaits
database writes, and the captured tap counter is not updated until that await returns.

Sources/HanReaderUI/Screens/PracticeComponents.swift[222-226]
Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[161-177]
Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[199-220]

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

## Issue description
Concurrent Mark tasks can capture the same upcoming phrase while the first save is suspended.
## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[161-177]
- Sources/HanReaderUI/Screens/PracticeComponents.swift[222-226]
## Recommended Fix
Make claiming and advancing the upcoming boundary synchronous on the main actor before awaiting persistence, or serialize Mark actions with an explicit in-flight state. Test two marks issued while the first database write is pending.

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


5. Boundary warnings return after reopening 🐞 Bug ≡ Correctness
Description
Phrase.moved sets a corrected phrase’s confidence to one, but the new save path calls
movePhrase, whose update writes only times and origin. After practice reloads phrases from the
database, a corrected low-confidence phrase regains its old confidence and the uncertainty warning
reappears.
Code

Sources/HanReaderCore/Listening/BoundaryEditing.swift[R223-227]

+            // A boundary somebody placed is not a guess, whatever the
+            // recogniser thought of it beforehand — so the warning triangle
+            // goes away once they have fixed it.
+            confidence: 1,
+            origin: .manual,
Evidence
The move constructs confidence one for the in-memory phrase, but the repository SQL leaves the
confidence column untouched. Reload reads that unchanged column, and the transport displays a
warning below 0.5.

Sources/HanReaderCore/Listening/BoundaryEditing.swift[217-230]
Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[199-217]
Sources/HanReaderPersistence/LibraryRepository+Listening.swift[87-118]
Sources/HanReaderPersistence/LibraryRepository+Listening.swift[134-145]
Sources/HanReaderUI/Screens/PracticeComponents.swift[96-104]

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 editor clears low-confidence warnings locally, but saving boundary corrections does not persist the new confidence.
## Fix Focus Areas
- Sources/HanReaderCore/Listening/BoundaryEditing.swift[217-230]
- Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[199-217]
- Sources/HanReaderPersistence/LibraryRepository+Listening.swift[134-145]
## Recommended Fix
Persist the updated confidence with each hand-placed boundary, or consistently derive the warning state from the persisted manual origin. Test the warning after closing and reopening practice.

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


View medium (2)
6. Failed saves appear to keep boundary edits 🐞 Bug ☼ Reliability
Description
PracticeModel.save applies moved phrases locally before writing them and only logs a database
error if a write fails. When storage fails, the editor continues displaying the changed boundaries
although reopening practice loads the old persisted times; a failure after one write can also leave
adjacent phrases only partly updated.
Code

Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[R202-205]

+        // Applied locally first, so the strip follows the drag rather than
+        // the database round trip.
+        apply(moved)
+        do {
Evidence
The local array is replaced before the try block finishes. Each affected phrase is written through a
separate awaited repository call, and the catch block logs without reverting the local array or
notifying the reader.

Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[97-100]
Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[199-220]
Sources/HanReaderPersistence/LibraryRepository+Listening.swift[134-146]

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 editor presents boundary changes as saved even when persistence fails, and adjacent phrase writes are not atomic.
## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[199-220]
- Sources/HanReaderPersistence/LibraryRepository+Listening.swift[134-146]
## Recommended Fix
Persist all phrases affected by a boundary in one transaction, then either confirm the local update or restore the prior phrases and surface the error when that transaction fails.

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


7. Waveform drifts out of step with the audio 🐞 Bug ≡ Correctness
Description
WaveformReader.fill uses integer division for samplesPerBucket, so its buckets cover less audio
than the duration / values.count spacing assumed by resampled. When the sample count is not
divisible by the bucket count—or is smaller than it—peaks drift earlier on the time axis, and
samples after the buckets fill are decoded without being represented, which can make a short
recording appear partly silent.
Code

Sources/HanReaderPlayback/WaveformPeaks.swift[R175-180]

+                samplesPerBucket = max(1, Int(rate * duration) / buckets)
+            }
+            for sample in samples(of: buffer) {
+                let bucket = samplesSeen / samplesPerBucket
+                samplesSeen += 1
+                guard bucket < buckets else { break }
Evidence
resampled maps time to bucket indices using values.count / duration, while fill assigns
samples using a truncated integer bucket size. For 5,300,000 samples and 12,000 buckets, that size
is 441 rather than approximately 441.67, so bucket i contains audio starting at sample i * 441
but is drawn at the time corresponding to approximately i * 441.67. For 22,050 samples and 12,000
buckets, the size is one sample, so samples after the first 12,000 do not affect the peaks even
though the returned duration covers the whole recording.

Sources/HanReaderPlayback/WaveformPeaks.swift[62-89]
Sources/HanReaderPlayback/WaveformPeaks.swift[156-185]
Sources/HanReaderPlayback/WaveformPeaks.swift[122-138]
Sources/HanReaderPlayback/WaveformPeaks.swift[163-184]

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

## Issue description
Integer-truncated samples-per-bucket sizing misaligns peaks with the time axis used by `resampled` and can leave later audio unrepresented.
## Fix Focus Areas
- Sources/HanReaderPlayback/WaveformPeaks.swift[163-184]
## Recommended Fix
Use a fractional bucket width, such as `let samplesPerBucket = rate * duration / Double(buckets)`, and assign each decoded sample with `Int(Double(samplesSeen) / samplesPerBucket)`, clamped to `buckets - 1`. Stop the outer read loop once all buckets have been filled instead of decoding the rest of the file without using it. Add test cases for sample counts that are not divisible by the bucket count and for recordings with fewer samples than buckets.

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



Informational

8. Editor shows unrevealed phrase text after moving on 🐞 Bug ≡ Correctness
Description
startEditing calls reveal() only once, but select (called by advance and by every
tapBoundary) resets isRevealed = false while isEditing stays true. The editor header then
shows each new phrase's full text with no reveal recorded. This is the 'side door' that the comments
in startEditing and BoundaryEditor say the design closes.
Code

Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift[R41-48]

+    func startEditing() {
+        isEditing = true
+        // Opening the editor shows the phrase, because a boundary cannot be
+        // placed without knowing which words it is meant to contain. That
+        // counts as having looked, exactly as "Show the answer" does, and
+        // saying so is better than letting a reader peek through a side
+        // door.
+        reveal()
Evidence
select sets isRevealed = false, and nothing re-reveals while the editor stays open.
BoundaryEditor.header shows phrase.text(in: model.content) whatever isRevealed is.

Sources/HanReaderUI/Models/PracticeModel.swift[183-190]
Sources/HanReaderUI/Screens/BoundaryEditor.swift[48-69]

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

## Issue description
While the boundary editor is open, changing phrase clears `isRevealed`, but the editor keeps showing each phrase's text.
## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel.swift[183-190]
## Recommended Fix
In `select`, set `isRevealed = isEditing` instead of `false`. Alternatively, have `BoundaryEditor` call `reveal()` whenever the current phrase changes.

ⓘ 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/HanReaderUI/Models/PracticeModel.swift Outdated
Comment thread Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift Outdated
Comment thread Sources/HanReaderCore/Listening/BoundaryEditing.swift
Comment thread Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift
Comment thread Sources/HanReaderCore/Listening/BoundaryEditing.swift
Comment thread Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift Outdated
Comment thread Sources/HanReaderPlayback/WaveformPeaks.swift Outdated
Comment thread Sources/HanReaderUI/Models/PracticeModel+Boundaries.swift
Base automatically changed from feat/m11-practice-screen to main October 20, 2026 10:43
…g (M11, 5/4)

M11, a fifth part the plan had folded into the fourth. The practice screen
shipped without it, so M11 stayed unticked; this is what ticks it.

Forced alignment is right most of the time and wrong in a way the reader can
hear: a boundary lands half a syllable late and the next phrase opens with
the tail of the last word. Correcting that is a drag, so the editor draws
the recording's shape around the phrase — the only waveform in the app, and
drawn only here, because everywhere else the question is "how far through am
I" and here it is "does that syllable belong inside".

Two operations, deliberately different. A **drag** corrects one phrase and
stops at its neighbour, leaving any silence alignment excluded where it is;
where two phrases already meet it moves both, because the line between them
is one thing and not two. A **tap** is a statement about both phrases made
while the recording plays, so it closes whatever gap was there. A reader
tapping sentences in has no way to say "leave 300 milliseconds between
them", and no reason to want to.

Tapping along is also the whole answer for a system that cannot listen: a
Mac older than macOS 26, a system with no Chinese speech model, or a reader
who will not download several hundred megabytes. The text is split into
sentences with no times worth having, the recording plays once, and ⌘↩
places each boundary. A few dozen lines, and always exactly right — which is
why it is here rather than a third alignment path guessing from silence.
That option is now offered from the invitation screen as well as after a
failure, since a reader who already knows the recogniser cannot follow their
recording should not have to watch it fail first.

What a reader places is marked `manual` and survives re-alignment. What was
merely shuffled out of their way is not, so a later listen is free to
replace it — which is why `movePhrase` now takes an origin instead of
assuming one.

Three things found on the running app rather than in the tests:

- Splitting without listening opened the editor on a flat line, because it
  set the flag instead of going through `startEditing`, which is what reads
  the shape.
- Stopping part-way through tapping left every phrase ahead collapsed to the
  minimum length, stacked up behind the playhead. They are now spread over
  the recording that is left — not right, but evenly wrong, which is what a
  placeholder split is.
- Mark was on ⌘M, which is the system's Minimize. A view's shortcut
  competing with a menu command is a fight to lose, so it is ⌘↩: free while
  the writing field is off screen, and already "commit this" elsewhere on
  the screen.

Verified against 听力64 and its narration: a drag moved both sides of a
shared boundary and wrote them down as `manual`; splitting without listening
gave 27 placeholders over the full 133 seconds at zero confidence; tapping
placed boundaries at the playhead and advanced; and stopping after one tap
left the remaining twenty-five sharing 4.75 seconds each rather than a quarter
of a second each, with the last ending exactly at the end of the recording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@widgetii
widgetii force-pushed the feat/m11-boundary-editor branch from c22e670 to b28c69a Compare October 20, 2026 10:46
Eight findings, all real. Two of them matter.

**A tap was pinned to a placeholder rather than to the playhead.** `place`
clamped the boundary to the opened phrase's end minus the minimum, and
during tap-along that end is wherever an equal-slice split happened to put
it. A spoken sentence running past its placeholder is the ordinary case, not
the exception, so the boundary was recorded somewhere the reader did not
press — and the phrase they were opening collapsed to a quarter of a second.
The tap now decides, held off only by the phrases either side needing a
length each.

**Stopping tap-along could replace a good alignment with equal slices.**
`settleUntapped` spread from the last opened phrase without distinguishing a
placeholder from a boundary somebody had placed, and with nothing tapped yet
that was the first phrase — so opening tap-along and pressing Stop rewrote
the lot. Each tap now lays out only the phrases ahead of it that nobody has
placed, stopping at the first `manual` one, which leaves the text consistent
at every moment and leaves nothing to settle. `settleUntapped` and
`respread` are gone, and so is the hole.

**Six more.** Two quick Marks either side of a database round trip both read
the same upcoming phrase and placed the same boundary twice; the counter now
advances before the write. `movePhrase` wrote times and origin but not
confidence, so a corrected phrase came back from the database still flagged
uncertain and the warning the reader had just answered reappeared. A failed
write left the moved boundary on screen as though it had been kept; the
screen is now taken back from the database. Dismissing the sheet part-way
through tapping cleared the flag directly instead of going through the path
Stop takes. `select` cleared `isRevealed` while the editor stayed open, so
the editor showed each new phrase's text with the model believing nobody had
looked. And `WaveformReader` bucketed by a fixed integer stride while
`resampled` assumed `duration / count`, so the far end of a long recording
was drawn out of step with what plays there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@widgetii
widgetii merged commit b87d0e2 into main Oct 20, 2026
8 checks passed
@widgetii
widgetii deleted the feat/m11-boundary-editor branch October 20, 2026 11:07
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