Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add custom music support to Jukebox #19

Open
wants to merge 8 commits into
base: main
Choose a base branch
from

Conversation

jfaz1
Copy link

@jfaz1 jfaz1 commented Apr 3, 2024

Adds the ability for users to use their own music with Jukebox.

Usage

In order to load custom music, all the user has to do is:

  • Create a music directory in the same directory as their Melee ISO
  • Create <stage> directories within the music folder
  • Fill each <stage> directory with audio files

E.g:

  • music
    • pokemon_stadium
      • song1.ogg
      • song2.mp3

Jukebox will then pick a track at random when loading a stage. The naming convention for stage directories is fully lowercase, no symbols, using _ as a separator.


While fully functional, I have a few questions. This is my first PR here so I made it as unobtrusive as possible, but there are some things to consider:

  • Should I rename music directory to jukebox?
  • Should I change the music directory to something like User/Slippi/Jukebox? I went with the ISO path because it was already available and made enough sense. Also I don't know if putting it inside the Slippi folder could make the files get lost when resetting the emulator, switching mainline/beta, etc. Could also potentially be a config option.
  • The way it determines what stage to play is based off the HPS offset. I went through every stage recording what the offset is for its main/alternate track, and used that to map what folder to access. It works just fine, as far as I can tell. I tested with vanilla and modded ISOs and it worked except when custom music was already added (e.g. Akaneia, which has other issues with Jukebox anyways).

I've been using this for a couple of days with no issues.

@jfaz1 jfaz1 marked this pull request as ready for review April 3, 2024 20:30
@JLaferri
Copy link
Member

JLaferri commented Apr 4, 2024

Hey there!

This looks like something we would be willing to merge. @DarylPinto will take a look within a few days hopefully.

I agree with your suggestions: I think the music would be better placed at User/Slippi/Jukebox.

What is the behavior if the HPS offset is not detected exactly?

@jfaz1
Copy link
Author

jfaz1 commented Apr 4, 2024

I agree with your suggestions: I think the music would be better placed at User/Slippi/Jukebox.

Gotcha, I'll make the changes.

What is the behavior if the HPS offset is not detected exactly?

It just falls back to the previous behavior and plays the vanilla stage music. If hps_to_stage() returns None , custom_song_path remains unset.

exi/src/lib.rs Show resolved Hide resolved
@DarylPinto
Copy link
Collaborator

DarylPinto commented Apr 5, 2024

Hey there,

I'm the author of Jukebox! Thanks for implementing this feature - it's a great idea, and I'm sure people will get a kick out of adding their own songs. I have some feedback about the implementation details that I'll put into a follow-up comment. Feel free to reach out to me on Discord in the #rust-extensions channel as well, and I'd be happy to guide you along!

jukebox/Cargo.toml Outdated Show resolved Hide resolved
Comment on lines 19 to 52
71324300 | 183962648 => "final_destination",
67606252 | 192481656 => "battlefield",
260526192 => "yoshis_story",
86968172 => "fountain_of_dreams",
135559532 => "dreamland",
156902380 | 160483852 => "pokemon_stadium",
// Misc
116194572 | 114173548 | 112087660 => "menu",
227529496 => "target_test",
// Other
258226448 | 180321356 => "yoshis_island",
138041836 => "yoshis_island_2",
129809196 => "kongo_jungle",
102329228 => "brinstar_depths",
40067628 => "fourside",
12767116 | 119550124 => "big_blue",
163012812 => "poke_floats",
79192940 | 24668300 => "kingdom",
83914380 => "kingdom_2",
264873488 => "brinstar",
144477996 | 140261388 => "onett",
124345132 => "mute_city",
168831468 => "rainbow_cruise",
94385004 => "jungle_japes",
173785356 => "temple",
56785644 => "green_greens",
250920912 => "venom",
10628844 | 75159404 => "icicle_mountain",
17422220 => "princess_peachs_castle",
45632108 => "kongo_jungle",
172460780 | 54558348 => "great_bay",
21447116 => "corneria",
36096908 => "flatzone",
_ => "",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this approach works and is something that was used in very early versions of jukebox, it can be a quite brittle. I highly encourage making use of what is known as the FST (Filesystem Table). It's essentially a table of contents that lists every file on the disc, and it's offset in the ISO. Fortunately, I've already written code to work with this in an earlier version of Jukebox: https://github.com/project-slippi/slippi-rust-extensions/blob/ec8db587/jukebox/src/fst/mod.rs#L20-L89

In this code I walk the FST and produce a hashmap of TrackIDs (an arbitrary enum) and the associated song's offset and length in bytes: HashMap::<TrackId, (Offset, Length)>

In your case, you would want to modify this so the hashmap that's produced is something like HashMap::<Offset, StageDirPath> where Offset: usize and StageDirPath: std:path::Path

{
  0x123456: Path("/path/to/User/Slippi/Music/yoshis_story"),
  0xABCDEF: Path("/path/to/User/Slippi/Music/final_destination"),
  ...
}

For example: you load into Yoshi's Story, you receive 0x123456 from start_song() and can look up the associated custom music directory in the hashmap with:

let stage_dir = custom_song_path_map.get(0x123456);

Obviously the FST doesn't contain a list of your custom folder paths in it (since we are making them up), but it does contain the names of specific files. You will need an intermediary match that associates each hps filename with it's corresponding stage_dir:

match filename {
  "ystory.hps" => Some(Path::new("/path/to/User/Slippi/Music/yoshis_story")),
  "sp_end.hps" => Some(Path::new("/path/to/User/Slippi/Music/final_destination"))
  ...
  _ => None
}

You can use this match as a starting point: https://github.com/project-slippi/slippi-rust-extensions/blob/ec8db587/jukebox/src/tracks.rs#L59-L108

I know this FST stuff is a lot of esoteric mumbo jumbo, so feel free to message me on Discord and I'll be happy to help you along.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh I like this, thanks! I was really worried about edge cases, this is great.

Comment on lines 142 to 184
let mut custom_song_path = None;
if let Some(stage) = hps_to_stage(real_hps_offset) {
let stage_dir = Path::new(&jukebox_path).join(stage);
if let Ok(entries) = read_dir(&stage_dir) {
// Get all files in folder
let files: Vec<_> = entries
.filter_map(|entry| {
if let Ok(entry) = entry {
let path = entry.path();
if path.is_file() {
if let Some(extension) = path.extension().and_then(|extension| extension.to_str()) {
if ["mp3", "wav", "ogg", "flac"].contains(&extension.to_lowercase().as_str()) {
return Some(path);
}
}
}
}
None
})
.collect();

// Decode the Hps into audio
let audio = match hps.decode() {
Ok(audio) => audio,
Err(e) => {
tracing::error!(target: Log::Jukebox, error = ?e, "Failed to decode hps into audio. Cannot play song.");
Dolphin::add_osd_message(
Color::Red,
OSDDuration::Normal,
"Invalid music data found in ISO. This music will not play.",
);
continue;
},
};
// Choose a random file from the stage folder if available
if !files.is_empty() {
if let Some(random_file) = files.choose(&mut rand::thread_rng()) {
custom_song_path = Some(random_file.clone())
}
}
}
}

// Append stage audio to sink
if let Some(custom_song_path) = custom_song_path {
match File::open(custom_song_path) {
Ok(custom_song_file) => {
if let Ok(custom_song) = rodio::Decoder::new(BufReader::new(custom_song_file)) {
sink.append(custom_song.repeat_infinite());
}
},
Err(e) => {
tracing::error!(target: Log::Jukebox, error = ?e, "Failed to open custom song. Cannot play song.");
continue;
},
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few notes about this:

  • I strongly suggest moving this logic into it's own function to keep start() terse and easy to read.
  • In a function you can greatly improve readability by reducing nesting. Instead of:
    if let Some(foo) = get_foo() {
    you can simply do:
    let foo = get_foo()?;
  • You should call as much of this as possible before the main loop as to avoid reading from the disk unnecessarily every time a new song loads.

Copy link
Author

@jfaz1 jfaz1 Apr 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually was originally using the try operator but I removed it so it just falls back on the default behavior instead of erroring out if anything goes wrong with the custom audio. Do you agree or should I just assume that if the right folders are present, any issues down the line should actually early return?
Edit: Given that I'm moving it to a separate function this might not even be an issue, you can probably ignore this comment

@@ -84,8 +84,11 @@ impl SlippiEXIDevice {
initial_dolphin_music_volume,
} = config
{
// TODO: Consider passing in user path directly and appending
let jukebox_path = self.config.paths.user_json.replace("user.json", "Jukebox");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There might be a better way to do this. Thoughts @ryanmcgrath?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This'll be easier once #18 is merged, since that migrates these kinds of things to true path types. Dunno if it's worth mucking with it here but I'll defer to whatever Nikki wants.

Comment on lines 148 to 160
.filter_map(|entry| {
if let Ok(entry) = entry {
let path = entry.path();
if path.is_file() {
if let Some(extension) = path.extension().and_then(|extension| extension.to_str()) {
if ["mp3", "wav", "ogg", "flac"].contains(&extension.to_lowercase().as_str()) {
return Some(path);
}
}
}
}
None
})
Copy link
Collaborator

@DarylPinto DarylPinto Apr 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be simplified using ? early returns for a less nested structure:

.filter_map(|entry| {
    let path = entry.ok()?.path();
    if !path.is_file() {
        return None;
    }
    let extension = path.extension()?.to_str()?.to_lowercase();
    match extension.as_str() {
        "mp3" | "wav" | "ogg" | "flac" => Some(path),
        _ => None,
    }
})

The logic might not be correct, so be sure to verify

@jfaz1
Copy link
Author

jfaz1 commented Apr 5, 2024

Hey there,

I'm the author of Jukebox! Thanks for implementing this feature - it's a great idea, and I'm sure people will get a kick out of adding their own songs. I have some feedback about the implementation details that I'll put into a follow-up comment. Feel free to reach out to me on Discord in the #rust-extensions channel as well, and I'd be happy to guide you along!

Thanks for all the comments! I'm a bit swamped right now but I should have it up to date within a couple of days.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants