ChatGPTを使って、音声認識文字起こしツールを開発してみた。

当ページのリンクには広告が含まれています。
  • URLをコピーしました!

最近、自分専用のAIチャットを作るために、過去のブログ記事をGPTsに学習させました。

その過程でふと思ったのが、

「もしかして、過去の音声配信からも記事を作れるのでは?」

過去にした音声配信のデータが400近くあるので、これを使って記事執筆ができたら面白そう。

早速、ChatGPTに相談してみたところ、

「音声データから直接書き起こしはできないので、文字起こししたテキストデータをくれれば、記事は書けます」

との回答が返ってきました。なるほど、音声データをテキストに変換する必要があるのか……。

そこで、ひとつのアイデアが浮かびました。

「それなら、自分で文字起こしツールを作ればいいのでは?」

最近はWebコーディングもAIチャットと相談しながらやっているので意外といけそうだなと思い、音声を文字に変換するツールの開発にチャレンジすることにしました。


なんと試行錯誤の末、約3時間で完成。商品やサービスとして提供するにはまだまだ粗削りですが、自分で使う分には十分すぎるツールになりました。

ちなみに完成品はこちら。

しっかり音声を文字起こししてくれています。

驚いたのは、プログラミングの知識が多少ある程度で、ここまでのものが作れたこと。

今まではAdobeのPremiere Proを使って文字起こしをしたり、既存のサービスに課金して文字起こしをしていました。

それが1クリックでしかも無料で文字起こしをしてくれるツールを自分で作れたことが、かなり衝撃でした。

生成AIがある今、多少のプログラミング経験があれば、欲しいものはほとんど作れるんじゃないでしょうか?

せっかくなので今回は、その開発の過程をまとめておくことにしました。

主要部分のみのため、細かい部分は省かれていますが、何かの参考になったら幸いです。

目次

選んだ技術とツール

今回のツール開発に使ったのは、以下の2つのライブラリです。

Whisper(OpenAI)

OpenAIが開発した音声認識モデルで、高精度な文字起こしが可能。特に、日本語の認識精度が高く、ノイズが多い環境でもしっかり文字を拾ってくれる。

Pyannote-audio

話者分離(ダイアライゼーション)に特化したツール。ラジオのように複数人が話す音声でも、「誰が話しているか」を区別できる。

この2つを組み合わせることで、高精度な音声文字起こしが可能になります。


🛠 実装の流れ

1️⃣ Whisper(OpenAI)を使って文字起こしを実装
 ✔ Whisper の基本機能を利用して、音声をテキストに変換

2️⃣ 文字起こしデータの作成
 ✔ Whisper の出力フォーマットを調整し、テキストデータとして取得

3️⃣ 文字起こししたテキストデータをテキストファイルとして出力
 ✔ .txt ファイルに保存して、あとから編集や分析ができるようにする

4️⃣ 長い音声ファイルにも対応
 ✔ 1時間以上の音声データにも対応できるように Whisper の処理方法を調整

5️⃣ 音声ファイルの分割(1分単位)を実装
 ✔ 1分ごとに音声を分割し、それぞれの部分を Whisper で処理
 ✔ 進捗管理ができるように進捗ステータスを更新

6️⃣ データのやり取りをしやすくするためにフロント部分を実装
 ✔ 文字起こし結果を Web 上で見やすく表示

7️⃣ Flask で Web アプリ化
 ✔ CLI だけでなく、ブラウザからアップロード・ダウンロードできる UI を作成
 ✔ Flask を利用して、API を実装

8️⃣ 話者分離の導入
 ✔ SpeechBrain を使って話者識別 → 精度がイマイチ
 ✔ 話者識別の精度が低く、実用レベルではなかった

9️⃣ Hugging Face の pyannote-audio に移行
 ✔ pyannote/speaker-diarization を導入
 ✔ 話者ごとの発話時間を識別

🔟 Hugging Face の認証トラブル
 ✔ huggingface-cli login の実施
 ✔ API トークンが無効 / 401エラー問題
 ✔ .huggingface のリセットや、新しいトークンの発行

1️⃣1️⃣ 最終版のコード
 ✔ Flask アプリの完成
 ✔ Whisper + Pyannote の統合
 ✔ 話者ごとの発話時間を記録
 ✔ Web上で進捗をリアルタイム表示

実際のプロンプトとコードの一部

ChatGPTにどのようなプロンプトを入力して、コードを生成していったのか後から振り返ってみました。

あくまで振り返りなので、実際のものと若干違う部分はありますが、雰囲気だけ感じてもらえれば。

📌 Whisper を使った基本的な文字起こし

🎯 目的

まずは OpenAI の Whisper を使って、基本的な音声文字起こしを実装する。

🛠 投げたプロンプト

Whisper を使って日本語の音声を文字起こしするPythonスクリプトを作成してください。

✅ 生成されたコード

import whisper

# モデルのロード
model = whisper.load_model("medium")

# 音声ファイルを読み込んで文字起こし
result = model.transcribe("input.wav")

# 出力
print(result["text"])

📌 結果

✅ 日本語の音声を高精度で文字起こしできる
❌ 文字起こししたテキストがターミナルでしかみられない


📌 文字起こししたテキストをファイルに保存

🎯 目的

Whisper の出力をテキストファイルとして保存し、データの管理をしやすくする。

🛠 投げたプロンプト

Whisper の文字起こし結果を txt ファイルに保存するコードを作成してください。

✅ 生成されたコード

import whisper

model = whisper.load_model("medium")
result = model.transcribe("input.wav")

# テキストファイルとして保存
with open("transcript.txt", "w", encoding="utf-8") as f:
    f.write(result["text"])

📌 結果

.txt ファイルとして保存可能に
✅ 後から編集やデータ解析が容易になった

この段階で文字起こししたテキストファイルをChat GPTに渡すという目的は達成したのですが、せっかくなので色々と機能を追加してみました。


📌 Flask を使って Web アプリ化

🎯 目的

CLI だけでなく、ブラウザからアップロード・ダウンロードできるようにする。

🛠 投げたプロンプト

Flask を使って、音声ファイルをアップロードし、文字起こしを行う Web アプリを作成してください。

✅ 生成されたコード

from flask import Flask, request, jsonify
import whisper

app = Flask(__name__)

@app.route('/transcribe', methods=['POST'])
def transcribe():
    file = request.files['file']
    file.save("uploaded.wav")

    model = whisper.load_model("small")
    result = model.transcribe("uploaded.wav")

    return jsonify({"transcript": result["text"]})

if __name__ == '__main__':
    app.run(debug=True)

📌 結果

✅ Web インターフェースでアップロード可能に
✅ 使いやすい API が完成


📌 フロントエンド(index.html)の実装

🎯 目的

Web UI を追加し、アップロードや進捗表示をわかりやすくする。

🛠 投げたプロンプト

HTMLとJavaScriptを使って、音声ファイルをアップロードし、Flask の API に送信する Web インターフェースを作成してください。

✅ 生成されたコード

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>音声文字起こしツール</title>
</head>
<body>
    <h1>音声文字起こしツール</h1>
    <input type="file" id="fileInput">
    <button onclick="uploadFile()">アップロードして文字起こし</button>
    <pre id="output"></pre>

    <script>
        function uploadFile() {
            const file = document.getElementById('fileInput').files[0];
            const formData = new FormData();
            formData.append("file", file);

            fetch('/transcribe', { method: "POST", body: formData })
                .then(response => response.json())
                .then(data => document.getElementById('output').innerText = data.transcript);
        }
    </script>
</body>
</html>

📌 結果

✅ ブラウザで音声ファイルをアップロード可能
✅ 文字起こし結果を即座に表示
❌2人以上の音声の場合、話者の分割ができないことに気づいた


📌話者分離の導入

🎯 目的

音声内の「誰が話しているのか」を識別できるようにする。

🛠 投げたプロンプト

音声を文字起こしする際に、話し手ごとに分割する機能を作成してください。

✅ 生成されたコード

from speechbrain.inference import SpeakerRecognition

spk_model = SpeakerRecognition.from_hparams(
    source="speechbrain/spkrec-ecapa-voxceleb",
    savedir="tmpdir"
)

📌 結果

❌ 精度が低かったため、Pyannote-audio に移行


📌 Hugging Face の pyannote-audio に移行

🎯 目的

精度の高い話者分離を実装する。

🛠 投げたプロンプト

Hugging Face の pyannote-audio を使って話者分離を実装する方法を教えてください。

✅ 生成されたコード

from pyannote.audio.pipelines.speaker_diarization import SpeakerDiarization
import torch

HUGGINGFACE_AUTH_TOKEN = "your_token"

pipeline = SpeakerDiarization.from_pretrained(
    "pyannote/speaker-diarization",
    use_auth_token=HUGGINGFACE_AUTH_TOKEN
)
pipeline.to(torch.device("cuda" if torch.cuda.is_available() else "cpu"))

diarization = pipeline("input.wav")

for turn, _, speaker in diarization.itertracks(yield_label=True):
    print(f"[{turn.start:.2f} - {turn.end:.2f}] {speaker}")

📌 結果

✅ 話者ごとの発話時間を識別
✅ Whisper と組み合わせて、「誰が何を話したか」がわかる

最終の完成コードがこちら

from flask import Flask, request, jsonify, send_file, Response, render_template
import os
import ssl
import whisper
import torch
import time
from pydub import AudioSegment
from pyannote.audio.pipelines.speaker_diarization import SpeakerDiarization
from pyannote.audio import Model

# 🔐 Hugging Face の正しいトークンを設定
HUGGINGFACE_AUTH_TOKEN = "YOUR_API_KEY"

# SSL証明書の問題を回避
ssl._create_default_https_context = ssl._create_unverified_context

app = Flask(__name__)
progress = {"status": "", "current_chunk": 0, "total_chunks": 0}

# 🚀 Whisper の文字起こしモデルを事前にロード
loaded_models = {
    "tiny": whisper.load_model("tiny"),
    "small": whisper.load_model("small"),
    "medium": whisper.load_model("medium"),
    "large": whisper.load_model("large")
}

# 🎙️ `pyannote-audio` の話者分離モデルをロード
pipeline = SpeakerDiarization.from_pretrained(
    "pyannote/speaker-diarization",
    use_auth_token=HUGGINGFACE_AUTH_TOKEN
)
pipeline.to(torch.device("cuda" if torch.cuda.is_available() else "cpu"))

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/progress')
def get_progress():
    def generate():
        while True:
            yield f"data: {progress}\n\n"
            time.sleep(1)
    return Response(generate(), mimetype='text/event-stream')

# 🎤 文字起こし & 話者分離処理
# 🎤 文字起こし & 話者分離処理
@app.route('/transcribe', methods=['POST'])
def transcribe_audio():
    global progress
    if 'file' not in request.files or 'model' not in request.form:
        return jsonify({"error": "ファイルまたはモデルが送信されていません。"}), 400

    file = request.files['file']
    selected_model = request.form['model']
    diarization_enabled = request.form.get('diarization') == 'true'  # 話者分離のオプション

    # 事前にロードされたモデルを取得
    model = loaded_models.get(selected_model)
    if model is None:
        return jsonify({"error": "指定されたモデルが見つかりません。"}), 400

    # 🔍 アップロードされたファイルの拡張子を取得
    filename = file.filename
    ext = os.path.splitext(filename)[-1].lower()

    # サポートするファイル形式
    supported_formats = [".mp3", ".m4a"]
    if ext not in supported_formats:
        return jsonify({"error": f"対応していないファイル形式です: {ext}"}), 400

    # 一時ファイルとして保存
    temp_audio_path = f"uploaded_audio{ext}"
    file.save(temp_audio_path)

    # 🎵 M4A または MP3 を WAV に変換
    audio = AudioSegment.from_file(temp_audio_path, format=ext.replace(".", ""))
    wav_path = "converted_audio.wav"
    audio.export(wav_path, format="wav")

    # 音声を1分ごとに分割
    chunk_length_ms = 60000  # 1分
    chunks =  for i in range(0, len(audio), chunk_length_ms)]

    progress["total_chunks"] = len(chunks)
    progress["current_chunk"] = 0
    progress["status"] = "ファイルの分割が完了しました。文字起こし開始します。"

    # 話者識別用のデータ
    speaker_transcript = {}
    full_transcript = []  # 全体の文字起こし結果

    # 文字起こし処理
    for i, chunk in enumerate(chunks):
        chunk_path = f"chunk_{i}.wav"
        chunk.export(chunk_path, format="wav")

        progress["current_chunk"] = i + 1
        progress["status"] = f"{i + 1}/{len(chunks)} 番目の分割ファイルを文字起こし中..."

        # 🎙️ Whisper で文字起こし
        result = model.transcribe(chunk_path, fp16=False)

        # 🔍 文字起こし結果が空でないかチェック
        transcript = result.get("text", "").strip()
        if not transcript:
            transcript = "[空のデータ]"

        if diarization_enabled:
            # 🎤 `pyannote-audio` を使って話者分離
            diarization = pipeline(chunk_path)

            for turn, _, speaker in diarization.itertracks(yield_label=True):
                speaker_id = f"Speaker_{speaker}"  # 話者ラベル
                full_transcript.append(f"[{speaker_id} {turn.start:.2f}-{turn.end:.2f}] {transcript}")
        else:
            full_transcript.append(transcript)

        os.remove(chunk_path)

    # 📄 テキストファイルに保存
    transcript_file = "transcript.txt" if not diarization_enabled else "transcript_diarized.txt"

    with open(transcript_file, "w", encoding="utf-8") as f:
        f.write("\n".join(full_transcript))  # 🔹 修正: 空のデータを防ぐためにfull_transcriptを使用

    # 一時ファイルを削除
    os.remove(temp_audio_path)

    progress["status"] = "文字起こし完了!(話者識別済み)" if diarization_enabled else "文字起こし完了!"

    return send_file(transcript_file, as_attachment=True)

if __name__ == '__main__':
    app.run(port=5000, debug=True)
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>音声文字起こしツール</title>
    <style>
        body {
            font-family: 'Arial', sans-serif;
            text-align: center;
            background-color: #f4f4f4;
            margin: 0;
            padding: 0;
        }

        h1 {
            color: #333;
            margin-top: 20px;
        }

        .container {
            background: #fff;
            width: 90%;
            max-width: 500px;
            margin: 40px auto;
            padding: 20px;
            border-radius: 10px;
            box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
        }

        select, input[type="file"], input[type="checkbox"] {
            width: 100%;
            padding: 10px;
            margin: 10px 0;
            border-radius: 5px;
            border: 1px solid #ccc;
            box-sizing: border-box;
        }

        input[type="checkbox"] {
            width: 20%;
        }

        label {
            font-size: 16px;
            font-weight: bold;
            color: #555;
            display: flex;
            align-items: center;
            justify-content: center;
            margin-bottom: 20px;
        }

        button {
            background-color: #007bff;
            color: white;
            font-size: 16px;
            padding: 12px 20px;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            transition: background 0.3s;
        }

        button:hover {
            background-color: #0056b3;
        }

        #status, #timeEstimate {
            font-size: 14px;
            margin-top: 10px;
            color: #333;
        }

        #progress {
            width: 100%;
            background: #ddd;
            border-radius: 5px;
            margin: 10px 0;
            height: 20px;
            overflow: hidden;
            position: relative;
        }

        #progressBar {
            height: 100%;
            width: 0%;
            background: #007bff;
            transition: width 0.5s ease-in-out;
        }

        #downloadLink {
            display: none;
            margin-top: 10px;
            padding: 10px;
            background: #28a745;
            color: white;
            text-decoration: none;
            border-radius: 5px;
            /* display: inline-block; */
        }

        #downloadLink:hover {
            background: #218838;
        }
    </style>
</head>
<body>
    <h1>音声文字起こしツール</h1>

    <div class="container">
        <label for="modelSelect">モデルを選択してください:</label>
        <select id="modelSelect" onchange="updateTimeEstimate()">
            <option value="tiny">Tiny(処理速度:速(10x)・精度:低)</option>
            <option value="small">Small(処理速度:中(4x)・精度:中)</option>
            <option value="medium">Medium(処理速度:遅(2x)・精度:高)</option>
            <option value="large">Large(処理速度:最遅(1x)・精度:最高)</option>
        </select>

        <input type="file" id="audioFile" accept="audio/*" onchange="updateTimeEstimate()">
        
        <label>
            <input type="checkbox" id="speakerDiarization"> <span>話者分離を有効にする</span>
        </label>

        <button onclick="uploadFile()">アップロードして文字起こし開始</button>

        <p id="status">ステータス: 待機中</p>
        <p id="timeEstimate">想定処理時間: -</p>

        <div id="progress">
            <div id="progressBar"></div>
        </div>

        <a id="downloadLink">文字起こし結果をダウンロード</a>
    </div>

    <script>
        let eventSource;

        function listenToProgress() {
            eventSource = new EventSource('/progress');
            eventSource.onmessage = function(event) {
                const data = JSON.parse(event.data.replace(/'/g, '"'));
                document.getElementById('status').innerText = `ステータス: ${data.status}`;

                // 進行状況をパーセンテージ表示
                let progressPercentage = (data.current_chunk / data.total_chunks) * 100;
                document.getElementById('progressBar').style.width = `${progressPercentage}%`;
            };
        }

        function updateTimeEstimate() {
            const fileInput = document.getElementById('audioFile').files[0];
            const modelSelect = document.getElementById('modelSelect').value;
            const timeEstimateElement = document.getElementById('timeEstimate');

            if (!fileInput) {
                timeEstimateElement.innerText = "想定処理時間: -";
                return;
            }

            // 音声の長さを取得
            const audio = new Audio(URL.createObjectURL(fileInput));
            audio.addEventListener("loadedmetadata", () => {
                const duration = audio.duration; // 秒単位
                let estimatedTime = 0;

                // モデルごとの処理速度
                switch (modelSelect) {
                    case "tiny":
                        estimatedTime = duration / 10;
                        break;
                    case "small":
                        estimatedTime = duration / 4;
                        break;
                    case "medium":
                        estimatedTime = duration / 2;
                        break;
                    case "large":
                        estimatedTime = duration / 1;
                        break;
                }

                const minutes = Math.floor(estimatedTime / 60);
                const seconds = Math.floor(estimatedTime % 60);
                timeEstimateElement.innerText = `想定処理時間: ${minutes}分 ${seconds}秒`;
            });
        }

        async function uploadFile() {
            const fileInput = document.getElementById('audioFile');
            const modelSelect = document.getElementById('modelSelect').value;
            const diarizationEnabled = document.getElementById('speakerDiarization').checked;
            const file = fileInput.files[0];

            if (!file) {
                alert("音声ファイルを選択してください。");
                return;
            }

            document.getElementById('status').innerText = 'ステータス: アップロード中...';
            document.getElementById('progressBar').style.width = '0%';
            listenToProgress();

            const formData = new FormData();
            formData.append('file', file);
            formData.append('model', modelSelect);
            formData.append('diarization', diarizationEnabled);

            try {
                const response = await fetch('/transcribe', {
                    method: 'POST',
                    body: formData
                });

                if (response.ok) {
                    document.getElementById('status').innerText = 'ステータス: 文字起こし完了!';
                    document.getElementById('progressBar').style.width = '100%';

                    const blob = await response.blob();
                    const downloadUrl = URL.createObjectURL(blob);
                    const downloadLink = document.getElementById('downloadLink');
                    downloadLink.href = downloadUrl;
                    downloadLink.download = diarizationEnabled ? 'transcript_diarized.txt' : 'transcript.txt';
                    downloadLink.style.display = 'block';
                    downloadLink.innerText = '文字起こし結果をダウンロード';
                } else {
                    document.getElementById('status').innerText = 'エラーが発生しました。';
                }

                eventSource.close();
            } catch (error) {
                console.error('エラー:', error);
                document.getElementById('status').innerText = '通信エラーが発生しました。';
            }
        }
    </script>
</body>
</html>

良かった点

  • 録音環境が悪くても意外と認識してくれる
  • 日本語の文字起こし精度が高い
  • 話者分離もある程度機能する

改善したい点

  • 長時間の音声ファイルだと処理に時間がかかる
  • 話者分離の精度はもう少し改善したい
  • ユーザーインターフェースを整えれば、さらに便利になりそう

とはいえ、無料かつローカル環境で動かせるツールとしては、かなり実用的です。

音声認識文字起こしツールを作ってみた感想と課題

1時間くらいで音声をテキストデータとして出力できるようになりました。本当に簡単すぎて呆気なかったくらい。

あまりに簡単にできたので欲張って色々模索してみましたが、それでも最終の完成コードの状態まで持っていくのに3時間くらい。本当にAIすごい。

ただ、もっと手軽で高性能サービスもあるので(NotebookLMとか)サービスとして提供するには、もっと改善が必要ではあります。

それから、ChatGPTに与えるプロンプトがかなり大事だということも勉強になりました。

今回説明するのは省きましたが、適切な情報・指示を与えないと、的外れな回答を返してくることがあります。

ただ、そこら辺も分からない場合は質問の仕方を質問したり、返ってきたコードを深掘りしていくことで解決できました。

まとめ

今回、思い立って試行錯誤しながら作ってみた音声文字起こしツール。想像以上に高精度で、自分のラジオ配信やインタビュー音源の文字起こしに活躍してくれそうです。

プログラミングの知識が少しあれば、こうしたツールを自作できる時代。市販のツールに頼らず、自分に最適な環境を整えられるのは大きなメリットだと感じました。

大規模開発ではもっと与える情報を整理した方が良さそうですが、ちゃんとその辺りのルールを決めて上手く使えば個人開発では色々できそうです。以下の記事が参考になりそうだったので、興味がある人はぜひ。

この記事が気に入ったら
フォローしてね!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次