🦁

WhisperとChatGPTで音声ファイルの文字起こしを行い要約してみた

2023/05/08に公開

どうも@yanteraです。

今回はChatGPTとWhisperを使って文字起こしをしたので、その忘備録になります。
githubで公開もしているので興味がある方は是非見てください。
https://github.com/yantera/whisper-and-chatgpt

NodeJS 18.16.0を使っています。

実行手順

  1. 10分以上の音声データの場合、10分毎にファイルの分割を行う。
  2. 分割したファイルにはPrifixを付け、音声ファイルの先頭からWhisper APIで文字起こしを行う。
  3. 文字起こしを行った結果を一つのテキストファイルにまとめる。

実行用のファイル

このファイルにコマンドをまとめています。使い方はgithubを見てください。

index.ts
#!/usr/bin/env node
import { Command } from "commander";
import { summaryLongTextApp } from "./summaryLongText";
import { transcribeAudioApp } from "./transcribeAudio";
import { splitVoiceMemoApp } from "./splitVoiceMemo";

function main() {
  const program = new Command();
  program
    .description("Learn ChatGPT API By Example");
  program
    .command("split-voice-memo <inputFileName> <outputDirName>")
    .action(async(inputFileName, outputDirName) => {
      await splitVoiceMemoApp(inputFileName, outputDirName);
    });
  program
    .command("transcribe-audio <dirName> <maxFileCount>")
    .description("transcribe audio files")
    .action(async(dirName, maxFileCount) => {
      await transcribeAudioApp(dirName, maxFileCount);
    });
  program
    .command("summary-long-text <file>")
    .description("summarize a long text file")
    .option("-d, --debug", "debug mode", false)
    .action(async (file, options) => {
      await summaryLongTextApp(file, { debug: options.debug });
    });
  program.parse(process.argv);
}

main();

音声ファイルの分割

以下のコードでは音声ファイルの分割を行っています。
mp3が一番扱いやすそうだったので、ffmpegですべての音声ファイルをmp3に変換しています。

splitVoiceMemo.ts
import { exec } from "child_process";
import * as fs from "fs";
import path from "path";

// ffmpegコマンドを実行する関数
function runFFmpegCommand(command: string): Promise<string> {
  return new Promise((resolve, reject) => {
    exec(command, (error, stdout, stderr) => {
      if (error) {
        reject(error);
      } else {
        resolve(stdout.trim());
      }
    });
  });
}

// 入力ファイルをmp3に変換する関数
function convertToMp3(inputFile: string): Promise<string> {
  const mp3InputFile: string = path.join(path.dirname(inputFile), `${path.basename(inputFile, path.extname(inputFile))}.mp3`);
  const command: string = `ffmpeg -i ${inputFile} -acodec libmp3lame -q:a 2 "${mp3InputFile}"`;
  console.log('Created mp3');
  return runFFmpegCommand(command).then(() => mp3InputFile);
}

// 入力ファイルの長さを取得する関数
function getInputFileDuration(mp3InputFile: string): Promise<number> {
  const command: string = `ffprobe -i "${mp3InputFile}" -show_entries format=duration -v quiet -of json | jq -r '.format.duration | tonumber'`;
  return runFFmpegCommand(command).then(output => {
    const duration: number = parseFloat(output);

    if (isNaN(duration)) {
      throw new Error('Invalid duration: ' + output);
    }

    return duration;
  });
}

async function splitMp3File(mp3File: string, dirName: string): Promise<void> {
  try {
    // 分割する時間間隔(秒単位)
    const interval: number = 600; // 10分 = 10 * 60秒

    // 出力ファイル名のプレフィックス
    const outputFilePrefix: string = "output_";
    const outputDirPath: string = `/app/voices/outputs/${dirName}`;

    // 変換したmp3ファイルの大きさを取得
    const duration: number = await getInputFileDuration(mp3File);

    // 分割する回数を計算する
    const numSegments: number = Math.ceil(duration / interval);

    // ディレクトリの作成
    if (!fs.existsSync(outputDirPath)) {
      fs.mkdirSync(outputDirPath);
    }

    // 分割するコマンドを生成して実行する
    for (let i = 1; i <= numSegments; i++) {
      const start: number = (i - 1) * interval;
      const end: number = Math.min(i * interval, duration);
      const outputFile: string = `${outputDirPath}/${outputFilePrefix}${i}.mp3`;
      const command: string = `ffmpeg -i "${mp3File}" -ss ${start} -to ${end} -c copy "${outputFile}"`;
      console.log(`Splitting segment ${i} (${start} - ${end}) to ${outputFile}`);
      await runFFmpegCommand(command);
    }
  } catch (error) {
    console.error(error);
  }
}

async function removeMp3File(mp3File: string): Promise<void> {
  try {
    console.log('Removing mp3');
    const removeMp3FileCommand: string = `rm -f "${mp3File}"`;
    await runFFmpegCommand(removeMp3FileCommand);
  } catch (error) {
    console.error(error);
  }
}

export async function splitVoiceMemoApp(inputFileName: string, dirName: string): Promise<void> {
  try {
    // 入力ファイルをmp3に変換する
    const originFile: string = `/app/voices/origin/${inputFileName}`;
    const mp3File: string = await convertToMp3(originFile);

    // mp3をファイル分割する
    await splitMp3File(mp3File, dirName);

    // 作成したmp3を削除
    await removeMp3File(mp3File);
  } catch (error) {
    console.error(error);
  }
}

Whisper APIを用いての文字起こし

前述で作成したoutput_xxx.mp3ファイルを元にWhisperAPIを使用して文字起こしを行います。
dirNameと作成したmp3のファイル数を設定すると下記のコードで実行出来ます。

transcribeAudio.ts
import { Configuration, OpenAIApi } from "openai";
import fs from "fs";

const configuration = new Configuration({
  organization: process.env.OPENAI_API_ORGANIZATION_ID,
  apiKey: process.env.OPENAI_API_KEY,
});

export async function transcribeAudioApp(dirName: string, maxFileCount: number): Promise<void> {
  const stream = fs.createWriteStream(`texts/origin/${dirName}.txt`);
  const openai = new OpenAIApi(configuration);

  for (let i = 1; i <= maxFileCount; i++) {
    const resp = await openai.createTranscription(
      fs.createReadStream(`voices/outputs/${dirName}/output_${i}.mp3`) as any,
      "whisper-1",
      undefined,
      "text"
    );
    console.log(resp.data);
    stream.write(resp.data);
  }
  stream.end("\n");
  // エラー処理
  stream.on("error", (err: Error) => {
    if (err) console.log(err.message);
  });
}

ここまでのコードを実行しますと、音声ファイルの文字起こしが完了しているはずです。この文字起こしした結果を元にChatGPT APIで要約していきます。

ChatGPTを用いて文字起こしをした内容を要約

下記のコードはNewsPicksさんのこちらを元にしております。
変更点としてはファイル数が多い場合、tooManyRequestsというエラーが出るのでsleepを追加しています。

summaryLongText.ts
import * as fs from "fs/promises";
import { Configuration, OpenAIApi, ChatCompletionRequestMessage } from "openai";

import { chunkString } from "./util";

const maxInputLength = 3500;
const maxSummaryLength = 400;
const maxRecursion = 10; // 念のため

function summaryPrompt(text: string): string {
    return `以下の文章を200字程度の日本語で要約してください。\n\n${text}`;
}

interface Config {
    debug: boolean;
}

function sleep(ms: number){
  return new Promise((resolve) => setTimeout(resolve, ms));
}

export async function summaryLongTextApp(file: string, { debug }: Config): Promise<void> {
    const rawText = await fs.readFile(file, "utf-8");
    const summaryText = await getSummary(rawText, debug);
    console.log(`# Final summary\n${summaryText}`);
}

async function getSummary(text: string, debug: boolean, level: number = 1): Promise<string> {
    const configuration = new Configuration({ apiKey: process.env.OPENAI_API_KEY });
    const openai = new OpenAIApi(configuration);

    // 再帰的要約
    if (text.length <= maxSummaryLength || level > maxRecursion) {
        return text;
    }

    const textChunks = chunkString(text, maxInputLength);
    const summaryChunks = await Promise.all(textChunks.map(
      async (chunk, index) => {
        await sleep(1000*index);
        const messages: ChatCompletionRequestMessage[] = [
          {role: "user", content: summaryPrompt(chunk)}
        ]
        const completion = await openai.createCompletion({
          model: "text-davinci-003",
          prompt: summaryPrompt(chunk),
          max_tokens: maxSummaryLength,
          temperature: 0,
        });
        const chunkSummary = completion.data.choices[0].text || "";
        if (debug) {
          console.log(`# Summary level ${level}, chunk ${index}\n${chunkSummary}\n\n`);
        }
        return chunkSummary;
      }
    ));
    const joinedSummary = summaryChunks.join("\n");
    if (debug) {
        console.log(`# Summary level ${level}\n${joinedSummary}\n\n`);
    }
    return getSummary(joinedSummary, debug, level + 1);
}

所感

至らない点はまだまだあると思いますが、一旦これで要件を満たすことが出来ました。
個人的には再帰的にChatGPT APIのリクエストを行った場合、精度がどんどん落ちるのが改善の余地があるなと思いました。
精度が80%程度と仮定して、結果に対して3回再帰処理を行う場合、0.8 x 0.8 x 0.8 = 0.512となるのでおよそ精度が50%程度に落ちます。
なので、この部分はまだ人の手が必要だなと感じました。

最後に

ここまで読んで下さりありがとうございます。
何かアドバイスや感想等を頂けますと幸いです。

関連URL

https://github.com/yantera/whisper-and-chatgpt
https://platform.openai.com/docs/api-reference
https://github.com/newspicks/learn-chatgpt-api

GitHubで編集を提案

Discussion