Whisper+Unityでマイクの音量によって音声の書き起こしを行う

WhisperとUnityを使って音声の書き起こしを行います。ボタンを押してマイクに話すと書き起こしが動作するという安全策を多くは取りますが、いちいちボタンを押すのが煩わしい場合があります。
そこでここでは音量をトリガーにして、マイクの音量が特定の範囲内であれば書き起こしを行うようにします。
どうやっても上手くいかなかったので、こちらのライブラリを参考にさせて頂きました。ありがとうございます。

準備

1.UniTaskをインポートする

2.UIのTextを3つ作成する。ひとつはボリューム表示用、ひとつは録音の状態を表示するもの、もうひとつは音声からの書き起こしを表示するもの
3.AudioSorceを適当なオブジェクトに適用する
4.JSONを成形する「Newtonsoft Json」をインポートするため、PackageManagerから「Add package from git URL...」→「com.unity.nuget.newtonsoft-json」と入力する

コード

マイクの音量が特定の範囲内のときに録音を開始し、AudioClipに保存する

■録音開始条件:
・maxVolume(最大音量) が voiceDetectionThreshold(音声検出の下限値) と voiceDetectionMaxThreshold(音声検出の上限値) の間である場合、音声が検出されたとみなされます (isDetectingVoice = true)
・音声が検出されると、録音開始時間 (recordingStartTime) を記録し、ステータスを「録音開始」に更新します
・音声が検出されて、かつ録音中でない (isRecording = false)の場合に録音を開始
■録音終了条件:
・録音フラグをリセットし (isRecording = false)、録音時間を計算します
・録音時間が最小録音長 (voiceDetectionMinimumLength) を満たしている場合、ステータスを「録音完了」に更新します
・録音データを AudioClip に変換し、AudioSource に設定します
・audioProcessor.ProcessRecordedData(recordedClip) を呼び出し、録音データを処理します。
・音声が検出されない (maxVolume < voiceDetectionThreshold または maxVolume > voiceDetectionMaxThreshold) 状態が続く場合、無音として扱います (isDetectingVoice = false)
・録音中 (isRecording = true) かつ無音の状態が一定時間 (silenceDurationToEndRecording) 続くと、録音が終了します

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using Cysharp.Threading.Tasks;
using System;

public class VoiceRecorder : MonoBehaviour
{
    public Text volumeText; // マイク入力のボリュームを表示するUIのText
    public Text statusText; // 録音開始・完了を表示するUIのText
    public AudioSource audioSource; // インスペクタで指定するAudioSource

    private List<float> recordedData = new List<float>(); // 録音データを保持するリスト
    private bool isRecording = false; // 録音中かどうかを示すフラグ
    private bool isDetectingVoice = false; // 音声を検出しているかどうかを示すフラグ
    private bool isListening = false; // マイクがリスニング中かどうかを示すフラグ
    private float lastVoiceDetectedTime = 0.0f; // 最後に音声を検出した時間
    private float recordingStartTime = 0.0f; // 録音開始時間
    private int lastSamplePosition = 0; // 最後に取得したサンプルの位置

    private float voiceDetectionThreshold = 0.1f; // 音声検出の下限値
    private float voiceDetectionMaxThreshold = 0.7f; // 音声検出の上限値
    private float voiceDetectionMinimumLength = 1.0f; // 録音最小長
    private float silenceDurationToEndRecording = 2.0f; // 無音で録音終了するまでの時間

    private AudioClip microphoneInput; // マイク入力を保持するAudioClip
    private int samplingFrequency = 16000; // サンプリング周波数
    private string microphoneDevice; // 使用するマイクデバイスの名前
    private float[] samplingData; // サンプリングデータを格納する配列

         [SerializeField] private AudioProcessorMemory audioProcessor;

    private void Start()
    {
        
        StartListening().Forget(); // 開始時にマイクのリスニングを開始
    }

    private void OnDestroy()
    {
        StopListening(); // 破棄時にマイクのリスニングを停止
    }

    private async UniTaskVoid StartListening()
    {
        if (Microphone.devices.Length > 0) // マイクデバイスが存在するかチェック
        {
            microphoneDevice = Microphone.devices[0]; // 最初のマイクデバイスを使用
            microphoneInput = Microphone.Start(microphoneDevice, true, 1, samplingFrequency); // マイクの録音を開始
            samplingData = new float[microphoneInput.samples * microphoneInput.channels]; // サンプリングデータの配列を初期化
            isListening = true; // リスニングを開始
            recordedData.Clear(); // 録音データをクリア
            lastSamplePosition = 0; // 最後のサンプル位置をリセット
            await MonitorMicrophone(); // マイクの監視を非同期で開始
        }
        else
        {
            Debug.LogError("No microphone devices found"); // マイクデバイスが見つからない場合はエラーメッセージを表示
        }
    }

    private void StopListening()
    {
        if (isListening) // リスニング中かどうかチェック
        {
            Microphone.End(microphoneDevice); // マイクの録音を停止
            isListening = false; // リスニングフラグをリセット
        }
    }

    private async UniTask MonitorMicrophone()
    {
        while (isListening) // リスニング中はループを継続
        {
            int currentPosition = Microphone.GetPosition(microphoneDevice); // マイクの現在の位置を取得
            if (currentPosition != lastSamplePosition) // 有効な位置かチェック
            {
                microphoneInput.GetData(samplingData, 0); // マイクからデータを取得
                float maxVolume = GetMaxVolume(samplingData); // データから最大音量を取得
                volumeText.text = $"Volume: {maxVolume:F2}"; // 音量をUIに表示

                if (maxVolume > voiceDetectionThreshold && maxVolume < voiceDetectionMaxThreshold) // 音声検出閾値をチェック
                {
                    isDetectingVoice = true; // 音声検出フラグをセット
                    if (!isRecording) // 録音中でない場合
                    {
                        recordingStartTime = Time.time; // 録音開始時間を設定
                        statusText.text = "録音開始"; // ステータスを更新
                    }
                    isRecording = true; // 録音フラグをセット
                    lastVoiceDetectedTime = Time.time; // 最後の音声検出時間を更新
                    // 現在の位置から最後のサンプル位置までのサンプルデータを追加
                    int sampleCount = currentPosition - lastSamplePosition;
                    if (sampleCount < 0)
                    {
                        sampleCount += samplingData.Length;
                    }
                    float[] newSamples = new float[sampleCount];
                    if (lastSamplePosition < currentPosition)
                    {
                        Array.Copy(samplingData, lastSamplePosition, newSamples, 0, sampleCount);
                    }
                    else
                    {
                        Array.Copy(samplingData, lastSamplePosition, newSamples, 0, samplingData.Length - lastSamplePosition);
                        Array.Copy(samplingData, 0, newSamples, samplingData.Length - lastSamplePosition, currentPosition);
                    }
                    recordedData.AddRange(newSamples);
                    lastSamplePosition = currentPosition;
                }
                else
                {
                    isDetectingVoice = false; // 音声検出フラグをリセット
                    if (isRecording && Time.time - lastVoiceDetectedTime >= silenceDurationToEndRecording) // 無音が一定時間続いた場合
                    {
                        isRecording = false; // 録音フラグをリセット
                        float recordedLength = recordedData.Count / (float)samplingFrequency - silenceDurationToEndRecording; // 録音長を計算
                        statusText.text = recordedLength >= voiceDetectionMinimumLength ? "録音完了" : "録音完了"; // ステータスを更新
                        // 録音したデータをAudioClipに変換
                        AudioClip recordedClip = AudioClip.Create("RecordedClip", recordedData.Count, microphoneInput.channels, samplingFrequency, false);
                        recordedClip.SetData(recordedData.ToArray(), 0);
                        audioSource.clip = recordedClip; // AudioSourceに録音データを設定
                        audioProcessor.ProcessRecordedData(recordedClip); // AudioProcessorに録音データを渡して処理
                        recordedData.Clear(); // 録音データをクリア
                        lastSamplePosition = 0; // 最後のサンプル位置をリセット
                    }
                }
            }
            await UniTask.Yield(); // 非同期処理を一時停止して次のフレームまで待機
        }
    }

    private float GetMaxVolume(float[] data)
    {
        float maxVolume = 0f; // 最大音量を初期化
        foreach (var sample in data) // サンプリングデータをループ
        {
            if (sample > maxVolume) // 現在のサンプルが最大音量を超える場合
            {
                maxVolume = sample; // 最大音量を更新
            }
        }
        return maxVolume; // 最大音量を返す
    }
}

生成されたAudioClipをWavに変換して、メモリに保存する

using System;
using UnityEngine;
using Cysharp.Threading.Tasks;

public class AudioProcessorMemory : MonoBehaviour
{
    [SerializeField] private WhisperSTTMemory whisperSTTMemory;

    // 録音データを処理するメソッド
    public async void ProcessRecordedData(AudioClip recordedClip)
    {
        // AudioClipからサンプルデータを取得
        float[] samples = new float[recordedClip.samples * recordedClip.channels];
        recordedClip.GetData(samples, 0);

        // 無音部分をカット
        AudioClip trimmedClip = TrimSilenceFromClip(recordedClip, 0.01f);

        // AudioClipをwavデータに変換してメモリに保存
        byte[] wavData = ConvertAudioClipToWav(trimmedClip);

        // メモリ上のwavデータを使って音声書き起こしを行う
        if (whisperSTTMemory != null)
        {
            await whisperSTTMemory.TranscribeAudioAsync(wavData);
        }
        else
        {
            Debug.LogError("whisperSTTMemory is not initialized!");
        }
    }

    // 無音部分をカットするメソッド
    private AudioClip TrimSilenceFromClip(AudioClip clip, float silenceThreshold)
    {
        // サンプルデータを取得
        float[] samples = new float[clip.samples * clip.channels];
        clip.GetData(samples, 0);

        // 無音部分の開始と終了を検出
        int startSample = 0;
        int endSample = samples.Length - 1;
        for (int i = 0; i < samples.Length; i++)
        {
            if (Mathf.Abs(samples[i]) > silenceThreshold)
            {
                startSample = i;
                break;
            }
        }
        for (int i = samples.Length - 1; i >= 0; i--)
        {
            if (Mathf.Abs(samples[i]) > silenceThreshold)
            {
                endSample = i;
                break;
            }
        }

        // 無音部分をカットしたサンプルデータを作成
        int trimmedLength = endSample - startSample + 1;
        float[] trimmedSamples = new float[trimmedLength];
        Array.Copy(samples, startSample, trimmedSamples, 0, trimmedLength);

        // 新しいAudioClipを作成
        AudioClip trimmedClip = AudioClip.Create("TrimmedClip", trimmedLength / clip.channels, clip.channels, clip.frequency, false);
        trimmedClip.SetData(trimmedSamples, 0);

        return trimmedClip;
    }

    // AudioClipをwavデータに変換するメソッド
    private byte[] ConvertAudioClipToWav(AudioClip clip)
    {
        int samples = clip.samples;
        int channels = clip.channels;
        int frequency = clip.frequency;

        float[] data = new float[samples * channels];
        clip.GetData(data, 0);

        byte[] wavData = new byte[samples * channels * 2 + 44];
        WriteHeader(wavData, clip);

        int offset = 44;
        for (int i = 0; i < data.Length; i++)
        {
            short value = (short)(data[i] * 32767);
            wavData[offset++] = (byte)(value & 0xFF);
            wavData[offset++] = (byte)((value >> 8) & 0xFF);
        }

        return wavData;
    }

    // wavファイルのヘッダーを書き込むメソッド
    private void WriteHeader(byte[] wavData, AudioClip clip)
    {
        int samples = clip.samples;
        int channels = clip.channels;
        int frequency = clip.frequency;

        int byteRate = frequency * channels * 2;

        // RIFFヘッダー
        wavData[0] = (byte)'R';
        wavData[1] = (byte)'I';
        wavData[2] = (byte)'F';
        wavData[3] = (byte)'F';

        int fileSize = 36 + samples * channels * 2;
        wavData[4] = (byte)(fileSize & 0xFF);
        wavData[5] = (byte)((fileSize >> 8) & 0xFF);
        wavData[6] = (byte)((fileSize >> 16) & 0xFF);
        wavData[7] = (byte)((fileSize >> 24) & 0xFF);

        wavData[8] = (byte)'W';
        wavData[9] = (byte)'A';
        wavData[10] = (byte)'V';
        wavData[11] = (byte)'E';

        // fmtチャンク
        wavData[12] = (byte)'f';
        wavData[13] = (byte)'m';
        wavData[14] = (byte)'t';
        wavData[15] = (byte)' ';

        wavData[16] = 16;
        wavData[17] = 0;
        wavData[18] = 0;
        wavData[19] = 0;

        wavData[20] = 1;
        wavData[21] = 0;

        wavData[22] = (byte)channels;
        wavData[23] = 0;

        wavData[24] = (byte)(frequency & 0xFF);
        wavData[25] = (byte)((frequency >> 8) & 0xFF);
        wavData[26] = (byte)((frequency >> 16) & 0xFF);
        wavData[27] = (byte)((frequency >> 24) & 0xFF);

        wavData[28] = (byte)(byteRate & 0xFF);
        wavData[29] = (byte)((byteRate >> 8) & 0xFF);
        wavData[30] = (byte)((byteRate >> 16) & 0xFF);
        wavData[31] = (byte)((byteRate >> 24) & 0xFF);

        wavData[32] = (byte)(channels * 2);
        wavData[33] = 0;

        wavData[34] = 16;
        wavData[35] = 0;

        // dataチャンク
        wavData[36] = (byte)'d';
        wavData[37] = (byte)'a';
        wavData[38] = (byte)'t';
        wavData[39] = (byte)'a';

        int dataSize = samples * channels * 2;
        wavData[40] = (byte)(dataSize & 0xFF);
        wavData[41] = (byte)((dataSize >> 8) & 0xFF);
        wavData[42] = (byte)((dataSize >> 16) & 0xFF);
        wavData[43] = (byte)((dataSize >> 24) & 0xFF);
    }
}

WavファイルをWhisperに送って書き起こしをする

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;
using Cysharp.Threading.Tasks;
using UnityEngine.UI; 

public class WhisperSTTMemory : MonoBehaviour
{
    [SerializeField]
    private string openAIApiKey;
    [SerializeField]
    private Text userVoiceText; // UIのTextコンポーネントへの参照

    public async UniTask TranscribeAudioAsync(byte[] audioData)
    {
        string url = "https://api.openai.com/v1/audio/transcriptions";
        string accessToken = openAIApiKey;

        var formData = new List<IMultipartFormSection>
        {
            new MultipartFormDataSection("model", "whisper-1"),
            new MultipartFormDataSection("language", "ja"),
            new MultipartFormFileSection("file", audioData, "audio.wav", "multipart/form-data")
        };

        using (UnityWebRequest request = UnityWebRequest.Post(url, formData))
        {
            request.SetRequestHeader("Authorization", "Bearer " + accessToken);
            await request.SendWebRequest().ToUniTask();

            if (request.result != UnityWebRequest.Result.Success)
            {
                Debug.LogError(request.error);
                return;
            }

            string jsonResponse = request.downloadHandler.text;
            string recognizedText = "";
            try
            {
                recognizedText = JsonUtility.FromJson<WhisperResponseModel>(jsonResponse).text;
            }
            catch (System.Exception e)
            {
                Debug.LogError(e.Message);
            }

            Debug.Log("書き起こし: " + recognizedText);
            userVoiceText.text = recognizedText;
        }
    }
}

public class WhisperResponseModel
{
    public string text;
}

上記のコード3つはすべてオブジェクトに適用します。

いいなと思ったら応援しよう!