Log

いろいろ

ポッドキャストを文字起こしして再生している文章をハイライト表示する

タイトル通りです。

再生中

自動でスクロールします。任意の文章から再生することもできます。実際に体験してもらった方が早いです。

Distortion.fm

めんどうであればApple Musicの歌詞表示と同じような機能と捉えてもらえればOKです。

そういえば

ミクロネシアのドメインを買う🇫🇲 - Log

ドメインを取得してからハリボテのHTMLをデプロイしたあとに

  1. デザイン変更
  2. Next.js導入

という2つの大きな対応をしていました。

1は友人がほとんどやってくれました。持つべきものはエンジニアの友人。2は勉強がてら自分で導入。SSGによる爆速表示に驚きました。速すぎてキモい。

「今回の文字起こし以前に変化があったよ!」という活動報告でした。

文字起こし

さて、本題。Whisperで文字起こし。GPUを利用するためにGoogle Colabで処理を実行します。

# install
!pip install git+https://github.com/openai/whisper.git
!sudo apt update
!sudo apt install ffmpeg postgresql

ffmpegが必要とREADMEに書いてあったのでインストールしています。postgresqlは「DBへ保存」で使います。

# settings
%env EP_ID=episode_id
import os, re
ep_no = re.match("^\d+", os.getenv("EP_ID")).group()
file_name = f"dfm-{ep_no}"
path = f"/content/drive/MyDrive/path/to/{file_name}"
%env FILE_NAME={file_name}
%cd {path}

Whisperの実行結果はGoogle Driveに保存します。

IPythonのマジックコマンドをちゃんと理解していないせいか、シェルで思った通りの動きを実現できなかったので一部Pythonを経由しています。

# transcript
!whisper $FILE_NAME.mp3 --language Japanese --model large

ここがWhisperの実行部分。

お仕事中のWhisper

実行するといろんな形式でファイルが作成されます。

  • vtt
  • srt
  • tsv
  • txt
  • json

txtファイルはこんな感じ。

やってる?スイカゲーム。
いや、大体概要は知ってるけどやったことはないな。
なんかやったこと、ひできなさそうな気がしたんで。
まあないで構わないわ。あれでしょ?
3年に1回ぐらいなんか人類がハマるよくわかんないゲームの類でしょ。

vttファイルはこんな感じ。

WEBVTT

00:00.000 --> 00:02.000
やってる?スイカゲーム。

00:02.000 --> 00:07.000
いや、大体概要は知ってるけどやったことはないな。

00:07.000 --> 00:11.000
なんかやったこと、ひできなさそうな気がしたんで。

00:11.000 --> 00:14.000
まあないで構わないわ。あれでしょ?

00:14.000 --> 00:19.000
3年に1回ぐらいなんか人類がハマるよくわかんないゲームの類でしょ。

テキスト情報だけであればtxtファイルが適していますが

  • 再生中の文章をハイライト表示する
  • 任意の文章から再生できるようにする

を実現するために開始時間・終了時間の情報も必要なので別のファイル形式を利用します。

DBへ保存

当初はvttファイルをINSERT文に変換するつもりでしたが、開始時間・終了時間がミリ秒である方がDB・アプリ供に都合が良かったのでtsvファイルを使うことにしました。

tsvファイルはこんな感じ。

start end text
0   2000    やってる?スイカゲーム。
2000    7000    いや、大体概要は知ってるけどやったことはないな。
7000    11000   なんかやったこと、ひできなさそうな気がしたんで。
11000   14000   まあないで構わないわ。あれでしょ?
14000   19000   3年に1回ぐらいなんか人類がハマるよくわかんないゲームの類でしょ。

これをINSERT文に変換してDBに突っ込みます。*1

アプリをホスティングしているVercelにて最近リリースされたVercel PostgresをDBサーバーとして採用しました。

Vercel Postgres | Vercel Docs

vttテーブル

column type constraint
id varchar PK
start_ms int PK
end_ms int NOT NULL
transcript varchar NOT NULL

idは「Spotify for Podcasters」のRSSからそれらしきものを抽出して格納しています。ただ、idは数値にしたいので変更したいなと考え中。

ここから「文字起こし」のスクリプトの続き。

# tsv -> sql
!cat $FILE_NAME.tsv | sed -e "1d" -e "s/^/INSERT INTO vtt VALUES('$EP_ID',/g" -e "s/\(\t[0-9]\+\t\)/,\1,'/g" -e "s/\t//g" -e "s/$/');/g" > $FILE_NAME.sql
# execute sql
!psql url -f $FILE_NAME.sql

以上でGoogle Colabで実行する全てのスクリプトが完成しました。installを実行後、エピソード毎にsettings、transcript、tsv -> sql、execute sqlをグルグルと回していきます。

グルグル回っていたら気持ち悪くなったのかエラーを吐いていました。

それって「It's my CUE.」*2

これこれ!!!想定外のデータによりエラーを吐く。生を実感する(IT土方並感)
「運用でカバー!!!!」と叫びながら真心を込めて手動でエスケープ処理を施しました。

しかし、これは深刻な脆弱性であるため根本対応が早急に求められます。

  • 私「今日は良い天気だね。」
  • HIDEKI「'; DROP TABLE vtt; --」
  • 私「ん?」
  • HIDEKI「いや、なんでもない。」

これでテーブルが消し飛びます。恐ろしき Speech To Text インジェクション。

アプリで表示

データが保存できたのであとは表示するだけ。

リポジトリpages/episode/[id].tsxがエピソード個別ページです。

github.com

任意の文章から再生を開始することは難しくありません。文章がクリックされた際にaudio要素のcurrentTimeを更新するだけです。

/**
  * 指定の秒数から再生する
  */
const playFrom = (ms: number): void => {
  if (!audioRef.current) return;
  // ms -> s
  audioRef.current.currentTime = ms / 1000;
  audioRef.current.play();
}

audio要素が継承するHTMLMediaElementはcurrentTimeの値が変わるとtimeupdateイベントを発火します。ここでstateを更新することで再生中の文章にclassを付与する仕組みを実現します。なお、timeupdateの発火は高頻度ではなく、throttleで間引く必要はないと考えています。

HTMLMediaElement: timeupdate イベント - Web API | MDN

/**
 * 現在再生している時間帯のtranscriptをactiveにする
 */
const updateActiveTranscript = (): void => {
  if (!audioRef.current) return;

  // s -> ms
  const currentMs = audioRef.current.currentTime * 1000;
  const activeTranscript = episode.transcripts.find(transcript =>
    transcript.startMs <= currentMs && currentMs < transcript.endMs
  );

  // 負荷を抑えるためactiveTranscriptMsの値が変わる場合のみstateを更新する
  if (activeTranscript && activeTranscript.startMs !== activeTranscriptMs) {
    setActiveTranscriptMs(activeTranscript.startMs);

    const transcriptWrapperElem = document.getElementById("transcriptWrapper");
    const activeTranscriptElem = document.getElementById(String(activeTranscript.startMs));
    if (transcriptWrapperElem && activeTranscriptElem) {
      // 直前のtranscriptを枠内に表示するためのoffsetを計算する
      const previousTranscriptElem = activeTranscriptElem.previousElementSibling as HTMLElement;
      const offsetScrollY = previousTranscriptElem?.offsetHeight + 12 || 0;

      // transcriptが枠内の上部に表示されるようスクロールする
      const scrollY = activeTranscriptElem.offsetTop - transcriptWrapperElem.offsetTop - offsetScrollY;
      transcriptWrapperElem.scroll({
        top: scrollY,
        behavior: "smooth"
      });
    }
  }
}

スクロール部分について。

読んでいる途中で切り替わって見えなくならないよう、直前の文章も画面内に表示します。

直前の文章が何行だろうと画面内に表示してやる

文章は複数行になることがあるため高さは動的に取得します。nextElementSiblingを使うことはありますが、previousElementSiblingを使ったのは初めてです。12はmarginやWrapperのpaddingを考慮したいい感じの値だと思います。*3もう記事を書くのに飽きてきました。

これでアプリの対応もおしまい。完成 🎉

おわりに

たまに開始時間・終了時間と文章がずれていることがあります。文字起こし全体でずれているわけではないので修正することは難しそうです。お金に困っていてSQLが書ける人がいたら連絡ください。雇います。

それとフィードバックも求めています。これはWebサイトというよりポッドキャスト自体、それも音質に対して、です。だんだんと改善されていると思うのですが、まだまだ聴き心地が良いとは言えません。何が良くないかを言語化できないため改善が難しいのです。

次は検索機能をつくりたいです。それでは。


手動関連記事。

*1:記事を書いていたら「tsvファイルのままでいいじゃん」ということに気づきました。PostgreSQLcsvファイルやtsvファイルを取り込むことができます。vttファイルから出発したせいで、INSERT文に変換することにこだわってしまっていたようです。

*2:画像はエラー再現のためにPgAdminから実行したものです。

*3:それが分かるように変数に切り出すかコメントをしろ。