Skip to content

feat: place a transcript in its recording by listening to it (M11, 2/4) - #36

Merged
widgetii merged 3 commits into
mainfrom
feat/m11-forced-alignment
Oct 20, 2026
Merged

widgetii merged 3 commits into
mainfrom
feat/m11-forced-alignment

Conversation

@widgetii

Copy link
Copy Markdown
Member

Second of four for M11. Forced alignment: the words are already known, so what the recogniser supplies is timing, and the characters it gets right carry the clock across the ones it does not.

The approach changed, because the obstacle was not what the plan assumed

The plan said the reader installs the Chinese Dictation language. On the machine this is built for, they cannot:

/Library/Managed Preferences/com.apple.applicationaccess.plist
  allowDictation  => false
  allowAssistant  => false

An MDM profile. Dictation cannot be switched on, so its languages cannot be added, so SFSpeechRecognizer(locale: "zh-CN").supportsOnDeviceRecognition is false and the system logs No Assistant asset for language zh-CN. en-US and ru-RU report true — precisely the two assets present.

macOS 26's SpeechTranscriber manages its own models and is not governed by that restriction. On the same machine it reports zh-CN supported, offers an installation request, and installs successfully.

So:

  • SpeechTranscriber only, floor of macOS 26 / iOS 26 for automatic alignment. Older systems place boundaries by hand — slower, never wrong. A second implementation on SFSpeechRecognizer would fail exactly where the first was needed, which is not a fallback.
  • Network recognition refused, not merely avoided: it would send the reader's own recordings to a server in an app whose premise is offline operation, and it is capped at about a minute of audio.
  • Speech is fenced to HanReaderPlayback by a SwiftLint rule, as NaturalLanguage and GRDB already are.

Verified on a real recording

147 seconds of 听力 material:

Transcription 1.2 s for 147 s — 122× real time
Granularity one run per character, 0.24 s typical
Round trip cut the audio at the aligned boundaries, transcribe each slice independently → 97–100%

The round trip is the part that matters: if the times were wrong, a slice would hold the neighbour's words. It is the only non-circular check available when the transcript is derived from the same recogniser.

What the real data caught

The character budget is a poor proxy for duration on material with pauses. Eighteen characters came to ten seconds; six phrases in eighteen ran past the seven-second cap, the longest at 29.8 s. No unit test would have found this — the synthetic recognisers all speak at a constant rate.

The fix came free from data already in hand. A recogniser folds the silence before a character into that character's own span, so a character running far longer than its neighbours followed a gap — 1.8 s against a typical 0.24 s. That is silence detection without decoding a sample, and it is what rescues this material, because recognised punctuation is sparse and cutting at punctuation alone left six phrases over.

before after
longest phrase 29.8 s 8.0 s
over the 7 s cap 6 of 18 3 of 26
round-trip accuracy 100% 97–100%

Tests

459 core, 46 playback. Ten mutants planted and killed across the alignment and the re-cutting.

The audio path is not covered by an automated test, deliberately: a test that downloaded a speech model would fail on a fresh clone and on CI. SpeechAlignerTests covers what can be checked without one — language support, the refusal to fetch a model unasked, unreadable recordings, and which failures are worth offering a download for. The audio behaviour is what the measurements above record.

Known

  • A slice scored 97% rather than 100% because cutting exactly on a character boundary clipped the onset of 白. The transport should begin a phrase a fraction early rather than the stored boundary being moved — PR 3.
  • 听力64's own recording is not on this machine, so the alignment was verified against a different 听力 file of the same kind. The gate in the plan named that text specifically; this is the nearest honest substitute.

🤖 Generated with Claude Code

M11, second of four. Forced alignment: the words are already known, so what
the recogniser supplies is timing, and the characters it gets right carry
the clock across the ones it does not.

The approach changed after checking what was actually stopping it. The
missing Chinese model was not an un-downloaded one — this machine's MDM
profile sets `allowDictation = false`, so Dictation cannot be switched on,
its languages cannot be added, and `SFSpeechRecognizer(zh-CN)
.supportsOnDeviceRecognition` is false with *No Assistant asset for language
zh-CN* in the log. `en-US` and `ru-RU` report true, being the two assets
that happen to be present.

macOS 26's `SpeechTranscriber` manages its own models and is not governed by
that restriction: on the same machine it reports zh-CN supported, offers an
installation request, and installs. So automatic alignment has a floor of
macOS 26 / iOS 26, and older systems place boundaries by hand instead. A
second implementation on `SFSpeechRecognizer` would fail precisely where the
first was needed, which is not a fallback.

Network recognition is refused rather than merely avoided. It would send
the reader's own recordings to a server in an app whose premise is that it
works offline, and it is capped at about a minute of audio besides.

Measured on a real 147-second recording: transcription takes 1.2 seconds,
and the transcriber hands back one run per character — 0.24 s for a typical
one. Slicing the audio at the resulting boundaries and transcribing each
slice on its own reproduces the phrase text, which is the only
non-circular way to check that times are right.

That run also showed the character budget to be a poor proxy for duration
on material with pauses: eighteen characters came to ten seconds, and six
phrases in eighteen ran past the seven-second cap with the longest at
thirty. Over-long phrases are now cut again once the clock is known.

The pauses come free. A recogniser folds the silence before a character
into that character's own span, so a character running far longer than its
neighbours is one that followed a gap — 1.8 seconds against a typical 0.24.
That is silence detection without decoding a sample, and it is what rescues
this material, because recognised punctuation is sparse and cutting at
punctuation alone left six phrases over. With both: longest 29.8 s to 8.0 s,
over-cap six of eighteen to three of twenty-six, slices still 97–100%.

`Speech` is fenced to `HanReaderPlayback` by a SwiftLint rule, as
`NaturalLanguage` and GRDB already are.

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

Copy link
Copy Markdown

PR Summary by Qodo

Add on-device forced alignment for transcript phrases

✨ Enhancement 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Align known transcript phrases to on-device speech timings without sending recordings to a server.
• Re-cut long phrases at punctuation or detected pauses and flag uncertain placements for review.
• Restrict Speech imports to playback and test alignment without downloading models.
Diagram

graph TD
  A["Recording"] --> B["SpeechAligner"] --> C["SpeechTranscriber"] --> D["Timed characters"] --> E["SequenceAlignment"] --> F["ForcedAlignment"] --> G["Timed phrases"]
  H["PhraseSplitter"] --> E
  H --> F
  D --> F
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Bundle an offline speech model
  • ➕ Could provide automatic alignment on older supported operating systems.
  • ➕ Would not depend on an installed Apple speech asset.
  • ➖ Adds model distribution, storage, and runtime costs.
  • ➖ Requires a second recognition integration and validation path.

Recommendation: Keep the SpeechTranscriber-only approach for this milestone: it works around the documented Dictation restriction while preserving offline operation and a single recognition path. A bundled model is worth revisiting only if automatic alignment on pre-26 systems becomes a requirement; SFSpeechRecognizer would not solve the managed-device case.

Files changed (6) +1174 / -0

Enhancement (3) +706 / -0
ForcedAlignment.swiftPlace transcript phrases using recognised character timings +473/-0

Place transcript phrases using recognised character timings

• Introduces a Speech-independent recognition type and matches its characters to the transcript through SequenceAlignment. It assigns phrase times and confidence, re-cuts overlong phrases at punctuation or inferred pauses, and repairs boundaries for playback.

Sources/HanReaderCore/Listening/ForcedAlignment.swift

PhraseSplitter.swiftExpose punctuation cut points within a phrase +23/-0

Expose punctuation cut points within a phrase

• Adds a UTF-16 cut-point API so alignment can reconsider phrase boundaries after audio duration is known.

Sources/HanReaderCore/Listening/PhraseSplitter.swift

SpeechAligner.swiftAdapt on-device SpeechTranscriber results for alignment +210/-0

Adapt on-device SpeechTranscriber results for alignment

• Adds a macOS 26/iOS 26 speech adapter that checks language and model availability, installs a model only when requested, and returns timed recognition segments. It also reports progress and distinguishes unsupported languages, missing models, and unreadable audio.

Sources/HanReaderPlayback/SpeechAligner.swift

Tests (2) +460 / -0
ForcedAlignmentTests.swiftTest phrase placement and playback boundary guarantees +369/-0

Test phrase placement and playback boundary guarantees

• Covers clean and damaged recognition, confidence, punctuation differences, duration-based re-cutting, pause cuts, and degenerate input. Separate tests exercise boundary repair.

Tests/HanReaderCoreTests/ForcedAlignmentTests.swift

SpeechAlignerTests.swiftTest speech availability and failure behavior without model downloads +91/-0

Test speech availability and failure behavior without model downloads

• Checks supported and unsupported languages, opt-in model installation behavior, error recoverability, progress shape, and unreadable files where an installed model permits. It deliberately leaves real-audio transcription to manual verification.

Tests/HanReaderPlaybackTests/SpeechAlignerTests.swift

Other (1) +8 / -0
.swiftlint.ymlConfine Speech imports to HanReaderPlayback +8/-0

Confine Speech imports to HanReaderPlayback

• Adds an error-level rule preventing other source modules from importing Speech. This keeps Apple's speech API behind the playback adapter and its Core data type.

.swiftlint.yml

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

Grey Divider


Action required

1. Misheard edge words vanish from playback ✓ Resolved
Description
place sets each phrase’s boundaries from its first and last matched characters, rather than
extending timing across unmatched characters at the edges. When the recogniser mishears a phrase’s
opening or closing character, the returned audio range excludes that character’s recording even if
the rest of the phrase matches.
Code

Sources/HanReaderCore/Listening/ForcedAlignment.swift[R310-311]

+            let start = matched.first?.lowerBound ?? previousEnd
+            let end = matched.last?.upperBound ?? start
Evidence
Only .match steps become anchors; place then uses the first and last anchors as the entire
phrase span. For a transcript beginning 甲乙 recognised as 错乙, the match for 乙 places the phrase
start at 乙’s timestamp, excluding the audio before it.

Sources/HanReaderCore/Listening/ForcedAlignment.swift[91-98]
Sources/HanReaderCore/Listening/ForcedAlignment.swift[300-319]
Sources/HanReaderCore/Listening/Phrase.swift[53-60]

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 phrase boundary is taken from its first or last matched character, dropping audio for unmatched characters at either edge.
## Fix Focus Areas
- Sources/HanReaderCore/Listening/ForcedAlignment.swift[300-323]
## Recommended Fix
Infer boundaries for unmatched edge characters using neighbouring recognition timings and phrase or recording bounds, rather than stopping at the nearest match. Add tests for misrecognition at both edges.

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


2. Adjacent phrases can replay the same audio ✓ Resolved
Description
tidy moves a short phrase’s start backward to meet minimumDuration without respecting the
previous phrase’s end. If a final recognised phrase ends near the recording limit, this repair can
overlap an earlier phrase even when the recording has room for both.
Code

Sources/HanReaderCore/Listening/ForcedAlignment.swift[R362-364]

+            if end - start < minimumDuration {
+                start = max(0, end - minimumDuration)
+            }
Evidence
With two anchored phrases at 0–0.9 and 0.95–1.0 seconds in a one-second recording, the second start
is initially clamped to 0.95 but is subsequently moved to 0.75. Its playable audio range then
overlaps the first phrase through 0.9.

Sources/HanReaderCore/Listening/ForcedAlignment.swift[300-323]
Sources/HanReaderCore/Listening/ForcedAlignment.swift[339-375]
Sources/HanReaderCore/Listening/Phrase.swift[53-60]

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 backward minimum-duration repair can move a phrase across the previous phrase’s end.
## Fix Focus Areas
- Sources/HanReaderCore/Listening/ForcedAlignment.swift[339-375]
## Recommended Fix
Repair short spans while preserving the previous end as a lower bound, redistributing neighbouring boundaries where needed. Test a short final phrase near the recording limit.

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



Remediation recommended

3. Readers get quote-only practice phrases ✓ Resolved
Description
PhraseSplitter.cutPoints offers a cut immediately after a terminator without carrying following
closing quotes or brackets into that clause. When recutting a timed phrase whose final spoken clause
already exceeds the duration cap, pack cannot merge the resulting punctuation-only piece and
returns it as a separate phrase.
Code

Sources/HanReaderCore/Listening/PhraseSplitter.swift[R285-289]

+            offset += character.utf16.count
+            guard offset > range.lowerBound, offset < range.upperBound else { continue }
+            if separators.contains(character) || terminators.contains(character) {
+                points.append(offset)
+            }
Evidence
The existing sentence splitter explicitly includes trailing marks with the sentence, but the new
cut-point method does not. Recutting builds a piece at each point, and pack merges it only if the
combined anchored span is within the cap; an over-cap clause followed by ” therefore leaves ”
alone.

Sources/HanReaderCore/Listening/PhraseSplitter.swift[91-116]
Sources/HanReaderCore/Listening/PhraseSplitter.swift[280-291]
Sources/HanReaderCore/Listening/ForcedAlignment.swift[159-169]
Sources/HanReaderCore/Listening/ForcedAlignment.swift[234-249]

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

## Issue description
Re-cutting can strand a closing quote or bracket in its own phrase after a sentence terminator.
## Fix Focus Areas
- Sources/HanReaderCore/Listening/PhraseSplitter.swift[280-291]
- Sources/HanReaderCore/Listening/ForcedAlignment.swift[159-169]
## Recommended Fix
Extend terminator cut points past adjacent closing marks, and ensure punctuation-only pieces merge with their predecessor regardless of the duration cap. Test an over-cap final clause ending in a quote.

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


4. Long recordings repeat whole-text scans 🐞 Bug ➹ Performance
Description
recut calls cutPoints for each overlong phrase, and each call rebuilds Metrics for the full
transcript while also enumerating every expected character. When many phrases in a chapter exceed
the duration cap, alignment repeats document-sized allocation and scanning per phrase instead of
restricting work to each phrase’s range.
Code

Sources/HanReaderCore/Listening/ForcedAlignment.swift[R195-200]

+        var points = Set(PhraseSplitter.cutPoints(in: phrase, of: transcript))
+        let threshold = max(pauseFloor, typical * pauseMultiple)
+        for (index, position) in expected.enumerated()
+            where phrase.contains(position.offset) && position.offset > phrase.lowerBound
+        {
+            guard let anchor = anchors[index] else { continue }
Evidence
The recut loop invokes cutPoints once per overlong phrase. That method constructs Metrics(text),
whose initializer traverses the full text, and the subsequent expected-character loop also begins at
index zero for every phrase.

Sources/HanReaderCore/Listening/ForcedAlignment.swift[137-152]
Sources/HanReaderCore/Listening/ForcedAlignment.swift[195-205]
Sources/HanReaderCore/Listening/PhraseSplitter.swift[280-285]
Sources/HanReaderCore/Listening/PhraseSplitter.swift[307-329]

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 overlong phrase triggers another full-transcript metrics build and expected-character scan.
## Fix Focus Areas
- Sources/HanReaderCore/Listening/ForcedAlignment.swift[137-152]
- Sources/HanReaderCore/Listening/ForcedAlignment.swift[195-205]
- Sources/HanReaderCore/Listening/PhraseSplitter.swift[280-285]
## Recommended Fix
Build text metrics once for recutting, and visit only expected positions within the current phrase’s offsets. Keep work proportional to the text and relevant cut points rather than their product with the phrase count.

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


5. Completed listening can show partial progress ✓ Resolved
Description
listen derives its progress fraction solely from the last recognised segment’s end and never emits
a completion update after consuming results. A recording with trailing silence finishes below 1.0,
while one with no recognised segments finishes without any listening-progress callback.
Code

Sources/HanReaderPlayback/SpeechAligner.swift[R177-179]

+            if duration > 0, let last = segments.last {
+                progress(SpeechAlignmentProgress(fraction: min(1, last.end / duration)))
+            }
Evidence
The only listening-progress call is conditional on a nonempty segments array and uses the last
segment’s end divided by full file duration. The method returns immediately after the results loop,
so trailing silence or an empty result cannot be reflected as completed progress.

Sources/HanReaderPlayback/SpeechAligner.swift[151-159]
Sources/HanReaderPlayback/SpeechAligner.swift[166-181]

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

## Issue description
Successful listening may return while its progress callback still reports a partial fraction or has emitted nothing.
## Fix Focus Areas
- Sources/HanReaderPlayback/SpeechAligner.swift[166-181]
## Recommended Fix
Emit a final listening-progress update with fraction 1.0 after all results have been consumed successfully, including for silent recordings and recordings with trailing silence.

ⓘ 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/HanReaderCore/Listening/ForcedAlignment.swift Outdated
Comment thread Sources/HanReaderCore/Listening/ForcedAlignment.swift
Comment thread Sources/HanReaderCore/Listening/PhraseSplitter.swift
Comment thread Sources/HanReaderCore/Listening/ForcedAlignment.swift Outdated
Comment thread Sources/HanReaderPlayback/SpeechAligner.swift
widgetii and others added 2 commits October 20, 2026 07:50
`SpeechAlignerTests` asserted that the system can transcribe Chinese. True
here, where the model had just been installed; false on a headless CI
runner, which reports no supported languages at all. So the test passed
locally and failed in CI for the only reason it could — it was checking the
environment rather than the code.

It is conditional now, on something that *is* a property of the code:
wherever the system can transcribe anything, it can transcribe Chinese,
because that is the language this app exists for and the one the
availability check names. Where it transcribes nothing there is nothing to
assert, and saying so is better than a green tick meaning "this machine
happens to be set up".

`SpeechAligner.supportedLanguages()` comes with it, because the same
distinction matters above: "this Mac has no speech support" and "it has
speech support but not Chinese" need different things said to the reader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Misheard characters at a phrase's edge were cut out of its audio. A phrase
anchored on its first and last *matched* characters begins after a
mis-recognised opening character has already been spoken — the character is
in the recording, the reader simply does not hear it. A phrase now reaches
out to where the neighbouring content ends and begins.

Stretching a short final phrase backwards to meet the minimum ignored the
phrase before it, so two could replay the same audio and the reader would
transcribe the same words twice without being told why.

A cut point offered immediately after a terminator stranded the closing
quote following it — the same defect as one found in the first part of this
milestone, reproduced in new code. Re-cutting a long sentence could hand
back a practice phrase that was one 」.

Re-cutting rebuilt the document's metrics once per over-long phrase, which
is also the same performance mistake as last time in new code: the whole
transcript scanned again for an answer concerning one range of it.

And listening never said it had finished. The fraction came from the last
recognised segment, so a recording trailing into silence stopped at
eight-tenths and looked stuck, and one the recogniser heard nothing in
never reported at all.

**A correction, from testing rather than reasoning.** The 97% slice in the
previous commit was attributed to the boundary clipping the onset of 白, and
a lead-in was proposed for the transport. Measured: with a 0.25 s lead-in
the slice reads 黑白电影里, because the boundary falls *inside the word* 黑白
— the recogniser had put a full stop there, 「只有在黑。白电影里」. So it is
neither clipping nor a timing error but a misplaced stop in an ASR-derived
transcript, which is an artefact of verifying against a transcript the
recogniser wrote. A human transcript punctuates correctly, and the
transport needs no lead-in on that evidence.

Re-measured after the fixes: progress reaches 1.0 exactly, mean slice
accuracy 99% over six phrases, twenty-six phrases with a median of 5.4 s
and a longest of 8.0 s.

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

Copy link
Copy Markdown
Member Author

All five addressed in fb1cd21, and one of them led me to retract a diagnosis in the PR description.

1 — misheard edge characters were cut out of their phrase. Anchoring on first and last matched characters means a phrase whose opening character was misheard begins after that character has already been spoken. It is in the recording; the reader simply does not hear it. A phrase now reaches out to where the neighbouring content ends and begins.

2 — the last-resort stretch could overlap its neighbour. Pulling a short final phrase's start backwards to meet the minimum ignored the previous phrase's end, so two phrases could replay the same audio.

3 — cut points stranded closing quotes. The same defect found in PR 1, reproduced in new code: re-cutting a long sentence could hand back a practice phrase that was one 」.

4 — re-cutting rebuilt document-sized metrics per phrase. Also the same performance mistake as PR 1, in new code. Built once now.

5 — listening never said it had finished. The fraction came from the last recognised segment, so a recording trailing into silence stopped at 0.8 and looked stuck; one with nothing recognised never reported at all.

Retraction

The PR description said a slice scored 97% because the boundary clipped the onset of 白, and proposed a lead-in for the transport in PR 3. That was wrong. Tested: with a 0.25 s lead-in the slice reads 黑白电影里 — the boundary falls inside the word 黑白, because the recogniser had put a full stop there: 「只有在黑。白电影里」.

So it is neither clipping nor a timing error. It is a misplaced full stop in an ASR-derived transcript, which is an artefact of verifying against a transcript the recogniser itself wrote. A human transcript — the actual use case — punctuates correctly. The transport needs no lead-in on that evidence, and PR 3 will not add one.

Re-measured after the fixes

progress on completion 1.0 exactly
slice accuracy, six phrases 97, 100, 100, 100, 100, 100 — mean 99%
phrases 26, median 5.4 s, longest 8.0 s

465 core tests. Four more mutants planted and killed. ForcedAlignment and its test suite were split as they crossed the length limit, along the seam the re-cutting created. Both app targets built locally before pushing.

🤖 Generated with Claude Code

@widgetii
widgetii merged commit 02bb709 into main Oct 20, 2026
8 checks passed
@widgetii
widgetii deleted the feat/m11-forced-alignment branch October 20, 2026 05:25
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