{{ label!='' ? 'Label : ' : (q!='' ? '검색 : ' : '전체 게시글') }} {{ label }} {{ q }} {{ ('('+(pubs|date:'yyyy-MM')+')') }}

[Unreal Engine] 오디오 시각화를 이용한 대화 연출

   Phil in the Mirror에서는 전화를 통해 대화하는 장면들이 있는데요. 규모가 작아 더빙을 할 수도 없는데 단순히 대사만 떠있는 것이 조금 허전해, 목소리가 들리는 느낌을 연출하고자 소리의 시각화를 함께 해주면 좋을 것 같다는 생각을 했습니다. 다음처럼요:

가운데의 원형 표시는 음성이 시각화되어 보여집니다.

  이를 수행하기 위해서, 다음과 같은 작업들이 필요했습니다:

  • 음성 데이터가 필요합니다.
  • 음성 데이터를 시간에 따라 분석하여, 각 주파수 영역의 크기가 어느정도 되는지 알아야 합니다.
  • 이렇게 분석된 크기를 텍스처로 구성하여 머티리얼 파라미터로 전달해야 합니다.
  • 머티리얼은 해당 파라미터를 기반으로 시각화를 하여 유저에게 보여줍니다.


음성 데이터

  상술하였듯, 이 게임에는 더빙 등을 하지 않았기 때문에 직접적으로 활용할 수 있는 음성 데이터는 없었습니다. 처음에는 대사 자체를 분석해서 절차적인 정보를 얻어낼 수 있는 방법이 있을까 고민하였으나, 소 잡는 칼을 쓰는 느낌이 들어 다른 방법을 고민하기로 했습니다. 여기서 필요한 음성은 플레이어가 들을 수 있는 것이 아니었기 때문에, 단순히 무료 TTS 서비스를 이용하여 대사들을 말하는 음성들을 얻어내 활용하기로 하였습니다. 

  이렇게 얻은 TTS 음원을 사용하기 위해서는 분석이 필요한데요. 푸리에 변환을 통해 각 음역대의 진폭을 얻은 뒤 이들을 이용해서 그려내야 할 것입니다. 이는 언리얼 엔진에서 제공하는 AudioSynesthesia 플러그인을 활용하기로 했습니다. 해당 플러그인에서는 ConstantQNRT 에셋 등을 지원하며, 이를 통해서 음원의 특정 시간에서 각 주파수 영역의 크기가 어느정도인지 분석할 수 있습니다.

  따라서 다음과 같은 에디터 스크립트를 작성하여 주어진 TTS 음원들을 자동으로 ConstantQNRT 에셋으로 생성하도록 하였습니다:

import unreal

def safe_update_or_create_nrt_assets():
    source_root_path = "/Game/Audio/Narrative"
    settings_asset_path = "/Game/Audio/Narrative/NRTS_Settings"
    
    asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
    factory = unreal.AudioSynesthesiaNRTFactory()
    factory.set_editor_property(
        "audio_synesthesia_nrt_class",
        unreal.ConstantQNRT
    )

    preset_settings = unreal.EditorAssetLibrary.load_asset(settings_asset_path)
    if not isinstance(preset_settings, unreal.ConstantQNRTSettings):
         unreal.log_error(f"'{settings_asset_path}' 애셋이 ConstantQNRTSettings 타입이 아닙니다.")
         return
         
    asset_paths = unreal.EditorAssetLibrary.list_assets(source_root_path, recursive=True)

    # --- 로직 시작 ---
    for path in asset_paths:
        asset = unreal.EditorAssetLibrary.load_asset(path)
        if not isinstance(asset, unreal.SoundWave):
            continue

        source_name = asset.get_name()
        asset_folder_path = unreal.Paths.get_path(path)
        new_asset_name = f"NRT_{source_name}"
        new_asset_path = f"{asset_folder_path}/{new_asset_name}"

        nrt_asset = None
        # 애셋 존재 여부에 따라 생성 또는 로드
        if unreal.EditorAssetLibrary.does_asset_exist(new_asset_path):
            unreal.log(f"기존 애셋 '{new_asset_name}'을(를) 업데이트합니다.")
            nrt_asset = unreal.EditorAssetLibrary.load_asset(new_asset_path)
        else:
            unreal.log(f"새 애셋 '{new_asset_name}'을(를) 생성합니다.")
            nrt_asset = asset_tools.create_asset(new_asset_name, asset_folder_path, unreal.ConstantQNRT, factory)

        if not nrt_asset:
            unreal.log_error(f"'{new_asset_name}' 처리 중 오류 발생.")
            continue
            
        # 속성 설정
        nrt_asset.set_editor_property('sound', asset)
        nrt_asset.set_editor_property('settings', preset_settings)
        
        # 저장
        unreal.EditorAssetLibrary.save_asset(nrt_asset.get_path_name(), only_if_is_dirty=True)
            
    unreal.log("안전한 업데이트/생성 작업이 완료되었습니다.")


# 스크립트 실행
safe_update_or_create_nrt_assets()

  이렇게 만들어진 스크립트는 [Tools-ExecutePythonScript]를 통해서 활용할 수 있습니다. 그리고 해당 에셋들을 대화에 사용되는 위젯에 바인딩해 주었습니다.

NRT 에셋들을 UPROPERTY로 넣어놓은 모습


음성 데이터 분석

  NRT 에셋은 분석에 필요한 음원 데이터를 구워낸(bake) 것입니다. 음원에 대한 다양한 분석 데이터들이 있고, ConstantQNRT는 주파수 관점의 분석에 집중하는 에셋입니다. 이를 이용해서 어느 음역대의 소리가 강한지를 알 수 있습니다. 분석 방법의 세부사항은 AudioSynesthesia 플러그인이 맡아주므로, 우리는 이를 이용해서 소리를 시각화할 수 있다는 점만 집중하면 됩니다.

  따라서 해당 UI 위젯이 나왔을 때, 시간에 따라서 정해진 음성을 분석하면 됩니다. 분석하는데에는 여러 가지 고려사항이 필요한데요. 음원의 주파수를 몇 개로 쪼개서 분석할 것인지에 대한 고려가 필요합니다. 개수는 마음대로 설정하면 되겠지만, 너무 많거나 너무 적어도 의미가 없을 것입니다. 저는 96개로 나누기로 결정했습니다. (왜냐하면 일반적으로 12반음 * 8옥타브로 나누어서 분석해주기 때문입니다)

오디오 시각화의 예시. 막대 하나하나가 특정 주파수 영역을 상징합니다.

  AudioSynesthesia 플러그인이 분석해준 주파수 영역의 각 크기들을, 제가 결정한 96개의 범위로 적절히 블렌딩해서 담아야 합니다. 일반적으로는 AudioSynesthesia 역시 96개로 나누어서 분석해주기 때문에 별다른 작업을 할 필요는 없으나, 나중에 96개가 아닌 다른 개수의 주파수 영역으로 나누어서 보는 것도 시도하고 싶었기 때문에 이를 위한 작업이었습니다. 예를 들어, 96개로 나누어진 주파수 영역을, 80개로 나누어진 주파수 영역으로 대응시켜 담아볼 수 있는 것이죠.

  이는 단순히 선형적으로 보간해서 담기로 했습니다. 로그 스케일로 나누어진 주파수 영역을 선형적으로 보간하는 것이 올바른지는 차치하고, 어쨌거나 그정도의 디테일이 필요한 시스템은 아니었기 때문입니다. 간단한 헬퍼 함수를 만들어서 해결할 수 있었고, 코드는 다음과 같습니다:

// 선형 리샘플: src(0..M-1) -> dst(0..NumBars-1)
static void ResampleLinear(const TArray<float> &Src, TArray<float> &Dst)
{
    const int32 M = Src.Num();
    const int32 N = Dst.Num();
    if (M <= 0 || N <= 0)
    {
        return;
    }
    if (M == N)
    {
        FMemory::Memcpy(Dst.GetData(), Src.GetData(), sizeof(float) * N);
        return;
    }

    for (int32 i = 0; i < N; ++i)
    {
        const float pos = (float)i * (float)(M - 1) / (float)(N - 1);
        const int32 i0 = FMath::Clamp((int32)FMath::FloorToInt(pos), 0, M - 1);
        const int32 i1 = FMath::Clamp(i0 + 1, 0, M - 1);
        const float a = pos - (float)i0;
        Dst[i] = FMath::Lerp(Src[i0], Src[i1], a);
    }
}

void UWaitingDialogWidgetSequence::UpdateFromConstantQNRT(float DeltaTime)
{
    if (TextAudioList.Num() <= CurrentIndex)
    {
        for (int32 i = 0; i < NumBars; ++i)
        {
            NRTMagnitudesResampled[i] = FMath::FInterpTo(NRTMagnitudesResampled[i], 0.0f, DeltaTime, 12.f);
        }

        return;
    }

    UConstantQNRT *ConstantQNRT = TextAudioList[CurrentIndex];
    if (ConstantQNRT == nullptr)
    {
        return;
    }

    if (PlayheadSec > ConstantQNRT->DurationInSeconds || PlayheadSec < 0.0f)
    {
        for (int32 i = 0; i < NumBars; ++i)
        {
            NRTMagnitudesResampled[i] = FMath::FInterpTo(NRTMagnitudesResampled[i], 0.0f, DeltaTime, 12.f);
        }
    }
    else
    {
        // 0번 채널의 Constant-Q (정규화) 값을 시간 위치에서 추출
        TArray<float> CQ;
        ConstantQNRT->GetNormalizedChannelConstantQAtTime(PlayheadSec, 0, CQ);

        if (CQ.Num() > 0)
        {
            TArray<float> Temp;
            Temp.SetNumUninitialized(NumBars);
            ResampleLinear(CQ, Temp); // 0..1 정규화 값

            for (int32 i = 0; i < NumBars; ++i)
            {
                NRTMagnitudesResampled[i] = FMath::FInterpTo(NRTMagnitudesResampled[i], Temp[i], DeltaTime, 12.f);
            }
        }
    }

    PlayheadSec += DeltaTime;
}

  UpdateFromConstantQNRT()는 매 틱마다 호출되어, NRTMagnitudesResampled을 갱신합니다. FInterpTo를 이용해서 갱신함에 유의하세요. 샘플된 값을 그대로 쓰는 것은 약간의 튀는 느낌을 만들 수 있습니다. 따라서 이들을 최대한 부드럽게 움직이도록 하였습니다. 이는 렌더링에서 anti-aliasing을 하는 것과 완벽하게 동일한 원리입니다. 주파수 관점에서 고주파 대역을 약하게 만드는 것이기 때문입니다.


분석 데이터 가공 및 전달

  이렇게 분석된 데이터는 0과 1 사이의 선형적인 값을 갖습니다. 하나의 가공이 필요했는데, 가장 낮은 음역대와 가장 높은 음역대의 값이 부드럽게 이어지도록 하는 것입니다. 왜냐하면 제가 원하는 것은 원형으로 이루어진 오디오 시각화였고, 가장 낮은 음역대와 가장 높은 음역대의 영역이 서로 만나게 될 것이기 때문이었습니다. 따라서 가우시안 필터를 통해 부드러운 값 변화를 유도했습니다.

  또한 이렇게 분석된 데이터는 렌더링에 쓰일 수 있도록 머티리얼 파라미터로 전달되어야 합니다. 저는 이렇게 만들어진 분석 데이터를 텍스처로 만들어 머티리얼 파라미터로 전달하기로 했습니다. 간단하게 텍스처를 하나 만들고, 해당 텍스처의 최상위 밉 레벨의 값에 앞서 도출된 값들을 입력했습니다. 이는 차후 해당 유저 위젯의 Image 위젯에 접근하여, 다이나믹 머티리얼의 파라미터를 수정하여 전달됩니다. 다음 코드를 참고하세요:

static void CircularSmooth(const TArray<float> &In, TArray<float> &Out, int32 HalfWidth, float Sigma)
{
    const int32 N = In.Num();
    if (N == 0)
    {
        return;
    }
    Out.SetNumUninitialized(N);

    // 가우시안 커널
    TArray<float> W;
    W.SetNumUninitialized(2 * HalfWidth + 1);
    float sumW = 0.f;
    for (int i = -HalfWidth; i <= HalfWidth; ++i)
    {
        const float w = FMath::Exp(-0.5f * (i * i) / FMath::Max(1e-6f, Sigma * Sigma));
        W[i + HalfWidth] = w;
        sumW += w;
    }
    for (float &v : W)
    {
        v /= sumW;
    }

    auto at = [&](int32 idx) -> float
    {
        // 원형 래핑 인덱싱
        idx %= N;
        if (idx < 0)
        {
            idx += N;
        }
        return In[idx];
    };

    for (int32 k = 0; k < N; ++k)
    {
        float acc = 0.f;
        for (int j = -HalfWidth; j <= HalfWidth; ++j)
        {
            acc += W[j + HalfWidth] * at(k + j);
        }
        Out[k] = acc;
    }
}

void UWaitingDialogWidgetSequence::CombineAndUpload()
{

    for (int32 i = 0; i < NumBars; ++i)
    {
        ...

            // 최종 출력 버퍼(Magnitudes)에 기록
            Magnitudes[i] = outLin;
    }

    // 마지막과 끝값이 자연스럽게 연결되도록
    static TArray<float> Smoothed;
    CircularSmooth(Magnitudes, Smoothed, /*HalfWidth=*/2, /*Sigma=*/0.5f);
    Magnitudes = Smoothed;

    UpdateDataTexture();
}

void UWaitingDialogWidgetSequence::UpdateDataTexture()
{
    // 첫 mip에 그대로 써 넣는 간단한 패스 (1*NumBars, PF_R32_FLOAT)
    FTexture2DMipMap &Mip = DataTexture->GetPlatformData()->Mips[0];
    void *Data = Mip.BulkData.Lock(LOCK_READ_WRITE);
    FMemory::Memcpy(Data, Magnitudes.GetData(), sizeof(float) * Magnitudes.Num());
    Mip.BulkData.Unlock();
    DataTexture->UpdateResource(); // 리소스 갱신(소형이라 오버헤드 작음)
}

void UWaitingDialogWidgetSequence::InitAudioVisualization()
{
    ...

	// MID 구성
	if (AudioVisualizationImage)
	{
		MID = AudioVisualizationImage->GetDynamicMaterial();
		MID->SetTextureParameterValue(TEXT("DataTex"), DataTexture);
		MID->SetScalarParameterValue(TEXT("NumBars"), static_cast<float>(NumBars));
		MID->SetScalarParameterValue(TEXT("InnerRadius"), InnerRadius);
		MID->SetScalarParameterValue(TEXT("AmplitudeScale"), AmplitudeScale);
	}
    
    ...
}


렌더링

  제가 원하는 것은 원형으로 그려지는 오디오 시각화였습니다. 따라서 우선 원을 그릴 필요가 있었습니다. 텍스처 좌표만을 이용해서 절차적으로 그려지는 것을 원했기 때문에, 전부 머티리얼 노드만으로 그려야 했습니다. 우선 원형으로 그릴 것이기 때문에, 원의 반지름을 정의해볼 수 있습니다. 텍스처의 특정 점과 중점 사이의 거리가, 이 반지름보다 길고 반지름에 음역대의 진폭값을 더한 것보다 작은 경우에만 시각화의 색깔을 렌더링하면 될 것입니다. 다음 그림을 참고해보세요:

중점으로부터의 거리를 기준으로 CircleRadius보다는 크고, CircleRadius+Magnitude보다는 작은 영역을 색칠하면 될 것입니다.

  CircleRadius < DistFromCenter < CircleRadius + Magnitude 영역에 대한 색을 다음과 같은 노드 구성으로 결정할 수 있습니다.


  이를 위해서는 Magnitude를 결정할 수 있어야 합니다. Magnitude는 각 픽셀을 극좌표계로 따졌을 때 각도를 기준으로, 머티리얼 파라미터로 넘어온 텍스처를 샘플링해야 할 것입니다. 저는 추가적으로 샘플링 시에 약간의 노이즈를 주어 조금 더 자연스럽게 보이도록 만들었습니다.


  Angle과 DistanceFromCenter는 다음과 같은 노드 구성으로 얻을 수 있습니다:


최종 결과는 다음과 같습니다.



끝.

댓글

이 블로그의 인기 게시물

[코딩의탑] 5층: 포탈(Portal), 더 나아가기

[코딩의탑] 4층: 툰 쉐이딩

[코딩의탑] 3층: 바다 렌더링