KLingo Project Documentation 1.0.0
Unreal Engine 5.6 C++ Project Documentation
로딩중...
검색중...
일치하는것 없음
UVoiceConversationSystem.cpp
이 파일의 문서화 페이지로 가기
1// Copyright (c) 2025 Doppleddiggong. All rights reserved. Unauthorized copying, modification, or distribution of this file, via any medium is strictly prohibited. Proprietary and confidential.
2
8
9#include "ALingoPlayerState.h"
10#include "GameLogging.h"
11#include "ULingoGameHelper.h"
12#include "UBroadcastManager.h"
13#include "UDialogManager.h"
15#include "UGameSoundManager.h"
17#include "ASpeakStageActor.h"
18#include "APlayerActor.h"
19#include "APlayerControl.h"
20#include "MiniOwlBot.h"
21#include "UChatHistorySystem.h"
22#include "UPopupManager.h"
23#include "UPopup_SpeakJudes.h"
24#include "UPopup_DailyStudy.h"
25#include "Sound/SoundWaveProcedural.h"
26#include "GameFramework/PlayerState.h"
27#include "Onepiece/Onepiece.h"
28
29UVoiceConversationSystem::UVoiceConversationSystem()
30{
31 PrimaryComponentTick.bCanEverTick = false;
32}
33
34void UVoiceConversationSystem::InitSystem(APlayerActor* InOwner)
35{
36 this->Owner = InOwner;
37
38 BroadcastManager = UBroadcastManager::Get(GetWorld());
39}
40
41void UVoiceConversationSystem::EndPlay(const EEndPlayReason::Type EndPlayReason)
42{
43 if (AudioCapture.IsValid())
44 AudioCapture->CloseStream();
45 Super::EndPlay(EndPlayReason);
46}
47
48// --- HTTP 방식 음성 대화 ---
49
50void UVoiceConversationSystem::StartRecording()
51{
52 if (bIsRecording || bIsProcessing)
53 {
54 PRINTLOG( TEXT("[VoiceConversation] Already recording or processing."));
55 return;
56 }
57
58 // 재생 중인 대화 음성이 있으면 정지 (UGameSoundManager 사용)
59 if (UWorld* World = GetWorld())
60 {
61 if (auto SoundManager = UGameSoundManager::Get(World))
62 {
63 if (SoundManager->IsConversationVoicePlaying())
64 {
65 SoundManager->StopConversationVoice();
66
67 // 타이머 정리
68 World->GetTimerManager().ClearTimer(VoiceFinishTimerHandle);
69
70 OnVoiceAudioFinished(); // 수동으로 호출하여 이전 상태를 정리합니다.
71 }
72 }
73 }
74
75 PCMData.Reset();
76
77 if (!AudioCapture)
78 AudioCapture = MakeUnique<Audio::FAudioCapture>();
79
80 // 사용 가능한 오디오 디바이스 목록 확인
81 TArray<Audio::FCaptureDeviceInfo> DeviceInfos;
82 AudioCapture->GetCaptureDevicesAvailable(DeviceInfos);
83
84 PRINTLOG(TEXT("[VoiceConversation] Available Audio Devices:"));
85 for (int32 i = 0; i < DeviceInfos.Num(); ++i)
86 {
87 PRINTLOG(TEXT(" [%d] %s (Channels: %d, SampleRate: %d, bSupportsHardwareAEC: %d)"),
88 i,
89 *DeviceInfos[i].DeviceName,
90 DeviceInfos[i].InputChannels,
91 DeviceInfos[i].PreferredSampleRate,
92 DeviceInfos[i].bSupportsHardwareAEC ? 1 : 0);
93 }
94
95 Audio::FAudioCaptureDeviceParams Params;
96 Params.DeviceIndex = 0; // TODO: 사용자가 선택할 수 있도록 개선 필요
97 Params.NumInputChannels = 1;
98
99 const bool bStreamOpened = AudioCapture->OpenAudioCaptureStream(
100 Params,
101 [this](const void* InAudio, int32 NumFrames, int32 InNumChannels, int32 InSampleRate, double StreamTime, bool bOverFlow)
102 {
103 // 첫 캡처 시 샘플레이트 로그 출력
104 static bool bLoggedOnce = false;
105 if (!bLoggedOnce)
106 {
107 PRINTLOG(TEXT("[VoiceConversation] Audio Capture Settings: SampleRate=%d, Channels=%d, Frames=%d"),
108 InSampleRate, InNumChannels, NumFrames);
109 bLoggedOnce = true;
110 }
111 HandleOnCapture(static_cast<const float*>(InAudio), NumFrames, InNumChannels, InSampleRate);
112 },
113 1024 // 버퍼 크기 증가 (512 → 1024) - 더 안정적인 녹음
114 );
115
116 if (!bStreamOpened || !AudioCapture->StartStream() )
117 {
118 if (auto DM = UDialogManager::Get(GetWorld()))
119 {
120 DM->ShowToast(TEXT("연결된 마이크가 없습니다"));
121 }
122
123 return;
124 }
125
126 // 스트림 시작 성공 후에 녹음 플래그 설정
127 bIsRecording = true;
128
129 BroadcastManager->SendAudioCapture(true);
130 PRINTLOG( TEXT("[VoiceConversation] Recording started."));
131}
132
133
134void UVoiceConversationSystem::HandleOnCapture(const float* InAudio, int32 InNumFrames, int32 InNumChannels, int32 InSampleRate)
135{
136 LastSampleRate = InSampleRate;
137 LastNumChannels = InNumChannels;
138
139 const int32 SampleCount = InNumFrames * InNumChannels;
140 PCMData.Reserve(PCMData.Num() + SampleCount * sizeof(int16));
141
142 float TotalVolume = 0.f;
143
144 for (int32 i = 0; i < SampleCount; ++i)
145 {
146 float Sample = InAudio[i];
147 Sample = FMath::Clamp(Sample, -1.0f, 1.0f);
148
149 TotalVolume += FMath::Abs(Sample);
150
151 int16 Int16Sample = static_cast<int16>(Sample * 32767.0f);
152 const uint8* SampleBytes = reinterpret_cast<const uint8*>(&Int16Sample);
153
154 PCMData.Append(SampleBytes, sizeof(int16));
155 }
156
157 const float AverageVolume = (SampleCount > 0) ? (TotalVolume / SampleCount) : 0.f;
158 const float AmplifiedVolume = AverageVolume * 10.f;
159
160 BroadcastManager->SendAudioSpectrum(FMath::Clamp(AmplifiedVolume, 0.f, 1.f));
161}
162
163void UVoiceConversationSystem::StopRecording()
164{
165 if (!bIsRecording)
166 {
167 PRINTLOG( TEXT("[VoiceConversation] Not currently recording."));
168 return;
169 }
170
171 bIsRecording = false;
172 bIsProcessing = true;
173
174 AudioCapture->StopStream();
175 AudioCapture->CloseStream();
176
177 BroadcastManager->SendAudioCapture(false);
178
179 PRINTLOG(TEXT("[VoiceConversation] Recording stopped. Original: SampleRate=%d, Channels=%d, PCM Size=%d bytes"),
180 LastSampleRate, LastNumChannels, PCMData.Num());
181
182 // STT 최적화: 스테레오 → 모노 변환
183 TArray<uint8> ProcessedPCM = PCMData;
184 int32 ProcessedChannels = LastNumChannels;
185
186 if (LastNumChannels == 2)
187 {
188 ProcessedPCM = UVoiceFunctionLibrary::ConvertStereoToMono(PCMData);
189 ProcessedChannels = 1; // 모노로 변경
190 }
191
192 // STT 최적화: 16kHz로 리샘플링
193 int32 TargetSampleRate = 16000;
194
195 if (LastSampleRate != TargetSampleRate)
196 {
197 ProcessedPCM = UVoiceFunctionLibrary::ResampleAudio(ProcessedPCM, LastSampleRate, TargetSampleRate, ProcessedChannels);
198 }
199
200 WAVData = UVoiceFunctionLibrary::ConvertPCM2WAV(ProcessedPCM, TargetSampleRate, ProcessedChannels, 16);
201 LastRecordedFilePath = UVoiceFunctionLibrary::SaveWavToFile(WAVData);
202
203 PRINTLOG(TEXT("[VoiceConversation] Recording saved to: %s"), *LastRecordedFilePath);
204 if (LastRecordedFilePath.IsEmpty())
205 {
206 PRINTLOG( TEXT("[VoiceConversation] FilePath is Empty") );
207 return;
208 }
209
210 UKLingoNetworkSystem* KLingoNetwork = UKLingoNetworkSystem::Get(GetWorld());
211 if (!KLingoNetwork)
212 {
213 PRINTLOG( TEXT("HttpSystem을 찾을 수 없습니다."));
214 bIsProcessing = false;
215 return;
216 }
217
218 // SpeakStageActor 존재 여부와 CurrentSpeaker 확인
219 auto SpeakStageActor = ULingoGameHelper::GetSpeakStageActor(GetWorld());
220 bool bUseSpeakJudges = false;
221
222 if (SpeakStageActor)
223 {
224 // 로컬 플레이어의 PlayerState 가져오기
225 APlayerState* LocalPlayerState = nullptr;
226 if (Owner)
227 {
228 APlayerController* PC = Cast<APlayerController>(Owner->GetController());
229 if (PC)
230 {
231 LocalPlayerState = PC->GetPlayerState<APlayerState>();
232 }
233 }
234
235 // CurrentSpeaker 확인
236 ALingoPlayerState* CurrentSpeaker = SpeakStageActor->GetCurrentSpeaker();
237
238 // SpeakStageActor가 있고 CurrentSpeaker가 나라면 SpeakJudges 사용
239 if (CurrentSpeaker && LocalPlayerState && CurrentSpeaker == LocalPlayerState)
240 {
241 bUseSpeakJudges = true;
242 }
243 }
244
245 if (bUseSpeakJudges)
246 {
247 // Toast 메시지 표시: 답변 분석 중
248 if (UDialogManager* DM = UDialogManager::Get(GetWorld()))
249 {
250 DM->ShowToast(TEXT("The officer is reviewing your answer"));
251 }
252
253 KLingoNetwork->RequestSpeakingJudges(
254 SpeakStageActor->GetCurrentQuestion(),
255 LastRecordedFilePath,
256 FResponseSpeakingJudesDelegate::CreateUObject(this, &UVoiceConversationSystem::OnResponseSpeakingsJudges));
257 }
258 else if (auto* DailyStudyPopup = Cast<UPopup_DailyStudy>(UPopupManager::Get(GetWorld())->GetCurrentPopupWidget()))
259 {
260 // Toast 메시지 표시: 답변 분석 중
261 if (UDialogManager* DM = UDialogManager::Get(GetWorld()))
262 DM->ShowToast(TEXT("The officer is reviewing your answer"));
263
264 KLingoNetwork->RequestSpeakingJudges(
265 DailyStudyPopup->GetCurrentQuestionText(),
266 LastRecordedFilePath,
267 FResponseSpeakingJudesDelegate::CreateUObject(this, &UVoiceConversationSystem::OnResponseSpeakingsJudges));
268 }
269 else
270 {
271 // Toast 메시지 표시: 답변 분석 중
272 if (UDialogManager* DM = UDialogManager::Get(GetWorld()))
273 {
274 DM->ShowToast(TEXT("Processing your voice message..."));
275 }
276
277 // PlayerState에서 Chat Context 가져오기
278 FString ChatContext = TEXT("You are a helpful assistant."); // 기본값
279 if (Owner)
280 {
281 APlayerController* PC = Cast<APlayerController>(Owner->GetController());
282 if (PC)
283 {
284 if (auto PS = PC->GetPlayerState<ALingoPlayerState>())
285 {
286 ChatContext = PS->GetChatContext();
287 }
288 }
289 }
290
291 // 일반 대화 모드: RequestChatAnswers 사용
292 KLingoNetwork->RequestChatAudio(
293 ChatContext,
294 LastRecordedFilePath,
295 FResponseChatAnswersDelegate::CreateUObject(this, &UVoiceConversationSystem::OnResponseChatAnswers));
296 }
297}
298
299void UVoiceConversationSystem::OnResponseSpeakingsJudges(FResponseSpeakingJudes& Response, bool bSuccess)
300{
301 bIsProcessing = false;
302
303 if (bSuccess)
304 {
305 if (auto* DailyStudyPopup = Cast<UPopup_DailyStudy>(UPopupManager::Get(GetWorld())->GetCurrentPopupWidget()))
306 {
307 DailyStudyPopup->OnResponseSpeakingsJudges(Response);
308 return;
309 }
310
311 // PlayerActor의 Server RPC 호출 (PlayerActor는 Client 소유!)
312 if (Owner)
313 Owner->Server_NotifySpeakJudgeComplete(Response);
314 }
315 else
316 {
317 PRINTLOG( TEXT("--- Network Response Received (FAIL) ---"));
318 }
319}
320
321void UVoiceConversationSystem::OnResponseChatAnswers(FResponseChatAnswers& Response, bool bSuccess)
322{
323 bIsProcessing = false;
324
325 if (bSuccess)
326 {
327 // 메시지로 답변 표시
328 if (auto* GS = GetWorld()->GetGameState<ALingoGameState>())
329 {
330 APlayerControl* PC = Cast<APlayerControl>(Owner->GetController());
331 if ( PC != nullptr )
332 {
333 // Chat History 저장
334 PC->GetChatHistorySystem()->SaveChatHistory(Response.question, Response.answer);
335
336 // ai tutor 봇에 메시지 표시
337 if (auto playerActor = Cast<APlayerActor>(PC->GetPawn()))
338 {
339 playerActor->GetMiniOwlBot()->UpdateText(Response.answer);
340 }
341
342 FText PlayerQuestion = FText::FromString(Response.question);
343 // Bot은 PlayerIndex -1 사용
344 GS->MulticastRPC_SendChat(PC->GetUserInfo(), PlayerQuestion, PC->GetUserId());
345
346 FText AIAnswer = FText::FromString(Response.answer);
347 // Bot은 PlayerIndex -1 사용
348 GS->MulticastRPC_SendChat(GS->GetBotInfo(), AIAnswer, DefineData::BotID);
349 }
350
351 PRINTLOG(TEXT("[AI Chat] AI Answer: %s"), *Response.answer);
352 }
353 }
354 else
355 {
356 PRINTLOG( TEXT("--- Chat Answers Response Received (FAIL) ---"));
357 }
358}
359
360bool UVoiceConversationSystem::PlayVoiceAudio(const TArray<uint8>& AudioData)
361{
362 // 녹음 중일 때는 TTS 재생 차단
363 if (bIsRecording)
364 {
365 PRINTLOG(TEXT("[VoiceConversation] TTS playback blocked: recording in progress"));
366 return false;
367 }
368
369 // 오디오 데이터가 없으면 재생 불가
370 if (AudioData.Num() == 0)
371 {
372 PRINTLOG(TEXT("[VoiceConversation] TTS playback failed: empty audio data"));
373 return false;
374 }
375
376 // SoundWave 생성 (Procedural 사용)
378 if (!IsValid(SoundWave))
379 {
380 PRINTLOG(TEXT("[VoiceConversation] TTS playback failed: invalid sound wave"));
381 return false;
382 }
383
384 // UGameSoundManager를 통해 대화 음성 재생 (기존 음성 자동 중지)
385 auto SoundManager = UGameSoundManager::Get(GetWorld());
386 if (!SoundManager)
387 {
388 PRINTLOG(TEXT("[VoiceConversation] TTS playback failed: could not get sound manager"));
389 return false;
390 }
391
392 CurVoiceAudio = SoundManager->PlayConversationVoice(SoundWave);
393 if (!CurVoiceAudio)
394 {
395 return false;
396 }
397
398 // Duration 기반 타이머로 재생 완료 감지
399 const float Duration = SoundWave->Duration;
400 PRINTLOG(TEXT("[VoiceConversation] TTS audio playing (duration: %.2f seconds)"), Duration);
401
402 // 기존 타이머 정리
403 if (auto World = GetWorld())
404 {
405 World->GetTimerManager().ClearTimer(VoiceFinishTimerHandle);
406
407 // Duration + 여유 시간(0.1초) 후에 OnVoiceAudioFinished 호출
408 World->GetTimerManager().SetTimer(
409 VoiceFinishTimerHandle,
410 this,
411 &UVoiceConversationSystem::OnVoiceAudioFinished,
412 Duration + 0.1f,
413 false
414 );
415 }
416
417 return true;
418}
419
420void UVoiceConversationSystem::OnVoiceAudioFinished()
421{
422 // 타이머 정리
423 if (auto World = GetWorld())
424 World->GetTimerManager().ClearTimer(VoiceFinishTimerHandle);
425
426 // AudioComponent 참조 초기화 (실제 파괴는 UGameSoundManager가 관리)
427 CurVoiceAudio = nullptr;
428}
Declares the player-controlled character actor.
APlayerControl 선언에 대한 Doxygen 주석을 제공합니다.
YiSan 전반에서 사용하는 공용 인터페이스를 선언합니다.
#define PRINTLOG(fmt,...)
Definition GameLogging.h:30
Chat 대화 기록을 GConfig를 이용하여 관리하는 컴포넌트입니다.
UDialogManager 클래스를 선언합니다.
UGameSoundManager 클래스를 선언합니다.
KLingo API 요청을 담당하는 서브시스템을 선언합니다.
STT·GPT·TTS 파이프라인을 연결하는 음성 대화 컴포넌트를 선언합니다.
음성 데이터 처리와 명령 파싱을 위한 블루프린트 함수 라이브러리를 선언합니다.
Main character driven directly by the player.
int32 GetUserId() const
class UChatHistorySystem * GetChatHistorySystem() const
Chat History System에 접근합니다.
const FResponseUserMe & GetUserInfo() const
토스트 메시지와 같은 간단한 다이얼로그 위젯의 표시를 관리하는 LocalPlayer 서브시스템입니다.
KLingo 서버와의 HTTP 요청을 중재하는 게임 인스턴스 서브시스템입니다.
void RequestSpeakingJudges(const FString &Question, const FString &AudioPath, FResponseSpeakingJudesDelegate InDelegate)
Speaking 평가 요청을 전송합니다.
void RequestChatAudio(const FString &Context, const FString &AudioPath, FResponseChatAnswersDelegate InDelegate)
Chat 답변을 요청합니다 (음성 질문).
static class ASpeakStageActor * GetSpeakStageActor(const UObject *WorldContextObject)
static FString SaveWavToFile(TArray< uint8 > &InWavData, const FString &InFileName=TEXT(""))
WAV 데이터를 파일로 저장합니다.
static TArray< uint8 > ConvertStereoToMono(const TArray< uint8 > &InStereoPCMData)
스테레오 PCM 데이터를 모노로 변환합니다. (좌우 채널 평균)
static TArray< uint8 > ResampleAudio(const TArray< uint8 > &InPCMData, int32 InSampleRate, int32 OutSampleRate, int32 InNumChannels)
PCM 오디오 데이터를 다른 샘플레이트로 리샘플링합니다.
static USoundWaveProcedural * CreateProceduralSoundWaveFromWavData(const TArray< uint8 > &AudioData)
절차형 사운드 웨이브를 생성해 스트리밍 재생에 사용합니다.
static TArray< uint8 > ConvertPCM2WAV(const TArray< uint8 > &InPCMData, int32 InSampleRate, int32 InChannel, int32 InBitsPerSample)
PCM 데이터를 WAV 포맷으로 감쌉니다.
static const int32 BotID
Definition Onepiece.h:56
Chat Answers 응답 구조체입니다.
Speaking Questions 응답 구조체입니다.