タイトル通りです。
自動でスクロールします。任意の文章から再生することもできます。実際に体験してもらった方が早いです。
めんどうであればApple Musicの歌詞表示と同じような機能と捉えてもらえればOKです。
そういえば
ドメインを取得してからハリボテのHTMLをデプロイしたあとに
- デザイン変更
- 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の実行部分。
実行するといろんな形式でファイルが作成されます。
- 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サーバーとして採用しました。
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土方並感)
「運用でカバー!!!!」と叫びながら真心を込めて手動でエスケープ処理を施しました。
しかし、これは深刻な脆弱性であるため根本対応が早急に求められます。
- 私「今日は良い天気だね。」
- HIDEKI「'; DROP TABLE vtt; --」
- 私「ん?」
- HIDEKI「いや、なんでもない。」
これでテーブルが消し飛びます。恐ろしき Speech To Text インジェクション。
アプリで表示
データが保存できたのであとは表示するだけ。
リポジトリのpages/episode/[id].tsx
がエピソード個別ページです。
任意の文章から再生を開始することは難しくありません。文章がクリックされた際に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ファイルのままでいいじゃん」ということに気づきました。PostgreSQLはcsvファイルやtsvファイルを取り込むことができます。vttファイルから出発したせいで、INSERT文に変換することにこだわってしまっていたようです。
*2:画像はエラー再現のためにPgAdminから実行したものです。
*3:それが分かるように変数に切り出すかコメントをしろ。