Skip to content

Long text becomes narrated audio, and the job tells you the truth while it runs

A production tool that pulls text out of a source, narrates it, and reports honestly on a job that takes minutes, without pretending to be instant.

Job 7Q3M · The Salt Roads, part 4Encoding

Working view · /jobs/7Q3M · submitted by Dana Whitcombe at 10:38:09 AM

Export · when output exists Thu, 09/17/2026DW

Source

Web page

Longreads › The Salt Roads › Part 4

Extractor (on job)

v2 · Structural extraction

Cheerio · selectors for known site shapes

Voice & pace

Maren · 0.96×

14 pronunciation rules

Current stage

Encoding · 41 of 58

Stage 4 of 4 · chunks written by FFmpeg

Status

Polled 2s ago

Job row written 10:43:22 AM

Pipeline stages

Each stage writes its own transition · long stages report their position
Extracting1/4Done4,812 words · via v2-structural10:38:09 → 10:38:31 AM
Segmenting2/4Done318 segments10:38:31 → 10:38:52 AM
Synthesizing3/4Done318 of 318 segments10:38:52 → 10:42:05 AM
Encoding4/4Runningchunk 41 of 58 · FFmpeg10:42:05 → now

What this screen polls

GET /api/jobs/7Q3M/status · reads the job's row
{
"id": "7Q3M",
"state": "running",
"stage": "encoding",
"position": { "done": 41, "of": 58, "unit": "chunk" },
"extractor": "v2-structural",
"stages": {
"extracting": { "state": "done", "at": "10:38:31" },
"segmenting": { "state": "done", "at": "10:38:52" },
"synthesizing": { "state": "done", "at": "10:42:05" },
"encoding": { "state": "running", "since": "10:42:05" }
},
"updatedAt": "2026-09-17T10:43:22"
}

No timer drives this view. If the row stops changing, the screen says stalled.

Your queue

Every job has its own id
7Q3MThe Salt Roads, part 4Encoding
4HX9The Salt Roads, part 3 · retakeEncoding
5TRWA Field Guide to Lost CanalsStalled
9K2DLetters from Cwm HollowStopped
4HX8The Salt Roads, part 3Export ready
/jobs/7Q3M/export opens once the encoded file exists.

Who, what, and how long

Industry

Media & Publishing

Duration

12 weeks

Cooperation model

Time and materials

Services
Pipeline engineeringJob orchestrationContent extraction
Integrations
Text-to-speech providerObject storageSentryCloud transcoding
Technologies
Next.jsNode.jsFFmpeglamejsCheerioJSDOM
Team
1 Project lead2 Backend engineers1 Frontend engineer

Client name withheld under NDA. Engagement details are shown to the extent our agreement permits.

Versioned extraction

Three extraction strategies kept live and comparable, because clean text out of arbitrary sources is the genuinely hard part.

Getting clean prose out of an arbitrary URL is what decides whether the audio is listenable, so all three strategies stay live: readability heuristics over the DOM, structural extraction with Cheerio against known site shapes, and a plain-text path. Each job records which strategy produced its text, so a bad render can be diagnosed instead of shrugged at.

What shipped
  • Three extraction strategies kept live and comparable
  • Every job records which one produced its text
  • A bad render is traceable to its extractor
Extraction lab · compare

Three strategies kept live and run on the same source · each job records which one produced its text

Run all three again Thu, 09/17/2026DW

Same source, three extractors

Longreads › The Salt Roads › Part 4 · web page · fetched 10:38:09 AM

Used by job 7Q3MNoise: text that is not the article

v1Readability heuristics

JSDOM
v1-readability4,951 words · 64

Part 4: The Winter Ledger

By the time the carts reached Aldmoor, the salt had been weighed four times, and each weighing had been written down by a different hand.

captionPhotograph: the Aldmoor weighhouse, c. 1890. Courtesy of the county archive.

The ledger that recorded the last of them survives as a single water-stained volume. Its clerk wrote the weather into the margins as carefully as he wrote the weights.

relatedRelated: The Salt Roads, part 3 — The Toll at Brennock →

In the winter of 1841 the road closed for eleven days, and the margins fill with snow.

Kept a photo caption and a related-story link

Headings and paragraph breaks intact

v2Structural extraction

On job 7Q3M
v2-structural4,812 words · 61

Part 4: The Winter Ledger

By the time the carts reached Aldmoor, the salt had been weighed four times, and each weighing had been written down by a different hand.

The ledger that recorded the last of them survives as a single water-stained volume. Its clerk wrote the weather into the margins as carefully as he wrote the weights.

In the winter of 1841 the road closed for eleven days, and the margins fill with snow.

The clerk's name was Josiah Penrose, and he kept the book for twenty-two winters.

Matched a known site shape: Longreads article

Caption and related links left out by selector

v3Plain text

No DOM
v3-plaintext5,037 words · 12

navLONGREADS SUBSCRIBE THE SALT ROADS

PART 4: THE WINTER LEDGER By the time the carts reached Aldmoor, the salt had been weighed four times, and each weighing had been written down by a different hand. [1] The ledger that recorded the last of them survives as a single water-stained volume. Its clerk wrote the weather into the margins as carefully as he wrote the weights. [2]

In the winter of 1841 the road closed for eleven days, and the margins fill with snow.

Heading merged into body; nav text and footnote markers kept

Nothing dropped: every line of the page is present

Recorded on each job

A bad render traces back to the extractor that produced its text
JobTitleSourceExtractorWordsText
7Q3MThe Salt Roads, part 4Web pagev2-structural4,812Open extracted text
9K2DLetters from Cwm HollowDocumentv1-readability5,230Open extracted text
3PLAEditor's letter, autumnPasted textv3-plaintext1,164Open extracted text
On screen

The extraction lab: readability heuristics, structural extraction with Cheerio and the plain-text path run on the same web page side by side, what each kept that isn't the article, and the strategy recorded on each job.

Job 7Q3M · Timeline

The Salt Roads, part 4: The Winter Ledger · Maren · four named stages instead of one percentage

Raw status Thu, 09/17/2026DW
WorkingTimelineStatus Export
Export unlocks when the encoded file existsEncoding · chunk 41 of 58

Four stages, four lengths

Widths drawn to shape from this job's transitions · not a benchmark
Stage10:38 AM · submittednow · 10:43 AM
Extracting4,812 words
Segmenting318 segments
Synthesizing318 of 318 segments
Encodingchunk 41 of 58
An averaged barExtracting = 25%Segmenting = 25%Synthesizing = 25%Encoding = 25%

Stage transitions

Written by each stage to the job row
AtStageEventWritten byPosition
10:38:09Extractingenteredextract-workerstrategy v2-structural
10:38:31Extractingleftextract-worker4,812 words
10:38:31Segmentingenteredsegment-worker—
10:38:52Segmentingleftsegment-worker318 segments
10:38:52Synthesizingenteredsynth-worker0 of 318
10:42:05Synthesizingleftsynth-worker318 of 318
10:42:05Encodingenteredencode-worker0 of 58
10:43:22Encodingpositionencode-worker41 of 58

Why there is no percentage

Same job, two readings

Encoding begins10:42:05 AM

Averaged75%
NamedEncoding · 0 of 58

A minute later10:43:22 AM

Averaged75%
NamedEncoding · 41 of 58

The stages are different lengths, so a bar that gives each one a quarter reads 75% for all of encoding. The stage name and its position are what actually move.

On screen

One job's timeline: four named stages of very different lengths, drawn to shape, the transition each stage wrote, and why a bar giving each stage a quarter would sit at 75% through all of encoding.

Named stages

Extracting, segmenting, synthesizing, encoding: progress that says exactly what's happening.

Progress is reported as the stage the job is in (extracting, segmenting, synthesizing, encoding), because those stages run for very different lengths and a percentage that treats them as equal lies. Each stage writes its own transition, so the screen can name what's happening. Long stages report their own internal position, so encoding a two-hour file never looks stalled.

What shipped
  • Four named stages in place of an averaged percentage
  • Each stage writes its own transition
  • Long stages report internal position so they never look stalled
Job 9K2D · Letters from Cwm HollowStopped

Working view · /jobs/9K2D · document · Idris · submitted by Priya Nandakumar

Resume from segment 212 Thu, 09/17/2026DW

Stopped in synthesizing · segment 212 of 318

Reason: Synthesis request timed out 3 times on segment 212 (a 1,140-character block quote)

Written to the job row by synth-worker at 11:06:47 AM · nothing has run since

ExtractingDone

5,230 words · v1-readability

Left 10:57:40 AM

SegmentingDone

318 segments

Left 10:58:06 AM

SynthesizingStopped

Segment 212 of 318

Stopped 11:06:47 AM

EncodingNot started

Waiting on synthesis

Not entered

What resuming will and will not redo

Read from the job row and its artifacts

Will not redo

Extraction5,230 words kept, from v1-readability
Segmenting318 segments kept; boundaries unchanged
Segments 1–211211 WAV files kept · 58.7 MB
Voice & pronunciationIdris, 9 rules, exactly as submitted

Will redo

Segment 212Partial audio discarded; synthesized again from its text
Segments 213–318106 segments never started
EncodingAll 318 segments, once synthesis completes
ExportAppears when the encoded file exists

Status endpoint

GET /api/jobs/9K2D/status
{
"id": "9K2D",
"state": "failed",
"stage": "synthesizing",
"position": { "done": 211, "of": 318 },
"unit": "segment",
"reason": "synthesis_timeout",
"detail": "3 attempts on segment 212",
"extractor": "v1-readability",
"updatedAt": "2026-09-17T11:06:47"
}

5TRW A Field Guide to Lost CanalsStalled

Stalled in encoding · no progress

Row last written 11:02:10 AM at chunk 23 of 49

Nothing has reported an error, so it is not marked failed. The screen stops with the row.

A real status endpoint

On screen

A job that stopped in synthesizing, with the stage and reason, exactly what resuming will and won't redo, the failed status response, and a different job shown as stalled in encoding with no progress.

The working screen polls the pipeline's actual position. No plausible-looking bar on a timer.

The working screen polls an endpoint that reads the job's actual row. If the pipeline stalls, the screen stalls with it and says so. That's the whole point: a progress bar that keeps moving through a dead job is worse than no progress bar. Failures surface the stage they failed in and the reason.

What shipped
  • Polls the job's real row; no timer-driven animation
  • A stalled pipeline shows as stalled
  • Failures name the stage and the reason
Jobs

Every job has an id, its own working view and, once there is output, its own export view

New job Thu, 09/17/2026DW
All 12Running 2Stalled 1Stopped 1Output ready 8 Find by id or titleNewest first

Today

Started a minute apart, 4HX8 and 4HX9 stay two jobs
JobTitleExtractorStateViews
7Q3MThe Salt Roads, part 4 Web page · DW · Maren · 10:38 AMv2-structuralEncoding · 41 of 58
9K2DLetters from Cwm Hollow Document · PN · Idris · 10:57 AMv1-readabilityStopped · synthesizing
5TRWA Field Guide to Lost Canals Web page · TA · Tove · 10:51 AMv2-structuralStalled · encoding
4HX9The Salt Roads, part 3 · retake Web page · DW · Idris · 10:22 AMv2-structuralEncoding · 12 of 56
4HX8The Salt Roads, part 3 Web page · DW · Maren · 10:21 AMv2-structuralOutput ready
3PLAEditor's letter, autumn Pasted text · TA · Anselm · 9:48 AMv3-plaintextOutput ready
3NQ7The Orchard Accounts Document · PN · Maren · 9:30 AMv1-readabilityOutput ready
2ZC4Night Ferries of the Firth Web page · TA · Tove · 9:12 AMv1-readabilityOutput ready
2YB1Readers write: on silence Pasted text · DW · Anselm · 8:55 AMv3-plaintextOutput ready
2WM6The Bellmakers of Tarn End Web page · PN · Idris · 8:40 AMv2-structuralOutput ready
2VK0Winter kitchens, a glossary Document · DW · Maren · 8:22 AMv1-readabilityOutput ready
2TJ3Salt Roads, part 2 Web page · DW · Maren · 8:04 AMv2-structuralOutput ready

Export · 4HX8

/jobs/4HX8/export

The Salt Roads, part 3

Maren · started 10:21 AM by Dana Whitcombe

Text from v2-structural

salt-roads-3_maren_4HX8.mp314.8 MB · 30:52 · MP3, mono

Encoded with FFmpeg and lamejs · job 4HX8 only

Download this job’s file

Stages

Extracting10:21:40 AM

Segmenting10:22:02 AM

Synthesizing10:25:31 AM

Encoding10:27:08 AM

4HX9 Part 3 · retakeEncoding

No export view yet

Idris, started a minute later. Its export appears at /jobs/4HX9/export when its own file exists.

On screen

The jobs list with every job's id, extractor and state, two renders of the same article started a minute apart kept apart, and the export view for the one that has output.

Per-job screens

Every job has an id, a working view and an export view, so two renders never get confused for each other.

Identity is what makes a long render survivable. The job is addressable, so it can be linked, reopened on another device or picked up tomorrow, and two jobs started a minute apart can't be mistaken for each other. The export view appears only once there's something to collect, so it never sits empty as a promise from the moment work begins.

What shipped
  • Per-job id with its own working and export routes
  • A job survives being closed, linked, or reopened elsewhere
  • The export view appears only when there is output

Explicit cleanup

Artifacts are listed and deleted deliberately. An audio pipeline fills a disk faster than anyone expects.

Intermediate WAVs, segment files and encoded output are listed with their sizes and deleted deliberately by a person. An audio pipeline fills a disk far faster than anyone plans for, and a silent background cleanup that removes the wrong artifact is worse than a full disk. Retention is stated per job type, never assumed.

What shipped
  • Artifacts listed with sizes and deleted deliberately
  • No background sweep that could remove the wrong file
  • Retention stated per job type
Artifacts

Intermediate WAVs, segment files and encoded output, listed with sizes and deleted by a person

Group by job Thu, 09/17/2026DW

On disk, listed

41.7 GB

Across 58 jobs with artifacts

Selected

177.3 MB

2 artifacts from job 4HX8

Background sweep

None

Nothing is removed on a timer

Locked

3

In use, or needed to resume

By job

Sizes read from disk
ArtifactKindFilesSizeStatus
4HX8The Salt Roads, part 3· Full narrationExported 10:52 AM
full.wavIntermediate WAV188.9 MBListed for deletion
segments/Segment files30288.4 MBListed for deletion
salt-roads-3_maren_4HX8.mp3Encoded output114.8 MBKept 90 days
9K2DLetters from Cwm Hollow· Full narrationStopped in synthesizing
segments/Segment files21158.7 MBNeeded to resume
7Q3MThe Salt Roads, part 4· Full narrationEncoding
segments/Segment files31891.6 MBIn use · running job
full.wavIntermediate WAV192.3 MBIn use · running job
3PLAEditor's letter, autumn· PreviewExported 9:54 AM
segments/Segment files416.1 MBListed after 7 days
preview_3PLA.mp3Encoded output12.3 MBListed after 7 days
2 selected · 177.3 MBEncoded output for 4HX8 is not selected Review deletion

Retention by job type

Stated, not assumed

Full narration

Intermediate WAV and segments listed once the output is exported. Encoded file kept 90 days.

Preview

Everything listed for deletion 7 days after export.

Retake

Segments kept until the retake is exported or abandoned.

“Listed” means shown here for a decision. Nothing is removed until someone deletes it.

Delete from job 4HX8?

full.wav88.9 MB

segments/ · 302 files88.4 MB

salt-roads-3_maren_4HX8.mp3Kept

Only these two, only from this job. The exported file and every other job stay as they are.

CancelDelete 177.3 MB
On screen

Artifacts by job: intermediate WAVs, segment files and encoded output with sizes, files locked while a job runs or needs them to resume, retention stated per job type, and a deletion someone has to confirm.

Introduction

What we were brought in to do

The production tool turns written material into narrated audio at volume. We built the extraction, the conversion pipeline, and the job surface that turns a multi-minute render into something a producer can watch instead of guess at.

A publisher turning written material into narrated audio at volume, where a single render takes minutes and a producer running a queue needs to know whether to wait or go do something else. Two genuinely hard problems sat side by side: getting clean text out of arbitrary source material, and reporting the progress of a long job honestly.

Backend & Pipeline Engineering

Where the old way broke

  1. 01

    Two hard problems sat side by side. Getting clean text out of arbitrary source material is genuinely difficult: the extractor was rewritten three times. And a render that takes minutes can't hide behind a spinner, because a producer needs to know whether to wait or go and do something else.

    Extraction was the one that fought back. Source material arrived as web pages, documents and pasted text, and every strategy that worked well on one kind produced garbage on another. The extractor was rewritten three times before the answer turned out to be keeping all three. Meanwhile the working screen showed a spinner, which on a six-minute job looks exactly like a hang.

    A staged pipeline with a real status endpoint behind it, so the working screen shows which stage a job is in and roughly what remains, plus an export screen that appears only when there's something to collect.

The solution

What we built together

  1. 01

    Built extraction as a versioned endpoint and kept all three versions live while they were compared

    Extraction is a versioned endpoint with all three strategies live, and each job records which one produced its text, so a bad render can be traced to its source.

  2. 02

    Split conversion into named stages so progress means something specific

    Conversion is split into extracting, segmenting, synthesizing and encoding, because those stages run for different lengths and one averaged percentage across them lies.

  3. 03

    Polled a real status endpoint in place of an indeterminate bar

    The working screen polls the job's actual row, so a stalled pipeline shows as stalled instead of animating on through a dead job.

  4. 04

    Gave every job an id and its own working and export screens

    Per-job routes replaced a single working screen that showed whichever render started last, which had already caused two producers to collect the wrong file.

  5. 05

    Cleaned up artifacts explicitly, because an audio pipeline fills a disk quickly

    Artifacts are listed with their sizes and deleted deliberately. No background job gets the chance to remove the wrong file.

Process

Phase by phase

  1. Phase 1: Extraction

    Three Attempts, Kept Side By Side

    Getting clean prose out of arbitrary sources took three distinct approaches. We kept all three versioned and live, so their output could be compared on real material.

    • Extraction v1/v2/v3
    • Comparison Harness
    • Source Handling
  2. Phase 2: The Pipeline

    Named Stages, Not A Spinner

    Split conversion into stages that mean something (extracting, segmenting, synthesizing, encoding), so the interface can say exactly what's happening.

    • Stage Model
    • FFmpeg Encoding
    • Segment Handling
  3. Phase 3: Honest Status

    A Job You Can Watch

    Built a status endpoint the working screen polls, so progress reflects the pipeline's actual position. No animation timed to look plausible.

    • Status Endpoint
    • Working Screen
    • Stage Progress
  4. Phase 4: Collection and Cleanup

    Export, Then Tidy Up

    Gave finished jobs an export screen and made deletion explicit, because an audio pipeline that never cleans up is a disk-space incident waiting for a quiet week.

    • Export Screen
    • File Listing
    • Explicit Deletion
New narration job

Choose the source and how it should sound · everything here is fixed on the job before it runs

Thu, 09/17/2026DW

Source

Web page, document or pasted text
Web page Document Pasted text

Page

Longreads › The Salt Roads › Part 5

Extractor

Recorded on the job either way
v1Readability heuristicsJSDOM
v2Structural extractionMatches known shape: Longreads article
v3Plain textNo DOM

Job type

Full narrationPreviewRetake

Retention: intermediates listed once exported · encoded file kept 90 days

Extracted text preview · v2-structural4,406 words · 57 ¶

Part 5: Brine and Brennock

The spring thaw reopened the road in a single week, and the first cart through carried no salt at all, only a letter for the clerk at Aldmoor and a barrel of brine from the pans at Brennock.

Start jobGets its own id, a working view now and an export view once there is output.

Voice, pronunciation & pacing

Set before the job runs

Voice

MarenWarm, measured · long reads
IdrisLow, unhurried · essays
ToveBright, quick · guides
AnselmDry, close · letters

Pronunciation

14 rules · The Salt Roads series
WrittenSay asScope
AldmoorAWLD-moreSeries
Cwm Hollowkoom HOL-ohPublication
BrennockBREN-ukSeries
1841eighteen forty-oneThis job
c.circaPublication

Pacing

Rate0.96×
Pause after paragraph650 ms
Pause after heading1.2 s
On screen

Submitting a source for narration: web page, document or pasted text, the extractor and job type, then voice, pronunciation and pacing, all set before the job runs.

Operational results after launch

3

Extraction strategies compared

4

Pipeline stages surfaced

0

Indeterminate spinners

−80%

Producer check-ins per job

Extraction strategies compared and pipeline stages surfaced are counts describing the system. Indeterminate spinners is zero by construction: every long-running surface polls a real status. Producer check-ins per job is the publisher's own estimate of how often someone opened a job to see whether it was still alive, before and after.

Client name withheld under NDA. Figures are approximate, drawn from the engagement’s own reporting.

About our collaboration

  1. 01

    Extraction was rebuilt three times and we kept all three versions live, which is how the comparison on real source material was possible at all. The status endpoint came before the working screen, because a progress bar with nothing behind it is worse than no progress bar.

    Time and materials, because the extraction work genuinely couldn't be estimated. Nobody would have signed up in advance to three rewrites. Keeping the strategies versioned and live is what made them comparable, and it's the decision the engagement's shape allowed.

What we'd carry into the next one

04
  1. 01

    A spinner on a six-minute job isn't a loading state. It's a refusal to answer.

    A spinner on a six-minute job answers no question a producer has. The only useful signal is which stage it's in and whether that stage is moving.

  2. 02

    When extraction is the hard part, version it and keep the versions comparable. Overwriting them destroys the evidence.

    When extraction is the hard part, overwriting each attempt destroys the evidence. Keeping them versioned is what made the comparison possible at all.

  3. 03

    Progress is only useful if the stages are named things a user recognizes.

    Named stages work because a producer recognizes them. A percentage only means something if the units behind it are equal, and here they aren't.

  4. 04

    A pipeline that produces files needs a deletion story on day one, well before the first disk alert.

    Audio fills a disk faster than anyone plans for, and a cleanup story added after the first alert gets written in a hurry and usually deletes something it shouldn't.

One six-minute job, three progress screens

A spinner can’t tell slow from stuck. A named stage can.

The same job replayed through the old spinner, a percentage that gives each stage a quarter, and the named stages the working screen now reads from the job’s row. Turn on the stall and see which screen notices. Switch tabs, or use the arrow keys once one is focused.

Job 7Q3M · The Salt Roads, part 4 · elapsed 5:00 of a six-minute jobRuns to completion

Encoding · chunk 29 of 58

Extracting4,812 of 4,812 words
Segmenting318 of 318 segments
Synthesizing318 of 318 segments
Encoding29 of 58 chunks

GET /api/jobs/7Q3M/status → "state": "running", "stage": "encoding", "position": 29/58

Illustrative stage durations for one six-minute job (extracting 0:20, segmenting 0:25, synthesizing 3:15, encoding 2:00), chosen to show that the stages differ in length. Not measured. Replayed fast.

Architecture

From a submitted source to the right file in the right producer’s hands

A render takes minutes, so every step leaves something a person can read: which extractor ran, which stage the job is in and where inside it, and which files exist and how big they are.

  1. 01 · Source
    Web page, document or pasted textSubmitted as a job with its own id, so it can be linked, reopened on another device or picked up tomorrow.
  2. 02 · Extract
    Versioned extractionReadability over the DOM, Cheerio against known site shapes, or plain text, all kept live. The job records which one produced its text.
  3. 03 · Convert
    Segment, synthesize, encodeNamed stages, each writing its own transition. Long stages report their internal position; encoding runs through FFmpeg.
  4. 04 · Truth
    Job row + status endpointThe working screen polls the job's actual row, not a timer. A stall shows as stalled; a failure names its stage and reason.
  5. 05 · Collect
    Per-job export + artifactsExport appears only once there is output. Artifacts are listed with sizes and deleted deliberately, never by a background sweep.

Right file, live job, known disk

Job identity, honest status & deliberate cleanup

One id per job, one export per id

Every job has its own working and export routes. They replaced a single screen that showed whichever render started last, which had already led two producers to collect the wrong file.

A dead job looks dead

The status endpoint reads the job's actual row, so a stalled pipeline shows as stalled and stops moving. A failure surfaces the stage it failed in and the reason.

Deletion is a decision

Intermediate WAVs, segment files and encoded output are listed with their sizes and deleted deliberately. There is no background sweep to remove the wrong artifact, and retention is stated per job type.

Turning written content into audio, or running long jobs that need to say where they are? Scope your build in 3 minutes.

Scope your build
Have a project?

Let's talk

Running a large platform, shaping a first MVP, or getting a product ready for a funding round? Tell us where you are. We'll shape the process around it, and stay with you after launch.