記事をブラウザーの読み上げで聞く /listen を作った #

issue #95 のタイトルは「記事などを聞きたい」だった。本文には、TTS の仕組みを用意すればよいこと、preview のリンクのところを「more tools」にしてそこに読み上げのページへのリンクを置くこと、記事のファイルパスを fzf のような UI で選べるとよいこと、速度や音程などを調整できること、そして DOM に入れた要素を読み上げる形にすれば preview と同じように任意のテキストも読める、という考えが書かれていた。

ユーザーは「ui ややることまで含めて決まってないことを決めてから実装に入りたい」と言った。この記事は、決めたこと、作ったもの、確かめたことの記録だ。

先に決めたこと #

Claude Code が、名前の候補、TTS で設定できる項目、UI の案、決めてほしいことを並べた。ユーザーが答えたのは次のことだ。

  • 名前は listen。/preview と並べて「見て確かめる / 聞いて確かめる」の対になる、という案をそのまま採った
  • 設定できるのはブラウザーの TTS が持つ項目だけでよく、ほかは任意にする。Web Speech API の SpeechSynthesisUtterance が持つのは、声・言語・速度・音高・音量の 5 つだけだ
  • ヘッダーは、preview があった場所を <details> のドロップダウン「more tools」にし、中に preview と listen を置く。記事ページには何も足さない
  • 何を読むか: コードブロックは読まない。リンクはテキストだけ読む。ほかはそのまま読む。見出しの前後にも間を入れない(間は、あとで設定できるようにする任意の項目に回した)

文に区切って読むことは、ユーザーに選ばせずに決めた。Chrome では長い発話が途中で止まるという話が一般に知られていて、区切るかどうかを設定にする意味が薄いと考えたからだ。ただしこの問題は、手元のブラウザーで再現させて確かめてはいない。

作ったもの #

/listen のページは、読む内容を #listen-out という要素に入れ、そこにあるブロック要素を順に読む。内容の入れ方は 2 つある。

  • ファイルを選ぶと、そのページを fetch して、中の article 要素を #listen-out に移す
  • 「Or paste Markdown」に Markdown を貼ると、preview と同じ preview.wasm で描画して #listen-out に入れる

issue に書かれていたとおり、DOM を入力にしたので、どちらの入れ方でも読む側の処理は同じになった。Shadow DOM は使っていない。サイトの CSS でそのまま描きたかったのと、読んでいる文の位置を DOM の Range で指すのに、普通の DOM のほうが扱いやすかったからだ。

読み上げは listen.js 1 本で、Go 版と MoonBit 版のサーバーは同じファイルを配信する。サーバー側に足したのは、ページを返すことと、そのスクリプトを配信することだけだ。

何をどう区切るか #

段落、見出し、リストの項目、表のセルをそれぞれ 1 つのブロックとして読む。リストの中に入れ子のリストがあれば、入れ子の項目は別のブロックになる。pre、svg、img、ボタン、見出しの横のパーマリンクは読まない。

ブロックの文字列は Intl.Segmenter で文に区切り、120 文字を超える文は読点や空白のところで切る。読んでいる文は CSS Custom Highlight API(CSS.highlights)で強調表示し、そのブロックの左端に線を引いてページを自動でスクロールする。段落をクリックすると、その段落の先頭から読む。

ファイル名で絞り込む #

最初は <datalist> に、パスを値、タイトルをラベルとして入れていた。ユーザーの指摘は「fuzzy find したいのはファイル名ですね。タイトルは日本語なので絞り込みに使いづらい」だった。

そこで <datalist> をやめ、サーバー側で全ページを「パスを文字にしたリンクと、横に小さくタイトル」の一覧として出し、listen.js がパスだけで絞り込むようにした。絞り込みは fzf と同じ考え方にした。空白で区切った単語ごとに、文字が順番どおりにパスに含まれていれば候補として残す。

最初の実装は、mb wasm で moonbit-workers-wasm.md を探すと、wasm の m を末尾の .md の m に当てていた。文字をパスの後ろから順に当てていたためだ。単語がそのまま含まれていればそこを優先するように直した。

読みの置換と設定の保存 #

ユーザーは、読みの置換は欲しく、そのために設定の保存も要りそうだと言った。置換は 1 行に 1 つ、パターン => 読み と書く。読みを空にすると、その部分は読まない。/.../flags と書けば正規表現、それ以外は書いたとおりの文字列として扱う。初期値は、絵文字を読まない規則と、URL を「URL」と読む規則の 2 つにした。声・速度・音高・音量と置換規則は localStorage に保存する。

置換は、読み上げる文の文字列にだけ当てる。画面の表示は変えないので、強調表示の位置はずれない。

これを確かめる途中で、URL の規則が効かない文が見つかった。Intl.Segmenter が URL の ? の後ろで文を区切るので、規則を当てる時点で URL がもう 2 つの文に分かれていたのだ。区切りの直後に空白がなく、しかも「。!?」以外で切れている場合は、次の文とつなげるようにした。

修正前(区切ってから規則を当てる) 修正後(空白のない区切りはつなげる) 詳細は https://example.com/a? b=1 を見る。 詳細は https://example.com/a?b=1 を見る。 詳細は URL b=1 を見る。 詳細は URL を見る。 区切られた文 読み上げる文 区切られた文 読み上げる文

枠だけの箱は区切られた文、塗った箱は規則を当てたあとに読み上げる文だ。修正前は、URL の後半の b=1 が規則の外に残っていた。英語の Hello. This is English! OK? Done. は区切りの直後に空白があるので、修正後もこれまでどおり 4 つの文に分かれる。

見た目の調整 #

ボタンについて、ユーザーから「play 以外のボタンは .outline が良いかも」「ボタンも .grid の方が隙間ができて良い」という提案があった。Play だけを塗りつぶし、Pause と Stop を pico.css の枠線だけのボタンにして、3 つを .grid に並べた。pico.css の .grid は幅 768px 未満で 1 列になるので、スマホの幅ではボタンが縦に積まれる。ここはまだそのままにしている。

「more tools」については 2 回直した。

1 回目。<details class="dropdown"> の summary は、pico.css ではフォームのセレクトボックスのように描かれ、隣のリンクと見た目が揃わなかった。pico.css は role 属性のない summary にだけその見た目を当てるので、role="link" を付けてリンクと同じ見た目にした。

2 回目は、ユーザーが Chrome と Firefox のスクリーンショットを貼って「sub item の表示が不思議な感じかも」と言ったときだ。Chrome では項目が右寄せになり、Firefox ではメニューが「more tools」の下からずれた位置に出ていた。原因は 2 つあった。

  • メニューを右端に揃えるために付けた dir="rtl" が、項目の文字まで右寄せにしていた
  • pico.css はナビの中のドロップダウンを display: inline で並べる。inline 要素を基準にした絶対配置は、Chrome と Firefox で置かれる位置が違った

dir="rtl" を外し、site.css でこのドロップダウンを inline-block にして、メニューを右端に揃えた。修正後、Playwright で Chromium と Firefox の両方を開いて位置を測ると、メニューの左端と右端はどちらも 839px と 952px で一致した。

どう確かめたか #

ヘッドレスの Chromium を Playwright で動かして確かめた。音を聞く代わりに、speechSynthesis を、読む文を記録して 5ms 後に「読み終わった」と返す偽物に差し替えた。差し替えでは 2 回つまずいた。

  • window.speechSynthesis = ... と代入しても差し替わらず、本物が動いていた。macOS のヘッドレス Chromium には、Kyoko などの本物の声が 100 以上あった。Object.defineProperty で差し替えた
  • 偽の声のオブジェクトを本物の SpeechSynthesisUtterance の voice に入れると、型が違うというエラーになった。SpeechSynthesisUtterance も偽のクラスにした

確かめた結果は次のとおりだ。

確かめたこと結果
slops の記事 1 本(subrequest の上限の記事)を読む107 文に分かれ、最長は 119 文字。コードブロックの中身は読まなかった
速度 1.5 と声の設定各文に rate 1.5 と Kyoko(ja-JP)が付いていた
3 つ目の段落をクリックするその段落の先頭の文から読み始めた
見出し、リンク入りの段落、コードブロック、入れ子のリストを貼る見出し / 本文です。 / リンクも読む。 / 項目一 / 入れ子 の順に読み、コードブロックは飛ばした
subreq で絞り込むslops/2026-10-28-workers-subrequest-limit.md の 1 件だけが残った
置換規則 MoonBit => ムーンビット と /v(\d+)\.(\d+)/ => バージョン$1.$2それぞれ置き換わった。誤った規則 /(/ => x は「line 8」と表示された
声と速度と規則を変えて再読み込みするすべて残っていた

途中で、確かめ方そのものを間違えた。「初期値に戻す」ボタンを押したあと、入力欄に ムーンビット が含まれていないことで戻ったと判定していたが、初期値のコメントにある例が MoonBit => ムーンビット だったので、判定は常に失敗していた。判定に使う文字列を、初期値に含まれない バージョン に変えて確かめ直した。

この表は、偽の読み上げで確かめた結果だ。実際の音については、ユーザーがブラウザーで試して「音は出てた」と伝えてくれた。

まだ確かめていないこと #

  • 長い記事を、途中で止まらずに最後まで実際の音で聞けるか。文に区切ったのはこのためだが、効いているかは聞いて確かめていない
  • 一時停止と再開が、ブラウザーごとに正しく効くか
  • MoonBit 版を Worker(make dev)で動かしたときの /listen。テストと make check は通っているが、ブラウザーで開いてはいない

見出しの前後などの間を設定できるようにする件は、任意の項目として TODO.md に残っている。