Carrot
본문 바로가기
Unity/OpenAI

[OpenAI API] Whisper API 사용해보기

by 독기품은토끼 2026. 2. 24.

오늘은 Whisper API를 활용해서

음성으로 명령을 내리고 NPC와 상호작용하는 걸 구현해보려고 한다.

 

1. Whisper 사용해보기

Whisper는 Open AI에서 제공하는 대규모 데이터로 학습된 딥러닝 기반의 음성 인식 서비스이다.

이 API를 활용하면 녹음된 음성 파일을 인식하여 텍스트로 변환할 수 있다.

 

Whisper에는 다양한 모델이 있는데 이번 실습에서는 Large-v2 모델을 사용해보려고 한다.

 

1. Unity에서 Whisper API로 음성 인식해보기

우선 Unity에서 인식할 마이크부터 가져오는 스크립트를 작성해주겠다.

using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;
using Unity.VisualScripting;

public class SetMicrophone : MonoBehaviour
{
    public Dropdown dropdown; // 마이크 목록을 표시할 드롭다운
    public string currentMicrophone; // 현재 선택된 마이크
    
    void Start()
    {
        SetDeviceMicrophone();
    }

    void SetDeviceMicrophone()
    {
        // 디바이스에 연결된 마이크 목록 가져오기
        string[] microphones = Microphone.devices;

        // 기존 드롭다운 옵션 초기화
        dropdown.ClearOptions();

        // 마이크 목록을 드롭다운 형식으로 변환
        List<Dropdown.OptionData> options = new List<Dropdown.OptionData>();

        foreach (string device in microphones)
        {
            options.Add(new Dropdown.OptionData(device));
        }

        // 변환된 옵션을 드롭다운에 추가
        dropdown.AddOptions(options);

        // currentMicrophone에 첫번째 마이크 지정
        if (microphones.Length > 0)
        {
            currentMicrophone = microphones[0];
            dropdown.value = 0;
        }
        else
            currentMicrophone = null;

        // 드롭다운의 값이 변경될 때마다 호출되는 이벤트
        dropdown.onValueChanged.AddListener(delegate { DropdownValueChanged(dropdown); });
    }

    void DropdownValueChanged (Dropdown change)
    {
        // 현재 선텍된 옵션의 텍스트를 currentMicrophone에 저장
        currentMicrophone = change.options[change.value].text;
        Debug.Log(currentMicrophone);
    }
}

 

연결된 마이크 장치를 탐색하고 드롭다운 UI를 통해 마이크를 선택할 수 있도록 구현해주었다.

 

기본적으로 Whisper API는

mp3, mp4, mpeg, mpga, m4a, wav, webm의 형식을 지원하며

25MB 미만의 파일만 지원하므로 이보다 긴 오디오 파일의 경우 25MB 미만의 청크로 분할하거나 압축된 오디오 형식을 사용해야 한다.

 

이 점에 유의하여 마이크를 통해 인식된 오디오를 WAV 파일로 저장하는 스크립트를 만들어주겠다.

 

using System;
using System.IO;
using UnityEngine;

public class SaveWav : MonoBehaviour
{
    const int HEADER_SIZE = 44;

    public static bool Save(string path, AudioClip clip, float minThreshold = 0.01f)
    {
        if (!path.EndsWith(".wav", System.StringComparison.OrdinalIgnoreCase))
            path += ".wav";

        Directory.CreateDirectory(Path.GetDirectoryName(path));
        AudioClip trimmed = TrimSilence(clip, minThreshold);

        if (trimmed == null)
        {
            Debug.Log("저장할 오디오가 모두 무음입니다.");
            return false;
        }

        using (FileStream fs = CreateEmpty(path))
        {
            ConvertAndWrite(fs, trimmed);
            WriteHeader(fs, trimmed);
        }

        return true;
    }

    /// <summary>
    /// [최적화 작업]
    /// 오디오클립에서 지정된 임계값 이하의 음량(무음) 부분을 제거하는 작업
    /// </summary>
    static AudioClip TrimSilence(AudioClip clip, float threshold)
    {
        // 오디오 데이터 가져오기
        float[] samples = new float[clip.samples * clip.channels];
        clip.GetData(samples, 0);

        // 오디오의 시작점과 종료점 찾기
        int start = 0, end = samples.Length - 1;

        for (int i = 0; i < samples.Length; i++)
        {
            if (Mathf.Abs(samples[i]) > threshold)
            {
                start = i;
                break;
            }
        }

        for (int i = samples.Length - 1; i >= 0; i--)
        {
            if (Mathf.Abs(samples[i]) > threshold)
            {
                end = i;
                break;
            }
        }

        // 유효한 길이의 데이터가 존재하지않으면 무음으로 처리
        int len = end - start + 1;
        if (len <= 0)
            return null;

        // 무음이 제거된 버전으로 데이터 저장
        float[] trimmedSamples = new float[len];
        Array.Copy(samples, start, trimmedSamples, 0, len);
        AudioClip tClip = AudioClip.Create(clip.name + "_trimmed", len / clip.channels, clip.channels, clip.frequency, false);
        tClip.SetData(trimmedSamples, 0);
        return tClip;
    }

    /// <summary>
    /// 빈 파일 생성
    /// WAV 파일 헤더 영역에 임시로 0을 채워 넣어 헤더 자리 공간 확보
    /// </summary>
    static FileStream CreateEmpty(string path)
    {
        FileStream fs = new FileStream(path, FileMode.Create);
        for (int i = 0; i < HEADER_SIZE; i++)
            fs.WriteByte(0);
        return fs;
    }

    /// <summary>
    /// 음성 데이터를 WAV 형식에 맞는 바이트 배열로 변환
    /// </summary>
    static void ConvertAndWrite(FileStream fs, AudioClip clip)
    {
        float[] samples = new float[clip.samples * clip.channels];
        clip.GetData(samples, 0);

        byte[] bytes = new byte[samples.Length * 2];
        const int rescale = 32767;

        for (int i = 0; i < samples.Length; i++)
        {
            short s = (short)(samples[i] * rescale);
            Array.Copy(BitConverter.GetBytes(s), 0, bytes, i * 2, 2);
        }
        fs.Write(bytes, 0, bytes.Length);
    }

    /// <summary>
    /// WAV 파일의 헤더를 작성하여 오데오 데이터의 메타 데이터를 저장
    /// </summary>
    static void WriteHeader(FileStream fs, AudioClip clip)
    {
        int hz = clip.frequency, channels = clip.channels, samples = clip.samples;
        fs.Seek(0, SeekOrigin.Begin);

        void WriteStr(string s)
        {
            byte[] bytes = System.Text.Encoding.UTF8.GetBytes(s);
            fs.Write(bytes, 0, bytes.Length);
        }

        WriteStr("RIFF");
        fs.Write(BitConverter.GetBytes((int)fs.Length - 8), 0, 4);
        WriteStr("WAVEfmt ");
        fs.Write(BitConverter.GetBytes(16), 0, 4);
        fs.Write(BitConverter.GetBytes((ushort)1), 0, 2);
        fs.Write(BitConverter.GetBytes((ushort)channels), 0, 2);
        fs.Write(BitConverter.GetBytes(hz), 0, 4);
        fs.Write(BitConverter.GetBytes(hz * channels * 2), 0, 4);
        fs.Write(BitConverter.GetBytes((ushort)(channels * 2)), 0, 2);
        fs.Write(BitConverter.GetBytes((ushort)16), 0, 2);
        WriteStr("data");
        fs.Write(BitConverter.GetBytes(samples * channels * 2), 0, 4);
    }
}

 

이 스크립트를 작성하면서 뇌절이 왔는데..

하나하나 풀어서 이해해보자면 ^^..

 

 

[Save 메서드]

전체 저장 프로세스를 총괄하며

파일 경로 확인, 무음 제거, 파일 생성, 데이터 변환 및 헤더 작성의 순서로 WAV 파일을 생성한다.

  • 조건문을 통해 입력받은 경로가 .wav 확장자로 끝나는지 확인하고
  • Directory.CreateDirectory() 메서드를 통해 파일이 저장될 디렉터리를 생성한다.

 

[TrimSlience 메서드]

주어진 오디오 클립에서 지정한 임계값 이하의 음량(무음으로 판단) 부분을 제거한다.

  • 마이크를 통해 입력받은 전체 오디오 데이터를 float 배열 형태로 가져온 후
  • 배열의 앞과 뒤에서부터 임곗값보다 큰 값을 갖는 지점을 찾아 오디오의 시작과 종료지점 결정
  • 무음이 제거된 샘플 배열을 기반으로 새로운 오디오 클립 생성
// Create 매개변수
AudioClip AudioClip.Create(
    string name,
    int lengthSamples,
    int channels,
    int frequency,
    bool stream
);

 

새로운 오디오 클립으로 저장할 때 주의할 점은

Audio clip 내부 데이터는 프레임 * 채널로 이루어져 있는데 lengthSamples는 채널을 제외한 프레임 수로만 이루어져있다.

그래서 전체 샘플 길이 len을 채널 수로 나눈 값을 넣어주어야 한다.

 

즉, GetData()로 얻은 배열은 채널이 펼쳐진 전체 샘플이므로
AudioClip.Create에는 채널을 제외한 프레임 수(len / channels)를 전달해야 한다.

 

[CreateEmpty 메서드]

FileStream을 사용하여 파일을 생성하고 WAV 파일 헤더 영역에 임시로 0을 채워 넣어 헤더 자리를 위한 공간을 확보한다.

 

실제 WAV 파일의 헤더를 살펴보면 요러한데.. 뭐 일단 44바이트 인 것만 기억하자..^^ㅎ

음성 데이터 길이는 실제 데이터를 다 쓰기 전에는 알 수 없기 때문에 미리 확보해서 덮어쓰는 구조로 사용해주었다.

(공간이 없으면 덮어쓸 수 없으니 44바이트를 다 0으로 채워넣은 것)

 

[ConvertAndWrite 메서드]

오디오 클립의 샘플 데이터를 WAV 형식에 맞는 바이트 배열로 변환한다.

  • float 데이터를 short 타입으로 변환하여 정숫값을 바이트 배열로 변환
  • 변환된 바이트 타입의 배열을 FileStream에 기록

 

[WriteHeader 메서드]

WAV 파일의 헤더를 작성하여 오디오 파일의 메타데이터(포맷, 채널 수 , 샘플링 레이트 등)을 지정한다.

WriteStr("RIFF"); 
// WAV 파일이 RIFF(Resource Interchange File Format) 규격임을 나타내는 식별자

fs.Write(BitConverter.GetBytes((int)fs.Length - 8), 0, 4); 
// 파일 전체 크기 - 8바이트 (RIFF 규격에서 요구하는 파일 사이즈 값)

WriteStr("WAVEfmt "); 
// WAVE 오디오 파일임을 나타내고, 이어서 포맷 정보(fmt) 청크가 시작됨을 의미

fs.Write(BitConverter.GetBytes(16), 0, 4); 
// fmt 청크의 크기 (PCM 포맷은 항상 16바이트)

fs.Write(BitConverter.GetBytes((ushort)1), 0, 2); 
// 오디오 포맷 코드 (1 = PCM, 비압축 오디오)

fs.Write(BitConverter.GetBytes((ushort)channels), 0, 2); 
// 오디오 채널 수 (1 = Mono, 2 = Stereo)

fs.Write(BitConverter.GetBytes(hz), 0, 4); 
// 샘플링 레이트 (1초당 샘플 수, 예: 44100Hz)

fs.Write(BitConverter.GetBytes(hz * channels * 2), 0, 4); 
// ByteRate: 1초 동안 처리되는 바이트 수 (샘플레이트 × 채널 수 × 2바이트)

fs.Write(BitConverter.GetBytes((ushort)(channels * 2)), 0, 2); 
// BlockAlign: 한 프레임의 크기 (채널 수 × 2바이트)

fs.Write(BitConverter.GetBytes((ushort)16), 0, 2); 
// BitsPerSample: 샘플당 비트 수 (16bit PCM)

WriteStr("data"); 
// 실제 오디오 데이터 청크의 시작을 알리는 식별자

fs.Write(BitConverter.GetBytes(samples * channels * 2), 0, 4); 
// 실제 오디오 데이터의 크기 (샘플 수 × 채널 수 × 2바이트)

 

갑자기 네트워크 배우는 거 같은 기분 ㅋ...

WAV 헤더에도 각 필드?마다 몇 바이트 씩 쓸 수 있도록 구조화 되어있을 거 아녀

그래서 각 필드에다가 형식들을 넣어준다고 생각하면 된다.

 

이제 Whisper API를 호출해서 음성을 텍스트로 변환하는 작업을 진행해주겠다.

 

using UnityEngine;
using UnityEngine.UI;
using System.IO;
using System.Collections;
using UnityEngine.Networking;
using System;
using Unity.VisualScripting;

public class WhisperManager : MonoBehaviour
{
    public Toggle recordToggle;
    private AudioClip clip;
    private SetMicrophone setMicrophoneScript;

    private bool isRecording = false;
    private int duration = 300; // 최대 녹음 시간
    private string fileName = "recordedAudio.wav";

    private string url = "https://api.openai.com/v1/audio/transcriptions";
    public string apiKey;

    public event Action<string> OnReceivedWhisper;
    public event Action OnStartRecording;
    public event Action OnStopRecording;

    /// <summary>
    /// JSON 파싱을 위해 text를 저장할 수 있는 구조 생성
    /// </summary>
    [Serializable]
    public class WhisperResponse
    {
        public string text;
    }

    public static WhisperManager Instance;
    private void Awake()
    {
        if (Instance == null)
            Instance = this;
        else
            Destroy(Instance);
    }

    void Start()
    {
        // 씬에서 SetMicrophone 스크립트 찾기
        setMicrophoneScript = FindAnyObjectByType<SetMicrophone>();
        if (setMicrophoneScript == null)
            Debug.LogError("SetMicrophone 스크립트를 찾을 수 없습니다.");

        recordToggle.onValueChanged.AddListener(OnToggleValueChanged);
    }

    void OnToggleValueChanged(bool isOn)
    {
        if (isOn)
        {
            StartRecording();
        }
        else
        {
            StopRecording();
        }
    }

    void StartRecording()
    {
        if (setMicrophoneScript == null || string.IsNullOrEmpty(setMicrophoneScript.currentMicrophone))
        {
            Debug.LogError("선택된 마이크가 없습니다.");
            return;
        }

        if (!isRecording)
        {
            // 선택된 마이크를 통해서 녹음 ㅅ ㅣ작
            clip = Microphone.Start(setMicrophoneScript.currentMicrophone, false, duration, 44100);
            isRecording = true;
            Debug.Log($"녹음을 시작합니다: {setMicrophoneScript.currentMicrophone}");
            OnStartRecording?.Invoke();
        }
    }

    void StopRecording()
    {
        if (isRecording)
        {
            // 녹음 중지
            Microphone.End(setMicrophoneScript.currentMicrophone);
            isRecording= false;
            Debug.Log("녹음을 중지하였습니다.");

            // 오디오 클립 저장
            SaveClip();
            OnStopRecording?.Invoke();
        }
    }

    void SaveClip()
    {
        if (clip != null)
        {
            var filePath = Path.Combine(Application.persistentDataPath, fileName);
            SaveWav.Save(filePath, clip);

            Debug.Log($"녹음이 저장되었습니다: {filePath}");

            // Whisper API에 오디오 파일을 전송하여 텍스트 받기
            StartCoroutine(SendWhisperRequest(filePath));
        }
        else
            Debug.LogError("저장할 오디오 클립이 없습니다.");
    }

    IEnumerator SendWhisperRequest(string filepath)
    {
        // 오디오 파일을 바이트 배열로 읽어오기
        byte[] audioData = File.ReadAllBytes(filepath);

        // Multipart form 데이터 생성
        WWWForm form = new WWWForm();
        form.AddField("model", "whisper-1");
        form.AddBinaryData("file", audioData, "audio.wav", "audio/wav");

        // 요청 생성
        UnityWebRequest request = UnityWebRequest.Post(url, form);
        request.SetRequestHeader("Authorization", "Bearer " + apiKey);

        // 응답 대기
        yield return request.SendWebRequest();

        if (request.result == UnityWebRequest.Result.Success)
        {
            // 응답 처리
            string responseText = request.downloadHandler.text;
            Debug.Log("Whisper API 응답 : " + responseText);

            // JSON 파싱을 통해 텍스트 추출
            try
            {
                var jsonResponse = JsonUtility.FromJson<WhisperResponse>(responseText);
                string transcribedText = jsonResponse.text;
                Debug.Log("인식된 텍스트 : " + transcribedText);
                OnReceivedWhisper?.Invoke(transcribedText);
            }
            catch (Exception e)
            {
                Debug.LogError("JSON 파싱 오류 : " + e.Message);
            }
        }
        else 
            Debug.LogError("Whisper API 요청 실패 : " + request.error);
    }
}

 

SetMicroPhone에서 선택된 마이크 정보를 참조해서 녹음을 제어하고

녹음 종료 시 SaveWav 유틸리티를 호출하여 WAV 파일을 생성 한 뒤

해당 파일을 Whisper API로 전송해 텍스트를 얻고

그 결과를 이벤트로 외부 시스템에 전달하는 역할을 하도록 구현해주었다!

 

🚨 WWWForm이 뭘까?

Unity에서 multipart/form-data 요청을 쉽게 만들기 위한 클래스

여러 종류의 데이터를 하나의 HTTP 요청으로 보내기 위해 사용됨

WWWForm form = new WWWForm();
form.AddField("model", "whisper-1");
form.AddBinaryData("file", audioData, "audio.wav", "audio/wav");

 

WAV 파일은 바이너리 데이터이고 JSON은 텍스트 기반이기 때문에
Whisper API에 모델 이름(텍스트)과 음성 파일(바이너리)을 함께 보내려면 multipart/form-data 방식이 필요하다.
Unity에서는 이를 쉽게 구성하기 위해 WWWForm을 사용해준다고 한다!

 

 

마이크를 선택한 후 말한 다음에 정지를 누르면..

 

 

내가 말한대로 제대로 텍스트가 입력된 것을 확인할 수 있었다!

재밌다 재밌다 ㅎㅋㅋㅋㅋㅋㅋ ^^..