mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-29 03:46:50 +01:00
Sources - Local Files: add a folder of music from the setup page (MP3, M4A, AAC, Ogg, FLAC, WAV; up to 200 songs a folder, 12 folders). Names and covers come from the files' tags. Each folder is a tile in its own row. - Online Radio: find stations by name (radio-browser.info) or add a stream's address (MP3, AAC, .pls, .m3u; no HLS). Each station is a tile in its own row; the track name follows what the station sends. Up to 24. - Both are decoded by the library built into the DLL (SpotifyHelper), which now plays files and HTTP streams as well as a program's output, reads tags and embedded pictures, and accepts MPEG-2 ADTS from AAC stations. Sound - Five-band equaliser (60 Hz, 250 Hz, 1 kHz, 4 kHz, 12 kHz) and loudness levelling, applied to every source on its way into the game. - Loudness, the speakers option, the equaliser and levelling are now kept in music-settings.json between sessions. Setup page - Remade in the mod's own colours and type, with numbered steps per source. - Queue pinned to the right: now playing, the playlist's songs with covers, find, and press to jump. Spotify's own queue shows once signed in. - Cards for Local files and Online radio; equaliser and levelling under Sound. In game - MUSIC page in the menu: player and queue, sound settings, source status and a button for the setup page. - "Loading song..." notice in the corner for songs that take a moment, with a switch to hide it. Presses made while a song is loading no longer restart it. - The Spotify row is hidden when Spotify is not in use. - Console: "spotify ..." is now "music ..." (music, music setup, music gain, music through, music spotify client|login|logout). Logs - The music sources' working lines (traces, per-song and per-bind lines, librespot's own) are debug only; stack captures are skipped unless debug logging is on. Grind exits: the per-pop line is debug only too. - A "Pace:" line when the game takes the sound faster or slower than real time, to find why music sounds fast after tabbing back in. Chat filter - A run of digits is a number, not a word spelled with look-alikes: a race time such as 2:55.0 is no longer masked.
376 lines
17 KiB
Rust
376 lines
17 KiB
Rust
//! Sound that is not Spotify's, decoded and handed to the game as it is to be heard
|
|
//! (Extension/Music/youtube_music.cpp). One stream at a time, from one of:
|
|
//! a program's output yt-dlp is run this way for YouTube Music
|
|
//! a file the player's own music (MP3, M4A, Ogg, FLAC, WAV)
|
|
//! an address an internet radio station (MP3 or AAC as it comes)
|
|
|
|
use std::ffi::{c_char, CStr, CString};
|
|
use std::io::Read;
|
|
use std::process::{Command, Stdio};
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::Duration;
|
|
|
|
use symphonia::core::audio::SampleBuffer;
|
|
use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL};
|
|
use symphonia::core::formats::FormatOptions;
|
|
use symphonia::core::io::{MediaSource, MediaSourceStream, ReadOnlySource};
|
|
use symphonia::core::meta::{MetadataOptions, MetadataRevision, StandardTagKey};
|
|
use symphonia::core::probe::Hint;
|
|
|
|
/// The sound: frames of two floats, at `rate` Hz. It returns when the game has room for more.
|
|
pub type Sound = extern "C" fn(samples: *const f32, frames: u32, rate: u32);
|
|
/// How a stream came to its end: "ended", or "failed <why>".
|
|
pub type Ended = extern "C" fn(stream: u64, how: *const c_char);
|
|
/// What a radio station says it is playing, each time it says so.
|
|
pub type Titled = extern "C" fn(stream: u64, title: *const c_char);
|
|
|
|
/// The stream that is wanted: one that is not it stops at its next piece of sound.
|
|
static WANTED: AtomicU64 = AtomicU64::new(0);
|
|
|
|
fn decode(source: Box<dyn MediaSource>, extension: Option<&str>, stream: u64, sound: Sound) -> Result<(), String> {
|
|
let media = MediaSourceStream::new(source, Default::default());
|
|
let mut hint = Hint::new();
|
|
if let Some(extension) = extension {
|
|
hint.with_extension(extension);
|
|
}
|
|
let probed = symphonia::default::get_probe()
|
|
.format(&hint, media, &FormatOptions::default(), &MetadataOptions::default())
|
|
.map_err(|error| format!("it is no sound this can read ({error})"))?;
|
|
let mut format = probed.format;
|
|
let track = format.tracks().iter().find(|track| track.codec_params.codec != CODEC_TYPE_NULL).ok_or("it has no sound in it")?;
|
|
let track_id = track.id;
|
|
let mut decoder = symphonia::default::get_codecs()
|
|
.make(&track.codec_params, &DecoderOptions::default())
|
|
.map_err(|error| format!("its sound is of a kind this cannot decode ({error})"))?;
|
|
let mut stereo: Vec<f32> = Vec::new();
|
|
let mut samples: Option<SampleBuffer<f32>> = None;
|
|
loop {
|
|
if WANTED.load(Ordering::Relaxed) != stream {
|
|
return Ok(());
|
|
}
|
|
let packet = match format.next_packet() {
|
|
Ok(packet) => packet,
|
|
// (The end of the file, or of what the program or the station sent.)
|
|
Err(symphonia::core::errors::Error::IoError(_)) => return Ok(()),
|
|
Err(symphonia::core::errors::Error::ResetRequired) => return Ok(()),
|
|
Err(error) => return Err(error.to_string()),
|
|
};
|
|
if packet.track_id() != track_id {
|
|
continue;
|
|
}
|
|
let decoded = match decoder.decode(&packet) {
|
|
Ok(decoded) => decoded,
|
|
// (A piece that cannot be decoded is passed over.)
|
|
Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
|
|
Err(error) => return Err(error.to_string()),
|
|
};
|
|
let spec = *decoded.spec();
|
|
let channels = spec.channels.count().max(1);
|
|
// (A piece with more in it than the last: room is made for it.)
|
|
if samples.as_ref().is_some_and(|buffer| buffer.capacity() < decoded.capacity() * channels) {
|
|
samples = None;
|
|
}
|
|
let buffer = samples.get_or_insert_with(|| SampleBuffer::<f32>::new(decoded.capacity() as u64, spec));
|
|
buffer.copy_interleaved_ref(decoded);
|
|
let interleaved = buffer.samples();
|
|
let frames = interleaved.len() / channels;
|
|
stereo.clear();
|
|
for frame in 0..frames {
|
|
let left = interleaved[frame * channels];
|
|
stereo.push(left);
|
|
stereo.push(if channels > 1 { interleaved[frame * channels + 1] } else { left });
|
|
}
|
|
if frames > 0 {
|
|
sound(stereo.as_ptr(), frames as u32, spec.rate);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn text(pointer: *const c_char) -> Option<String> {
|
|
if pointer.is_null() {
|
|
return None;
|
|
}
|
|
// SAFETY: the game passes a string that ends in a zero and outlives the call.
|
|
unsafe { CStr::from_ptr(pointer) }.to_str().ok().map(str::to_string)
|
|
}
|
|
|
|
/// Runs `work` as the stream that is wanted from now on, on a thread of its own, and tells
|
|
/// `ended` how it came out. Returns the stream's number, or 0 when it could not be begun.
|
|
fn begin(ended: Ended, work: impl FnOnce(u64) -> Result<(), String> + Send + 'static) -> u64 {
|
|
let stream = WANTED.fetch_add(1, Ordering::Relaxed) + 1;
|
|
let begun = std::thread::Builder::new().name("ReSkate stream".to_string()).spawn(move || {
|
|
// (Nothing that goes wrong in here is to take the game with it.)
|
|
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || work(stream)));
|
|
let how = match outcome {
|
|
Ok(Ok(())) => "ended".to_string(),
|
|
Ok(Err(why)) => format!("failed {why}"),
|
|
Err(_) => "failed on a fault of its own".to_string(),
|
|
};
|
|
if let Ok(how) = CString::new(how.replace('\0', " ")) {
|
|
ended(stream, how.as_ptr());
|
|
}
|
|
});
|
|
if begun.is_ok() {
|
|
stream
|
|
} else {
|
|
0
|
|
}
|
|
}
|
|
|
|
/// Runs `program` with `arguments` (one to a line) and plays what it writes, in the place of
|
|
/// any stream before it.
|
|
#[no_mangle]
|
|
pub extern "C" fn reskate_stream_play(program: *const c_char, arguments: *const c_char, sound: Sound, ended: Ended) -> u64 {
|
|
let (Some(program), Some(arguments)) = (text(program), text(arguments)) else { return 0 };
|
|
begin(ended, move |stream| {
|
|
let mut command = Command::new(&program);
|
|
command.args(arguments.lines().filter(|line| !line.is_empty())).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null());
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::process::CommandExt;
|
|
command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
|
}
|
|
let mut child = command.spawn().map_err(|error| format!("{program} could not be run ({error})"))?;
|
|
let output = child.stdout.take().ok_or("it has no output")?;
|
|
let result = decode(Box::new(ReadOnlySource::new(Watched { inner: output, stream })), None, stream, sound);
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
result
|
|
})
|
|
}
|
|
|
|
/// Plays a file of the player's.
|
|
#[no_mangle]
|
|
pub extern "C" fn reskate_stream_play_file(path: *const c_char, sound: Sound, ended: Ended) -> u64 {
|
|
let Some(path) = text(path) else { return 0 };
|
|
begin(ended, move |stream| {
|
|
let file = std::fs::File::open(&path).map_err(|error| format!("the file could not be opened ({error})"))?;
|
|
let extension = std::path::Path::new(&path).extension().and_then(|extension| extension.to_str()).map(str::to_lowercase);
|
|
decode(Box::new(file), extension.as_deref(), stream, sound)
|
|
})
|
|
}
|
|
|
|
/// What a file says of itself in its tags: its artist, its title, and its picture.
|
|
fn about(path: &str) -> Option<(String, String, Vec<u8>)> {
|
|
let file = std::fs::File::open(path).ok()?;
|
|
let mut hint = Hint::new();
|
|
if let Some(extension) = std::path::Path::new(path).extension().and_then(|extension| extension.to_str()) {
|
|
hint.with_extension(&extension.to_lowercase());
|
|
}
|
|
let media = MediaSourceStream::new(Box::new(file), Default::default());
|
|
let mut probed = symphonia::default::get_probe().format(&hint, media, &FormatOptions::default(), &MetadataOptions::default()).ok()?;
|
|
let (mut artist, mut title, mut picture) = (String::new(), String::new(), Vec::new());
|
|
let mut take = |revision: &MetadataRevision| {
|
|
for tag in revision.tags() {
|
|
match tag.std_key {
|
|
Some(StandardTagKey::Artist) if artist.is_empty() => artist = tag.value.to_string(),
|
|
Some(StandardTagKey::TrackTitle) if title.is_empty() => title = tag.value.to_string(),
|
|
_ => {}
|
|
}
|
|
}
|
|
if picture.is_empty() {
|
|
if let Some(visual) = revision.visuals().first() {
|
|
picture = visual.data.to_vec();
|
|
}
|
|
}
|
|
};
|
|
{
|
|
let metadata = probed.format.metadata();
|
|
if let Some(revision) = metadata.current() {
|
|
take(revision);
|
|
}
|
|
}
|
|
if let Some(metadata) = probed.metadata.get() {
|
|
if let Some(revision) = metadata.current() {
|
|
take(revision);
|
|
}
|
|
}
|
|
Some((artist, title, picture))
|
|
}
|
|
|
|
/// Writes `text` where the game gave room for it, ended with a zero, and whole characters only.
|
|
fn write(text: &str, to: *mut c_char, room: u32) {
|
|
if to.is_null() || room == 0 {
|
|
return;
|
|
}
|
|
let mut most = text.len().min(room as usize - 1);
|
|
while !text.is_char_boundary(most) {
|
|
most -= 1;
|
|
}
|
|
// SAFETY: the game gave `room` bytes at `to`.
|
|
unsafe {
|
|
std::ptr::copy_nonoverlapping(text.as_ptr(), to.cast::<u8>(), most);
|
|
*to.add(most) = 0;
|
|
}
|
|
}
|
|
|
|
/// A file's artist and title (each into `text_room` bytes; empty when it does not say), and
|
|
/// its picture when room is given for one. Returns the picture's size, or 0 when it has none
|
|
/// or it is larger than the room.
|
|
#[no_mangle]
|
|
pub extern "C" fn reskate_file_about(path: *const c_char, artist: *mut c_char, title: *mut c_char, text_room: u32, picture: *mut u8, picture_room: u32) -> u32 {
|
|
write("", artist, text_room);
|
|
write("", title, text_room);
|
|
let Some(path) = text(path) else { return 0 };
|
|
let Ok(Some((its_artist, its_title, its_picture))) = std::panic::catch_unwind(move || about(&path)) else { return 0 };
|
|
write(its_artist.replace(' |