텍스트를 입력하면 음성으로 변환해 출력하고~ LLM AI응답도 음성으로 출력해 AI와 음성대화를 하는 심플음성채팅을 OPENAPI를 이용해 작동되는 데모로

Blazor의 특성과 액터모델의 장점을 이용해 더 강력한 커스텀화된 RealTime AI기능을 만들수 있습니다.

DEMO

  • 진행되는 대화내용은 텍스트로 표시되며 동시에 음성으로 출력됩니다. ( 음성을 내기 힘든 상황에서의 인풋형 음성채팅 )
  • TTS및 컴플리트 LLM기능만 이용하지만 음성간 응답흐름을 최대한 짧게응답해 리얼타임인것처럼
  • 세션및 리얼타임 LLM을 이용하지 않고~ 문맥과 응답을 이어갈수 있는 개인별 상태를 유지하는 액터베이스로 설계


어플리케이션 컴포넌트 구성 

핵심구현코드를 간단하게 살펴보겠습니다.

주요핵심코드

응답속도를 높이기 위한 트릭

_ = Task.Run(() => GetChatCompletion(command.Text));
var recVoice = _openAIService.ConvertTextToVoiceAsync(command.Text, command.Voice).Result;
_blazorCallback?.Invoke("AddMessage", new object[] { command.From, command.Text });
_blazorCallback?.Invoke("PlayAudioBytes", new object[] { recVoice, 0.5f, playType });
  • Text가 입력되면 TTS 음성파일을 준비하는 동안, LLM요청을 미리 분리된 스레드에서 요청합니다.
    • VoiceAPI보다 LLM Text응답이 더 빠르기때문에 가능한 전략
  • 여기서 응답속도를 더 줄이려면~ LLM에 응답받은 Text + 응답해야할 음성파일을 미리 만들수도 있습니다.


OPEN AI API 활용

public async Task<string> GetChatCompletion(string message, List<string> assist)
{
    var completionResult = await _client.CompleteChatAsync(new ChatMessage[]
    {
        ChatMessage.CreateUserMessage(message),
        ChatMessage.CreateAssistantMessage(string.Join("\n", assist))
    });
    string aiResponse = completionResult.Value.Content.FirstOrDefault()?.Text ?? string.Empty;

    return aiResponse;
}


/// <summary>
/// voice alloy, ash, ballad, coral, echo, fable, onyx, nova, sage, , shimme
/// </summary>
/// <param name="text"></param>
/// <param name="voice"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public async Task<float[]> ConvertTextToVoiceAsync(string text, string voice = "alloy")
{
    var requestBody = new
    {
        model = "gpt-4o-mini-tts",  // TTS 모델 이름
        input = text,               // 변환할 텍스트
        voice                       // 음성 스타일
    };
    
    var ttsTask = _httpClient.PostAsJsonAsync("audio/speech", requestBody);
    
    await Task.WhenAll(ttsTask); // 두 작업을 동시실행하고 , 동시완료될 때까지 기다림

    var response = await ttsTask;

    if (!response.IsSuccessStatusCode)
    {
        throw new InvalidOperationException($"TTS API 호출 실패: {response.ReasonPhrase}");
    }

    // MP3 데이터를 byte 배열로 변환하여 반환
    var audioBytes = await response.Content.ReadAsByteArrayAsync();

    // MP3 데이터를 PCM 데이터로 변환
    return ConvertMp3ToFloatArray(audioBytes);
}
  • ChatCompletion와 VoiceAPI의 TTS두개만 활용되었으며 더 다양한 스트림기능을 이용해 확장해나갈수 있습니다.


MP3 TO PCM

private float[] ConvertMp3ToFloatArray(byte[] mp3Data)
{
    using var mp3Stream = new MemoryStream(mp3Data);
    using var mp3Reader = new Mp3FileReader(mp3Stream);

    // PCM 데이터를 float 배열로 변환
    var sampleProvider = mp3Reader.ToSampleProvider();
    var totalSamples = (int)(mp3Reader.TotalTime.TotalSeconds * mp3Reader.WaveFormat.SampleRate * mp3Reader.WaveFormat.Channels);
    var floatBuffer = new float[totalSamples];
    int samplesRead = sampleProvider.Read(floatBuffer, 0, floatBuffer.Length);

    return floatBuffer.Take(samplesRead).ToArray();
}
  • OpenAPI의 TTS는 mp3로 반환하며~ 순수웹에서 스트리밍 재생하려면 PCM형태의 스트림 데이터 변환이 필요하며 서버에서 변환합니다.


웹에서의 재생

async function playAudioBytes(audioBytes, playbackRate, type, dotNetRef) {
    try {
        if (!audioContext || audioContext.state === 'closed') {
            audioContext = new AudioContext();
        }

        // Float32Array로 변환된 PCM 데이터 사용
        const float32Array = new Float32Array(audioBytes);

        // AudioBuffer 생성
        const audioBuffer = audioContext.createBuffer(1, float32Array.length, audioContext.sampleRate);
        audioBuffer.copyToChannel(float32Array, 0);

        // 재생
        const bufferSource = audioContext.createBufferSource();
        bufferSource.buffer = audioBuffer;
        bufferSource.playbackRate.value = playbackRate; // 재생 속도 설정
        bufferSource.connect(audioContext.destination);                

        // 재생 완료 이벤트 핸들러 추가
        bufferSource.onended = () => {            
            // 타입에 따라 추가 작업 수행
            console.log(`오디오 재생 완료 재생 타입: ${type}`);
            if (type === 1) {
                console.log("Type 1: 휴먼요청 재생완료~ LLM응답재생 요청");
                if (dotNetRef && typeof dotNetRef.invokeMethodAsync === "function") {
                    dotNetRef.invokeMethodAsync("OnAudioPlaybackCompleted", 1)
                        .catch(err => console.error("Blazor 메서드 호출 OnAudioPlaybackCompleted 중 오류 발생:", err));
                }                
            } else if (type === 2) {
                console.log("Type 2: AI재생완료");
            } else if (type === 3) {
                console.log("Type 3: 사용자 정의 작업");
            }
        };

        bufferSource.start();
    } catch (err) {
        console.error("오디오 재생 중 오류 발생:", err);
    }
}
  • PCM Byte를 웹에서 재생할수 있으며, 재생이 완료되면~ 오디오 재생이 끝난 순간의 이벤트를 서버(Blazor)에게 리얼타임으로 호출할수 있습니다.
    • Blazor는 웹소켓 인터페이스를 기본으로 사용해 리모트지점의 함수를 리얼타임처럼 호출가능한 전략으로, Blazor가 아닌경우 WS인터페이스로 구현대할수 있습니다.


BlazorPage

<PageTitle>WebRTC</PageTitle>
<h1>WebRTC</h1>
    <!-- 채팅창 -->
    <MudItem xs="12">
        <MudPaper Class="pa-3">
            <MudButton OnClick="StartWebRTC" Class="mt-4">
                Start WebRTC
            </MudButton>
        </MudPaper>
        <MudPaper Class="pa-3">
            <MudText Typo="Typo.subtitle1">채팅</MudText>
            <MudStack>
                <!-- 채팅 리스트 -->
                <MudList T="string">
                    @foreach (var chat in ChatMessages)
                    {
                        <MudListItem Text="@chat" Icon="@Icons.Material.Filled.Chat" />
                    }
                </MudList>
                <!-- 채팅 입력 -->
                <MudTextField @bind-Value="ChatInput" Placeholder="메시지를 입력하세요..." />
                <MudButton OnClick="SendChatMessage" Class="mt-2">전송</MudButton>
            </MudStack>
        </MudPaper>
    </MudItem>
@code {
    [JSInvokable]
    public async Task OnAudioPlaybackCompleted(int option)
    {
      MyVoiceActor.Tell(new TTSCommand()
      {
      From = "AI",
      Text = "LLM자동재생",
      Voice = "alloy"
      });
    }

    private void PlayAudioBytes(float[] voice, float speed, int playtype)
    {
      InvokeAsync(() =>
      {
      var dotNetRef = DotNetObjectReference.Create(this);
      JSRuntime.InvokeVoidAsync("playAudioBytes", voice, speed, playtype, dotNetRef);
      });
    }
}
  • BlazorPage는 서버 코드의 상태값 변경으로 프론트의 페이지를 부분 갱신할수 있는 장점이 있습니다. 
    • 이러한 방식은 서버렌더링에서는 불가능하며 WS를 이용한 InteractiveServer 방식입니다.
  • JSInvokable 를 통해 JS가 호출할수 있는 원격함수를 만들수도 있으며, InvokeVoidAsync를 통해 프론트의 js함수를 호출할수 있습니다 ( 양방향 호출가능)
  • WebRTC이 제대로 활용되지 않았으나~ webrtc로 부터 발생한 데이터도 서버로 분석되어 작동됩니다.
    • WebRTC에 발생된 음성 오디오 데이터와 볼륨데이터가 전송되어 음성채팅데이터 서버에서 활용가능

VoiceChatActor

public class VoiceChatActor : ReceiveActor, IWithTimers
{    
    private List<String> _conversationHistory = new();

    private string lastAiMessage = string.Empty;

    private Action<string, object[]> _blazorCallback;

    private OpenAIService _openAIService;

    private int MaxAIWordCount = 100; // AI 응답 최대 단어 수 설정

    private sealed class TimerKey
    {
        public static readonly TimerKey Instance = new();
        private TimerKey() { }
    }

    public int RefreshTimeSecForContentAutoUpdate { get; set; } = 30;

    public ITimerScheduler Timers { get; set; } = null!;

    public VoiceChatActor(IServiceProvider serviceProvider)
    {
        logger.Info($"VoiceChatActor : Constructor - {Self.Path}");

        _openAIService = new OpenAIService();

        // 액터별 반복스케줄러 기능을 가져~ 응답이 아닌 능동형기능에 이용될수 있습니다.
        Timers.StartPeriodicTimer(
            key: TimerKey.Instance,
            msg: new ContentAutoUpdateCommand(),
            initialDelay: TimeSpan.FromSeconds(10),
            interval: TimeSpan.FromSeconds(RefreshTimeSecForContentAutoUpdate));

        Receive<ContentAutoUpdateCommand>( command =>
        {
            logger.Info("VoiceChatActor : ContentAutoUpdateCommand");                
        });

        Receive<TTSCommand>( command =>
        {
            logger.Info($"VoiceChatActor : Received Command - {command.GetType().Name}");
            switch (command.From)
            {
                case "Your":
                {
                    int playType = 1;
                    _ = Task.Run(() => GetChatCompletion(command.Text));
                    var recVoice = _openAIService.ConvertTextToVoiceAsync(command.Text, command.Voice).Result;
                    _blazorCallback?.Invoke("AddMessage", new object[] { command.From, command.Text });
                    _blazorCallback?.Invoke("PlayAudioBytes", new object[] { recVoice, 0.5f, playType });
                }                        
                break;
                case "AI":
                {
                    var msg = lastAiMessage;
                    int playType = 2;
                    var recVoice = _openAIService.ConvertTextToVoiceAsync(msg, command.Voice).Result;
                    _blazorCallback?.Invoke("AddMessage", new object[] { command.From, msg });
                    _blazorCallback?.Invoke("PlayAudioBytes", new object[] { recVoice, 0.5f, playType });
                }
                break;
                default:
                    logger.Warning($"Unknown command received: {command.From}");
                break;
            }
        });
    }

    /// <summary>
    /// 주어진 메시지에 대한 ChatCompletion을 생성합니다.
    /// </summary>
    /// <param name="message">보낼 메시지</param>
    /// <returns>ChatCompletion 결과</returns>
    public async Task<string> GetChatCompletion(string message)
    {
        _conversationHistory.Add($"User:{message}");

        // 최근 20개의 대화 기록을 가져옵니다.
        var recentHistory = _conversationHistory.Skip(Math.Max(0, _conversationHistory.Count - 20)).ToList();

        // 수정된 코드: ChatMessage 생성 시 올바른 정적 메서드 사용
        var aiResponse = await _openAIService.GetChatCompletion(

            $"요청메시지는 : {message} 이며 첨부메시지는 현재 대화내용의 히스토리이며 이 맥락을 유지하면서 답변, 답변은 {MaxAIWordCount}자미만으로 줄여서 답변을 항상해~ AI는 너가답변한것이니 언급없이 너인것처럼하면됨",
            recentHistory
        );            

        _conversationHistory.Add($"AI:{aiResponse}");
        lastAiMessage = aiResponse;

        return aiResponse;
    }
}
  • 음성채팅 기능의 이벤트를 처리하며 OpenAI API를 이용하고~ blazor에게 완료를 수행해 UI업데이트및 최종 프론트에게 재생 스트림을 전달해 재생시킬수도 있습니다.
  • 비교적 간단한 LLM ChatCompletion이 이용되었으며 이 부분을 개선해, 더 스마트한 AI음성봇을 만들수도 있습니다.
  • 사용자로부터 Input Text를 받는형태이지만 필요하면 음성 입력스트림을 바로 받아 처리할수도 있습니다.


데모샘플 전체코드





  • No labels