🗣️

OpenAI APIで25MB超の音声ファイルを文字起こしする方法

2024/12/05に公開

背景

OpenAI APIのTranscription APIは25MBのファイルサイズ制限が存在する。
そのため、25MB超の音声ファイルを対象に文字起こしを行う場合、工夫が必要となる。

1つが音声ファイルの圧縮である。
以下の記事にあるように、FFmpegを用いて音声ファイルを25MB以下に圧縮すると制限にかからなくなる。
OpenAIのWhisper APIの25MB制限に合うような調整を検討する

しかし、この方法の場合、音声品質の劣化によって文字起こしの精度が悪化する可能性があるため、長尺の音声ファイルには向かない。

もう一つが音声ファイルを分割する方法である。
これはX分間隔で音声ファイルを分割し、各ファイルごとに文字起こしを行う。
そして、最後にその結果をマージすることで文字起こしの最終結果を得る方法である。
この方法の場合、音声品質の劣化はないため、文字起こしの精度は問題ない。
しかし、分割と結合方法を考慮しないと繋ぎ目部分の文字起こし情報が失われてしまう。
また、分割した各ファイルの文字起こし情報は全て0秒スタートの時刻となるため、マージ後に正しい時刻を付けなおす必要も発生する。

上記のように、25MBのファイルサイズ制限の対処法は存在するが、実用することを考えた場合、精度に不安が残る形となる。
そこで、25MB超の音声ファイルでも精度を落とすことなく、また正しい時刻が付与された文字起こし情報を得る方法を考えた。

検討

先述した圧縮と分割、基本方針はこのどちらかとなる。
圧縮は音声品質の劣化が生じるため、音声品質の劣化が生じない分割の方を選択する。

分割の問題は以下の2つであった。

  • 繋ぎ目部分の文字起こし情報の損失
  • 正しい時刻の付けなおしが難しい

この問題の解決策を検討していく。

繋ぎ目部分の文字起こし情報の損失

繋ぎ目部分の文字起こし情報が損失する原因は「音声の途中で分割すること」が挙げられる。
例えば、
「皆さん、こんにちは。これからXXXを始めていきたいと思います。」
という音声が存在したとする。

このとき、「皆さん、こんにちは。」と「これからXXXを始めていきたいと思います。」の二つに分割できたならば問題はない。
しかし、「皆さん、こんにちは。これからXXXを始め」と「ていきたいと思います。」の二つに分割した場合、音声の途中で分割が行われているため、正しい文字起こし情報が得られなくなってしまう。

これを防ぐためにどうするか?
解決策の一つとして、分割した前後の音声ファイルで重複期間を作っておく方法がある。

つまり、以下のように分割すると文字起こし情報の損失が生じるため、

以下のように、分割した前後の音声ファイルで重複期間を作っておく。
以下の例だと、1分間の重複期間を設けている。
この1分間の重複期間の中で、文の区切りが存在する(息継ぎなしに1分以上話し続ける人はいない)はずであるため、その情報をもとに分割の繋ぎ目を決定する。

正しい時刻の付けなおしが難しい

分割した場合は時刻が0秒スタートとなる。
例えば、以下のように分割した場合、どちらも「00:00.000 - 30:00.000」の間で時刻が付けられる。

これを防ぐために、マージする前に時刻を補正する必要がある。
先ほどの例では、「30:00.000」地点で分割をしている。
そのため、二つ目の動画の時刻に「30:00.000」だけ加算し、「00:00.000 - 30:00.000」を「30:00.000 - 60:00.000」のように補正することで解決できる。

検討まとめ

上記で挙げた2つのロジックを合わせることで、25MB超の音声でも正しい時刻付きの文字起こしを行うことができる。
次は、これらのロジックを実装ロジックに落とし込んでいく。

実装ロジック

全体の流れは以下のようになる。
※簡略化のため、分割した音声ファイルは常に25MB以内であるとしている

  1. 音声ファイルを分割する
  2. OpenAI APIで各音声ファイルの文字起こしを行う
  3. 文字起こし情報をもとに時刻を補正する
  4. 文字起こし情報をマージする

以上のような流れとなる。

ここからは実際にコードを見ながら解説を行っていく。

1. 音声ファイルを分割する

ここでは、音声ファイルを5分間隔で分割、重複期間を1分間と定義して実装。
FFmpegを用いると簡単に音声をカットすることができる。
開始秒数は「分割間隔5分×index」、終了秒数は「分割間隔5分×(index + 1) + 1分」で算出することができる。
例で挙げると以下のようになる。

# index=0より、0分から5分+1分
ffmpeg -i input.mp3 -ss 00:00:00 -to 00:06:00 -c copy output1.mp3
# index=1より、5分から10分+1分
ffmpeg -i input.mp3 -ss 00:05:00 -to 00:11:00 -c copy output2.mp3
# index=2より、10分から15分+1分
ffmpeg -i input.mp3 -ss 00:10:00 -to 00:16:00 -c copy output3.mp3

2. OpenAI APIで各音声ファイルの文字起こしを行う

文字起こしはOpenAI APIのTranscription APIを利用する。
Node.jsだと以下のようなコードで文字起こしを行うことができる。
response_formatverbose_jsonにしている理由は、次の操作で必須のため。

import fs from "fs";
import OpenAI from "openai";

const openai = new OpenAI({
    apiKey: process.env['OPENAI_API_KEY'],
});

async function transcription(filename) {
    const translation = await openai.audio.translations.create({
        file: fs.createReadStream(filename),
        model: "whisper-1",
        response_format: "verbose_json",
    });

    return translation;
}

Promise.all([
    transcription("output1.mp3"),
    transcription("output2.mp3"),
    transcription("output3.mp3")
]);

3. 文字起こし情報をもとに時刻を補正する

先ほど取得したverbose_jsonは以下のようなフォーマットとなる。

{
  "task": "transcribe",
  "language": "english",
  "duration": 8.470000267028809,
  "text": "The beach was a popular spot on a hot summer day. People were swimming in the ocean, building sandcastles, and playing beach volleyball.",
  "segments": [
    {
      "id": 0,
      "seek": 0,
      "start": 0.0,
      "end": 3.319999933242798,
      "text": " The beach was a popular spot on a hot summer day.",
      "tokens": [
        50364, 440, 7534, 390, 257, 3743, 4008, 322, 257, 2368, 4266, 786, 13, 50530
      ],
      "temperature": 0.0,
      "avg_logprob": -0.2860786020755768,
      "compression_ratio": 1.2363636493682861,
      "no_speech_prob": 0.00985979475080967
    },
    ...
  ]
}

durationは入力ファイルの尺、segmentsが文字起こしの情報を示すものである。
これらの値を用いることで時刻を補正することができる。
具体的には、durationの累積和を各segmentstartendに加算して補正すればよい。
ただし、durationは1分間の重複期間が含まれているため、そのままでは利用できない。
そのため、以下のようにする必要がある。

let cumulativeDuration = 0;
translations.forEach(translation => {
    // 文字起こし情報の時刻を補正
    translation.segments.forEach(segment => {
        segment.start += cumulativeDuration;
        segment.end += cumulativeDuration;
    });

    // 尺の累積和を計算(重複期間1分を排除)
    cumulativeDuration += translation.duration - 1 * 60;
});

上記のようにすることで、時刻が正しい文字起こし情報を得ることができる。

4. 文字起こし情報をマージする

最後に各ファイルから生成された文字起こし情報をマージし、1つの文字起こし情報を生成する。
マージの方法であるが、重複期間である1分間で文字起こし情報の損失が小さな箇所を見つけ、そこを基点としてマージする。
画像を用いて具体的なイメージを説明する。

文字起こし情報は現在以下のように時刻を保持している。
start - end形式で記載

上記の時刻をもとに、以下のように開始時刻の比較を実施する。

比較の結果、開始時刻の差分が最も小さなものをマージの基点とし、マージを実行する。
以下のように、基点より前のものと基点以降のものをマージして1つの文字起こし情報を作成する。

上記をコードで記載すると以下のようになる。

// 最初のtranslationを取得
const mergedTranslation = translations.splice(0, 1)[0];

translations.forEach(translation => {
    let minDiff = Infinity;
    let closestIndices = [];
    mergedTranslation.segments.forEach((mergedSegment, i) => {
        translation.segments.forEach((segment, j) => {
            const diff = segment.start - mergedSegment.start;
            if (diff < 0 || minDiff < diff) {
                return;
            }

            // 基点となる組み合わせをインデックスで保存
            closestIndices = [i, j];
            minDiff = diff;
        });
    });

    if (closestIndices.length === 2) {
        // 基点となる文字起こし情報以降を削除
        mergedTranslation.segments.splice(closestIndices[0]);
        // 基点となる文字起こし情報以降のデータを追加
        mergedTranslation.segments.push(...translation.segments.slice(closestIndices[1]));
    }

    // 尺を更新(重複期間の1分を排除)
    mergedTranslation.duration += translation.duration - 1 * 60;
});

これで正しい時刻が付与された文字起こし情報を得ることができる。

コード全体

import fs from "fs";
import OpenAI from "openai";

const openai = new OpenAI({
    apiKey: process.env['OPENAI_API_KEY'],
});

async function transcription(filename) {
    const translation = await openai.audio.translations.create({
        file: fs.createReadStream(filename),
        model: "whisper-1",
        response_format: "verbose_json",
    });

    return translation;
}

Promise.all([
    transcription("output1.mp3"),
    transcription("output2.mp3"),
    transcription("output3.mp3")
]).then(translations => {
    let cumulativeDuration = 0;
    translations.forEach(translation => {
        // 文字起こし情報の時刻を補正
        translation.segments.forEach(segment => {
            segment.start += cumulativeDuration;
            segment.end += cumulativeDuration;
        });
    
        // 尺の累積和を計算(重複期間1分を排除)
        cumulativeDuration += translation.duration - 1 * 60;
    });

    return translations;
}).then(translations => {
    // 最初のtranslationを取得
    const mergedTranslation = translations.splice(0, 1)[0];
    
    translations.forEach(translation => {
        let minDiff = Infinity;
        let closestIndices = [];
        mergedTranslation.segments.forEach((mergedSegment, i) => {
            translation.segments.forEach((segment, j) => {
                const diff = segment.start - mergedSegment.start;
                if (diff < 0 || minDiff < diff) {
                    return;
                }
    
                // 基点となる組み合わせをインデックスで保存
                closestIndices = [i, j];
                minDiff = diff;
            });
        });

        if (closestIndices.length === 2) {
            // 基点となる文字起こし情報以降を削除
            mergedTranslation.segments.splice(closestIndices[0]);
            // 基点となる文字起こし情報以降のデータを追加
            mergedTranslation.segments.push(...translation.segments.slice(closestIndices[1]));
        }
    
        // 尺を更新(重複期間の1分を排除)
        mergedTranslation.duration += translation.duration - 1 * 60;
    });

    return mergedTranslation;
});

まとめ

本記事ではOpenAI APIを利用した文字起こしにおいて、ファイルサイズ制限の上限である25MBを超える音声ファイルでも正しい時刻が付与された文字起こし情報を得る方法を紹介した。
この方法を利用すれば、音声品質を劣化させることなく文字起こしを行うことができるため、音声圧縮を行うよりも精度は高くなる。
また、以下の記事によるとOpenAI APIではRTF=0.07程度であることが分かる。
【OpenAIアップデート】音声サービスの処理時間を調べてみた【Whisper・TTS・GPT3.5-Turbo-1106】

これにより、例えば20分尺の音声ファイルの処理時間は
duration * RTF = 20 * 60 * 0.07 = 84(秒)
であることが分かる。

つまり、並列に動作させた場合、長尺の音声ファイルでも20分間隔で分割すれば理論的には84秒で処理が完了するということである。
※リクエスト数が多くなる問題があるが。。。

そのため、本記事の方法は精度の向上と処理速度の向上という恩恵が受けられるといってもよい。

Discussion