Files
ReSkate/Extension/Music/spotify_station.cpp
T
AyeZeeBB 86aa8d38fb Music: local files, online radio, equaliser, queue, and a Music page in the menu
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.
2026-10-12 16:03:35 -05:00

462 lines
24 KiB
C++

#include "spotify_station.h"
#include "external_player.h"
#include "spotify_cover.h"
#include "spotify_web.h"
#include "spotify_device.h"
#include "external_audio.h"
#include "youtube_music.h"
#include "spotify_rows.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Core/Platform/memory.h"
#include <Windows.h>
#include <algorithm>
#include <atomic>
#include <cstring>
#include <format>
#include <memory>
#include <mutex>
namespace dingosdk::spotify_station {
namespace {
constexpr std::uint32_t no_channel = ~0u;
std::atomic<std::uint32_t> station_channel{no_channel}; // the music channel bound to one of them
std::atomic<int> station_index{-1}; // and which
std::atomic<std::uint64_t> binds_seen{};
std::atomic<std::size_t> station_count{};
std::atomic<std::uint64_t> last_said{~0ull};
std::atomic<bool> skip{};
// The channel last bound to a playlist that is no station, and when.
std::atomic<std::uint32_t> plain_channel{no_channel};
std::atomic<std::uint64_t> plain_at{};
void left_at_store() noexcept;
// The music player's queue, as last seen choosing a song while a station is on, how it was
// flagged when the player asked for the next song, and when this last asked it itself.
std::atomic<std::uintptr_t> player_queue{}, player_queue_table{}; // (the table a queue begins with: by it the queue is known to be there still)
std::atomic<std::uint8_t> asked_flag{1};
std::atomic<std::uint64_t> self_asked{};
constexpr std::size_t queue_asked = 0xf1, queue_start = 0xf2;
// The stations, and the artist and title of the one that is on (see the header). The game may
// still be reading a text it was given earlier, so none of them is ever freed: a song's worth
// of bytes for each song heard.
constexpr std::size_t artist_at = 0x18, title_at = 0x20;
std::mutex mutex;
std::vector<Station> stations;
std::array<unsigned char, 16> picked_row{};
std::atomic<bool> row_picked{};
std::uintptr_t metadata_table{};
// The songs whose artist and title read as Spotify's now, with what they read before: the
// station's own, and any other song of the game's begun while it is on (a speaker's in the
// world, one grabbed from it), whose sound is Spotify's as well. `current_*`: what they read.
struct Shown {
std::uintptr_t metadata{}, artist{}, title{};
};
std::vector<Shown> shown;
// Those that are to read so again after they were put back for the catalog to be read (the
// station's own is not among them: it is shown again as a matter of course).
std::vector<std::uintptr_t> again;
// What each of the game's music channels last began (its metadata asset).
std::array<std::uintptr_t, 16> channel_song{};
std::uintptr_t current_artist{}, current_title{};
std::vector<std::unique_ptr<char[]>> kept;
bool poke(std::uintptr_t address, std::uintptr_t value) noexcept {
__try {
std::memcpy(reinterpret_cast<void*>(address), &value, sizeof(value));
return true;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return false;
}
}
bool there(std::uintptr_t metadata) noexcept {
std::uintptr_t table{};
return metadata && memory::peek(metadata, table) && table == metadata_table;
}
std::uintptr_t keep(const std::string& text) {
auto copy = std::make_unique<char[]>(text.size() + 1);
std::memcpy(copy.get(), text.c_str(), text.size() + 1);
kept.push_back(std::move(copy));
return reinterpret_cast<std::uintptr_t>(kept.back().get());
}
// (With the mutex held.)
void restore_locked() noexcept {
for (const auto& entry : shown)
if (there(entry.metadata)) {
poke(entry.metadata + artist_at, entry.artist);
poke(entry.metadata + title_at, entry.title);
}
shown.clear();
}
// (With the mutex held.) A song reads as Spotify's from now on.
void show_locked(std::uintptr_t metadata) noexcept {
if (!current_artist || !current_title || !there(metadata)) return;
if (std::none_of(shown.begin(), shown.end(), [&](const Shown& entry) { return entry.metadata == metadata; })) {
Shown entry{metadata};
if (shown.size() >= 64 || !memory::peek(metadata + artist_at, entry.artist) || !memory::peek(metadata + title_at, entry.title)) return;
shown.push_back(entry);
}
const bool done = poke(metadata + artist_at, current_artist) && poke(metadata + title_at, current_title);
// (Each song made to read as Spotify's, into the log while the speakers are worked out.)
if (static int said{}; said++ < 60) {
try {
logging::log(logging::Level::debug, logging::Channel::music, "Spotify station: a song of the game's {} as Spotify's ({} in all).", done ? "now reads" : "could not be made to read", shown.size());
} catch (...) {}
}
}
bool is_shown_locked(std::uintptr_t metadata) noexcept {
return std::any_of(shown.begin(), shown.end(), [&](const Shown& entry) { return entry.metadata == metadata; });
}
void show(int index, const std::string& artist, const std::string& title) {
std::lock_guard lock(mutex);
if (index < 0 || static_cast<std::size_t>(index) >= stations.size() || artist.empty() || title.empty()) return;
current_artist = keep(artist);
current_title = keep(title);
show_locked(stations[static_cast<std::size_t>(index)].metadata);
for (const auto metadata : again) show_locked(metadata);
again.clear();
for (const auto metadata : channel_song)
if (metadata) show_locked(metadata);
for (const auto& entry : std::vector<Shown>(shown)) show_locked(entry.metadata);
}
// Which of YouTube Music's playlists a station's is ("~yt:<number>").
std::size_t youtube_list(const std::string& playlist) {
try {
return std::stoul(playlist.substr(std::strlen(youtube_music::station_prefix)));
} catch (...) {
return ~std::size_t{};
}
}
} // namespace
bool spotify_used() noexcept {
try {
return spotify_device::in_use() || spotify_web::state().signed_in;
} catch (...) {
return false;
}
}
bool is_station(std::string_view id) noexcept { return id == playlist_id || id.starts_with(playlist_prefix); }
void set_stations(std::vector<Station> next, std::uintptr_t table) {
std::lock_guard lock(mutex);
restore_locked();
stations = std::move(next);
metadata_table = table;
station_count.store(stations.size(), std::memory_order_release);
}
std::vector<std::string> playlist_ids() {
std::lock_guard lock(mutex);
std::vector<std::string> ids;
for (const auto& station : stations) ids.push_back(station.id);
return ids;
}
bool has_stations() noexcept { return station_count.load(std::memory_order_acquire) != 0; }
int station_of(const unsigned char* members, std::uint32_t count, unsigned char* carrier, bool& add) noexcept {
std::lock_guard lock(mutex);
add = false;
int best{-1};
std::uint32_t best_rows{};
for (std::size_t i = 0; i < stations.size(); ++i) {
const auto& station = stations[i];
// Every member is one of its rows or its carrier, and the more of its rows the better.
std::uint32_t rows{};
bool mine{true}, carried{};
for (std::uint32_t member = 0; member < count && mine; ++member) {
const auto* bytes = members + std::size_t{member} * 16;
const bool row = std::any_of(station.rows.begin(), station.rows.end(), [&](const auto& candidate) { return std::memcmp(candidate.data(), bytes, 16) == 0; });
const bool is_carrier = std::memcmp(station.carrier.data(), bytes, 16) == 0;
rows += row;
carried = carried || is_carrier;
mine = row || is_carrier;
}
if (!mine || !rows || rows <= best_rows) continue;
best = static_cast<int>(i);
best_rows = rows;
add = !carried;
std::memcpy(carrier, station.carrier.data(), 16);
}
return best;
}
void song_begun(std::uint32_t channel, std::uintptr_t metadata) noexcept {
std::lock_guard lock(mutex);
if (channel < channel_song.size()) channel_song[channel] = metadata;
}
void song_started(std::uintptr_t metadata) noexcept {
if (!active()) return;
std::lock_guard lock(mutex);
show_locked(metadata);
}
int row_of(int station, const unsigned char* song) noexcept {
std::lock_guard lock(mutex);
if (station < 0 || static_cast<std::size_t>(station) >= stations.size()) return -1;
const auto& rows = stations[static_cast<std::size_t>(station)].rows;
for (std::size_t i = 0; i < rows.size(); ++i)
if (std::memcmp(rows[i].data(), song, 16) == 0) return static_cast<int>(i);
return -1;
}
void row_started(const unsigned char* row) noexcept {
std::lock_guard lock(mutex);
std::memcpy(picked_row.data(), row, 16);
row_picked.store(true, std::memory_order_release);
}
void bound(std::uint32_t channel, bool music_channel, std::uint32_t playlist, std::uint32_t members, int station) noexcept {
binds_seen.fetch_add(1, std::memory_order_relaxed);
// (Each change of a channel's playlist to a station, into the log.)
if (const auto signature = (std::uint64_t{channel} << 40) ^ (std::uint64_t{playlist} << 8) ^ members ^ (static_cast<std::uint64_t>(station + 1) << 52);
last_said.exchange(signature, std::memory_order_relaxed) != signature && station >= 0) {
try {
logging::log(logging::Level::debug, logging::Channel::music, "Spotify station: the game bound channel {} to playlist {}, station {}{}.",
channel, playlist, station, music_channel ? "" : " (not yet a music channel)");
} catch (...) {}
}
const auto before = station_channel.load(std::memory_order_relaxed);
if (station < 0) {
plain_channel.store(channel, std::memory_order_relaxed);
plain_at.store(GetTickCount64(), std::memory_order_relaxed);
}
if (station >= 0 || before == channel) player_queue.store(0, std::memory_order_relaxed);
if (station >= 0) {
station_index.store(station, std::memory_order_relaxed);
station_channel.store(channel, std::memory_order_relaxed);
} else if (before == channel) {
station_channel.store(no_channel, std::memory_order_relaxed);
station_index.store(-1, std::memory_order_relaxed);
left_at_store();
}
}
void restore_presentation() noexcept {
std::lock_guard lock(mutex);
// (While a station is on they are only put back for a moment, for the catalog to be read.)
if (active())
for (const auto& entry : shown)
if (again.size() < 64 && std::find(again.begin(), again.end(), entry.metadata) == again.end()) again.push_back(entry.metadata);
restore_locked();
}
void song_chosen(std::uintptr_t queue, bool asked) noexcept {
// (A queue is the game's for as long as its channel stays on a playlist: a new one is made
// when the channel is bound again, and the old one's memory goes to something else. Written
// into after that, it took the game down, 2026-10-12.)
if (std::uintptr_t table{}; memory::peek(queue, table)) {
player_queue_table.store(table, std::memory_order_relaxed);
player_queue.store(queue, std::memory_order_relaxed);
}
if (!asked) return;
// (Its own asking is answered within a frame or two; a press of the player's is not it.)
if (const auto mine = self_asked.exchange(0, std::memory_order_relaxed); mine && GetTickCount64() - mine < 3000) return;
if (std::uint8_t start{}; memory::peek(queue + queue_start, start)) asked_flag.store(start, std::memory_order_relaxed);
skip.store(true, std::memory_order_relaxed);
}
void refresh_now_playing() noexcept {
const auto queue = player_queue.load(std::memory_order_relaxed);
if (!queue || !active()) return;
if (std::uintptr_t table{}; !memory::peek(queue, table) || table != player_queue_table.load(std::memory_order_relaxed)) {
player_queue.store(0, std::memory_order_relaxed);
return;
}
std::uint8_t asked{};
if (!memory::peek(queue + queue_asked, asked) || asked) return; // (it is choosing one as it is)
self_asked.store(GetTickCount64(), std::memory_order_relaxed);
__try {
*reinterpret_cast<std::uint8_t*>(queue + queue_start) = asked_flag.load(std::memory_order_relaxed);
*reinterpret_cast<std::uint8_t*>(queue + queue_asked) = 1;
} __except (EXCEPTION_EXECUTE_HANDLER) {
self_asked.store(0, std::memory_order_relaxed);
}
}
bool active() noexcept { return station_channel.load(std::memory_order_relaxed) != no_channel; }
std::atomic<bool> screen_open{};
void set_music_screen(bool open) noexcept { screen_open.store(open, std::memory_order_relaxed); }
bool music_screen() noexcept { return screen_open.load(std::memory_order_relaxed); }
std::atomic<std::uint64_t> left_at{}, grabbed_at{};
void next_pressed() noexcept {
if (active()) skip.store(true, std::memory_order_relaxed);
}
void grabbed() noexcept { grabbed_at.store(GetTickCount64(), std::memory_order_relaxed); }
namespace {
void left_at_store() noexcept { left_at.store(GetTickCount64(), std::memory_order_relaxed); }
}
bool recent() noexcept { return active() || GetTickCount64() - left_at.load(std::memory_order_relaxed) < 15000; }
std::uint32_t channel() noexcept { return station_channel.load(std::memory_order_relaxed); }
int current() noexcept { return active() ? station_index.load(std::memory_order_relaxed) : -1; }
void tick() noexcept {
static int was{-1};
static std::uint64_t said{}, entered{}, picked_at{}, tube_start_at{};
static std::size_t tube_start{};
const int now = active() ? station_index.load(std::memory_order_relaxed) : -1;
if (now != was) {
const bool first = was < 0;
was = now;
// Entered, or gone from one to another: the outside player is watched and started, on
// the playlist when the station has one. Left: it is paused, and the watcher stays (it
// costs a look a second) so the next entry is at once.
if (now >= 0) {
external_player::start();
std::string playlist;
{
std::lock_guard lock(mutex);
if (static_cast<std::size_t>(now) < stations.size()) playlist = stations[static_cast<std::size_t>(now)].playlist;
}
// The External Audio station (external_audio.h) is another application's sound and
// session; the others are Spotify's. What was playing is paused as one gives way to
// the other.
// (And a YouTube Music station, youtube_music.h, is a playlist of the player's own.)
const bool others = playlist == external_audio::station, tube = playlist.starts_with(youtube_music::station_prefix);
if (others != external_audio::on() || tube != youtube_music::on()) {
external_player::send(external_player::Command::pause);
external_audio::set_on(others);
youtube_music::set_on(tube);
}
external_player::use_app(others ? external_audio::app() : std::string());
if (tube) {
tube_start = youtube_list(playlist);
tube_start_at = GetTickCount64();
} else if (others) external_player::send(external_player::Command::play);
// (On the game's own device, Spotify is had to play there, wherever it was playing.)
else if (!playlist.empty() && GetTickCount64() - picked_at > 1500) {
if (spotify_device::connected()) spotify_device::activate();
spotify_web::play(playlist);
} else if (spotify_device::connected()) spotify_device::start_radio();
else if (first) external_player::send(external_player::Command::play);
entered = GetTickCount64();
try {
logging::log(logging::Level::debug, logging::Channel::music, "Spotify station: station {} is on{}.", now,
playlist.empty() ? std::string() : " (Spotify playlist " + playlist + ")");
} catch (...) {}
} else {
external_player::send(external_player::Command::pause);
}
}
// A row the player picked: Spotify is started at its track, and the game is had to go on
// to the station's own song (the row's is a copy of some song of the game's, which would
// otherwise play out under its own name).
if (row_picked.exchange(false, std::memory_order_acquire)) {
// (The row is looked for in the station that is on first, then in the others: the game
// may ask for it a moment before it binds the playlist.)
std::string playlist, track;
int found{-1};
{
std::lock_guard lock(mutex);
const auto look = [&](const Station& station) {
for (std::size_t i = 0; i < station.rows.size() && i < station.tracks.size(); ++i)
if (station.rows[i] == picked_row && !station.tracks[i].empty()) {
playlist = station.playlist;
track = station.tracks[i];
return true;
}
return false;
};
if (now >= 0 && static_cast<std::size_t>(now) < stations.size() && look(stations[static_cast<std::size_t>(now)])) found = now;
else
for (std::size_t i = 0; i < stations.size() && found < 0; ++i)
if (look(stations[i])) found = static_cast<int>(i);
}
// A playlist added since the game began has no playlist of the game's behind its tile
// (the game makes those as it starts), so the game binds the channel to none: the row
// picked says which station is meant, and it is taken to be on, on the channel the game
// has just bound. Whatever song of the game's plays there is the one whose sound is
// replaced. (Seen 2026-10-12: a playlist added from the setup page played the game's
// own songs.)
if (found >= 0 && found != now && music_screen() && GetTickCount64() - plain_at.load(std::memory_order_relaxed) < 4000) {
if (const auto channel = plain_channel.load(std::memory_order_relaxed); channel != no_channel) {
station_index.store(found, std::memory_order_relaxed);
station_channel.store(channel, std::memory_order_relaxed);
try {
logging::log(logging::Level::debug, logging::Channel::music, "Spotify station: station {} is taken to be on, on channel {} (its tile has no playlist of the game's behind it yet).", found, channel);
} catch (...) {}
}
}
// The song Spotify is playing now is asked for when the player grabs the music of a
// speaker in the world: its song reads as Spotify's, and the game finds the row of that
// name. Spotify is playing it already, and is left to.
bool current{};
try {
std::array<unsigned char, 16> row{};
{
std::lock_guard lock(mutex);
row = picked_row;
}
if (const auto playing = external_player::now(); playing.available && now >= 0) current = spotify_rows::id_of(row.data()) == playing.artist + " - " + playing.title;
} catch (...) {}
// A row is picked in the music screen, which is a menu of the game's. A song asked for
// with no menu up is the world's doing (the player grabbed a speaker's music, whose song
// reads as one Spotify played): Spotify is left where it is.
if (!music_screen()) track = "asked for with no menu up: Spotify is left as it is";
else if (current) track = "the song Spotify is playing: left to play";
else if (playlist.starts_with(youtube_music::station_prefix) && track.starts_with(youtube_music::track_prefix))
youtube_music::play(youtube_list(playlist), track.substr(std::strlen(youtube_music::track_prefix)));
else if (!playlist.empty() && !track.empty()) spotify_web::play(playlist, track);
picked_at = GetTickCount64(); // (with a track or not: the game's next that follows is not Spotify's)
try {
logging::log(logging::Level::debug, logging::Channel::music, "Spotify station: a row was picked{}.", track.empty() ? std::string(" (not one with a track)") : " (" + track + ")");
} catch (...) {}
}
// A YouTube Music station that has come on starts its playlist a moment after, unless a row
// of it was picked meanwhile (which is how it usually comes on, and is what is played).
if (tube_start_at && GetTickCount64() - tube_start_at > 400) {
if (now >= 0 && youtube_music::on() && GetTickCount64() - picked_at > 1500) youtube_music::play(tube_start);
tube_start_at = 0;
}
if (now < 0) {
skip.store(false, std::memory_order_relaxed);
player_queue.store(0, std::memory_order_relaxed);
restore_presentation();
{
std::lock_guard lock(mutex);
again.clear();
}
return;
}
// The game's "next" is Spotify's (once for a press: the game may ask more than once).
static std::uint64_t skipped{};
if (const auto tick_now = GetTickCount64(); skip.exchange(false, std::memory_order_relaxed) && tick_now - skipped > 1200 &&
tick_now - entered > 2000 && tick_now - picked_at > 2500 && tick_now - grabbed_at.load(std::memory_order_relaxed) > 2500) { // (the game asks the same way for a station's first song)
skipped = tick_now;
// (The game asks the same way when its music is taken off pause, which is not a next:
// Spotify is paused then, by music_inject, and is only played again.)
bool playing{true};
try {
if (const auto track = external_player::now(); track.available) playing = track.playing;
} catch (...) {}
if (playing) external_player::send(external_player::Command::next);
try {
logging::log(logging::Level::debug, logging::Channel::music, "Spotify station: the game asked for its next song; {}.", playing ? "Spotify's next" : "Spotify is paused, so it is left where it is");
} catch (...) {}
}
try {
const auto track = external_player::now();
if (!track.available) return;
// Its cover is made ready for the music screen as Windows comes to have it.
static std::uint64_t cover{~0ull};
if (!track.artwork.empty() && track.artwork_revision != cover) {
cover = track.artwork_revision;
spotify_cover::want(track.artwork, track.artwork_revision);
}
bool unshown{};
{
std::lock_guard lock(mutex);
unshown = static_cast<std::size_t>(now) >= stations.size() || !is_shown_locked(stations[static_cast<std::size_t>(now)].metadata);
}
const auto revision = external_player::revision();
if (unshown || revision != said) show(now, track.artist, track.title);
if (revision != said) {
said = revision;
logging::log(logging::Level::debug, logging::Channel::music, "Spotify station: {} {}: {} - {}{}.", track.spotify ? "Spotify" : track.app,
track.playing ? "is playing" : "is paused on", track.artist, track.title, track.artwork.empty() ? "" : " (with a cover)");
}
} catch (...) {}
}
std::string status() {
const auto channel = station_channel.load(std::memory_order_relaxed);
const auto track = external_player::now();
return std::format("Spotify station: {} station(s); {}; {} playlist binding(s) seen. Outside player: {}.", station_count.load(std::memory_order_relaxed),
channel == no_channel ? std::string("none selected") : std::format("station {} selected on channel {}", station_index.load(std::memory_order_relaxed), channel),
binds_seen.load(std::memory_order_relaxed),
!track.available ? std::string("none seen (it is watched once a station has been selected)")
: (track.spotify ? std::string("Spotify") : track.app) + (track.playing ? " playing " : " paused on ") + track.artist + " - " + track.title);
}
} // namespace dingosdk::spotify_station