Blog  /  Kafka reader engine

Word-level audio and text sync in Jetpack Compose: building Kafka's read-along.

The player knows the time. The book knows the words. The layout knows where they landed. Getting all three to agree is how a voice keeps your place.

Published
Reading
10 minutes
Stack
Jetpack Compose, Media3, AVPlayer
White NightsFyodor Dostoevsky

It was a wonderful night, such a night as is only possible when we are young, dear reader. The sky was so starry, so bright that, looking at it, one could not help asking oneself whether ill-humoured and capricious people could live under such a sky. That is a youthful question too, dear reader, very youthful, but may the Lord put it more frequently into your heart! . . . Speaking of capricious and ill-humoured people, I cannot help recalling my moral condition all that day. From early morning I had been oppressed by a strange despondency. It suddenly seemed to me that I was lonely, that everyone was forsaking me and going away from me. Of course, anyone is entitled to ask who "everyone" was. For though I had been living almost eight years in Petersburg I had hardly an acquaintance. But what did I want with acquaintances? I was acquainted with all Petersburg as it was; that was why I felt as though they were all deserting me when all Petersburg packed up and went to its summer villa.

First Night66 seconds of narration

Press Play, or tap a word to find your place.

Real narration, repaired word timings. White Nights, First Night, starting after the title at 2.08 seconds. The browser reads the audio clock every frame.

Kafka is a reading app I build in Kotlin Multiplatform, with Compose UI on Android and iOS. It has 1,000 audiobooks, and in read-along mode the narrator and the page move together: a small pill slides behind the word being spoken, the text already read goes quiet, and the page turns when the voice gets there.

The feature fits in one sentence: get the audio position, find the current word, highlight it. Everything hard sits between those verbs. The recording has its own clock. The book has paragraphs and character offsets. Compose has shaped glyphs and lines. Pagination divides those paragraphs differently whenever the reader changes the font size.

All four have to agree, ten times a second, without rebuilding the page each time the player moves. And the failure modes are lopsided: audio with no highlight is fine, a highlight on the wrong sentence is worse than nothing.

Three coordinates for one word.

A word has three coordinates: milliseconds, a range within a text block, and a rectangle in the layout. Only the first two belong in the narration file. Pixel positions and page numbers cannot survive a font change, a rotation, or a move from an Android phone to an iPhone. The app resolves those at rendering time.

Three coordinates for one word The real word wonderful occupies 3360 to 3947 milliseconds in the chapter and characters 9 through 17 in its block. An illustrative narrow layout splits it into wonder and ful, yielding two rectangles. The app polls position every 100 milliseconds; geometry is resolved after layout. Audio: 3360 to 3947 ms Player position, polled every 100 ms Text: characters [9, 18) An offset in this edition, not a page number It was awonderfulnight, Layout: two line extents One range, clipped to each shaped line … it was awonder fulnight, …
One word, three spaces. “Wonderful” uses the real chapter interval and text offset. The narrow line break is illustrative: two extents keep the mark on the ink, even when one word wraps.

English and Hindi exercise different parts of this contract. Devanagari marks must stay attached to the shaped text. The reader also supports Urdu, where logical text order and horizontal movement differ. A character offset is not a count of visible letters, and a word's width is not the sum of separately measured characters.

The implementation lives across the reader's narration feature and the vendored player modules, :core:playback and :ui:playback. The player owns sound and position. The reader owns the relationship between that position and the book. NarrationManager.kt connects them and checks that incoming positions still belong to its audio session.

The data: timings tied to an edition.

The server pipeline uses ElevenLabs v4 through the text-to-dialogue endpoint with timestamps. In tts_audiobook.py, the response supplies characters, character start times, character end times, and voice segments identifying which input each section belongs to.

The pipeline groups non-whitespace characters into words. A word starts at its first character's timestamp and ends at its last character's timestamp. Seconds become milliseconds, and the accumulated duration of earlier audio chunks moves each interval into chapter time.

Chunks preserve exact substrings of the source. Their offsets point back into the block's narration text, which concatenates spans and trims the result. When the returned alignment exactly matches the submitted piece, the generator writes an o offset. If that comparison fails, it omits the offset instead of guessing.

The app decodes these structures from NarrationSync.kt:

Kotlin, commonMainNarrationSync.kt
@Serializable
data class NarrationSyncBlock(
    @SerialName("blockId") val blockId: String,
    @SerialName("sha") val sha: String,
    @SerialName("startMs") val startMs: Long,
    @SerialName("endMs") val endMs: Long,
    @SerialName("words") val words: List<NarrationSyncWord>,
)

@Serializable
data class NarrationSyncWord(
    @SerialName("w") val word: String,
    @SerialName("startMs") val startMs: Long,
    @SerialName("endMs") val endMs: Long,
    @SerialName("o") val offset: Int? = null,
)

The surrounding sidecar identifies the book, chapter and narration, declares a spec version and word granularity, and carries the duration. narrate.py adds the publication envelope and exposes the file through the track's syncUrl.

The sha is the first 16 hexadecimal characters of a SHA-256 hash of the narrated block text. That matters because a narration belongs to a particular text, not just a title. Correcting a paragraph after recording changes the agreement between the voice and the page.

NarrationSyncResolver.kt checks identity, intervals, block existence, hashes and offsets before exposing a timeline. It can reject one block while keeping valid neighbours. A word's timestamp can remain valid while its text offset becomes unavailable. That distinction lets playback survive damage to the visual mapping.

One thing I'd tighten before the format grows: Python counts string offsets in code points and Kotlin in UTF-16 units. They agree for Latin, Devanagari and Urdu, which all sit in the Basic Multilingual Plane, and the substring check catches the day they don't.

The clock: Media3 ExoPlayer position, not elapsed wall time.

On Android, AudioPlayer.kt reads currentPosition from Media3 ExoPlayer. On iOS, IosAudioPlayer.kt reads AVPlayer's current time and converts it to milliseconds. Both feed the same shared position-flow implementation.

The ordinary player progress flow updates at a one-second interval. That is separate from read-along. NarrationManager.kt collects observePosition(), whose default interval is 100 ms.

Here is the shared implementation in PrecisePositionFlow.kt:

Kotlin, commonMainPrecisePositionFlow.kt
internal fun precisePositionFlow(
    playbackState: StateFlow<PlaybackState>,
    seekEvents: SharedFlow<Unit>,
    intervalMs: Long,
    readPosition: () -> Long,
    readDuration: () -> Long,
): Flow<Long> {
    require(intervalMs > 0L) { "intervalMs must be positive" }
    return channelFlow {
        launch {
            playbackState.collectLatest { state: PlaybackState ->
                sendClampedPosition(readPosition, readDuration)
                while (state.isPlaying && !state.isBuffering) {
                    delay(intervalMs)
                    sendClampedPosition(readPosition, readDuration)
                }
            }
        }
        launch {
            seekEvents.collect {
                sendClampedPosition(readPosition, readDuration)
            }
        }
    }
}

A state change emits a position immediately. Playing and non-buffering sessions then poll at the requested interval. Seeks have a separate emission path. The helper clamps negative positions and bounds positions by duration when duration is known.

An earlier playback implementation extrapolated wall-clock time and was wrong at faster playback speeds. Reading the live player position fixed the underlying mistake. There is no extra multiplication by playback speed in the read-along lookup.

The 100 ms interval is a polling cadence, not a measured end-to-end synchronization guarantee. It chooses the word. A separate Compose animation moves the mark between words, so its motion does not have to look like ten discrete updates per second.

Keeping the clock out of page composition.

NarrationRenderState.kt holds stable render state keyed by chapter and block. Installing a timeline prepares word ranges. Updating the position updates the active location, the read edge, and the blocks involved in the moving mark.

The important boundary is where that state gets read. drawNarrationWords in NarrationWordHighlight.kt uses drawWithCache to prepare layout-dependent geometry. Inside its draw callback:

Kotlin, commonMainNarrationWordHighlight.kt
            onDrawWithContent {
                // Only the block(s) holding the mark read the motion clock, play state and mark style.
                val underline: NarrationUnderlineSpec? = state?.underline
                val rawPaints: List<NarrationWordPaint> = state?.paints.orEmpty()
                val isPill: Boolean =
                    (underline != null || rawPaints.isNotEmpty()) && renderState?.wordMark == NarrationWordMark.PILL

The rapidly changing paint and motion values are consumed during drawing. The text does not need a new AnnotatedString, composition pass, or layout merely because another audio sample arrived. Geometry can stay cached until the layout or its relevant inputs change.

NarrationAnimationEffect runs an Animatable for the mark's movement and another for the read dim. Motion progress is read during drawing. The current default is the pill; an underline treatment remains available internally. The pill starts its lookup 60 ms ahead and uses a bounded glide duration. That is visual tuning, not a correction applied to the audio clock. The held word stays marked between words and while paused; stopping lifts the read dim.

Drawing a word in laid-out text.

The renderer starts with the existing TextLayoutResult. It maps the narration offset into the rendered text, subtracts the page slice's unitOffset, and clips the range to the text unit on this page.

That intermediate mapping matters for added line breaks and whitespace. A ghazal couplet, for example, has a narration representation formed from its lines without separators, while its visible layout needs line breaks. narrationToRenderedOffsets tolerates those whitespace differences. It refuses a mapping when the letters themselves differ.

The per-line geometry comes from NarrationWordHighlight.kt:

Kotlin, commonMainNarrationWordHighlight.kt
internal fun narrationLineExtents(
    geometry: NarrationTextGeometry,
    range: NarrationTextRange,
): List<NarrationLineExtent> {
    if (range.endExclusive <= range.start) return emptyList()
    val firstLine: Int = geometry.lineForOffset(range.start)
    val lastLine: Int = geometry.lineForOffset(range.endExclusive - 1)
    val extents: MutableList<NarrationLineExtent> = mutableListOf()
    for (line: Int in firstLine..lastLine) {
        val segmentStart: Int = max(range.start, geometry.lineStart(line))
        val segmentEnd: Int = min(range.endExclusive, geometry.lineVisibleEnd(line))
        if (segmentEnd <= segmentStart) continue
        val lastOffset: Int = segmentEnd - 1
        val startShift: Float = geometry.justificationShift(line, segmentStart)
        val endShift: Float = geometry.justificationShift(line, lastOffset)
        // min/max keeps right-to-left lines, whose first glyph sits rightmost, covered too.
        val left: Float = min(geometry.boxLeft(segmentStart) + startShift, geometry.boxLeft(lastOffset) + endShift)
        val right: Float = max(geometry.boxRight(segmentStart) + startShift, geometry.boxRight(lastOffset) + endShift)
        extents += NarrationLineExtent(line, left, right)
    }
    return extents
}

A range crossing a line break produces separate extents. Taking minimum and maximum edges also handles a right-to-left line whose first character sits farther right than its last.

The layout engine owns shaping. I do not split sentences into independently laid-out word composables just to get convenient backgrounds.

Justified text needed additional care. The code records cases where rectangle queries did not include the spacing adjustments used to paint the line. JustificationCorrection.kt computes a shift from the completed inter-word gaps and the metric widths those gaps replaced. Each end of the word gets its own shift. This correction skips RTL paragraphs, CJK lines, final lines and hard breaks.

The pill drops punctuation at the edges of its visual range and uses line metrics to keep neighbouring pills apart. The read-dim mask has extra room above the first line for tall marks, including Devanagari matras. These are small details, but they determine whether a highlight feels attached to the printed word.

On the same line the pill slides and resizes. Across a wrap it fades out at the old word and in at the new one. It does not stretch across the empty space between lines. This is the part shared with karaoke text highlighting in Compose, but a book also needs stable page slices and long paragraphs.

Real speed, 10 secondsFollowing the voice. A continuous recording from the Kafka reader. The pill moves through the paragraph while the words already read go quiet.

Following the voice across pages.

A block ID alone cannot identify the page containing the spoken word. One paragraph can span several pages.

NarrationPageResolution.kt maps the narrated character offset into rendered text and asks the page plan for the page containing that character. PagedBookReader.kt resolves the result into the current sheet, including two-page spreads. If the word moves to the other page already visible in a spread, no turn is needed.

Follow lands the page with scrollToPage, no curl, and refreshes stale pagination around an anchor before it resolves the destination, so a font change mid-chapter doesn't send the reader to the wrong page.

Manual navigation needs a separate policy. A drag suspends follow so the voice cannot immediately pull the reader back. A qualifying forward page turn while following can seek narration to the new page's starting text. Programmatic moves are marked separately so they do not masquerade as reader intent.

Scrolling mode uses a different approximation. KeepNarratedBlockVisible.kt keeps the focus within a safe band from 15% to 75% of the viewport and moves it toward one-third from the top when necessary. It estimates position within a block from word progress and item height. It is not tracking the exact spoken line's rectangle. A manual scroll suspends it, and reduced-motion settings select immediate movement.

The failures that made the boundaries useful.

The most instructive failure was an 80 ms overlap.

The pipeline joined generated chunks using their measured MP3 durations. The aligner's last character could end about 80 ms beyond the encoded chunk. After stitching, that tail overlapped the next chunk's first block. The client rejected the overlapping block, so a tiny timing error could remove highlighting for an entire long paragraph.

An 80 millisecond tail crosses the chunk seam Before repair the alignment extends 80 milliseconds beyond chunk A's encoded end into chunk B. Clamping to the measured audio window removes that tail. The client separately tolerates seams up to 250 milliseconds; larger overlaps fail validation. The encoded seam is the boundary Relative to the end of chunk A 0 ms Chunk AChunk B Before: aligner tail +80 ms After: clamp to audio window Client tolerance: up to 250 ms A safety margin, not an extra audio offset
Eighty milliseconds, a whole block. A tail beyond the encoded duration can invalidate the next paragraph. Clamp upstream to the measured chunk window; keep the client's bounded tolerance as a separate safeguard. Timeline schematic, not to scale.

The repair was upstream first. narration_sync.py clamps each chunk to its actual audio window:

Python, pipelinenarration_sync.py
def clamp_entries(entries: list[dict], lo_ms: int, hi_ms: int) -> None:
    """Clamp every block/word timestamp of one chunk into [lo_ms, hi_ms] in place."""
    for entry in entries:
        entry["startMs"] = _clamp(entry["startMs"], lo_ms, hi_ms)
        entry["endMs"] = _clamp(entry["endMs"], lo_ms, hi_ms)
        for word in entry.get("words") or []:
            word["startMs"] = _clamp(word["startMs"], lo_ms, hi_ms)
            word["endMs"] = _clamp(word["endMs"], lo_ms, hi_ms)

A second pass normalizes the merged chapter: ordered, non-overlapping word intervals, positive durations, and bounds within the audio. The generator uses this shared logic, as do repair and splice paths.

The client also tolerates small seams up to 250 ms by trimming the earlier interval where permitted. Larger overlaps remain validation failures. Tolerance makes the reader resilient to bounded alignment noise without making arbitrary timing data trustworthy.

Another failure looked similar but needed a different repair. Kafan's text had changed after narration. Its block hashes no longer matched. Retiming could not fix words that belonged to an earlier text. The repair notes describe splicing replacement audio for changed passages.

The offset check in NarrationSyncResolver.kt makes the same distinction at word level:

Kotlin, commonMainNarrationSyncResolver.kt
    private fun validateOffset(
        word: NarrationSyncWord,
        blockText: String,
        blockId: String,
        wordIndex: Int,
        diagnostics: MutableList<NarrationSyncDiagnostic>,
    ): Int? {
        val offset: Int = word.offset ?: return null
        val hasValidRange: Boolean = offset in 0..blockText.length && word.word.length <= blockText.length - offset
        val matches: Boolean = hasValidRange && blockText.substring(offset, offset + word.word.length) == word.word
        if (matches) return offset
        diagnostics +=
            NarrationSyncDiagnostic(
                reason = NarrationSyncRejectionReason.STALE_WORD_OFFSET,
                message = "Word offset $offset does not match the current block text",
                blockId = blockId,
                wordIndex = wordIndex,
            )
        return null
    }

A missing offset returns null. A stale offset records a diagnostic and returns null. It never goes looking for the word elsewhere in the paragraph. In prose full of "the" and "and", a guess is how a highlight ends up three lines from the voice.

The sentence-back button exposed a different race. Seeking is asynchronous. The manager updated its position optimistically, then the flow emitted the old player position before the seek completed. A second tap calculated from that stale position and landed on the same sentence again.

NarrationManager.kt now tracks the pending seek and filters stale positions while it settles, with a 1.5-second timeout. Rapid repeat taps can build on the intended position instead of being undone by a delayed sample.

An earlier audiobook persistence bug taught the same ownership lesson at a larger scale. Tracking lived in the full-player sheet's ViewModel. Mini-player listening could miss it, and dismissing the sheet ended tracking while sound continued. AudioProgressInitializer.kt now starts tracking for the process lifetime, with position snapshots and pause/stop flushing.

Fallbacks are part of the feature.

An invalid sidecar envelope produces no validated timeline. Individual bad blocks can be excluded while valid blocks remain. Missing or stale offsets prevent precise word marking. Audio can still be useful without that mapping.

If the visible text uses a different script and cannot map back to the narration text, the renderer keeps block-level read dimming but marks no individual words.

Keeping your place.

Each layer answers a small question. The player knows the recording position, the timeline identifies the text, the layout locates it, and the reader decides whether to follow.

When one answer becomes unavailable, I can stop using that answer without stopping the book. That is the standard I want for read-along: a reader should be able to pause, look away, change the type size, or go back a sentence without having to reconstruct where the voice went.

The voice should never cost you your place

Read and listen in Kafka on Android or iPhone.

More from the blog: A realistic page curl in Jetpack Compose

Vipul Kumar, 23 October 2026. Kafka is on Google Play and the App Store.All posts