Add Games File
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"FileVersion": 3,
|
||||
"Version": 1,
|
||||
"VersionName": "1.0.0",
|
||||
"FriendlyName": "Qwen TTS Streaming",
|
||||
"Description": "Streams Qwen TTS Gateway audio_b64 chunks into a procedural audio component.",
|
||||
"Category": "Audio",
|
||||
"CreatedBy": "FunnyGirl",
|
||||
"CanContainContent": false,
|
||||
"IsBetaVersion": true,
|
||||
"EnabledByDefault": true,
|
||||
"Modules": [
|
||||
{
|
||||
"Name": "QwenTTSStreaming",
|
||||
"Type": "Runtime",
|
||||
"LoadingPhase": "Default"
|
||||
},
|
||||
{
|
||||
"Name": "QwenTTSStreamingEditor",
|
||||
"Type": "Editor",
|
||||
"LoadingPhase": "Default"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# Qwen TTS Streaming
|
||||
|
||||
Runtime plugin for streaming Qwen TTS Gateway audio into Unreal Engine.
|
||||
|
||||
## Assets
|
||||
|
||||
Create two data assets in the editor:
|
||||
|
||||
- `Qwen TTS Streaming Settings Data Asset`
|
||||
- `Qwen TTS Preset Data Asset`
|
||||
|
||||
They are registered in the Content Browser create menu under the Audio asset
|
||||
category.
|
||||
|
||||
Default gateway URL:
|
||||
|
||||
```text
|
||||
wss://services.ai-games-studio.ru/qwen-tts/generate/stream/ws
|
||||
```
|
||||
|
||||
Default Kira preset id:
|
||||
|
||||
```text
|
||||
23c7eff6-5a38-451c-9b31-2d312a4e8b3d
|
||||
```
|
||||
|
||||
## Component
|
||||
|
||||
Add `Qwen TTS Streaming Component` to an actor. It inherits from `AudioComponent`.
|
||||
|
||||
Set:
|
||||
|
||||
- `Settings`
|
||||
- `Preset`
|
||||
|
||||
Then call:
|
||||
|
||||
```text
|
||||
Stream Text
|
||||
```
|
||||
|
||||
The component opens a WebSocket, sends the request, receives JSON messages with
|
||||
`audio_b64`, decodes each WAV chunk into PCM, and queues it into
|
||||
`USoundWaveProcedural`.
|
||||
|
||||
Useful events:
|
||||
|
||||
- `On Stream Started`
|
||||
- `On Audio Chunk`
|
||||
- `On Stream Finished`
|
||||
- `On Stream Error`
|
||||
|
||||
Each `On Audio Chunk` event includes the original `audio_b64`, decoded PCM byte
|
||||
count, sample rate, channel count, and chunk index.
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
|
||||
#include "QwenTTSPresetDataAsset.h"
|
||||
|
||||
UQwenTTSPresetDataAsset::UQwenTTSPresetDataAsset()
|
||||
{
|
||||
PresetId = TEXT("23c7eff6-5a38-451c-9b31-2d312a4e8b3d");
|
||||
}
|
||||
+454
@@ -0,0 +1,454 @@
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
|
||||
#include "QwenTTSStreamingComponent.h"
|
||||
|
||||
#include "Async/Async.h"
|
||||
#include "IWebSocket.h"
|
||||
#include "Json.h"
|
||||
#include "Misc/Base64.h"
|
||||
#include "QwenTTSPresetDataAsset.h"
|
||||
#include "QwenTTSStreamingSettingsDataAsset.h"
|
||||
#include "QwenTTSStreamingLog.h"
|
||||
#include "Sound/SoundWaveProcedural.h"
|
||||
#include "WebSocketsModule.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
struct FDecodedWavChunk
|
||||
{
|
||||
TArray<uint8> PCM;
|
||||
int32 SampleRate = 0;
|
||||
int32 NumChannels = 0;
|
||||
int32 BitsPerSample = 0;
|
||||
};
|
||||
|
||||
uint16 ReadLE16(const uint8* Data)
|
||||
{
|
||||
return static_cast<uint16>(Data[0]) | (static_cast<uint16>(Data[1]) << 8);
|
||||
}
|
||||
|
||||
uint32 ReadLE32(const uint8* Data)
|
||||
{
|
||||
return static_cast<uint32>(Data[0]) | (static_cast<uint32>(Data[1]) << 8) | (static_cast<uint32>(Data[2]) << 16) | (static_cast<uint32>(Data[3]) << 24);
|
||||
}
|
||||
|
||||
bool HasTag(const TArray<uint8>& Bytes, int32 Offset, const ANSICHAR* Tag)
|
||||
{
|
||||
return Bytes.IsValidIndex(Offset + 3)
|
||||
&& Bytes[Offset] == static_cast<uint8>(Tag[0])
|
||||
&& Bytes[Offset + 1] == static_cast<uint8>(Tag[1])
|
||||
&& Bytes[Offset + 2] == static_cast<uint8>(Tag[2])
|
||||
&& Bytes[Offset + 3] == static_cast<uint8>(Tag[3]);
|
||||
}
|
||||
|
||||
bool DecodeWavPCM16(const TArray<uint8>& WavBytes, FDecodedWavChunk& OutChunk, FString& OutError)
|
||||
{
|
||||
if (WavBytes.Num() < 44 || !HasTag(WavBytes, 0, "RIFF") || !HasTag(WavBytes, 8, "WAVE"))
|
||||
{
|
||||
OutError = TEXT("audio_b64 is not a RIFF/WAVE chunk");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool bFoundFmt = false;
|
||||
bool bFoundData = false;
|
||||
uint16 AudioFormat = 0;
|
||||
int32 Offset = 12;
|
||||
int32 DataOffset = 0;
|
||||
int32 DataSize = 0;
|
||||
|
||||
while (Offset + 8 <= WavBytes.Num())
|
||||
{
|
||||
const uint32 ChunkSize = ReadLE32(&WavBytes[Offset + 4]);
|
||||
const int32 ChunkDataOffset = Offset + 8;
|
||||
const int32 NextOffset = ChunkDataOffset + static_cast<int32>(ChunkSize) + (ChunkSize % 2);
|
||||
|
||||
if (ChunkDataOffset + static_cast<int32>(ChunkSize) > WavBytes.Num())
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (HasTag(WavBytes, Offset, "fmt "))
|
||||
{
|
||||
if (ChunkSize < 16)
|
||||
{
|
||||
OutError = TEXT("Invalid WAV fmt chunk");
|
||||
return false;
|
||||
}
|
||||
|
||||
AudioFormat = ReadLE16(&WavBytes[ChunkDataOffset]);
|
||||
OutChunk.NumChannels = static_cast<int32>(ReadLE16(&WavBytes[ChunkDataOffset + 2]));
|
||||
OutChunk.SampleRate = static_cast<int32>(ReadLE32(&WavBytes[ChunkDataOffset + 4]));
|
||||
OutChunk.BitsPerSample = static_cast<int32>(ReadLE16(&WavBytes[ChunkDataOffset + 14]));
|
||||
bFoundFmt = true;
|
||||
}
|
||||
else if (HasTag(WavBytes, Offset, "data"))
|
||||
{
|
||||
DataOffset = ChunkDataOffset;
|
||||
DataSize = static_cast<int32>(ChunkSize);
|
||||
bFoundData = true;
|
||||
}
|
||||
|
||||
Offset = NextOffset;
|
||||
}
|
||||
|
||||
if (!bFoundFmt || !bFoundData)
|
||||
{
|
||||
OutError = TEXT("WAV chunk has no fmt/data section");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (AudioFormat != 1 || OutChunk.BitsPerSample != 16)
|
||||
{
|
||||
OutError = FString::Printf(TEXT("Unsupported WAV format: format=%d bits=%d"), AudioFormat, OutChunk.BitsPerSample);
|
||||
return false;
|
||||
}
|
||||
|
||||
OutChunk.PCM.SetNumUninitialized(DataSize);
|
||||
FMemory::Memcpy(OutChunk.PCM.GetData(), &WavBytes[DataOffset], DataSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
void AddStringField(TSharedRef<FJsonObject> JsonObject, const FString& Key, const FString& Value)
|
||||
{
|
||||
if (!Value.IsEmpty())
|
||||
{
|
||||
JsonObject->SetStringField(Key, Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UQwenTTSStreamingComponent::UQwenTTSStreamingComponent()
|
||||
{
|
||||
bAutoActivate = false;
|
||||
bAutoDestroy = false;
|
||||
PrimaryComponentTick.bCanEverTick = false;
|
||||
}
|
||||
|
||||
bool UQwenTTSStreamingComponent::StreamText(const FString& Text)
|
||||
{
|
||||
if (Text.TrimStartAndEnd().IsEmpty())
|
||||
{
|
||||
BroadcastError(TEXT("Text is empty"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Settings)
|
||||
{
|
||||
BroadcastError(TEXT("Qwen TTS settings data asset is not set"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Preset)
|
||||
{
|
||||
BroadcastError(TEXT("Qwen TTS preset data asset is not set"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Settings->GatewayWebSocketUrl.IsEmpty())
|
||||
{
|
||||
BroadcastError(TEXT("GatewayWebSocketUrl is empty"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Settings->bStopAudioBeforeRequest)
|
||||
{
|
||||
UE_LOG(LogQwenTTSStreaming, Verbose, TEXT("Stopping current audio before a new Qwen TTS request."));
|
||||
Stop();
|
||||
}
|
||||
|
||||
StopStreaming();
|
||||
PendingText = Text;
|
||||
|
||||
UE_LOG(
|
||||
LogQwenTTSStreaming,
|
||||
Log,
|
||||
TEXT("Starting Qwen TTS stream. Url=%s PresetId=%s Language=%s TextChars=%d"),
|
||||
*Settings->GatewayWebSocketUrl,
|
||||
Preset ? *Preset->PresetId : TEXT("<none>"),
|
||||
Preset ? *Preset->Language : TEXT("<none>"),
|
||||
Text.Len());
|
||||
|
||||
Connect();
|
||||
return true;
|
||||
}
|
||||
|
||||
void UQwenTTSStreamingComponent::StopStreaming()
|
||||
{
|
||||
if (bStreaming || Socket.IsValid() || ProceduralSoundWave)
|
||||
{
|
||||
UE_LOG(LogQwenTTSStreaming, Verbose, TEXT("Stopping Qwen TTS stream."));
|
||||
}
|
||||
|
||||
bStreaming = false;
|
||||
bPlaybackStarted = false;
|
||||
PendingText.Reset();
|
||||
|
||||
if (Socket.IsValid())
|
||||
{
|
||||
Socket->Close();
|
||||
Socket.Reset();
|
||||
}
|
||||
|
||||
if (ProceduralSoundWave)
|
||||
{
|
||||
ProceduralSoundWave->ResetAudio();
|
||||
}
|
||||
}
|
||||
|
||||
bool UQwenTTSStreamingComponent::IsStreaming() const
|
||||
{
|
||||
return bStreaming && Socket.IsValid();
|
||||
}
|
||||
|
||||
bool UQwenTTSStreamingComponent::IsGeneratingVoice() const
|
||||
{
|
||||
return bStreaming;
|
||||
}
|
||||
|
||||
bool UQwenTTSStreamingComponent::IsPlayingVoiceAudio() const
|
||||
{
|
||||
return IsPlaying();
|
||||
}
|
||||
|
||||
void UQwenTTSStreamingComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
|
||||
{
|
||||
StopStreaming();
|
||||
Super::EndPlay(EndPlayReason);
|
||||
}
|
||||
|
||||
void UQwenTTSStreamingComponent::Connect()
|
||||
{
|
||||
FWebSocketsModule& WebSocketsModule = FModuleManager::LoadModuleChecked<FWebSocketsModule>(TEXT("WebSockets"));
|
||||
Socket = WebSocketsModule.CreateWebSocket(Settings->GatewayWebSocketUrl);
|
||||
|
||||
UE_LOG(LogQwenTTSStreaming, Log, TEXT("Connecting Qwen TTS WebSocket: %s"), *Settings->GatewayWebSocketUrl);
|
||||
|
||||
Socket->OnConnected().AddUObject(this, &UQwenTTSStreamingComponent::HandleConnected);
|
||||
Socket->OnConnectionError().AddUObject(this, &UQwenTTSStreamingComponent::HandleConnectionError);
|
||||
Socket->OnClosed().AddUObject(this, &UQwenTTSStreamingComponent::HandleClosed);
|
||||
Socket->OnMessage().AddUObject(this, &UQwenTTSStreamingComponent::HandleMessage);
|
||||
|
||||
bStreaming = true;
|
||||
Socket->Connect();
|
||||
}
|
||||
|
||||
void UQwenTTSStreamingComponent::HandleConnected()
|
||||
{
|
||||
if (!Socket.IsValid())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const FString RequestJson = BuildRequestJson();
|
||||
UE_LOG(LogQwenTTSStreaming, Log, TEXT("Qwen TTS WebSocket connected. Sending request bytes=%d"), RequestJson.Len());
|
||||
Socket->Send(RequestJson);
|
||||
OnStreamStarted.Broadcast();
|
||||
}
|
||||
|
||||
void UQwenTTSStreamingComponent::HandleConnectionError(const FString& Error)
|
||||
{
|
||||
UE_LOG(LogQwenTTSStreaming, Error, TEXT("Qwen TTS WebSocket connection error: %s"), *Error);
|
||||
bStreaming = false;
|
||||
BroadcastError(Error);
|
||||
}
|
||||
|
||||
void UQwenTTSStreamingComponent::HandleClosed(int32 StatusCode, const FString& Reason, bool bWasClean)
|
||||
{
|
||||
if (bWasClean)
|
||||
{
|
||||
UE_LOG(LogQwenTTSStreaming, Log, TEXT("Qwen TTS WebSocket closed. StatusCode=%d Clean=true Reason=%s"), StatusCode, *Reason);
|
||||
}
|
||||
else
|
||||
{
|
||||
UE_LOG(LogQwenTTSStreaming, Warning, TEXT("Qwen TTS WebSocket closed. StatusCode=%d Clean=false Reason=%s"), StatusCode, *Reason);
|
||||
}
|
||||
|
||||
bStreaming = false;
|
||||
if (!bWasClean && StatusCode != 1000)
|
||||
{
|
||||
BroadcastError(FString::Printf(TEXT("Qwen TTS socket closed: %d %s"), StatusCode, *Reason));
|
||||
}
|
||||
}
|
||||
|
||||
void UQwenTTSStreamingComponent::HandleMessage(const FString& Message)
|
||||
{
|
||||
TSharedPtr<FJsonObject> JsonObject;
|
||||
const TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Message);
|
||||
if (!FJsonSerializer::Deserialize(Reader, JsonObject) || !JsonObject.IsValid())
|
||||
{
|
||||
UE_LOG(LogQwenTTSStreaming, Warning, TEXT("Bad JSON message from Qwen TTS gateway. Bytes=%d"), Message.Len());
|
||||
BroadcastError(TEXT("Bad JSON message from Qwen TTS gateway"));
|
||||
return;
|
||||
}
|
||||
|
||||
const FString Type = JsonObject->GetStringField(TEXT("type"));
|
||||
UE_LOG(LogQwenTTSStreaming, VeryVerbose, TEXT("Qwen TTS message received. Type=%s Bytes=%d"), *Type, Message.Len());
|
||||
|
||||
if (Type == TEXT("chunk"))
|
||||
{
|
||||
FString AudioBase64;
|
||||
if (JsonObject->TryGetStringField(TEXT("audio_b64"), AudioBase64))
|
||||
{
|
||||
const int32 Index = static_cast<int32>(JsonObject->GetIntegerField(TEXT("index")));
|
||||
HandleAudioBase64(Index, AudioBase64);
|
||||
}
|
||||
else
|
||||
{
|
||||
UE_LOG(LogQwenTTSStreaming, Warning, TEXT("Qwen TTS chunk message has no audio_b64 field."));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Type == TEXT("done"))
|
||||
{
|
||||
const int32 ChunkCount = JsonObject->HasTypedField<EJson::Number>(TEXT("chunks"))
|
||||
? static_cast<int32>(JsonObject->GetIntegerField(TEXT("chunks")))
|
||||
: -1;
|
||||
UE_LOG(LogQwenTTSStreaming, Log, TEXT("Qwen TTS stream finished. Chunks=%d"), ChunkCount);
|
||||
|
||||
bStreaming = false;
|
||||
if (Socket.IsValid())
|
||||
{
|
||||
Socket->Close();
|
||||
Socket.Reset();
|
||||
}
|
||||
OnStreamFinished.Broadcast();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Type == TEXT("error"))
|
||||
{
|
||||
FString ErrorMessage;
|
||||
if (!JsonObject->TryGetStringField(TEXT("message"), ErrorMessage))
|
||||
{
|
||||
JsonObject->TryGetStringField(TEXT("body"), ErrorMessage);
|
||||
}
|
||||
UE_LOG(LogQwenTTSStreaming, Error, TEXT("Qwen TTS gateway error message: %s"), *(ErrorMessage.IsEmpty() ? Message : ErrorMessage));
|
||||
BroadcastError(ErrorMessage.IsEmpty() ? Message : ErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
void UQwenTTSStreamingComponent::HandleAudioBase64(int32 Index, const FString& AudioBase64)
|
||||
{
|
||||
TArray<uint8> WavBytes;
|
||||
if (!FBase64::Decode(AudioBase64, WavBytes))
|
||||
{
|
||||
UE_LOG(LogQwenTTSStreaming, Error, TEXT("Failed to decode Qwen TTS audio chunk. Index=%d Base64Chars=%d"), Index, AudioBase64.Len());
|
||||
BroadcastError(TEXT("Failed to decode audio_b64"));
|
||||
return;
|
||||
}
|
||||
|
||||
FDecodedWavChunk DecodedChunk;
|
||||
FString Error;
|
||||
if (!DecodeWavPCM16(WavBytes, DecodedChunk, Error))
|
||||
{
|
||||
UE_LOG(LogQwenTTSStreaming, Error, TEXT("Failed to decode Qwen TTS WAV chunk. Index=%d Error=%s"), Index, *Error);
|
||||
BroadcastError(Error);
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureProceduralWave(DecodedChunk.SampleRate, DecodedChunk.NumChannels);
|
||||
if (!ProceduralSoundWave)
|
||||
{
|
||||
UE_LOG(LogQwenTTSStreaming, Error, TEXT("Qwen TTS procedural sound wave is missing after decode. Index=%d"), Index);
|
||||
return;
|
||||
}
|
||||
|
||||
ProceduralSoundWave->QueueAudio(DecodedChunk.PCM.GetData(), DecodedChunk.PCM.Num());
|
||||
UE_LOG(
|
||||
LogQwenTTSStreaming,
|
||||
Verbose,
|
||||
TEXT("Queued Qwen TTS audio chunk. Index=%d SampleRate=%d Channels=%d PCMBytes=%d"),
|
||||
Index,
|
||||
DecodedChunk.SampleRate,
|
||||
DecodedChunk.NumChannels,
|
||||
DecodedChunk.PCM.Num());
|
||||
|
||||
if (Settings && Settings->bAutoPlayOnFirstChunk && !bPlaybackStarted)
|
||||
{
|
||||
SetSound(ProceduralSoundWave);
|
||||
Play();
|
||||
bPlaybackStarted = true;
|
||||
UE_LOG(LogQwenTTSStreaming, Log, TEXT("Qwen TTS playback started on first chunk. Index=%d"), Index);
|
||||
}
|
||||
|
||||
FQwenTTSStreamChunk Chunk;
|
||||
Chunk.Index = Index;
|
||||
Chunk.AudioBase64 = AudioBase64;
|
||||
Chunk.SampleRate = DecodedChunk.SampleRate;
|
||||
Chunk.NumChannels = DecodedChunk.NumChannels;
|
||||
Chunk.PCMBytes = DecodedChunk.PCM.Num();
|
||||
OnAudioChunk.Broadcast(Chunk);
|
||||
}
|
||||
|
||||
void UQwenTTSStreamingComponent::EnsureProceduralWave(int32 SampleRate, int32 NumChannels)
|
||||
{
|
||||
if (ProceduralSoundWave && CurrentSampleRate == SampleRate && CurrentNumChannels == NumChannels)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ProceduralSoundWave = NewObject<USoundWaveProcedural>(this);
|
||||
ProceduralSoundWave->SetSampleRate(SampleRate);
|
||||
ProceduralSoundWave->NumChannels = NumChannels;
|
||||
ProceduralSoundWave->Duration = Settings ? Settings->ProceduralDurationSeconds : INDEFINITELY_LOOPING_DURATION;
|
||||
ProceduralSoundWave->bLooping = false;
|
||||
ProceduralSoundWave->SampleByteSize = sizeof(int16);
|
||||
ProceduralSoundWave->SoundGroup = SOUNDGROUP_Voice;
|
||||
|
||||
CurrentSampleRate = SampleRate;
|
||||
CurrentNumChannels = NumChannels;
|
||||
SetSound(ProceduralSoundWave);
|
||||
|
||||
UE_LOG(
|
||||
LogQwenTTSStreaming,
|
||||
Log,
|
||||
TEXT("Created Qwen TTS procedural sound wave. SampleRate=%d Channels=%d Duration=%.2f"),
|
||||
SampleRate,
|
||||
NumChannels,
|
||||
ProceduralSoundWave->Duration);
|
||||
}
|
||||
|
||||
FString UQwenTTSStreamingComponent::BuildRequestJson() const
|
||||
{
|
||||
TSharedRef<FJsonObject> JsonObject = MakeShared<FJsonObject>();
|
||||
JsonObject->SetStringField(TEXT("text"), PendingText);
|
||||
|
||||
if (Preset)
|
||||
{
|
||||
AddStringField(JsonObject, TEXT("preset_id"), Preset->PresetId);
|
||||
AddStringField(JsonObject, TEXT("language"), Preset->Language);
|
||||
AddStringField(JsonObject, TEXT("mode"), Preset->Mode);
|
||||
JsonObject->SetNumberField(TEXT("chunk_size"), Preset->ChunkSize);
|
||||
JsonObject->SetNumberField(TEXT("first_chunk_size"), Preset->FirstChunkSize);
|
||||
JsonObject->SetNumberField(TEXT("crossfade_samples"), Preset->CrossfadeSamples);
|
||||
JsonObject->SetNumberField(TEXT("temperature"), Preset->Temperature);
|
||||
JsonObject->SetNumberField(TEXT("top_k"), Preset->TopK);
|
||||
JsonObject->SetNumberField(TEXT("repetition_penalty"), Preset->RepetitionPenalty);
|
||||
JsonObject->SetNumberField(TEXT("repetition_penalty_window"), Preset->RepetitionPenaltyWindow);
|
||||
|
||||
for (const TPair<FString, FString>& Pair : Preset->ExtraFields)
|
||||
{
|
||||
AddStringField(JsonObject, Pair.Key, Pair.Value);
|
||||
}
|
||||
}
|
||||
|
||||
if (Settings)
|
||||
{
|
||||
for (const TPair<FString, FString>& Pair : Settings->ExtraFields)
|
||||
{
|
||||
AddStringField(JsonObject, Pair.Key, Pair.Value);
|
||||
}
|
||||
}
|
||||
|
||||
FString Output;
|
||||
const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Output);
|
||||
FJsonSerializer::Serialize(JsonObject, Writer);
|
||||
return Output;
|
||||
}
|
||||
|
||||
void UQwenTTSStreamingComponent::BroadcastError(const FString& Error)
|
||||
{
|
||||
UE_LOG(LogQwenTTSStreaming, Error, TEXT("Qwen TTS stream error: %s"), *Error);
|
||||
OnStreamError.Broadcast(Error);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
|
||||
#include "QwenTTSStreamingLog.h"
|
||||
|
||||
#include "Modules/ModuleManager.h"
|
||||
|
||||
DEFINE_LOG_CATEGORY(LogQwenTTSStreaming);
|
||||
|
||||
IMPLEMENT_MODULE(FDefaultModuleImpl, QwenTTSStreaming)
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
|
||||
#include "QwenTTSStreamingSettingsDataAsset.h"
|
||||
|
||||
UQwenTTSStreamingSettingsDataAsset::UQwenTTSStreamingSettingsDataAsset()
|
||||
{
|
||||
GatewayWebSocketUrl = TEXT("wss://services.ai-games-studio.ru/qwen-tts/generate/stream/ws");
|
||||
GatewayStatusUrl = TEXT("https://services.ai-games-studio.ru/qwen-tts/status");
|
||||
}
|
||||
|
||||
FString UQwenTTSStreamingSettingsDataAsset::GetResolvedGatewayStatusUrl() const
|
||||
{
|
||||
if (!GatewayStatusUrl.IsEmpty())
|
||||
{
|
||||
return GatewayStatusUrl;
|
||||
}
|
||||
|
||||
FString ResolvedUrl = GatewayWebSocketUrl;
|
||||
if (ResolvedUrl.StartsWith(TEXT("wss://")))
|
||||
{
|
||||
ResolvedUrl.RemoveFromStart(TEXT("wss://"));
|
||||
ResolvedUrl.InsertAt(0, TEXT("https://"));
|
||||
}
|
||||
else if (ResolvedUrl.StartsWith(TEXT("ws://")))
|
||||
{
|
||||
ResolvedUrl.RemoveFromStart(TEXT("ws://"));
|
||||
ResolvedUrl.InsertAt(0, TEXT("http://"));
|
||||
}
|
||||
|
||||
ResolvedUrl.RemoveFromEnd(TEXT("/generate/stream/ws"));
|
||||
ResolvedUrl.RemoveFromEnd(TEXT("/"));
|
||||
return ResolvedUrl / TEXT("status");
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Engine/DataAsset.h"
|
||||
#include "QwenTTSPresetDataAsset.generated.h"
|
||||
|
||||
class UQwenTTSStreamingSettingsDataAsset;
|
||||
|
||||
UCLASS(BlueprintType)
|
||||
class QWENTTSSTREAMING_API UQwenTTSPresetDataAsset : public UPrimaryDataAsset
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UQwenTTSPresetDataAsset();
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS|Preset")
|
||||
TObjectPtr<UQwenTTSStreamingSettingsDataAsset> Settings;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS|Preset")
|
||||
FString PresetId;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS|Preset")
|
||||
FString Language = TEXT("Russian");
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS|Preset")
|
||||
FString Mode = TEXT("voice_clone");
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS|Generation", meta = (ClampMin = "1"))
|
||||
int32 ChunkSize = 25;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS|Generation", meta = (ClampMin = "1"))
|
||||
int32 FirstChunkSize = 8;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS|Generation", meta = (ClampMin = "0"))
|
||||
int32 CrossfadeSamples = 256;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS|Generation", meta = (ClampMin = "0.0", ClampMax = "2.0"))
|
||||
float Temperature = 0.7f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS|Generation", meta = (ClampMin = "1"))
|
||||
int32 TopK = 20;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS|Generation", meta = (ClampMin = "0.0"))
|
||||
float RepetitionPenalty = 1.1f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS|Generation", meta = (ClampMin = "1"))
|
||||
int32 RepetitionPenaltyWindow = 64;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS|Payload")
|
||||
TMap<FString, FString> ExtraFields;
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Components/AudioComponent.h"
|
||||
#include "QwenTTSStreamingTypes.h"
|
||||
#include "QwenTTSStreamingComponent.generated.h"
|
||||
|
||||
class IWebSocket;
|
||||
class UQwenTTSPresetDataAsset;
|
||||
class UQwenTTSStreamingSettingsDataAsset;
|
||||
class USoundWaveProcedural;
|
||||
|
||||
UCLASS(ClassGroup = (Audio, QwenTTS), meta = (BlueprintSpawnableComponent))
|
||||
class QWENTTSSTREAMING_API UQwenTTSStreamingComponent : public UAudioComponent
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UQwenTTSStreamingComponent();
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS")
|
||||
TObjectPtr<UQwenTTSStreamingSettingsDataAsset> Settings;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS")
|
||||
TObjectPtr<UQwenTTSPresetDataAsset> Preset;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Qwen TTS|Events")
|
||||
FQwenTTSStreamSimpleDelegate OnStreamStarted;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Qwen TTS|Events")
|
||||
FQwenTTSStreamChunkDelegate OnAudioChunk;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Qwen TTS|Events")
|
||||
FQwenTTSStreamSimpleDelegate OnStreamFinished;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Qwen TTS|Events")
|
||||
FQwenTTSStreamMessageDelegate OnStreamError;
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "Qwen TTS")
|
||||
bool StreamText(const FString& Text);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "Qwen TTS")
|
||||
void StopStreaming();
|
||||
|
||||
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Qwen TTS")
|
||||
bool IsStreaming() const;
|
||||
|
||||
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Qwen TTS")
|
||||
bool IsGeneratingVoice() const;
|
||||
|
||||
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Qwen TTS")
|
||||
bool IsPlayingVoiceAudio() const;
|
||||
|
||||
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Qwen TTS")
|
||||
USoundWaveProcedural* GetProceduralSoundWave() const { return ProceduralSoundWave; }
|
||||
|
||||
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
|
||||
|
||||
private:
|
||||
TSharedPtr<IWebSocket> Socket;
|
||||
|
||||
UPROPERTY(Transient)
|
||||
TObjectPtr<USoundWaveProcedural> ProceduralSoundWave;
|
||||
|
||||
FString PendingText;
|
||||
bool bStreaming = false;
|
||||
bool bPlaybackStarted = false;
|
||||
int32 CurrentSampleRate = 0;
|
||||
int32 CurrentNumChannels = 0;
|
||||
|
||||
void Connect();
|
||||
void HandleConnected();
|
||||
void HandleConnectionError(const FString& Error);
|
||||
void HandleClosed(int32 StatusCode, const FString& Reason, bool bWasClean);
|
||||
void HandleMessage(const FString& Message);
|
||||
void HandleAudioBase64(int32 Index, const FString& AudioBase64);
|
||||
void EnsureProceduralWave(int32 SampleRate, int32 NumChannels);
|
||||
FString BuildRequestJson() const;
|
||||
void BroadcastError(const FString& Error);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
|
||||
QWENTTSSTREAMING_API DECLARE_LOG_CATEGORY_EXTERN(LogQwenTTSStreaming, Log, All);
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Engine/DataAsset.h"
|
||||
#include "QwenTTSStreamingSettingsDataAsset.generated.h"
|
||||
|
||||
UCLASS(BlueprintType)
|
||||
class QWENTTSSTREAMING_API UQwenTTSStreamingSettingsDataAsset : public UPrimaryDataAsset
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UQwenTTSStreamingSettingsDataAsset();
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS|Gateway")
|
||||
FString GatewayWebSocketUrl;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS|Gateway")
|
||||
FString GatewayStatusUrl;
|
||||
|
||||
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Qwen TTS|Gateway")
|
||||
FString GetResolvedGatewayStatusUrl() const;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS|Gateway", meta = (ClampMin = "1.0"))
|
||||
float ConnectionTimeoutSeconds = 15.0f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS|Audio")
|
||||
bool bAutoPlayOnFirstChunk = true;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS|Audio")
|
||||
bool bStopAudioBeforeRequest = true;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS|Audio", meta = (ClampMin = "0.01"))
|
||||
float ProceduralDurationSeconds = 300.0f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Qwen TTS|Payload")
|
||||
TMap<FString, FString> ExtraFields;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "QwenTTSStreamingTypes.generated.h"
|
||||
|
||||
USTRUCT(BlueprintType)
|
||||
struct QWENTTSSTREAMING_API FQwenTTSStreamChunk
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Qwen TTS")
|
||||
int32 Index = 0;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Qwen TTS")
|
||||
FString AudioBase64;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Qwen TTS")
|
||||
int32 SampleRate = 0;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Qwen TTS")
|
||||
int32 NumChannels = 0;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Qwen TTS")
|
||||
int32 PCMBytes = 0;
|
||||
};
|
||||
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FQwenTTSStreamChunkDelegate, const FQwenTTSStreamChunk&, Chunk);
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FQwenTTSStreamMessageDelegate, const FString&, Message);
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FQwenTTSStreamSimpleDelegate);
|
||||
@@ -0,0 +1,24 @@
|
||||
using UnrealBuildTool;
|
||||
|
||||
public class QwenTTSStreaming : ModuleRules
|
||||
{
|
||||
public QwenTTSStreaming(ReadOnlyTargetRules Target) : base(Target)
|
||||
{
|
||||
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
|
||||
|
||||
PublicDependencyModuleNames.AddRange(new string[]
|
||||
{
|
||||
"Core",
|
||||
"CoreUObject",
|
||||
"Engine"
|
||||
});
|
||||
|
||||
PrivateDependencyModuleNames.AddRange(new string[]
|
||||
{
|
||||
"AudioMixer",
|
||||
"Json",
|
||||
"JsonUtilities",
|
||||
"WebSockets"
|
||||
});
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
|
||||
#include "QwenTTSDataAssetFactories.h"
|
||||
|
||||
#include "QwenTTSPresetDataAsset.h"
|
||||
#include "QwenTTSStreamingSettingsDataAsset.h"
|
||||
|
||||
UQwenTTSStreamingSettingsDataAssetFactory::UQwenTTSStreamingSettingsDataAssetFactory()
|
||||
{
|
||||
SupportedClass = UQwenTTSStreamingSettingsDataAsset::StaticClass();
|
||||
bCreateNew = true;
|
||||
bEditAfterNew = true;
|
||||
}
|
||||
|
||||
UObject* UQwenTTSStreamingSettingsDataAssetFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn)
|
||||
{
|
||||
return NewObject<UQwenTTSStreamingSettingsDataAsset>(InParent, Class, Name, Flags | RF_Transactional);
|
||||
}
|
||||
|
||||
UQwenTTSPresetDataAssetFactory::UQwenTTSPresetDataAssetFactory()
|
||||
{
|
||||
SupportedClass = UQwenTTSPresetDataAsset::StaticClass();
|
||||
bCreateNew = true;
|
||||
bEditAfterNew = true;
|
||||
}
|
||||
|
||||
UObject* UQwenTTSPresetDataAssetFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn)
|
||||
{
|
||||
return NewObject<UQwenTTSPresetDataAsset>(InParent, Class, Name, Flags | RF_Transactional);
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
|
||||
#include "QwenTTSPresetDetailsCustomization.h"
|
||||
|
||||
#include "DetailCategoryBuilder.h"
|
||||
#include "DetailLayoutBuilder.h"
|
||||
#include "DetailWidgetRow.h"
|
||||
#include "HttpModule.h"
|
||||
#include "Interfaces/IHttpResponse.h"
|
||||
#include "Json.h"
|
||||
#include "PropertyHandle.h"
|
||||
#include "QwenTTSPresetDataAsset.h"
|
||||
#include "QwenTTSStreamingLog.h"
|
||||
#include "QwenTTSStreamingSettingsDataAsset.h"
|
||||
#include "Widgets/Input/SButton.h"
|
||||
#include "Widgets/Input/SComboBox.h"
|
||||
#include "Widgets/Text/STextBlock.h"
|
||||
|
||||
#define LOCTEXT_NAMESPACE "FQwenTTSPresetDetailsCustomization"
|
||||
|
||||
FText FQwenTTSPresetChoice::GetDisplayText() const
|
||||
{
|
||||
if (!Label.IsEmpty())
|
||||
{
|
||||
return FText::FromString(FString::Printf(TEXT("%s (%s)"), *Label, *Id));
|
||||
}
|
||||
return FText::FromString(Id);
|
||||
}
|
||||
|
||||
TSharedRef<IDetailCustomization> FQwenTTSPresetDetailsCustomization::MakeInstance()
|
||||
{
|
||||
return MakeShared<FQwenTTSPresetDetailsCustomization>();
|
||||
}
|
||||
|
||||
void FQwenTTSPresetDetailsCustomization::CustomizeDetails(IDetailLayoutBuilder& DetailBuilder)
|
||||
{
|
||||
TArray<TWeakObjectPtr<UObject>> Objects;
|
||||
DetailBuilder.GetObjectsBeingCustomized(Objects);
|
||||
if (Objects.Num() > 0)
|
||||
{
|
||||
PresetAsset = Cast<UQwenTTSPresetDataAsset>(Objects[0].Get());
|
||||
}
|
||||
|
||||
SettingsHandle = DetailBuilder.GetProperty(GET_MEMBER_NAME_CHECKED(UQwenTTSPresetDataAsset, Settings));
|
||||
PresetIdHandle = DetailBuilder.GetProperty(GET_MEMBER_NAME_CHECKED(UQwenTTSPresetDataAsset, PresetId));
|
||||
DetailBuilder.HideProperty(SettingsHandle);
|
||||
DetailBuilder.HideProperty(PresetIdHandle);
|
||||
|
||||
IDetailCategoryBuilder& PresetCategory = DetailBuilder.EditCategory(TEXT("Qwen TTS|Preset"));
|
||||
PresetCategory.AddProperty(SettingsHandle.ToSharedRef());
|
||||
PresetCategory.AddCustomRow(LOCTEXT("PresetPickerFilter", "Preset Picker"))
|
||||
.NameContent()
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(LOCTEXT("PresetPickerLabel", "Preset Picker"))
|
||||
.Font(IDetailLayoutBuilder::GetDetailFont())
|
||||
]
|
||||
.ValueContent()
|
||||
.MinDesiredWidth(520.0f)
|
||||
[
|
||||
SNew(SVerticalBox)
|
||||
+ SVerticalBox::Slot()
|
||||
.AutoHeight()
|
||||
[
|
||||
SNew(SHorizontalBox)
|
||||
+ SHorizontalBox::Slot()
|
||||
.FillWidth(1.0f)
|
||||
[
|
||||
SAssignNew(PresetComboBox, SComboBox<TSharedPtr<FQwenTTSPresetChoice>>)
|
||||
.OptionsSource(&PresetChoices)
|
||||
.OnGenerateWidget(this, &FQwenTTSPresetDetailsCustomization::MakePresetComboWidget)
|
||||
.OnSelectionChanged(this, &FQwenTTSPresetDetailsCustomization::HandleSelectionChanged)
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(this, &FQwenTTSPresetDetailsCustomization::GetSelectedPresetText)
|
||||
.Font(IDetailLayoutBuilder::GetDetailFont())
|
||||
]
|
||||
]
|
||||
+ SHorizontalBox::Slot()
|
||||
.AutoWidth()
|
||||
.Padding(6.0f, 0.0f, 0.0f, 0.0f)
|
||||
[
|
||||
SNew(SButton)
|
||||
.Text(LOCTEXT("RefreshPresets", "Refresh"))
|
||||
.OnClicked(this, &FQwenTTSPresetDetailsCustomization::RefreshPresets)
|
||||
]
|
||||
]
|
||||
+ SVerticalBox::Slot()
|
||||
.AutoHeight()
|
||||
.Padding(0.0f, 4.0f, 0.0f, 0.0f)
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(this, &FQwenTTSPresetDetailsCustomization::GetStatusText)
|
||||
.Font(IDetailLayoutBuilder::GetDetailFont())
|
||||
.AutoWrapText(true)
|
||||
]
|
||||
];
|
||||
PresetCategory.AddProperty(PresetIdHandle.ToSharedRef());
|
||||
|
||||
RefreshPresets();
|
||||
}
|
||||
|
||||
FReply FQwenTTSPresetDetailsCustomization::RefreshPresets()
|
||||
{
|
||||
const FString StatusUrl = GetStatusUrl();
|
||||
if (StatusUrl.IsEmpty())
|
||||
{
|
||||
StatusText = LOCTEXT("MissingStatusUrl", "Set Settings or Gateway Status Url before refreshing presets.");
|
||||
return FReply::Handled();
|
||||
}
|
||||
|
||||
StatusText = FText::Format(LOCTEXT("LoadingPresets", "Loading presets from {0}"), FText::FromString(StatusUrl));
|
||||
UE_LOG(LogQwenTTSStreaming, Log, TEXT("Refreshing Qwen TTS presets from %s"), *StatusUrl);
|
||||
|
||||
const TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
|
||||
Request->SetURL(StatusUrl);
|
||||
Request->SetVerb(TEXT("GET"));
|
||||
Request->SetHeader(TEXT("Accept"), TEXT("application/json"));
|
||||
Request->OnProcessRequestComplete().BindSP(StaticCastSharedRef<FQwenTTSPresetDetailsCustomization>(AsShared()), &FQwenTTSPresetDetailsCustomization::HandlePresetResponse);
|
||||
Request->ProcessRequest();
|
||||
|
||||
return FReply::Handled();
|
||||
}
|
||||
|
||||
void FQwenTTSPresetDetailsCustomization::HandlePresetResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful)
|
||||
{
|
||||
if (!bWasSuccessful || !Response.IsValid())
|
||||
{
|
||||
StatusText = LOCTEXT("PresetRequestFailed", "Preset request failed.");
|
||||
UE_LOG(LogQwenTTSStreaming, Warning, TEXT("Qwen TTS preset request failed."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (Response->GetResponseCode() < 200 || Response->GetResponseCode() >= 300)
|
||||
{
|
||||
StatusText = FText::Format(LOCTEXT("PresetHttpFailed", "Preset request failed with HTTP {0}."), Response->GetResponseCode());
|
||||
UE_LOG(LogQwenTTSStreaming, Warning, TEXT("Qwen TTS preset request failed. HTTP=%d Body=%s"), Response->GetResponseCode(), *Response->GetContentAsString());
|
||||
return;
|
||||
}
|
||||
|
||||
SetPresetChoicesFromStatusJson(Response->GetContentAsString());
|
||||
}
|
||||
|
||||
void FQwenTTSPresetDetailsCustomization::SetPresetChoicesFromStatusJson(const FString& ResponseText)
|
||||
{
|
||||
TSharedPtr<FJsonObject> JsonObject;
|
||||
const TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(ResponseText);
|
||||
if (!FJsonSerializer::Deserialize(Reader, JsonObject) || !JsonObject.IsValid())
|
||||
{
|
||||
StatusText = LOCTEXT("BadPresetJson", "Preset response is not valid JSON.");
|
||||
UE_LOG(LogQwenTTSStreaming, Warning, TEXT("Qwen TTS preset response is not valid JSON."));
|
||||
return;
|
||||
}
|
||||
|
||||
const TArray<TSharedPtr<FJsonValue>>* PresetRefs = nullptr;
|
||||
if (!JsonObject->TryGetArrayField(TEXT("preset_refs"), PresetRefs))
|
||||
{
|
||||
StatusText = LOCTEXT("NoPresetRefs", "Preset response has no preset_refs array.");
|
||||
UE_LOG(LogQwenTTSStreaming, Warning, TEXT("Qwen TTS preset response has no preset_refs array."));
|
||||
return;
|
||||
}
|
||||
|
||||
PresetChoices.Reset();
|
||||
for (const TSharedPtr<FJsonValue>& Value : *PresetRefs)
|
||||
{
|
||||
const TSharedPtr<FJsonObject> PresetObject = Value.IsValid() ? Value->AsObject() : nullptr;
|
||||
if (!PresetObject.IsValid())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
FString Id;
|
||||
if (!PresetObject->TryGetStringField(TEXT("id"), Id) || Id.IsEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TSharedRef<FQwenTTSPresetChoice> Choice = MakeShared<FQwenTTSPresetChoice>();
|
||||
Choice->Id = Id;
|
||||
PresetObject->TryGetStringField(TEXT("label"), Choice->Label);
|
||||
PresetObject->TryGetStringField(TEXT("ref_text"), Choice->RefText);
|
||||
PresetObject->TryGetStringField(TEXT("source"), Choice->Source);
|
||||
PresetChoices.Add(Choice);
|
||||
}
|
||||
|
||||
if (PresetComboBox.IsValid())
|
||||
{
|
||||
PresetComboBox->RefreshOptions();
|
||||
}
|
||||
|
||||
StatusText = FText::Format(LOCTEXT("PresetLoadedCount", "Loaded {0} preset(s)."), PresetChoices.Num());
|
||||
UE_LOG(LogQwenTTSStreaming, Log, TEXT("Loaded %d Qwen TTS preset(s)."), PresetChoices.Num());
|
||||
}
|
||||
|
||||
FString FQwenTTSPresetDetailsCustomization::GetStatusUrl() const
|
||||
{
|
||||
UObject* SettingsObject = nullptr;
|
||||
if (SettingsHandle.IsValid() && SettingsHandle->GetValue(SettingsObject) == FPropertyAccess::Success)
|
||||
{
|
||||
if (const UQwenTTSStreamingSettingsDataAsset* Settings = Cast<UQwenTTSStreamingSettingsDataAsset>(SettingsObject))
|
||||
{
|
||||
return Settings->GetResolvedGatewayStatusUrl();
|
||||
}
|
||||
}
|
||||
|
||||
if (const UQwenTTSPresetDataAsset* Preset = PresetAsset.Get())
|
||||
{
|
||||
if (Preset->Settings)
|
||||
{
|
||||
return Preset->Settings->GetResolvedGatewayStatusUrl();
|
||||
}
|
||||
}
|
||||
|
||||
return GetDefault<UQwenTTSStreamingSettingsDataAsset>()->GetResolvedGatewayStatusUrl();
|
||||
}
|
||||
|
||||
TSharedPtr<FQwenTTSPresetChoice> FQwenTTSPresetDetailsCustomization::GetSelectedChoice() const
|
||||
{
|
||||
FString CurrentPresetId;
|
||||
if (PresetIdHandle.IsValid())
|
||||
{
|
||||
PresetIdHandle->GetValue(CurrentPresetId);
|
||||
}
|
||||
|
||||
for (const TSharedPtr<FQwenTTSPresetChoice>& Choice : PresetChoices)
|
||||
{
|
||||
if (Choice.IsValid() && Choice->Id == CurrentPresetId)
|
||||
{
|
||||
return Choice;
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
FText FQwenTTSPresetDetailsCustomization::GetSelectedPresetText() const
|
||||
{
|
||||
if (const TSharedPtr<FQwenTTSPresetChoice> Choice = GetSelectedChoice())
|
||||
{
|
||||
return Choice->GetDisplayText();
|
||||
}
|
||||
|
||||
FString CurrentPresetId;
|
||||
if (PresetIdHandle.IsValid() && PresetIdHandle->GetValue(CurrentPresetId) == FPropertyAccess::Success && !CurrentPresetId.IsEmpty())
|
||||
{
|
||||
return FText::FromString(CurrentPresetId);
|
||||
}
|
||||
|
||||
return LOCTEXT("NoPresetSelected", "Select preset");
|
||||
}
|
||||
|
||||
FText FQwenTTSPresetDetailsCustomization::GetStatusText() const
|
||||
{
|
||||
if (const TSharedPtr<FQwenTTSPresetChoice> Choice = GetSelectedChoice())
|
||||
{
|
||||
if (!Choice->RefText.IsEmpty())
|
||||
{
|
||||
return FText::FromString(FString::Printf(TEXT("%s: %s"), *Choice->Source, *Choice->RefText));
|
||||
}
|
||||
}
|
||||
return StatusText;
|
||||
}
|
||||
|
||||
void FQwenTTSPresetDetailsCustomization::HandleSelectionChanged(TSharedPtr<FQwenTTSPresetChoice> Choice, ESelectInfo::Type SelectInfo)
|
||||
{
|
||||
if (!Choice.IsValid() || SelectInfo == ESelectInfo::Direct)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (PresetIdHandle.IsValid())
|
||||
{
|
||||
PresetIdHandle->SetValue(Choice->Id);
|
||||
StatusText = FText::Format(LOCTEXT("PresetSelected", "Selected {0}."), Choice->GetDisplayText());
|
||||
UE_LOG(LogQwenTTSStreaming, Log, TEXT("Selected Qwen TTS preset. Label=%s Id=%s"), *Choice->Label, *Choice->Id);
|
||||
}
|
||||
}
|
||||
|
||||
TSharedRef<SWidget> FQwenTTSPresetDetailsCustomization::MakePresetComboWidget(TSharedPtr<FQwenTTSPresetChoice> Choice) const
|
||||
{
|
||||
return SNew(STextBlock)
|
||||
.Text(Choice.IsValid() ? Choice->GetDisplayText() : LOCTEXT("InvalidPresetChoice", "Invalid preset"))
|
||||
.ToolTipText(Choice.IsValid() ? FText::FromString(Choice->RefText) : FText::GetEmpty());
|
||||
}
|
||||
|
||||
#undef LOCTEXT_NAMESPACE
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "IDetailCustomization.h"
|
||||
#include "Interfaces/IHttpRequest.h"
|
||||
#include "Widgets/Input/SComboBox.h"
|
||||
|
||||
class IPropertyHandle;
|
||||
class UQwenTTSPresetDataAsset;
|
||||
|
||||
struct FQwenTTSPresetChoice
|
||||
{
|
||||
FString Id;
|
||||
FString Label;
|
||||
FString RefText;
|
||||
FString Source;
|
||||
|
||||
FText GetDisplayText() const;
|
||||
};
|
||||
|
||||
class FQwenTTSPresetDetailsCustomization : public IDetailCustomization
|
||||
{
|
||||
public:
|
||||
static TSharedRef<IDetailCustomization> MakeInstance();
|
||||
|
||||
virtual void CustomizeDetails(IDetailLayoutBuilder& DetailBuilder) override;
|
||||
|
||||
private:
|
||||
TWeakObjectPtr<UQwenTTSPresetDataAsset> PresetAsset;
|
||||
TSharedPtr<IPropertyHandle> PresetIdHandle;
|
||||
TSharedPtr<IPropertyHandle> SettingsHandle;
|
||||
TArray<TSharedPtr<FQwenTTSPresetChoice>> PresetChoices;
|
||||
TSharedPtr<SComboBox<TSharedPtr<FQwenTTSPresetChoice>>> PresetComboBox;
|
||||
FText StatusText = FText::FromString(TEXT("Not loaded"));
|
||||
|
||||
FReply RefreshPresets();
|
||||
void HandlePresetResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful);
|
||||
void SetPresetChoicesFromStatusJson(const FString& ResponseText);
|
||||
FString GetStatusUrl() const;
|
||||
TSharedPtr<FQwenTTSPresetChoice> GetSelectedChoice() const;
|
||||
FText GetSelectedPresetText() const;
|
||||
FText GetStatusText() const;
|
||||
void HandleSelectionChanged(TSharedPtr<FQwenTTSPresetChoice> Choice, ESelectInfo::Type SelectInfo);
|
||||
TSharedRef<SWidget> MakePresetComboWidget(TSharedPtr<FQwenTTSPresetChoice> Choice) const;
|
||||
};
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
|
||||
#include "AssetTypeActions_Base.h"
|
||||
#include "AssetToolsModule.h"
|
||||
#include "IAssetTools.h"
|
||||
#include "Modules/ModuleManager.h"
|
||||
#include "PropertyEditorModule.h"
|
||||
#include "QwenTTSPresetDataAsset.h"
|
||||
#include "QwenTTSPresetDetailsCustomization.h"
|
||||
#include "QwenTTSStreamingSettingsDataAsset.h"
|
||||
|
||||
#define LOCTEXT_NAMESPACE "FQwenTTSStreamingEditorModule"
|
||||
|
||||
namespace
|
||||
{
|
||||
class FQwenTTSSettingsAssetTypeActions : public FAssetTypeActions_Base
|
||||
{
|
||||
public:
|
||||
explicit FQwenTTSSettingsAssetTypeActions(EAssetTypeCategories::Type InCategory)
|
||||
: Category(InCategory)
|
||||
{
|
||||
}
|
||||
|
||||
virtual FText GetName() const override { return LOCTEXT("QwenTTSSettingsName", "Qwen TTS Streaming Settings"); }
|
||||
virtual FColor GetTypeColor() const override { return FColor(86, 182, 194); }
|
||||
virtual UClass* GetSupportedClass() const override { return UQwenTTSStreamingSettingsDataAsset::StaticClass(); }
|
||||
virtual uint32 GetCategories() override { return Category; }
|
||||
virtual FText GetAssetDescription(const FAssetData& AssetData) const override
|
||||
{
|
||||
return LOCTEXT("QwenTTSSettingsDescription", "Gateway and playback settings for Qwen TTS streaming.");
|
||||
}
|
||||
|
||||
private:
|
||||
EAssetTypeCategories::Type Category;
|
||||
};
|
||||
|
||||
class FQwenTTSPresetAssetTypeActions : public FAssetTypeActions_Base
|
||||
{
|
||||
public:
|
||||
explicit FQwenTTSPresetAssetTypeActions(EAssetTypeCategories::Type InCategory)
|
||||
: Category(InCategory)
|
||||
{
|
||||
}
|
||||
|
||||
virtual FText GetName() const override { return LOCTEXT("QwenTTSPresetName", "Qwen TTS Preset"); }
|
||||
virtual FColor GetTypeColor() const override { return FColor(120, 176, 96); }
|
||||
virtual UClass* GetSupportedClass() const override { return UQwenTTSPresetDataAsset::StaticClass(); }
|
||||
virtual uint32 GetCategories() override { return Category; }
|
||||
virtual FText GetAssetDescription(const FAssetData& AssetData) const override
|
||||
{
|
||||
return LOCTEXT("QwenTTSPresetDescription", "Voice preset and generation parameters for Qwen TTS streaming.");
|
||||
}
|
||||
|
||||
private:
|
||||
EAssetTypeCategories::Type Category;
|
||||
};
|
||||
}
|
||||
|
||||
class FQwenTTSStreamingEditorModule : public IModuleInterface
|
||||
{
|
||||
public:
|
||||
virtual void StartupModule() override
|
||||
{
|
||||
IAssetTools& AssetTools = FModuleManager::LoadModuleChecked<FAssetToolsModule>("AssetTools").Get();
|
||||
AudioCategory = AssetTools.FindAdvancedAssetCategory(FName("Audio"));
|
||||
if (AudioCategory == EAssetTypeCategories::None)
|
||||
{
|
||||
AudioCategory = AssetTools.RegisterAdvancedAssetCategory(FName("Audio"), LOCTEXT("AudioCategory", "Audio"));
|
||||
}
|
||||
|
||||
RegisterAssetTypeAction(AssetTools, MakeShared<FQwenTTSSettingsAssetTypeActions>(AudioCategory));
|
||||
RegisterAssetTypeAction(AssetTools, MakeShared<FQwenTTSPresetAssetTypeActions>(AudioCategory));
|
||||
|
||||
FPropertyEditorModule& PropertyEditor = FModuleManager::LoadModuleChecked<FPropertyEditorModule>("PropertyEditor");
|
||||
PropertyEditor.RegisterCustomClassLayout(
|
||||
UQwenTTSPresetDataAsset::StaticClass()->GetFName(),
|
||||
FOnGetDetailCustomizationInstance::CreateStatic(&FQwenTTSPresetDetailsCustomization::MakeInstance));
|
||||
PropertyEditor.NotifyCustomizationModuleChanged();
|
||||
}
|
||||
|
||||
virtual void ShutdownModule() override
|
||||
{
|
||||
if (FModuleManager::Get().IsModuleLoaded("AssetTools"))
|
||||
{
|
||||
IAssetTools& AssetTools = FModuleManager::GetModuleChecked<FAssetToolsModule>("AssetTools").Get();
|
||||
for (const TSharedRef<IAssetTypeActions>& Action : RegisteredAssetTypeActions)
|
||||
{
|
||||
AssetTools.UnregisterAssetTypeActions(Action);
|
||||
}
|
||||
}
|
||||
RegisteredAssetTypeActions.Empty();
|
||||
|
||||
if (FModuleManager::Get().IsModuleLoaded("PropertyEditor"))
|
||||
{
|
||||
FPropertyEditorModule& PropertyEditor = FModuleManager::GetModuleChecked<FPropertyEditorModule>("PropertyEditor");
|
||||
PropertyEditor.UnregisterCustomClassLayout(UQwenTTSPresetDataAsset::StaticClass()->GetFName());
|
||||
PropertyEditor.NotifyCustomizationModuleChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
void RegisterAssetTypeAction(IAssetTools& AssetTools, TSharedRef<IAssetTypeActions> Action)
|
||||
{
|
||||
AssetTools.RegisterAssetTypeActions(Action);
|
||||
RegisteredAssetTypeActions.Add(Action);
|
||||
}
|
||||
|
||||
EAssetTypeCategories::Type AudioCategory = EAssetTypeCategories::Sounds;
|
||||
TArray<TSharedRef<IAssetTypeActions>> RegisteredAssetTypeActions;
|
||||
};
|
||||
|
||||
IMPLEMENT_MODULE(FQwenTTSStreamingEditorModule, QwenTTSStreamingEditor)
|
||||
|
||||
#undef LOCTEXT_NAMESPACE
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Factories/Factory.h"
|
||||
#include "QwenTTSDataAssetFactories.generated.h"
|
||||
|
||||
UCLASS()
|
||||
class UQwenTTSStreamingSettingsDataAssetFactory : public UFactory
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UQwenTTSStreamingSettingsDataAssetFactory();
|
||||
|
||||
virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override;
|
||||
virtual bool ShouldShowInNewMenu() const override { return true; }
|
||||
};
|
||||
|
||||
UCLASS()
|
||||
class UQwenTTSPresetDataAssetFactory : public UFactory
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UQwenTTSPresetDataAssetFactory();
|
||||
|
||||
virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override;
|
||||
virtual bool ShouldShowInNewMenu() const override { return true; }
|
||||
};
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using UnrealBuildTool;
|
||||
|
||||
public class QwenTTSStreamingEditor : ModuleRules
|
||||
{
|
||||
public QwenTTSStreamingEditor(ReadOnlyTargetRules Target) : base(Target)
|
||||
{
|
||||
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
|
||||
|
||||
PublicDependencyModuleNames.AddRange(new string[]
|
||||
{
|
||||
"Core",
|
||||
"CoreUObject",
|
||||
"Engine",
|
||||
"QwenTTSStreaming"
|
||||
});
|
||||
|
||||
PrivateDependencyModuleNames.AddRange(new string[]
|
||||
{
|
||||
"AssetTools",
|
||||
"HTTP",
|
||||
"InputCore",
|
||||
"Json",
|
||||
"PropertyEditor",
|
||||
"Slate",
|
||||
"SlateCore",
|
||||
"UnrealEd"
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user