【Unity】ChatGPT APIを使ってみたい#1 音声を文字おこし

創作活動

みそ味です。大晦日と正月はポケモンZAをしていました。

ここ数年は心が苦しく、自分のことにあまり手がつかない日々が続いていましたが、この年末年始には部屋を掃除し、昨日は休日なのに入浴と歯磨きが出来ました。偉すぎます。

2024年の夏に↓のオセロゲームの製作を通してUnityの練習をしました。

今度はUnity×ChatGPT APIで何かしてみようかなと思いました。
トークンに応じて課金しなくちゃいけないのは要注意ですが、Unityとうまく組み合わせられれば面白いことが出来そうです。

練習のため、まずはUnityを使ってChatGPTとやり取りするプログラムを組めるように頑張ります。
その第一歩として、こちらが発した音声を文字おこしするプログラムを組んでみました。

実際に書いたプログラムはこちら↓ GhatGPT君に手取り足取り教えてもらいながら書きました。
プログラミング経験皆無人間なのであまり参考にはしないでください。

using System.Collections;
using System.Collections.Generic;
using System.IO;    //ファイルを保存したり読み込んだりするための道具箱
using System.Text;
using UnityEngine;  //Unityで必須のクラスが入っている道具箱
using UnityEngine.Networking;
using UnityEngine.UI;

public class VoiceChatController : MonoBehaviour
{
    //[SerializeField]→外部のスクリプトからはいじれないが、UnityエディタのInspectorに表示できるようにするタグ
    //(pubricとprivateの中間のイメージ)
    [Header("再生設定")][SerializeField,Tooltip("録音した音を再生するスピーカー(Unity内の話、デバイスのことではない)")] AudioSource playbackSource;
    [Header("音質設定")][SerializeField] int sampleRate = 16000;    //「人の会話に十分・ファイルも軽い」らしい
    [Header("最大録音時間")][SerializeField] int maxRecordSeconds = 10;
    [Header("最短長制限(秒)")][SerializeField] float minDurationSec = 0.8f;
    [Header("平均振幅の目安")][SerializeField] float silenceThreshold = 0.005f;

    bool isUploading;
    private string micDevice;
    private bool isRecording = false;
    private AudioClip recordedClip;


    //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    // Start is called before the first frame update
    void Start()
    {
        //マイク確認(用いるマイクはUniity側で選べる、スピーカーはOS依存)
        if(Microphone.devices.Length == 0)
        //Microphone.devices→PCに接続されているマイクの一覧を返す
        {
            Debug.LogError("マイクが見つかりません。接続や設定を確認してください。");
            enabled = false;    //スクリプトの無効化(private bool enabled = true;みたいな定義は不要)
            return;             //マイクが接続されていなかったらStart()を抜ける
        }

        micDevice = Microphone.devices[0];  //最初のマイクを使う(後でマイク選択UI追加の場合ChatGPT相談する)

        if(!playbackSource)
        {
            Debug.LogError("AudioSourceが割り当てられていません!Inspectorで設定してください。");
            //enabled = false;    //スクリプトの無効化

        }

        Debug.Log($"使用マイク: {micDevice}");
        Debug.Log("スペースキーで録音開始/停止できます。");

    }

    //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    // Update is called once per frame
    void Update()
    {
        if(Input.GetKeyDown(KeyCode.Space))     // スペースキーを押された瞬間に録音開始or終了
        {
            if (!isRecording) StartRecording();
            else StopRecordingAndPlay();
        }

    }

    //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    void StartRecording()
    {
        isRecording = true;
        recordedClip = Microphone.Start(micDevice, false, maxRecordSeconds, sampleRate);    //録音開始のスイッチ(毎フレーム呼び出す必要はない)

        Debug.Log("録音開始");
    }

    //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    void StopRecordingAndPlay()
    {
        isRecording = false;

        int pos = Microphone.GetPosition(micDevice);    //何サンプル目まで音が溜まったか → 実際に録れた長さの把握
        Microphone.End(micDevice);                      //録音を終了
        Debug.Log("録音停止");

        //実際の長さに合わせて切り出し
        var ch = recordedClip.channels;
        int sr = sampleRate;
        float[] data = new float[pos * ch];
        recordedClip.GetData(data, 0);
        var trimmed = AudioClip.Create("trimmed", pos, ch, sr, false);
        trimmed.SetData(data, 0);

        //再生して確認
        //playbackSource.clip = trimmed;
        //playbackSource.Play();
        //Debug.Log("自分の声を再生中…");


        //①短すぎるデータの送信防止
        float dur = pos / (float)sr;
        if (dur < minDurationSec)
        {
            Debug.Log($"短すぎるため送信しません: {dur:F2}s");
            return;
        }


        //②簡易無音判定
        if (IsMostlySilent(data, silenceThreshold))  //IsMostlySilentメソッドを呼び出し、結果がTrueならば
        {
            Debug.Log("無音判定のため送信しません");
            return;
        }


        //③タイムスタンプ付きでWAV保存(上書き回避)
        var fileName = $"input_{System.DateTime.Now:yyyyMMdd_HHmmss}.wav";
        var path = System.IO.Path.Combine(Application.persistentDataPath, fileName);    //Application.persistentDataPath = OSごとに決まった「アプリ保存用フォルダ」
        System.IO.File.WriteAllBytes(path, ToWav(trimmed));
        Debug.Log($"保存先:{path}");


        //④自動送信(多重ガード)
        if (isUploading)
        {
            Debug.Log("送信中のためスキップ");
            return;
        }
        StartCoroutine(Co_SendWavToStt(path));

    }

    //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    bool IsMostlySilent(float[] samples, float threshold)
    {
        double sum = 0;
        int n = samples.Length;
        for (int i = 0; i < n; i++) sum += System.Math.Abs(samples[i]);
        double avg = sum / System.Math.Max(1, n);
        return avg < threshold;
    }

    //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    private byte[] ToWav(AudioClip clip)   //AudioClipをWAVに変換する関数(16bit PCM
    {
        int samples = clip.samples * clip.channels;

        // AudioClipのfloatデータ(-1.0f〜1.0f)を取り出す
        var floatData = new float[samples];
        clip.GetData(floatData, 0);

        // 16bit PCM へ変換
        var intData = new short[samples];
        for (int i = 0; i < samples; i++)
        {
            float f = Mathf.Clamp(floatData[i], -1f, 1f);
            intData[i] = (short)Mathf.RoundToInt(f * short.MaxValue);
        }

        // WAVヘッダを書いてからデータを書く
        using var ms = new MemoryStream();
        using var bw = new BinaryWriter(ms);

        int channels = clip.channels;
        int sampleRate = clip.frequency;
        int byteRate = sampleRate * channels * 2; // 16bit = 2bytes
        int subchunk2Size = intData.Length * 2;
        int chunkSize = 36 + subchunk2Size;

        bw.Write(Encoding.ASCII.GetBytes("RIFF"));
        bw.Write(chunkSize);
        bw.Write(Encoding.ASCII.GetBytes("WAVE"));
        bw.Write(Encoding.ASCII.GetBytes("fmt "));
        bw.Write(16);                 // Subchunk1Size (PCM)
        bw.Write((short)1);           // AudioFormat = PCM
        bw.Write((short)channels);
        bw.Write(sampleRate);
        bw.Write(byteRate);
        bw.Write((short)(channels * 2)); // BlockAlign
        bw.Write((short)16);             // BitsPerSample
        bw.Write(Encoding.ASCII.GetBytes("data"));
        bw.Write(subchunk2Size);

        foreach (var s in intData) bw.Write(s);

        return ms.ToArray();
    }

    //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~


    [ContextMenu("Send input.wav to STT")]  //Inspectorから右クリックで呼べるようになる。(後で変える)
    public void SendLatestWavToStt()
    {
        var path = System.IO.Path.Combine(Application.persistentDataPath, "input.wav");
        if (!System.IO.File.Exists(path))
        {
            Debug.LogError("input.wav が見つかりません。まず録音して保存してください。");
            return;
        }
        StartCoroutine(Co_SendWavToStt(path));
    }

    //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    [System.Serializable] private class SttResponse //レスポンス用クラス([Serializable] がないと Unity が中身を読めない)
    {
        public string text;
    }


    //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    private IEnumerator Co_SendWavToStt(string filePath)
    {
        isUploading = true;
        try
        {
            // 1) APIキー:環境変数から取得(直書きしない)
            var apiKey = System.Environment.GetEnvironmentVariable("OPENAI_API_KEY");   //apiキーを読む
            if (string.IsNullOrWhiteSpace(apiKey))
            {
                Debug.LogError("OPENAI_API_KEY が見つかりません。環境変数を設定してください。");
                yield break;
            }

            // 2) 送り先URL(音声→テキスト)。必要に応じて最新ドキュメントで確認・更新
            var url = "https://api.openai.com/v1/audio/transcriptions";

            // 3) multipart/form-data 組み立て
            var form = new List<IMultipartFormSection>();
            form.Add(new MultipartFormDataSection("model", "whisper-1"));   // 必要に応じて推奨モデル名へ
            var wavBytes = System.IO.File.ReadAllBytes(filePath);           //ファイルをバイト列に読む
            form.Add(new MultipartFormFileSection("file", wavBytes, "input.wav", "audio/wav"));

            // 4) POSTして送る(Unityが自動で multipart にしてくれる)
            using var req = UnityWebRequest.Post(url, form);
            req.SetRequestHeader("Authorization", $"Bearer {apiKey}");

            // 5) 送信&待機
            yield return req.SendWebRequest();

            // 6) 結果チェック
            if (req.result != UnityWebRequest.Result.Success)
            {
                Debug.LogError($"STT失敗: {req.responseCode} {req.error}\n{req.downloadHandler.text}");
                yield break;
            }

            // 7) 返ってくる典型は { "text": "..." }
            var json = req.downloadHandler.text;
            var text = ExtractTextField(json); // 下にあるJSONバーサ
            Debug.Log($"[音声→文字] {text}");
        }
        finally
        {
            //WAVファイルはもういらないから消す
            //if (File.Exists(filePath))
            //{
            //    File.Delete(filePath);
            //    Debug.Log($"WAV削除: {filePath}");
            //}

            isUploading = false;
        }
    }

    // JSONパーサ
    private string ExtractTextField(string json)
    {
        try
        {
            var res = JsonUtility.FromJson<SttResponse>(json);  //JSON文字列を解析してSttResponse クラスに詰めてくれる
            return res.text;                                    //結果の文字列だけを返す
        }
        catch
        {
            Debug.LogError("JSONのパースに失敗しました");
            return json;
        }
    }
}

スペースキーで録音開始・停止を制御して、できたWAVデータをSTT送信して文字起こしします。

実際に動かしてみましたが、発した言葉をちゃんと文字起こししてくれています。
録音停止してから文字が返ってくるまで体感3秒ほどでした。

次は文字起こしした文章をChatGPTに送って、お返事をもらうプログラムを書いてみようと思います。

コメント

タイトルとURLをコピーしました