// Fill out your copyright notice in the Description page of Project Settings. #include "MusicSubsystem.h" #include "Components/AudioComponent.h" #include "Engine/Engine.h" #include "Engine/GameInstance.h" #include "Engine/World.h" #include "Kismet/GameplayStatics.h" #include "MusicPlaylistDataAsset.h" #include "Sound/SoundBase.h" #include "Sound/SoundClass.h" #include "SurvivalAudioSettings.h" #include "UObject/UObjectGlobals.h" UMusicSubsystem* UMusicSubsystem::Get(const UObject* WorldContextObject) { if (!GEngine || !WorldContextObject) { return nullptr; } const UWorld* World = GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::LogAndReturnNull); UGameInstance* GameInstance = World ? World->GetGameInstance() : nullptr; return GameInstance ? GameInstance->GetSubsystem() : nullptr; } void UMusicSubsystem::Initialize(FSubsystemCollectionBase& Collection) { Super::Initialize(Collection); // Le seul endroit d'ou l'on voit passer un changement de scene : le lecteur // survit aux maps, donc aucun BeginPlay ni aucun EndPlay ne peut jouer ce // role -- et la map d'arrivee n'a justement pas a connaître le sujet. PostLoadMapHandle = FCoreUObjectDelegates::PostLoadMapWithWorld.AddUObject(this, &UMusicSubsystem::HandlePostLoadMap); } void UMusicSubsystem::Deinitialize() { if (PostLoadMapHandle.IsValid()) { FCoreUObjectDelegates::PostLoadMapWithWorld.Remove(PostLoadMapHandle); PostLoadMapHandle.Reset(); } CancelPendingGap(); // Fondu a zero : on quitte le jeu, plus personne n'ecoute. FadeOutAndRelease(MusicComponent, 0.f); if (FadingOutComponent) { FadingOutComponent->Stop(); FadingOutComponent = nullptr; } Super::Deinitialize(); } void UMusicSubsystem::PlayPlaylist(UMusicPlaylistDataAsset* Playlist, bool bForceRestart) { if (!Playlist || Playlist->Tracks.IsEmpty()) { StopMusic(); return; } // Deja en cours : on la laisse tranquille. C'est tout l'interet de faire // pointer la map du menu et celle du jeu vers le meme asset -- le chargement // devient inaudible. if (CurrentPlaylist == Playlist && bIsPlaying && !bForceRestart) { return; } CancelPendingGap(); // L'ancienne musique s'eteint sur sa propre voie pendant que la nouvelle // entre : un composant audio ne peut pas se croiser avec lui-meme. FadeOutAndRelease(MusicComponent, CurrentPlaylist ? CurrentPlaylist->FadeOutDuration : Playlist->FadeOutDuration); CurrentPlaylist = Playlist; bShuffleEnabled = Playlist->bShuffle; LastPlayedTrackIndex = INDEX_NONE; bIsPlaying = true; BuildPlayOrder(); if (PlayOrder.IsEmpty()) { // Que des cases vides dans la liste. bIsPlaying = false; return; } PlayTrackAtCursor(Playlist->FadeInDuration); } void UMusicSubsystem::StopMusic(float FadeOutDuration) { CancelPendingGap(); const float Duration = (FadeOutDuration >= 0.f) ? FadeOutDuration : (CurrentPlaylist ? CurrentPlaylist->FadeOutDuration : 0.f); FadeOutAndRelease(MusicComponent, Duration); bIsPlaying = false; CurrentPlaylist = nullptr; PlayOrder.Reset(); OrderCursor = INDEX_NONE; OnMusicTrackChanged.Broadcast(nullptr); } void UMusicSubsystem::SkipToNextTrack() { if (!bIsPlaying || !CurrentPlaylist) { return; } CancelPendingGap(); // On LIBERE le composant au lieu de le reutiliser. Stop() et FadeOut() // rediffusent OnAudioFinished, et cette diffusion transite par le thread audio : // elle peut donc arriver une frame plus tard, soit APRES le demarrage du morceau // suivant, ce qui en sauterait un second. Un composant neuf, celui d'avant // detache, rend ce retour tardif sans effet. const float SkipFade = FMath::Min(CurrentPlaylist->FadeOutDuration, 1.f); FadeOutAndRelease(MusicComponent, SkipFade); AdvanceAndPlay(SkipFade); } void UMusicSubsystem::SetShuffleEnabled(bool bEnabled) { if (bShuffleEnabled == bEnabled) { return; } bShuffleEnabled = bEnabled; if (!CurrentPlaylist) { return; } // Le morceau en cours continue ; seule la SUITE change. On recale le curseur // dessus pour qu'en mode ordonne la lecture reprenne juste apres lui, et non // au debut de la liste. BuildPlayOrder(); const int32 Found = PlayOrder.IndexOfByKey(LastPlayedTrackIndex); OrderCursor = (Found != INDEX_NONE) ? Found : 0; } void UMusicSubsystem::SetMusicVolume(float NewVolume) { MusicVolume = FMath::Clamp(NewVolume, 0.f, 1.f); if (MusicComponent) { // SetVolumeMultiplier est independant du fader utilise par FadeIn/FadeOut : // regler le volume au milieu d'un fondu ne l'interrompt pas. MusicComponent->SetVolumeMultiplier(GetEffectiveVolume()); } } USoundBase* UMusicSubsystem::GetCurrentTrack() const { return MusicComponent ? MusicComponent->Sound : nullptr; } void UMusicSubsystem::HandleTrackFinished() { // N'est atteint que par une fin naturelle : toute coupure manuelle passe par // FadeOutAndRelease, qui detache le delegue avant d'arreter le son. if (!bIsPlaying || !CurrentPlaylist) { return; } const float MinGap = FMath::Max(0.f, CurrentPlaylist->MinGapBetweenTracks); const float MaxGap = FMath::Max(MinGap, CurrentPlaylist->MaxGapBetweenTracks); const float Gap = FMath::FRandRange(MinGap, MaxGap); if (Gap > KINDA_SMALL_NUMBER) { ScheduleGap(Gap); } else { AdvanceAndPlay(0.f); } } void UMusicSubsystem::HandlePostLoadMap(UWorld* LoadedWorld) { // En PIE plusieurs instances peuvent charger des maps en parallele : on ne // reagit qu'a la nôtre. if (!LoadedWorld || LoadedWorld->GetGameInstance() != GetGameInstance()) { return; } if (LevelChangeMode != EMusicLevelChangeMode::New || !bIsPlaying) { return; } // Volontairement apres le chargement et non avant : le nouveau morceau entre // en meme temps que la nouvelle scene s'affiche, pas pendant l'ecran de // chargement ou personne ne l'associe a quoi que ce soit. SkipToNextTrack(); } void UMusicSubsystem::BuildPlayOrder() { PlayOrder.Reset(); OrderCursor = 0; if (!CurrentPlaylist) { return; } PlayOrder.Reserve(CurrentPlaylist->Tracks.Num()); for (int32 Index = 0; Index < CurrentPlaylist->Tracks.Num(); ++Index) { // Filtre en amont : une case vide ne doit jamais devenir un morceau a jouer, // sinon il faudrait la sauter au moment de la lecture -- et une liste // entierement vide y bouclerait a l'infini. if (CurrentPlaylist->Tracks[Index]) { PlayOrder.Add(Index); } } if (!bShuffleEnabled || PlayOrder.Num() < 2) { return; } // Fisher-Yates : chaque permutation equiprobable, une seule passe. for (int32 Index = PlayOrder.Num() - 1; Index > 0; --Index) { PlayOrder.Swap(Index, FMath::RandRange(0, Index)); } // Un nouveau cycle ne redemarre jamais sur le morceau qui vient de finir. // C'est la difference entre "aleatoire" et "aleatoire agreable a ecouter". if (PlayOrder[0] == LastPlayedTrackIndex) { PlayOrder.Swap(0, FMath::RandRange(1, PlayOrder.Num() - 1)); } } void UMusicSubsystem::AdvanceAndPlay(float FadeInDuration) { if (!CurrentPlaylist) { return; } ++OrderCursor; if (!PlayOrder.IsValidIndex(OrderCursor)) { if (!CurrentPlaylist->bLoop) { StopMusic(CurrentPlaylist->FadeOutDuration); return; } BuildPlayOrder(); if (PlayOrder.IsEmpty()) { StopMusic(0.f); return; } } PlayTrackAtCursor(FadeInDuration); } void UMusicSubsystem::PlayTrackAtCursor(float FadeInDuration) { if (!CurrentPlaylist || !PlayOrder.IsValidIndex(OrderCursor)) { return; } const int32 TrackIndex = PlayOrder[OrderCursor]; USoundBase* Track = CurrentPlaylist->Tracks.IsValidIndex(TrackIndex) ? CurrentPlaylist->Tracks[TrackIndex].Get() : nullptr; if (!Track) { // La playlist a ete editee pendant la lecture : on reconstruit plutot que // de jouer un index perime. BuildPlayOrder(); return; } // Un curseur de volume ne doit RIEN faire d'autre que changer le volume. // // Sans PlayWhenSilent, une source devenue inaudible -- curseur Musique a zero -- // est declaree TERMINEE par le moteur : OnAudioFinished part, HandleTrackFinished // enchaîne, et le silence entre deux morceaux (MinGapBetweenTracks) demarre. Le // joueur qui remonte son volume n'entend alors plus rien jusqu'a la piste // suivante, alors qu'il n'a fait que bouger un reglage. Avec ce mode le morceau // continue d'avancer sans etre rendu, et redevient simplement audible. // // Pose ICI et pas sur les assets, pour la meme raison que la SoundClass dans // CreateMusicComponent : ajouter une piste a une playlist ne doit rien demander // de plus. Ecrire ce champ ne salit pas l'asset -- rien n'appelle MarkPackageDirty. Track->VirtualizationMode = EVirtualizationMode::PlayWhenSilent; if (!MusicComponent) { MusicComponent = CreateMusicComponent(Track); if (!MusicComponent) { bIsPlaying = false; return; } } else { MusicComponent->SetSound(Track); } MusicComponent->SetVolumeMultiplier(GetEffectiveVolume()); if (FadeInDuration > 0.f) { MusicComponent->FadeIn(FadeInDuration, 1.f); } else { MusicComponent->Play(); } LastPlayedTrackIndex = TrackIndex; OnMusicTrackChanged.Broadcast(Track); } UAudioComponent* UMusicSubsystem::CreateMusicComponent(USoundBase* Sound) { const UGameInstance* GameInstance = GetGameInstance(); UWorld* World = GameInstance ? GameInstance->GetWorld() : nullptr; if (!World || !Sound) { return nullptr; } // bPersistAcrossLevelTransition : le composant est cree sans monde ni acteur // proprietaire et marque bIgnoreForFlushing, le seul moyen de traverser un // OpenLevel sans que le peripherique audio ne le coupe. // bAutoDestroy a faux : on veut le reutiliser d'un morceau au suivant, et // surtout recevoir son OnAudioFinished plutot que de le voir disparaître. UAudioComponent* Component = UGameplayStatics::CreateSound2D( World, Sound, /*VolumeMultiplier*/ 1.f, /*PitchMultiplier*/ 1.f, /*StartTime*/ 0.f, /*ConcurrencySettings*/ nullptr, /*bPersistAcrossLevelTransition*/ true, /*bAutoDestroy*/ false); if (!Component) { return nullptr; } // La classe est imposee ICI plutot que sur chaque asset de morceau : ajouter une // piste a une playlist ne doit pas demander de penser a la taguer, sinon le // curseur Musique cesse d'agir sur elle sans le moindre signe. if (USoundClass* MusicClass = USurvivalAudioSettings::Get()->GetMusicSoundClass()) { Component->SoundClassOverride = MusicClass; } Component->bIsMusic = true; Component->OnAudioFinished.AddDynamic(this, &UMusicSubsystem::HandleTrackFinished); return Component; } void UMusicSubsystem::FadeOutAndRelease(TObjectPtr& Component, float FadeOutDuration) { if (!Component) { return; } // Detacher AVANT d'arreter : Stop() comme FadeOut() finissent par rediffuser // OnAudioFinished, qui enchainerait sur le morceau suivant alors qu'on cherche // justement a couper. Component->OnAudioFinished.RemoveDynamic(this, &UMusicSubsystem::HandleTrackFinished); // Une seule voie de sortie : si un croisement precedent n'est pas fini, il est // coupe net. Empiler les fondus ferait monter le nombre de voix pour rien. if (FadingOutComponent && FadingOutComponent != Component) { FadingOutComponent->Stop(); FadingOutComponent = nullptr; } if (FadeOutDuration > 0.f && Component->IsPlaying()) { Component->FadeOut(FadeOutDuration, 0.f); FadingOutComponent = Component; } else { Component->Stop(); } Component = nullptr; } void UMusicSubsystem::ScheduleGap(float DelaySeconds) { CancelPendingGap(); TWeakObjectPtr WeakThis(this); GapTickerHandle = FTSTicker::GetCoreTicker().AddTicker(TEXT("MusicSubsystem.Gap"), DelaySeconds, [WeakThis](float /*DeltaTime*/) { if (UMusicSubsystem* Self = WeakThis.Get()) { Self->GapTickerHandle.Reset(); Self->AdvanceAndPlay(0.f); } return false; // un seul declenchement }); } void UMusicSubsystem::CancelPendingGap() { if (GapTickerHandle.IsValid()) { FTSTicker::RemoveTicker(GapTickerHandle); GapTickerHandle.Reset(); } } float UMusicSubsystem::GetEffectiveVolume() const { return MusicVolume * (CurrentPlaylist ? CurrentPlaylist->VolumeMultiplier : 1.f); }