Show original
Try the app
Enjoyed this article?
Use "Request tipping" to ask the author to set up tip receiving.
AI translation
きっかけ 技術記事やドキュメント、下書き中の企画書——読みたいMarkdownファイルは手元にたくさんあるのに、画面に向かって目で追う時間がなかなか取れない。家事をしながら、通勤しながら、あるいは目を休めながらでも中身を頭に入れたい。 そんな動機から生まれたわけではないのが「読み…
Try the app
Enjoyed this article?
Use "Request tipping" to ask the author to set up tip receiving.
I have plenty of Markdown files I want to read—technical articles, documentation, draft proposals—but I can't find the time to sit in front of a screen and read them. I want to absorb the content while doing housework, commuting, or resting my eyes.
But that's not actually what motivated "Markdown Voice Reader." It's a simple web app that loads Markdown files, displays them beautifully formatted as usual, and reads the text aloud.
This was KOUSUKE's idea.
There are really just two main things it does.
1. Display Markdown properly It supports all standard Markdown syntax: headings, lists, checklists, tables, code blocks, and blockquotes. You can load files by selecting them, dragging and dropping, or specifying a URL directly.
2. Tell you where you are while reading aloud When playback starts, the current sentence is highlighted. Even without following along visually, you can listen and know "I'm here right now," so you won't get lost even if you suddenly look at the screen while listening passively. Click anywhere in the text and it starts reading from there immediately.
You can choose from two speech engines:
Beyond that, you can customize all the fine details: whether to say "heading" before headings, whether to announce "there's a link," whether to skip code blocks—all according to your preferences. It's subtle, but this is the part that matters most for creating a document that's actually meant to be heard.
After rendering Markdown to HTML, tags like <strong> and <a> get mixed in throughout the text. To "read and highlight one sentence at a time," I needed to find the exact sentence boundaries while keeping that complex HTML structure intact, then wrap them with tags.
Using Range.surroundContents() straightforwardly caused browser errors in ordinary cases like "the start of a sentence happens to begin inside <strong>" (because the element would be considered "partially contained"). I ultimately solved it by switching to a method that extracts content with extractContents() and then wraps it back. It's a subtle issue, but unavoidable when creating a "listening" experience without breaking the text's appearance.
Dragging the seekbar during playback causes it to stutter and not jump to the intended position—a common complaint with these kinds of apps. The cause was simple: I was sending playback commands to the speech engine every time a input event fired during dragging, causing async operations to conflict.
The fix was to update only the visual position during dragging, then execute the actual seek only once when the user releases. I also added a "playback token" mechanism so that old speech-completion events issued before the seek don't interfere with the new playback.
Browser-standard speech synthesis may pause when the tab goes to the background per spec (especially on iOS Safari). I've added a keep-alive process that calls resume() periodically, but I can't guarantee it completely. The ElevenLabs engine, on the other hand, plays actual audio files through <audio> elements, so it works well with the Media Session API and can even respond to lock screen controls. "If something can't be done, say so honestly"—it's subtle, but an attitude I valued.
The frontend is a single HTML file with minimal dependencies. It uses marked for Markdown parsing, DOMPurify for sanitization, highlight.js for code display, KaTeX for math, and Mermaid for diagrams. To avoid passing ElevenLabs API keys to the browser at all, I route calls through a single Cloudflare Worker that all users share. Combined with GitHub Pages, it runs with almost zero server management.
This isn't an app packed with big features. Rather, I tried to face head-on the subtle but universal inconvenience of "wanting to read but not being able to." Why not turn the Markdown on your desk into a document you can listen to, starting today?
技術記事やドキュメント、下書き中の企画書——読みたいMarkdownファイルは手元にたくさんあるのに、画面に向かって目で追う時間がなかなか取れない。家事をしながら、通勤しながら、あるいは目を休めながらでも中身を頭に入れたい。
そんな動機から生まれたわけではないのが「読み上げリーダー(Markdown Voice Reader)」です。Markdownファイルを読み込むと、見た目はそのままきれいに整形して表示しつつ、文章を音声で読み上げてくれる、シンプルなWebアプリです。
KOUSUKEさんの案デス、、
やっていることは大きく分けて2つだけです。
1. Markdownをちゃんと表示する 見出し、リスト、チェックリスト、表、コードブロック、引用——一通りのMarkdown記法に対応しています。ファイルを選ぶか、ドラッグ&ドロップするか、URLを直接指定するだけで読み込めます。
2. 読み上げながら、今どこを読んでいるか教えてくれる 再生を始めると、今読んでいる一文がハイライトされます。文章を目で追わなくても、聞きながら「今ここだな」と把握できるので、ながら聞きから急に画面を見ても迷いません。文中の好きな場所をクリックすれば、そこから読み上げがすぐ始まります。
読み上げエンジンは2種類から選べます。
そのほか、見出しの前に「見出し」と読み上げるかどうか、リンクを「リンクがあります」と案内するか、コードブロックを読み飛ばすか——といった細かい読み方のルールも、全部自分好みに設定できます。地味ですが、実際に「聞く」文書として仕上げるには一番効いてくる部分です。
Markdownをレンダリングした後のHTMLには、<strong>や<a>のようなタグが文の途中に混ざります。「一文ごとに読み上げてハイライトする」ためには、この入り組んだHTML構造をそのままに、文の切れ目だけを正確に見つけてタグで囲む必要がありました。
素直にRange.surroundContents()を使うと、「文の先頭がちょうど<strong>の内側から始まっている」ようなごく普通のケースで、ブラウザの仕様上エラーが出ることが分かりました(該当要素が“部分的に含まれている”とみなされるためです)。最終的にはextractContents()で中身を取り出してから包み直す方式に切り替えて解決しています。地味な話ですが、テキストの見た目を一切崩さずに「聞く」体験を作るには避けて通れない部分でした。
読み上げ中にシークバーをドラッグすると、位置がガクガクして思った場所に飛ばない——というのは、この手のアプリでよくある不満点です。原因は単純で、ドラッグ中に発生するinputイベントのたびに読み上げエンジンへ再生指示を送ってしまい、非同期処理同士が競合していたことでした。
対策として、ドラッグ中は見た目の位置だけを更新し、指を離した瞬間に一度だけ実際のシークを実行するように変更。さらに「再生トークン」という仕組みを入れて、シーク前に発行された古い読み上げ完了イベントが新しい再生を邪魔しないようにしました。
ブラウザ標準の音声合成は、タブがバックグラウンドになると仕様上一時停止されることがあります(特にiOS Safari)。定期的にresume()を呼ぶ延命処理は入れていますが、完全な保証はできません。一方でElevenLabsエンジンは実際の音声ファイルを<audio>要素で再生しているため、Media Session APIとも相性がよく、ロック画面からの操作にも対応できました。「できないことは、できないと正直に書く」——地味ですが大事にした姿勢です。
フロントエンドは依存を最小限に抑えた単一のHTMLファイルです。Markdownのパースにmarked、サニタイズにDOMPurify、コード表示にhighlight.js、数式にKaTeX、図にMermaidを使っています。ElevenLabsのAPIキーをブラウザに一切渡さないよう、呼び出しはCloudflare Workerを1つ挟んでいて、これは全利用者が共有する構成です。GitHub Pagesと組み合わせれば、サーバー管理もほぼゼロで動かせます。
大きな機能を詰め込んだアプリではありません。むしろ「読みたいのに読めない」という、地味だけれど誰もが一度は感じたことのある不便さに、できるだけまっすぐ向き合ったつもりです。手元のMarkdownを、今日から「聞く」文書にしてみませんか。