Build Speechify

KINDAreplaces $29/mosaves $348/yrback to the verdict

0%0 of 20 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

Import PDFs, EPUBs and web pages, extract clean readable text, speak it through a local neural text-to-speech server, highlight the sentence being spoken, control speed, and remember where you stopped in every document.

estimated effort weekendthe files for this build are in the project pack

RuntimeNode 22 with a small HTTP server and a browser front endSpeechKokoro served behind an OpenAI-compatible endpoint, with Piper as the lighter fallbackExtractionpdfjs for PDF, epub parsing for EPUB, and Readability for web pagesSegmentationSentence splitting with Intl.SegmenterStorageSQLite for the library and progress

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    Why Runs the server and the import pipeline.

    Get it nodejs.org, or nvm install 22. open ↗

    Verify node --version prints v22 or higher

  2. installfree, both are open source; the cost is memory and CPU or GPU time

    Why The whole point. Kokoro sounds considerably better; Piper is far lighter and runs comfortably on a laptop with no GPU.

    Get it Run Kokoro behind its OpenAI-compatible server image, or install Piper and a voice model. Try both before choosing, because the quality difference is large and so is the resource difference.

    Verify A curl to the speech endpoint returns playable audio

  3. have readyfree

    Why Kokoro wants materially more than Piper, and a model that swaps produces synthesis slower than real time, which changes the design.

    Get it Check available memory before choosing. If the ratio measured in Phase 1 is above one, use the lighter engine.

    Verify The synthesis ratio measured in Phase 1 is below one

  4. have readyfree

    Why Phase 2 is only meaningful against real files: a two column academic PDF, an EPUB with chapters, and a web article behind a cookie banner.

    Get it A paper from an open archive, a public domain EPUB, and any news article. Keep them as fixtures.

    Verify All three are saved in a fixtures directory

  5. have readyfree

    Why Cached sentence audio for a long book adds up quickly, and a full disk mid-playback is an unpleasant failure.

    Get it Check free space and set the cache limit in configuration accordingly.

    Verify The configured cache limit is below your free space

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- documents(id, title, source_kind, source_path, imported_at, language) · source_kind is pdf, epub, web or text
- blocks(id, document_id, ordinal, kind, text) · kind is heading, paragraph or caption, in reading order
- sentences(id, block_id, ordinal, text, char_start, char_end) · the highlight and playback unit
- audio_cache(sentence_id, voice, speed, path, duration_ms) · synthesis is slow enough to be worth caching
- progress(document_id, sentence_id, updated_at) · one row per document, so resuming is exact
- Extraction happens once at import. Playback never reparses the source file.

Environment variables

These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.

VariableNeededExampleWhere the value comes from
TTS_ENDPOINTrequiredhttp://localhost:8880/v1/audio/speechYour local speech server. The OpenAI-compatible shape is what makes engines swappable.
TTS_VOICErequiredaf_skyVoice identifier from your engine's voice list.
TTS_ENGINErequiredkokorokokoro or piper. Chosen by the ratio measured in Phase 1.
PREFETCH_SENTENCESrequired4How far ahead to synthesise. Derived from the synthesis ratio, not guessed.
AUDIO_CACHE_DIRrequired./data/audioWhere cached sentence audio is written. Safe to delete.
AUDIO_CACHE_MAX_GBrequired5Cache ceiling. Oldest entries are evicted first.
DB_PATHrequired./data/reader.dbLibrary, documents, sentences and progress. This is the backup.

The build, in order

  1. A local voice that speaks a sentence

    Text goes in, audio comes out, from a server on your own machine.

    1. Start Kokoro behind its OpenAI-compatible endpoint, or Piper if your machine is modest. Confirm with a curl before writing any application code.

      terminal
      curl -s http://localhost:8880/v1/audio/speech -H 'Content-Type: application/json' -d '{"model":"kokoro","input":"Hello there.","voice":"af_sky"}' --output test.wav
    2. A single synthesize function taking text, voice and speed. Every engine difference lives behind it, which is what makes Piper a fallback rather than a rewrite.

      Files src/tts.ts

    3. Time synthesis against the duration of the audio produced, across ten sentences of varying length. Write the ratio in the README.

      Files README.md

    done when · tick each as it passes
    watch out
    • If synthesis is slower than real time, streaming sentence by sentence will stutter and you must synthesise ahead. Measure the ratio now rather than discovering it during Phase 4, when the fix is a queue you did not plan for.
  2. Getting clean text out of real files

    A PDF, an EPUB and a web page all become the same clean block structure.

    1. pdfjs gives positioned text items. Cluster their x positions per page to detect columns, then order within each column before moving to the next.

      Files src/import/pdf.ts

    2. Text appearing at the same position on many pages is a header or a footer. Detect by repetition across pages rather than by guessing margins.

      Files src/import/pdf.ts

    3. EPUB is structured already, so preserve its heading levels. For web pages, use Readability to get the article, then convert to the same block shape.

      Files src/import/epub.tssrc/import/web.ts

    done when · tick each as it passes
    watch out
    • PDF has no concept of reading order. Text is positioned glyphs, and a two column layout extracted naively interleaves the columns into nonsense. Detect columns by clustering horizontal positions, and test on an academic paper rather than a single column report.
  3. Sentences and synchronised highlighting

    The highlighted sentence is the sentence you are hearing, all the way through a long document.

    1. Intl.Segmenter with the sentence granularity, storing character start and end so the highlight maps back to the rendered text exactly.

      Files src/segment.ts

    2. Play a sentence, wait for the ended event, then advance. No timers anywhere in the playback loop.

      Files src/player.ts

    3. Clicking sets the position and starts from there, which is also how you will test that offsets are right.

      Files src/reader.ts

    done when · tick each as it passes
    watch out
    • Driving the highlight from a timer and an estimated duration drifts, and by the third chapter the highlight is a paragraph away from the audio. Advance on the audio element's ended event, which cannot drift because it is the audio itself telling you.
  4. Playing ahead so it never stutters

    Playback is continuous, and speed changes take effect without a gap.

    1. Keep a few sentences ahead synthesised. The right number is derived from the ratio you measured in Phase 1, not guessed.

      Files src/prefetch.ts

    2. The cache key must include voice and speed or a speed change will replay stale audio.

      Files src/cache.ts

    3. Abort in-flight synthesis when the user jumps. Otherwise the queue delivers audio for a position nobody is at any more.

      Files src/prefetch.ts

    done when · tick each as it passes
    watch out
    • Changing playback rate on the audio element is instant and free but raises pitch on some engines' output; re-synthesising at a different speed sounds better and costs a delay. Pick one deliberately, and if you use the element's rate, check whether your engine's audio survives it.
  5. Library, progress and export

    You can leave mid-chapter and come back a week later exactly where you were.

    1. Documents with progress as a proportion and the last read time. Sorted by recency, because that is how people actually return to reading.

      Files src/library.ts

    2. Store the sentence id. On re-import, detect that ids changed and tell the user rather than guessing.

      Files src/progress.ts

    3. Concatenate cached sentence audio into one file. Clearing the cache must never touch documents or progress.

      Files src/export.ts

    done when · tick each as it passes
    watch out
    • Storing progress as a character offset breaks the moment a document is re-imported and extraction changes slightly. Store the sentence identity, and if re-import produces different sentences, say so rather than silently resuming somewhere wrong.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more reading.