🦁
WhisperとChatGPTで音声ファイルの文字起こしを行い要約してみた
どうも@yanteraです。
今回はChatGPTとWhisperを使って文字起こしをしたので、その忘備録になります。
githubで公開もしているので興味がある方は是非見てください。
NodeJS 18.16.0を使っています。
実行手順
- 10分以上の音声データの場合、10分毎にファイルの分割を行う。
- 分割したファイルにはPrifixを付け、音声ファイルの先頭からWhisper APIで文字起こしを行う。
- 文字起こしを行った結果を一つのテキストファイルにまとめる。
実行用のファイル
このファイルにコマンドをまとめています。使い方は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
Discussion