Music playback¶
This game offers many ways to configure how and what background music to play. A background music is meant to be an AudioClip with loop points defined.
All music playback goes through a single audio source which is stored in MainManager.music[0]
. This means only one music can play at a given time and it has to go through this AudioSource because the game manages the music system using it. It differ from a sound which is meant to be played once and there might be multiple of them.
They are different than sounds because sounds have 15 pooled AudioSources available to them and they are meant to play short AudioClips in burst as needed. Check the sounds documentation to learn more.
This page talks about the music system in general, but a part of this system is managed by MapControl's map music system. Check the documentation there to learn more about MapControl's influence on the music system.
ChangeMusic¶
The only way to play a music or to stop playing the one currently playing is via the MainManager.ChangeMusic method which there are several overloads to choose from.
This overload is where all other overloads ends up using:
(main)
public static void ChangeMusic(AudioClip musicclip, float fadespeed, int id, bool seamless)
Parameters¶
musicclip
: The AudioClip corresponding to the music to play. If this is null, the method will instead stop the current music and leave it stoppedfadespeed
: A multiplier that controls the speed at which the existing music will fade to silence if a music was playing. It corresponds to a multiplier applied toframestep
and the result is used as a lerp factor of the volume into silence. However, this resulting volume is still scaled accordingly with MainManager.musicvolume
which is a game setting so both values stacks multiplacitively with each otherid
: This is supposed to represent the index ofmusic
used to change the music playback, but in practice, only the value 0 is valid. This is becausemusic
always has a single element in its array. Any value other than 0 will cause an exception to be thrownseamless
: If this is true, the fading will be done by doing a crossfade with the existing music instead of a fade out / fade in. This is achieved by temporarily using the first freesounds
AudioSource where the music will initially play muted. During the fade, as themusic
gets faded out, thesounds
will fade in by the same inverse proportion. When the cross fade is complete, the playback onsounds
is stopped which frees it while playback continues at the same time on themusic
. NOTE: This feature assumes that both AudioClip have matching times and are relatively similar with each other
Overloads¶
(1) public static void ChangeMusic();
(2) public static void ChangeMusic(AudioClip musicclip);
(3) public static void ChangeMusic(AudioClip musicclip, float fadespeed);
(4) public static void ChangeMusic(AudioClip musicclip, float fadespeed, int id);
(5) public static void ChangeMusic(int mapid);
(6) public static void ChangeMusic(string musicclip);
(7) public static void ChangeMusic(string musicclip, float fadespeed);
(8) public static void ChangeMusic(string musicclip, float fadespeed, int id);
(10) public static void ChangeMusic(string musicclip, float fadespeed, int id, bool seamless);
- (1): Calls (2) where the musicclip is
map.music[map.musicic]
ifmap
isn't null (it's not being loaded),map
.music
isn't empty andmap
.musicid
isn't negative. Otherwise, (2) is called with null as musicclip (meaning to stop playing the current music) - (2): Calls (3) with 0.075 fadespeed
- (3): Calls (4) with 0 as the id (this is essentially the same then calling (4) directly since the id should always be 0)
- (4): Calls (main) with false as seamless
- (5): Calls (2) with
map.music[mapid]
- (6): If musicclip is null, (3) is called with null as the musicclip and 0.1 as the fadespeed. Otherwise, (4) is called with musicclip being the result of loading the ressource asset at
Audio/Music/X
whereX
is musicclip, 0.1 fadespeed and id of 0 - (7): Calls (4) with musicclip being the result of loading the ressource asset at
Audio/Music/X
whereX
is musicclip and id of 0 (the fadespeed is passed as is) - (8): Calls (4) with musicclip being the result of loading the ressource asset at
Audio/Music/X
whereX
is musicclip (the fadespeed and id are passed as is). NOTE: This overload is functional, but is UNUSED and calling (7) directly has the same effect because the id should always be 0 - (9) Calls (main) with musicclip being the result of loading the ressource asset at
Audio/Music/X
whereX
is musicclip (every other parameters are passed as is)
(1) and (5) involve the configuration specific to MapControl map music.
Since all of them ends up at (main), this will be what is documented.
Procedure (main overload)¶
- If
musiccoroutine
is in progress (a music was playing), the coroutine is stopped as it is about to be set to a new call musiccoroutine
is set to a new SwitchMusic call with the same parameters sent (See the section below for details)- The matching
musicsids
element (normally always the first one) gets set to a value depending on musicclip:- If it's not null, it is set to the integer value of the matching Musics enum value obtained by musicclip.name. It also sets
lastmusic
to the same value - Otherwise, it's set to -1
- If it's not null, it is set to the integer value of the matching Musics enum value obtained by musicclip.name. It also sets
FadeMusic¶
There's a helper method that calls ChangeMusic to fade the current music to silence:
public static void FadeMusic(float fadespeed)
It calls ChangeMusic(null, fadespeed
) to smoothly change the current music to silence.
SwitchMusic¶
This is a coroutine with similar same signature than the main overload of ChangeMusic and it is what ends up doing the logic of changing the music:
private static IEnumerator SwitchMusic(AudioClip musicclip, float fadespeed, int id)
private static IEnumerator SwitchMusic(AudioClip musicclip, float fadespeed, int id, bool seamless)
It's a part of ChangeMusic that will attempt to change music[id]
.clip to musicclip
(or silence if musicclip
is null) if it's different than the current music by fading the current music using fadespeed
as the speed scaler for the fading. NOTE: id
should always be 0 in practice because only music[0]
is a valid music AudioSource. The coroutine is meant to be stored in musiccoroutine
if one is in progress (otherwise, the field remains null).
The overload without a seamless
parameter is UNUSED since the coroutine is never called, but it remains functional and the value of the parameter will default to false with an extra frame yield after calling the other coroutine.
The seamless
parameter tells if a crossfade with a sounds
will happen (this is explained above in the ChangeMusic documentation).
Most of logic happens only if the musicclip
isn't the same than the one in music[id]
's clip (if it was the same, it means the music is already playing):
- If
seamless
is true, the first freesounds
is picked for the crossfade with the following being setup on it:- clip:
musicclip
- time:
music[id]
's time (this makes the assumption that both AudioClip are similar and have matching times) - volume: 0.0 (starts muted)
- loop: true
- Play called
- clip:
- If the existing
music[id]
's clip isn't null (a music is currently playing), it is faded out. This fading is done by tracking a value from 1.0 towards 0.0 and is considered done when it reaches below 0.045. This is forced stopped if this lasts for more than 500.0 frames. On each frame of the fading, the following happens (once the fading is done,music[id]
volume gets set to 0.0):music[id]
volume is set tomusicvolume
* a lerp from the current fading value to 0.0 with a factor offadespeed
(essentially, it progressively gets muted)music[id]
volume gets decreased by 10% for the following Musics values (checked by parsing themusic[id]
clip name as aMusics
value):Theater
Tension
Venus
Battle3
Bee
TermiteLoop
Pier
- If
seamless
is true, the fade in part of the crossfade is done:- The
sounds
volume gets set tomusicvolume
-music[id]
volume (essentially, asmusic[id]
gets faded out,sounds
volume increases until it reachesmusicvolume
) - If the time of the
sounds
andmusic[id]
differs by more than 0.15 seconds, thesounds
time is synced to themusic[id]
one
- The
- Yield for 0.1 seconds
- If
musicclip
is null,music[id]
is stopped and its clip set to null - Otherwise:
music[id]
clip is set tomusicclip
- If
seamless
is true,sounds
gets fully stopped andmusic[id]
takes over.music[id]
time is set to thesounds
one andsounds
gets stopped with time set to 0.0 and loop to false - Otherwise, (
seamless
is false),music[id]
time is set to 0.0 so it resets the playback from the begining followed by a frame yield - If
musicresume
is 0 or above (meaning that StartBattle recorded the time to resume the overworld music):music[id]
time is set tomusicresume
. NOTE: This may be incorrect, see the section below for details on the music playback issuemusicresume
is reset to -1.0
music[id]
volume is set tomusicvolume
music[id]
plays
Regardless if a new music was played or not, the following is done after:
- If any of the following conditions are true, CheckSamira is called with the
music[id].clip
that's being played (more details in a section below):- We are in a
battle
- We are in instance.
inevent
map
isn't null (the map isn't being loaded) while itsmusicid
is in the bounds ofmusicids
- We are in a
- Yield for a frame
musiccoroutine
is set to null which signals the rest of the game that the music switch is complete
Issue with musicresume
¶
This static value is heavily involved in an issue where it's possible the music where this resume feature is used isn't expected. This feature involves the keepmusicafterbattle
setting where if enabled, this is how it's supposed to work normally:
- On StartBattle, as long as the current map contains
music
and itsmusicid
isn't negative, battle.overmusic
will be set tomusic[0]
's time. This records the timestamp to resume the overworld music that's to be played later - On ReturnToOverworld, if we aren't instance.
inevent
, it will setmusicresume
to battle.overmusic
. This essentially slates the next ChangeMusic call to restore the music timestamp, but only on the next call where musicclip isn't null and isn't the same music than the one playing onmusic[0]
at the moment. This is meant to be consumed right away because ReturnToOverworld also does a ChangeMusic to the overworld music - On the next SwitchMusic that applies,
music[0]
time is set tomusicresume
and thenmusicresume
gets set back to -1. This not only restores the timestamp that was saved earlier, but it also resets the value so it's not used again until needed
However, there is an edgecase that isn't handled correctly: if map.nobattlemusic
is enabled on the map, the ChangeMusic call that normally happens on ReturnToOverworld won't consume the musicresume
value. This is because since the music hasn't changed between the moment StartBattle was called and the moment ReturnToOverworld was called, the ChangeMusic call won't switch anything and the musicresume
value is left dangling.
The result is the next ChangeMusic call that happens on any musicclip that isn't null and isn't the current music[0]
clip will have the timestamp restored.
However, this is unexpected: it's possible musicclip is a different AudioClip. It would mean the game can attempt to resume the playback, but at an incorrect or invalid timestamp. In the case of the latter, a Unity assertion will be logged as error, but it will be gracefully handled by not changing the time property of the AudioClip so the music starts playing from the start. In the case of the former however, it will result in the music playback starting from the middle instead of the start.
Music looping¶
The music are designed to be played in loop. The loop points are defined in the LoopPoints TextAsset. As for how it is done, it involves a private method on MainManager called LoopMusic:
private void LoopMusic()
It checks the current music[0]
.clip playing and if its end loop point in musicloop
isn't 0.0 and its playback is past that end point, music[0]
.time is set to the start loop point in musicloop
. It is only called by FixedUpdate.
The first time this is called, musicloop
will be null so this method will initialise the value to the return of LoopPoint if that is the case. Here's the definition of LoopPoint:
public static float[][] LoopPoint()
It the data from Data/LoopPoints
which is meant to be set to the value of musicloop
.
As for how LoopMusic accesses the musicloop
element, it's done via lastmusic
which is only set by ChangeMusic. This allows to address the data by the Musics id that matches the one currently playing.
Samira¶
The game has a built in music player accessible in the form of an NPCControl called Samira. The way it works is any newly played music in the game gets added to the samiramusics
list which is saved on the save file. Each element in that list contains the following:
- A Musics id
- Its purchase state (-1 indicates not bought yet and 1 indicates bought)
The purchasing part is done as part of the NPCControl interaction, but the addition to samiramusics
and working with it in general heavily involves the music playback system itself because checking if a music should be added has to be checked on most music playback.
How it works is SwitchMusic involves an important method that interacts with music playback and it involves instance.samiramusics
. This method is CheckSamira:
public static void CheckSamira(AudioClip music)
public static void CheckSamira(string music)
This method allows to unlock a music when it's being played for the first time by adding it to instance.samiramusics
if it wasn't present. The string version is parsed as a Musics enum value while the AudioClip version uses its music
.name as the value to parse. They are technically each their own method with their own code, but their logic doesn't change besides the value to parse as the Musics value.
In practice, 5 Musics are excluded from the Samira system. This is because on MapControl's Start, another method called FixSamira is called:
public static void FixSamira()
If samiramusics
isn't null or empty, this method will remove the following elements from samiramusics
as these are intentionally excluded from the Samira purchase list:
Title
Wind
Water
MachineHum
Breathing
There are also other utilities methods related to working with samiramusics
. Here there are.
public static bool SamiraGotAll()
Returns true if PurchasedMusicAmmount's return value is the amount of Musics enum values - 8, false otherwise. 8 is a hardcoded value that represents the amount of Musics enum values whose music aren't purchasable from Samira.
public static int PurchasedMusicAmmount()
A part of SamiraGotAll.
Returns the amount of samiramusics
elements which aren't marked as "not bought" (assuming it implies being bought).
public static int[] SamiraMissing()
Return a newly created array that contains all samiramusics
elements that haven't been bought yet.
Music volume¶
There are various utilities that specifically deals with the music volume. Here there are.
private static IEnumerator LowerVolume(int musicid, float volume, float frametime, bool percent)
Changes music[musicid]
.volume over frametime
+ 1.0 amount of frames via a lerp to volume
(or the existing volume * volume
if percent
is true).
public static void ChangeMusicVolume(float frametime)
public static void ChangeMusicVolume(float targetvolume, float frametime)
public static void ChangeMusicVolume(int clipid, float targetvolume, float frametime)
Stops musiccoroutine
if it was in progress and set it to a new ChangeMVolume(clipid
, targetvolume
, frametime
) coroutine. This coroutine will smoothly change music[clipid]
.volume towards targetvolume
over the course of frametime
amount of frames. NOTE: In practice, clipid
should always be 0 since music
only contains 1 AudioSource.
The overloads that doesn't take every parameters are UNUSED, but remains functionals where clipid
defaults to musicchannel
and targetvolume
defaults to musicvolume
.
private static IEnumerator ChangeMVolume(int clipid, float volume, float frametime)
Smoothly changes music[clipid]
.volume via a Lerp from the current volume to volume
over the course of frametime
amount of frames + 1 frame. Once the Lerp completes and the additional frame yield, the volume is set to volume
. NOTE: In practice, clipid
should always be 0 since music
only contains 1 AudioSource.