🎙️

日本語ラップから俳句を生成するアプリを作った(フリースタイルバトルを俳句バトルとして楽しむ) 🤖

に公開

はじめに

こんにちは プログラマーの 君塚です
本日は 自作アプリを 披露します
8月の 19日は 俳句の日
AIで 俳句生成 試みた

--✂️--

はい。ここから普通に書きます。
Electron + Vite + OpenAI APIを使って「俳句生成アプリ」を作ったので披露したいと思います。

完成したもの

まずは、完成したものをご覧ください。

日本語ラップを俳句に変換することで、ラップバトルを俳句バトルして楽しめるアプリです。

真ん中の画面で再生されているラップバトルから生成された俳句を左右のウィンドウにタイミングよく表示します。

https://www.youtube.com/watch?v=YOXUYPO8Ioc

こちらの動画をラップバトルに変換しました。

ことの発端

本日8月19日。俳句の日ですね。なので、俳句生成アプリを作ろうと思いました。
ただ、無から俳句を生み出すのは難しいので、何かしらのインプットを俳句に変換するのが良さそうだと思い、候補を色々考えました。

  • 絵日記 👉 AI 👉 俳句
  • Xの投稿 👉 AI 👉 俳句
  • J-POPの歌詞 👉 AI 👉 俳句

などなど。色々考えてみたのですが、「俳句って現代でいうところの何に近いのかな」と考えると、「日本語ラップ」が近い気がしてきたことがことの発端です。

俳句とラップの共通点

一言で言えば、どちらも「制約の中で美しい短文を詠む詩」です。
表で表すと、

俳句 ラップ
「型」による表現 五・七・五の17音という制限 ビートの上での小節、韻・リズムの制限
リズムと言葉遊び 季語や掛詞、切れ字などの技巧 脚韻・頭韻・ダブルミーニング、語呂の妙
一瞬を切り取る感性 一瞬の風景・感情・季節を凝縮 瞬間の感情や社会状況、対人関係を即興や短いバースで表現
反骨精神と個の美学 風刺や世相批判、洒落の効いた表現 社会や常識への挑戦、アイデンティティの主張
美と真の融合 自然や人生の本質を、美しい日本語で描く 真実や怒りを込めたリリックで心を打つ

という感じですね。相違点まみれです。
相違点が多いので、これなら、AIで簡単に変換できそうですね。
(ラップも俳句も詳しくないので有識者の方がいたら是非コメントで教えてください!)

箇条書きで相違点を羅列すると、

  • どちらも、少ない音数/語数でいかに伝えるか、言葉の選び方と間の取り方が命。
  • どちらも「美しいテクニック」と「鋭い内容」の両立が求められる。
  • どちらも、今この瞬間のリアルを定着させるという点で共通。
  • どちらも「個の声で時代を斬る」という本質を持つ。
  • どちらも、「嘘ではない言葉」が、人の心を動かす。

という感じです。やっぱり相違点まみれですね。
これなら、AIで簡単に変換できそうですね。
(ラップも俳句も詳しくないので有識者の方がいたら是非コメントで教えてください!)

さらに、

  • 連歌・俳諧の「付け合い」
  • 句会での競い合い

など、かつては俳句でも俳句バトルのようなものが行われていたようです。
これは、いよいよ「ラップバトル」を「俳句バトル」に変換できそうな気がしてきましたね。
(重ね重ねのお願いですが、ラップも俳句も詳しくないので有識者の方がいたら是非コメントで教えてください!)

アプリの使い方

準備フェーズと鑑賞フェーズがあります。

準備

  1. YouTubeAPIでラップバトルの動画を再生する
  2. 俳句に変換したいラップのテロップが表示されたタイミングで「一句生成」ボタンを押下(ここは人力)
  3. 動画の再生終了まで ❷ の処理を続ける

鑑賞

  1. 準備が完了すると「再生」ボタンが表示される
  2. 再生を押すと準備の ❷ のタイミングで生成された俳句が表示される
  3. 俳句バトルを楽しむ

という流れで楽しむアプリです。
当初はラップの音声をインプットにしようと思ったのですが、音声認識に難があったのでやめました。
今回の動画にはテロップが付いているので、範囲を指定してOCRで読み取るという方針です。

開発環境

electron-viteを使って実装しました。
詳しい説明は省略しますが、Electronは、HTML+CSS+JavaScriptでデスクトップアプリが作れるフレームワークで、Viteはビルドツールです。

https://electron-vite.org/
https://www.electronjs.org/
https://ja.vite.dev/

それらを組み合わせた、electron-viteを使うと、恐ろしく簡単にElectronの開発環境が整います。

Electronには、

  • メインプロセス
  • レンダラープロセス

の2種類のプロセスがあり、メインプロセスは、アプリケーションのエントリポイントとして機能するNode.jsの実行環境、レンダラープロセスは、ウィンドウのレンダリングを担当するHTML・JavaScript・CSSの実行環境といったイメージです。(詳細は こちら

https://www.electronjs.org/ja/docs/latest/tutorial/process-model

メインプロセスもレンダラープロセスもJavaScript(Node.js)で開発できるため、ウェブサイトを作るかのようにデスクトップアプリを作ることができることがElectronを使った開発の利点です。

ソースコード(抜粋)の解説

長くなるのと、Electronの前提知識が必要になるので折りたたみました。
興味のある方は是非ご覧ください!

ソースコード(抜粋)の解説


テロップを撮影し、base64に変換


レンダラープロセス

buttonGenerate?.addEventListener('click', async () => {
  const currentTime = await player.getCurrentTime();
  const json = await window.api.capture(currentTime);

  if (json) {
    try {
      if (json[575]) {
        timeline[`T${json.timestamp}`] = {
          '575': json[575],
          color: json.color,
          base64: json.base64,
        };
      }
    } catch (err) {
      console.error(err);
    }
  }
});


preload/index.ts

const api = {
  // 中略
  capture: (currentTime: number) => ipcRenderer.invoke('capture', currentTime),
  // 中略
};


メインプロセス

ipcMain.handle('capture', async (_, currentTime: number) => {
  return mainWindow
    ?.capturePage({ // スクリーンショットを撮影
      x: process.env.CAPTURE_X ? parseInt(process.env.CAPTURE_X, 10) : 0,
      y: process.env.CAPTURE_Y ? parseInt(process.env.CAPTURE_Y, 10) : 0,
      width: process.env.CAPTURE_WIDTH
        ? parseInt(process.env.CAPTURE_WIDTH, 10)
        : mainWindow.getBounds().width,
      height: process.env.CAPTURE_HEIGHT
        ? parseInt(process.env.CAPTURE_HEIGHT, 10)
        : mainWindow.getBounds().height,
    })
    .then(async (image) => {
      const timestamp = Date.now();
      const url = image.toDataURL(); // base64に変換
      // 以下略...

レンダラープロセスでボタンがクリックされた際のコールバックを設定し、メインプロセスでスクリーンショットを撮影しています。
OCRの処理は別にして、読み取った文字をOpenAIに投げようと思っていたのですが、結果として、OCR・俳句生成を一度に処理できちゃいました。AI、恐るべし。

OpenAIにプロンプトとbase64を投げて俳句を生成


メインプロセス

const chatCompletion = await openai.chat.completions.create({
  // 中略
  model: 'gpt-4.1',
  messages: [
    {
      role: 'user',
      content: [
        {
          type: 'text',
          text: `
OCRとして、この画像内のテキストを読み取り、下記のJSON形式でJSONのみを返却してください
JSON.parseして使用したいので、JSONのみを返却してください
\`\`\`jsonなども不要です
テキストは赤か青で縁取られています

# JSONの形式
{
"text": "読み取ったテキスト(読み取れない場合は空文字)",
"575": "読み取ったテキストを俳句の形式で要約したテキスト(読み取れない場合もどうにかして生成)",
"seasonal_words": "俳句形式の要約に含めた季語(無い場合は空文字)",
"color": "red" | "blue"
}

# 俳句生成のルール
- 575形式であること
- なるべく字余り、字足らずにならないようにすること
- 575の内容は、読み取ったテキストの内容を要約したものであること
- 季語はなくても良いが、自然に含められそうであれば含めること
- 季語を含める場合は、現在の実際の季節に依存せず、タイムスタンプから季節を決定するなどして、季節をランダムに抽選すること
`,
        },
        {
          type: 'image_url',
          image_url: {
            url,
          },
        },
      ],
    },
  ],
  max_tokens: 300,
});

console.log(chatCompletion.choices[0].message.content);
// 以下略...

季語の扱いに最後まで頭を悩ませたのですが、必須にするとあんまり良くない結果になったので、「自然に入れられそうなら入れてね」というバランスにしました。
自然言語で指示が出せるからこその調整ですね。
あと、赤と青どちらで縁取りされた文字かも読み取ってもらっています。
改良の余地のあるプロンプトだと思うので、有識者の方がいたらアドバイスいただきたいです。

YouTubeのcurrentTimeを見て、表示する句を選択


レンダラープロセス(動画ウィンドウ)

  async function render() {
    const currentTime = await player.getCurrentTime();

    Object.keys(timeline).forEach((key) => {
      const time = Math.max(parseFloat(key.replace('T', '')) - 1, 0); // キャプチャされた時間の1秒前に表示

      if (lastCurrentTime <= time && time <= currentTime) {
        lastCurrentTime = time;

        const entry = timeline[key];

        if (lastEntry !== entry) {
          lastEntry = entry;
          window.api.render(entry);
        }
      }
    });

    if (isPlayingVideo) {
      requestAnimationFrame(render);
    }
  }


preload/index.ts

const api = {
  // 中略
  render: (entry: TimelineEntry) => ipcRenderer.send('render', entry),
  haiku: (callback: (json: Json) => void) =>
    ipcRenderer.on('haiku', (_, json: Json) => callback(json)),
  // 中略
};


メインプロセス

ipcMain.on('render', (_, entry) => {
  switch (entry.color) {
    case 'red':
      redWindow?.webContents.send('haiku', entry);
      break;
    case 'blue':
      blueWindow?.webContents.send('haiku', entry);
      break;
  }
});


レンダラープロセス(俳句ウィンドウ)

window.api.haiku((json: TimelineEntry) => {
  const output = document.querySelector('#haiku p');
  const img = document.querySelector('img');

  try {
    console.log(json);

    if (json['575']) {
      if (output) {
        output.innerHTML = json['575'].replace(/\s/g, '<br />');
      }
    } else {
      if (output) {
        output.innerHTML = '';
      }
    }

    if (json.base64) {
      if (img) {
        img.src = json.base64;
      }
    } else {
      if (img) {
        img.src = '';
      }
    }
  } catch (err) {
    console.error(err);
  }
});

レンダラープロセスの動画画面で動画の現在位置を取得し、表示タイミングが来たらメインプロセスの'render'を実行し、対応するレンダラープロセスの俳句ウィンドウに俳句を表示します。

ニコニコ動画のコメントのようなイメージで実装しました。
俳句を生成したcurrentTimeに表示するとちょっと遅いので、1秒前に表示してます。
また、俳句表示ウィンドウは動画ウィンドウの左右に表示しました。
動画に合わせて、青い縁取りのテロップから生成された句を左、赤い縁取りを右に表示します。

ざっくりですが、こんな感じでラップから俳句を生成しました。

リポジトリ

https://github.com/kimizuka/free-style-575

ソースコード一式はリポジトリにPushしているので、ビルド環境を用意できる方は是非動かしてみてください!

DEMO

アプリの動作の様子をまるまる動画に収めてしまうと引用要件の「引用の目的上正当な範囲内」を超えてしまう気がするので、無音のGIFアニメと、生成した句だけを記載します。

是非、YouTubeの元動画を見ながら「このラップがこの句になったのか」と確認してみてください!

  • 🎙️ 証言1 投げんな匙って言われてから ずっとここまできた価値
    • 🖌️ 匙投げず 歩んだ道に 春の価値
  • 🎙️ 俺もあの頃憧れた B-BOY 10年 フリースタイルの番組はすげぇよ
    • 🖌️ 憧れし 十年フリースタイル 輝けり
  • 🎙️ 2台目モンスターだったらしいな おう 今も変わらないモンスターか?
    • 🖌️ モンスター 二代目ずっと 輝いて
  • 🎙️ こちとら変わりません 泥の田んぼから 泥田坊のように這い上がる
    • 🖌️ 泥まみれ 這い上がる力 春の田に

(ここからは生成された句のみを掲載しますので、YouTubeと合わせてご確認ください)

  • 🟥 俺は強し どんな相手も 怖くない

  • 🟥 今は今 昔よりハード 忙しい

  • 🟥 六度目も 今日が一番 爽やかに

  • 🟥 辛きほど やれば道あり 待つ心

  • 🟦 痛みなく 全部持ってく 運ぶだけ

  • 🟦 転んでも 立ち上がるのさ 達磨かな

  • 🟦 勝ちの誓い 一生一度と 風薫る

  • 🟦 普通じゃない NAIKAで勝って 春の風

  • 🟥 逃したこと ついに掴みて 春一番

  • 🟥 達磨かな 七転びても まず千葉へ

  • 🟥 ガソリンで 未知に挑んで 明日晴れ

  • 🟥 君の前 心は少年 春駆ける

https://www.youtube.com/watch?v=YOXUYPO8Ioc

おわりに

文字起こし 精度に驚く OCR
もう少し 良いプロンプト 考えたい
AIの 考える季語 春ばかり
工夫次第 まだ上がるはず クオリティ
AIに 興味がある方 募集中
https://www.wantedly.com/projects/2148960

クロステックマネジメント(京都芸術大学)

Discussion