TweetDeckを読み上げるやつ

追記: 改良しました roodni.hatenablog.com

JavaScriptにはWeb Speech APIとかいうものがあって、ブラウザを喋らせることができます。

Web Speech APIを利用してTweetDeckのホームタイムラインを読み上げさせるReadTweetDeckというChrome拡張があるんですが、これを私の趣味にあわせて勝手に改造してブックマークレットにして使っています。

github.com

↑このコードをコピーしてブックマークのURLとして登録すると使えるはずです。TweetDeckのタブで実行すると読み上げが開始され、再度実行すると読み上げを中止します。

圧縮していないソースコード(折り畳み)

(()=>{
const run = () => {
  const stateKey = Symbol.for("read");

  if (window[stateKey] === undefined) {
    const voices = speechSynthesis.getVoices().filter(voice => voice.lang === "ja-JP");
    if (voices.length === 0) {
      alert("声の取得に失敗しました.再度試してください.");
      return;
    }
    const num = prompt(`声を選択してください\n${
      voices.map((v, id) => `${id}: ${v.name}`).join("\n")
    }`, "0");
    if (num === null) { return; }

    const voice = voices[parseInt(num)] || voices[0];

    const createUttr = (name, text) => {
      const pitchTableKey = Symbol.for("pitch");
      if (window[pitchTableKey] === undefined) {
        window[pitchTableKey] = new Map();
      }
      const pitchTable = window[pitchTableKey];
      let pitch = pitchTable.get(name);
      if (pitch === undefined) {
        pitch = Math.random() * 0.4 + 0.8;
        pitchTable.set(name, pitch);
      }
      console.log("name = %s, pitch = %f", name, pitch);

      const uttr = new SpeechSynthesisUtterance(text);
      uttr.pitch = pitch;
      uttr.voice = voice;
      return uttr;
    };

    speechSynthesis.speak(createUttr("", "読み上げをはじめます"));

    const extractText = node => {
      const tweetElement = node.firstElementChild.firstElementChild;
      const name = tweetElement.getElementsByClassName("fullname")[0].textContent;
      const text = tweetElement.getElementsByClassName("js-tweet-text")[0].textContent;
      return {name, text};
    };

    const isHomeTimeline = container => container.parentNode.parentNode.previousElementSibling.textContent.trim().substr(0, 4) === "Home";

    const target = document.getElementsByClassName("js-chirp-container");
    const observers = Array.from(target).filter(isHomeTimeline).map(column => {
      const observer = new MutationObserver(mutations => {
        mutations.forEach(mutation => {
          Array.from(mutation.addedNodes).reverse().forEach(tweetNode => {
            const {name, text} = extractText(tweetNode);
            speechSynthesis.speak(createUttr(name, speechSynthesis.pending ? text.substr(0, 30) : text));
          });
        });
      });
      observer.observe(column, {childList: true});
      return observer;
    });
    window[stateKey] = observers;
  } else {
    window[stateKey].forEach(observer => observer.disconnect());
    window[stateKey] = undefined;
    speechSynthesis.cancel();
    alert("読み上げを中止しました");
  }
};
speechSynthesis.getVoices();
setTimeout(run, 100);
})()

元のChrome拡張との違い

  • 読み上げ速度はデフォルト
    元のChrome拡張はなぜか読み上げ速度が通常の2倍速なんですが、聞き取りが困難なので通常速度に戻しました。

  • ツイート主の名前は読み上げず本文だけ読み上げる
    名前が絵文字や記号だけで構成されている人が結構いて、そういうのは読み上げてもよくわからないので名前自体読み上げるのをやめました。

  • 読み上げの音声エンジンを選択できる
    MacChromeだと"Kyoko"と"Google 日本語"から選べます。ちなみに前者はオフラインでも利用可能で後者はオンライン専用らしいです。

  • 読み上げを中止できる
    Google 日本語を使っているとなぜか声が出ないまま永遠に再生中状態になってそれ以降の読み上げが働かなくなることがあるんですが、そういうときはいったん読み上げを中止すると直ります。

  • 名前によって声のピッチを変える
    この機能は正直微妙で、改善の余地があります。ピッチにあまり極端な値を指定すると聞き取りにくくなるうえに、音声エンジンによって聞き取りやすい範囲が異なるのでやっかいです。

雑記

speechSynthesis.getVoices()で利用可能な声の一覧を取得できるんですが、私の環境だとこのメソッドは1回目の実行ではなぜか空の配列を返します。そこでブックマークレットでは1回ダミーで呼んだあとに100ミリ秒待機して再度呼ぶというアレな実装をしているんですが、それでもたまに失敗するのが難儀です。

Firefoxで動かない」という報告があったので対応しました。どうもブックマークレットスクリプトが値を返すとその値でページコンテンツが書き換えられてしまうようです(setTimeoutの返り値が悪さをしていました)。