initial commit
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
#include "audio/audio_downloader.h"
|
||||
|
||||
#include "db/database_manager.h"
|
||||
#include "logger/logger.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <openssl/evp.h>
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <sstream>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace radiantwave::audio {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* AUDIO_DIR_SUFFIX = "/.local/radiantwave/audio/";
|
||||
constexpr const char* USER_AGENT = "RadiantWave-Kiosk/0.1";
|
||||
|
||||
std::string audioDir() {
|
||||
const char* home = std::getenv("HOME");
|
||||
return std::string(home && *home ? home : "/tmp") + AUDIO_DIR_SUFFIX;
|
||||
}
|
||||
|
||||
// Strips the "*" binary-mode marker and any "./" prefixes md5sum emits.
|
||||
std::string stripManifestPrefix(std::string name) {
|
||||
if (!name.empty() && name.front() == '*') name.erase(0, 1);
|
||||
while (name.compare(0, 2, "./") == 0) name.erase(0, 2);
|
||||
return name;
|
||||
}
|
||||
|
||||
// Produces a friendly display name from a bare filename for legacy manifests
|
||||
// that carry no display name.
|
||||
std::string humanizeFilename(const std::string& filename) {
|
||||
std::string name = filename;
|
||||
size_t dot = name.rfind('.');
|
||||
if (dot != std::string::npos) name.erase(dot);
|
||||
for (char& c : name) {
|
||||
if (c == '_') c = ' ';
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
// Tokenizes a manifest line, honoring a single-quoted display name that may
|
||||
// contain spaces.
|
||||
std::vector<std::string> tokenizeManifestLine(const std::string& line) {
|
||||
std::vector<std::string> tokens;
|
||||
size_t i = 0;
|
||||
const size_t n = line.size();
|
||||
while (i < n) {
|
||||
while (i < n && std::isspace(static_cast<unsigned char>(line[i]))) ++i;
|
||||
if (i >= n) break;
|
||||
if (line[i] == '\'') {
|
||||
size_t close = line.find('\'', i + 1);
|
||||
if (close == std::string::npos) break;
|
||||
tokens.push_back(line.substr(i + 1, close - i - 1));
|
||||
i = close + 1;
|
||||
} else {
|
||||
size_t start = i;
|
||||
while (i < n && !std::isspace(static_cast<unsigned char>(line[i]))) ++i;
|
||||
tokens.push_back(line.substr(start, i - start));
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ─── HTTP helpers (libcurl) ─────────────────────────────────────────────
|
||||
|
||||
bool httpGetString(const std::string& url, std::string& body) {
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) return false;
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, USER_AGENT);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +[](char* ptr, size_t size, size_t nmemb, void* userdata) -> size_t {
|
||||
auto* out = static_cast<std::string*>(userdata);
|
||||
out->append(ptr, size * nmemb);
|
||||
return size * nmemb;
|
||||
});
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
long status = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
return res == CURLE_OK && status >= 200 && status < 300;
|
||||
}
|
||||
|
||||
bool httpGetFile(const std::string& url, const std::string& path) {
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) return false;
|
||||
|
||||
std::FILE* f = std::fopen(path.c_str(), "wb");
|
||||
if (!f) {
|
||||
curl_easy_cleanup(curl);
|
||||
return false;
|
||||
}
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 120L);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 15L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, USER_AGENT);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +[](char* ptr, size_t size, size_t nmemb, void* userdata) -> size_t {
|
||||
return std::fwrite(ptr, size, nmemb, static_cast<std::FILE*>(userdata));
|
||||
});
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, f);
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
long status = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
std::fclose(f);
|
||||
|
||||
return res == CURLE_OK && status >= 200 && status < 300;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
// ─── Public pure helpers ────────────────────────────────────────────────
|
||||
|
||||
// Frequency value (Hz) -> internal solfeggio frequency id. Mirrors the id<->Hz
|
||||
// relationships documented in DatabaseManager::seedColorPalettes.
|
||||
int frequencyIdForHz(int hz) {
|
||||
switch (hz) {
|
||||
case 174:
|
||||
case 285: return 1;
|
||||
case 396: return 2;
|
||||
case 417: return 3;
|
||||
case 432: return 4;
|
||||
case 528: return 5;
|
||||
case 639: return 6;
|
||||
case 741: return 7;
|
||||
case 852: return 8;
|
||||
case 963: return 9;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
int hzFromFilename(const std::string& filename) {
|
||||
size_t i = 0;
|
||||
while (i < filename.size() && filename[i] >= '0' && filename[i] <= '9') ++i;
|
||||
if (i == 0) return 0;
|
||||
// The digits must be a frequency: "396Hz.wav", "14Hz_Binaural.wav".
|
||||
if (i + 2 <= filename.size() &&
|
||||
(filename[i] == 'H' || filename[i] == 'h') &&
|
||||
(filename[i + 1] == 'Z' || filename[i + 1] == 'z')) {
|
||||
int hz = 0;
|
||||
for (size_t d = 0; d < i; ++d) {
|
||||
hz = hz * 10 + (filename[d] - '0');
|
||||
}
|
||||
return hz;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool parseManifest(const std::string& text, std::vector<AudioManifestEntry>& out) {
|
||||
std::vector<AudioManifestEntry> result;
|
||||
std::istringstream stream(text);
|
||||
std::string line;
|
||||
while (std::getline(stream, line)) {
|
||||
size_t start = line.find_first_not_of(" \t\r\n");
|
||||
if (start == std::string::npos) continue;
|
||||
size_t end = line.find_last_not_of(" \t\r\n");
|
||||
line = line.substr(start, end - start + 1);
|
||||
if (line.empty() || line.front() == '#') continue;
|
||||
|
||||
std::vector<std::string> tokens = tokenizeManifestLine(line);
|
||||
if (tokens.empty()) continue;
|
||||
|
||||
AudioManifestEntry entry;
|
||||
if (tokens.size() == 4) {
|
||||
// Current format: <filename> '<display name>' <hz> <md5>
|
||||
entry.filename = stripManifestPrefix(tokens[0]);
|
||||
entry.display_name = tokens[1];
|
||||
entry.solfeggio_frequency_hz = std::atoi(tokens[2].c_str());
|
||||
entry.md5 = tokens[3];
|
||||
if (entry.filename.empty() || entry.md5.size() != 32) continue;
|
||||
// Tolerant parse: honor the Hz value only when it maps to a known
|
||||
// frequency (a transitional manifest may carry an id, a typo may
|
||||
// carry garbage); otherwise fall back to the Hz from the filename.
|
||||
if (frequencyIdForHz(entry.solfeggio_frequency_hz) == 0) {
|
||||
int fromFilename = hzFromFilename(entry.filename);
|
||||
if (fromFilename != 0) {
|
||||
entry.solfeggio_frequency_hz = fromFilename;
|
||||
}
|
||||
}
|
||||
result.push_back(std::move(entry));
|
||||
} else if (tokens.size() == 2 && tokens[0].size() == 32) {
|
||||
// Legacy format: <md5> <filename>
|
||||
entry.md5 = tokens[0];
|
||||
entry.filename = stripManifestPrefix(tokens[1]);
|
||||
entry.display_name = humanizeFilename(entry.filename);
|
||||
entry.solfeggio_frequency_hz = hzFromFilename(entry.filename);
|
||||
if (entry.filename.empty()) continue;
|
||||
result.push_back(std::move(entry));
|
||||
}
|
||||
// else: malformed line, skip
|
||||
}
|
||||
if (result.empty()) return false;
|
||||
out = std::move(result);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string computeFileMd5(const std::string& path) {
|
||||
std::FILE* f = std::fopen(path.c_str(), "rb");
|
||||
if (!f) return std::string();
|
||||
|
||||
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
|
||||
if (!ctx) {
|
||||
std::fclose(f);
|
||||
return std::string();
|
||||
}
|
||||
if (EVP_DigestInit_ex(ctx, EVP_md5(), nullptr) != 1) {
|
||||
EVP_MD_CTX_free(ctx);
|
||||
std::fclose(f);
|
||||
return std::string();
|
||||
}
|
||||
|
||||
unsigned char buf[8192];
|
||||
size_t n;
|
||||
while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) {
|
||||
if (EVP_DigestUpdate(ctx, buf, n) != 1) {
|
||||
EVP_MD_CTX_free(ctx);
|
||||
std::fclose(f);
|
||||
return std::string();
|
||||
}
|
||||
}
|
||||
bool readError = std::ferror(f) != 0;
|
||||
std::fclose(f);
|
||||
if (readError) {
|
||||
EVP_MD_CTX_free(ctx);
|
||||
return std::string();
|
||||
}
|
||||
|
||||
unsigned char digest[EVP_MAX_MD_SIZE];
|
||||
unsigned int digestLen = 0;
|
||||
if (EVP_DigestFinal_ex(ctx, digest, &digestLen) != 1) {
|
||||
EVP_MD_CTX_free(ctx);
|
||||
return std::string();
|
||||
}
|
||||
EVP_MD_CTX_free(ctx);
|
||||
|
||||
static const char* hexDigits = "0123456789abcdef";
|
||||
std::string hex;
|
||||
hex.reserve(digestLen * 2);
|
||||
for (unsigned int i = 0; i < digestLen; ++i) {
|
||||
hex += hexDigits[digest[i] >> 4];
|
||||
hex += hexDigits[digest[i] & 0x0f];
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
// ─── Lifecycle ──────────────────────────────────────────────────────────
|
||||
|
||||
AudioDownloader::~AudioDownloader() {
|
||||
shutdown();
|
||||
}
|
||||
|
||||
void AudioDownloader::start(db::DatabaseManager& db) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (worker_.joinable()) return;
|
||||
db_ = &db;
|
||||
stop_ = false;
|
||||
worker_ = std::thread(&AudioDownloader::run, this);
|
||||
}
|
||||
|
||||
void AudioDownloader::shutdown() {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
stop_ = true;
|
||||
}
|
||||
cv_.notify_all();
|
||||
if (worker_.joinable()) worker_.join();
|
||||
}
|
||||
|
||||
std::string AudioDownloader::statusString() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return status_;
|
||||
}
|
||||
|
||||
void AudioDownloader::setStatus(const std::string& msg) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
status_ = msg;
|
||||
}
|
||||
cv_.notify_all();
|
||||
}
|
||||
|
||||
void AudioDownloader::wait(std::chrono::seconds seconds) {
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
cv_.wait_for(lock, seconds, [this]() { return stop_.load(); });
|
||||
}
|
||||
|
||||
// ─── Worker loop ────────────────────────────────────────────────────────
|
||||
|
||||
void AudioDownloader::run() {
|
||||
logger::Logger::instance().info("Audio", "Audio downloader started");
|
||||
setStatus("Checking for audio updates...");
|
||||
|
||||
while (!stop_.load()) {
|
||||
std::vector<AudioManifestEntry> manifest;
|
||||
if (!fetchManifest(manifest)) {
|
||||
setStatus("Waiting for internet...");
|
||||
wait(std::chrono::seconds(5));
|
||||
continue;
|
||||
}
|
||||
|
||||
std::string dir = audioDir();
|
||||
std::unordered_map<std::string, int> orderByFilename;
|
||||
for (size_t i = 0; i < manifest.size(); ++i) {
|
||||
orderByFilename[manifest[i].filename] = static_cast<int>(i + 1);
|
||||
}
|
||||
|
||||
// `available` is the set of files actually present on disk: files
|
||||
// whose checksum matches, plus files present but stale (they stay
|
||||
// listed while being re-downloaded). Files that don't exist yet are
|
||||
// excluded so the settings list reflects live download progress.
|
||||
std::vector<AudioManifestEntry> queue;
|
||||
std::vector<db::AudioFileRecord> available;
|
||||
std::unordered_set<std::string> listed;
|
||||
available.reserve(manifest.size());
|
||||
|
||||
auto addAvailable = [&](const AudioManifestEntry& entry) {
|
||||
if (!listed.insert(entry.filename).second) return;
|
||||
available.push_back({0, entry.display_name, entry.filename,
|
||||
orderByFilename[entry.filename],
|
||||
frequencyIdForHz(entry.solfeggio_frequency_hz)});
|
||||
};
|
||||
// Publishes the current on-disk set to the database so the audio
|
||||
// settings list updates while the user is viewing it.
|
||||
auto syncNow = [&]() {
|
||||
if (db_ && !available.empty()) db_->syncAudioFiles(available);
|
||||
};
|
||||
|
||||
for (const auto& entry : manifest) {
|
||||
std::string localMd5 = computeFileMd5(dir + entry.filename);
|
||||
if (localMd5 == entry.md5) {
|
||||
addAvailable(entry);
|
||||
} else if (!localMd5.empty()) {
|
||||
logger::Logger::instance().warning(
|
||||
"Audio", "Checksum mismatch for " + entry.filename + ", re-downloading");
|
||||
addAvailable(entry);
|
||||
queue.push_back(entry);
|
||||
} else {
|
||||
queue.push_back(entry);
|
||||
}
|
||||
}
|
||||
|
||||
size_t toDownload = queue.size();
|
||||
size_t downloaded = 0;
|
||||
if (!queue.empty()) {
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(dir, ec);
|
||||
|
||||
for (const auto& work : queue) {
|
||||
if (stop_.load()) break;
|
||||
setStatus("Downloading audio files (" + std::to_string(downloaded + 1) +
|
||||
" / " + std::to_string(queue.size()) + ")...");
|
||||
if (!downloadAndVerify(work.filename, work.md5)) break;
|
||||
addAvailable(work);
|
||||
++downloaded;
|
||||
// Publish immediately so a file that just finished downloading
|
||||
// appears in the list without waiting for the rest.
|
||||
syncNow();
|
||||
}
|
||||
}
|
||||
|
||||
// Remove leftover audio files that are no longer in the manifest.
|
||||
std::error_code ec;
|
||||
if (std::filesystem::exists(dir)) {
|
||||
for (auto it = std::filesystem::directory_iterator(dir, ec), end = std::filesystem::directory_iterator();
|
||||
!ec && it != end; it.increment(ec)) {
|
||||
if (!it->is_regular_file(ec)) continue;
|
||||
std::string name = it->path().filename().string();
|
||||
if (name.empty() || name.front() == '.' ||
|
||||
name.size() >= 5 && name.compare(name.size() - 5, 5, ".part") == 0) {
|
||||
continue;
|
||||
}
|
||||
if (orderByFilename.count(name) == 0) {
|
||||
logger::Logger::instance().info("Audio", "Removing leftover " + name);
|
||||
std::filesystem::remove(it->path(), ec);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final reconcile so rows for files removed from disk are dropped too.
|
||||
// Runs even when nothing is available, so an empty on-disk set clears
|
||||
// any stale rows.
|
||||
if (db_) db_->syncAudioFiles(available);
|
||||
|
||||
bool upToDate = downloaded == toDownload;
|
||||
if (upToDate && !stop_.load()) {
|
||||
setStatus("Audio files up to date");
|
||||
wait(std::chrono::seconds(60));
|
||||
} else {
|
||||
wait(std::chrono::seconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
logger::Logger::instance().info("Audio", "Audio downloader stopped");
|
||||
}
|
||||
|
||||
bool AudioDownloader::fetchManifest(std::vector<AudioManifestEntry>& out) {
|
||||
std::string body;
|
||||
std::string url = std::string(AUDIO_BASE_URL) + MANIFEST_FILENAME;
|
||||
if (!httpGetString(url, body)) {
|
||||
logger::Logger::instance().debug("Audio", "Failed to fetch audio manifest");
|
||||
return false;
|
||||
}
|
||||
if (!parseManifest(body, out)) {
|
||||
logger::Logger::instance().warning("Audio", "Audio manifest was unparseable");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AudioDownloader::downloadAndVerify(const std::string& filename, const std::string& expectedMd5) {
|
||||
std::string dir = audioDir();
|
||||
std::string destPath = dir + filename;
|
||||
std::string tmpPath = destPath + ".part";
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(tmpPath, ec);
|
||||
|
||||
if (!httpGetFile(std::string(AUDIO_BASE_URL) + filename, tmpPath)) {
|
||||
logger::Logger::instance().warning("Audio", "Download failed for " + filename);
|
||||
std::filesystem::remove(tmpPath, ec);
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string gotMd5 = computeFileMd5(tmpPath);
|
||||
if (gotMd5 != expectedMd5) {
|
||||
logger::Logger::instance().warning(
|
||||
"Audio", "Checksum mismatch after download for " + filename);
|
||||
std::filesystem::remove(tmpPath, ec);
|
||||
return false;
|
||||
}
|
||||
|
||||
std::filesystem::rename(tmpPath, destPath, ec);
|
||||
if (ec) {
|
||||
logger::Logger::instance().warning("Audio", "Could not move " + filename + " into place");
|
||||
std::filesystem::remove(tmpPath, ec);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger::Logger::instance().info("Audio", "Downloaded and verified " + filename);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace radiantwave::audio
|
||||
@@ -0,0 +1,108 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace radiantwave::db {
|
||||
class DatabaseManager;
|
||||
}
|
||||
|
||||
namespace radiantwave::audio {
|
||||
|
||||
// One entry from the audio checksums manifest.
|
||||
//
|
||||
// Supported line formats (in priority order):
|
||||
// <filename> '<display name>' <hz> <md5> // current
|
||||
// <md5> <filename> // legacy fallback
|
||||
//
|
||||
// hz is the actual frequency value of the track (e.g. 396), written by hand
|
||||
// or by a script that has no access to the frequency-id table; 0 means
|
||||
// "none / unset". The manifest author may also write the frequency id (or any
|
||||
// unrecognized number): only values that resolve to a known frequency are
|
||||
// honored, anything else falls back to the Hz parsed from the filename, so
|
||||
// the file's own name is always the safety net.
|
||||
struct AudioManifestEntry {
|
||||
std::string filename;
|
||||
std::string display_name;
|
||||
int solfeggio_frequency_hz = 0;
|
||||
std::string md5;
|
||||
};
|
||||
|
||||
// Downloads audio assets from files.radiantwavetech.com in the background
|
||||
// and keeps the database's audio_files table in sync with what is actually
|
||||
// on disk.
|
||||
//
|
||||
// Fetches checksums.md5, compares each entry against the local file's MD5,
|
||||
// and re-downloads anything that is missing or whose checksum differs.
|
||||
// Files present on disk but absent from the manifest are deleted, and the
|
||||
// audio_files table is reconciled on every pass (and after each individual
|
||||
// download) so the settings audio list reflects live progress: a file that
|
||||
// just finished downloading appears in the list immediately, and files not
|
||||
// yet downloaded are not listed. The worker runs a persistent loop: if
|
||||
// there is no internet at startup the status shows "Waiting for internet..."
|
||||
// and a download starts automatically once connectivity returns. When all
|
||||
// files verify it keeps re-checking the manifest periodically so changed
|
||||
// checksums are picked up while running.
|
||||
class AudioDownloader {
|
||||
public:
|
||||
AudioDownloader() = default;
|
||||
~AudioDownloader();
|
||||
|
||||
AudioDownloader(const AudioDownloader&) = delete;
|
||||
AudioDownloader& operator=(const AudioDownloader&) = delete;
|
||||
|
||||
// Spawns the background thread. Safe to call repeatedly; only one worker
|
||||
// runs at a time. The manifest is reconciled against `db` on every pass.
|
||||
void start(db::DatabaseManager& db);
|
||||
|
||||
// Signals the worker to stop and joins the thread.
|
||||
void shutdown();
|
||||
|
||||
// Returns the current human-readable status for display (e.g. footer).
|
||||
std::string statusString() const;
|
||||
|
||||
static constexpr const char* AUDIO_BASE_URL = "https://files.radiantwavetech.com/audio/";
|
||||
static constexpr const char* MANIFEST_FILENAME = "checksums.md5";
|
||||
|
||||
private:
|
||||
void run();
|
||||
bool fetchManifest(std::vector<AudioManifestEntry>& out);
|
||||
bool downloadAndVerify(const std::string& filename, const std::string& expectedMd5);
|
||||
|
||||
void setStatus(const std::string& msg);
|
||||
void wait(std::chrono::seconds seconds);
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
std::condition_variable cv_;
|
||||
std::thread worker_;
|
||||
std::atomic<bool> stop_{false};
|
||||
std::string status_;
|
||||
db::DatabaseManager* db_ = nullptr;
|
||||
};
|
||||
|
||||
// Parses checksums.md5 manifest text into entries. Blank lines and lines
|
||||
// starting with '#' are ignored. Lines that don't match a known format are
|
||||
// skipped. Returns false if no valid entries were found.
|
||||
bool parseManifest(const std::string& text, std::vector<AudioManifestEntry>& out);
|
||||
|
||||
// Maps a frequency value in Hz to its internal solfeggio frequency id using
|
||||
// the same table the color palettes are seeded from (174/285->1, 396->2,
|
||||
// 417->3, 432->4, 528->5, 639->6, 741->7, 852->8, 963->9). Returns 0 for
|
||||
// frequencies with no id (binaurals, AlphaWaves, OriginalAudio, ...).
|
||||
int frequencyIdForHz(int hz);
|
||||
|
||||
// Parses the leading frequency value out of an audio filename like
|
||||
// "396Hz.wav" -> 396. Requires the digits to be followed by "Hz"
|
||||
// (case-insensitive); returns 0 when the name has no frequency (e.g.
|
||||
// "AlphaWaves.wav", "OriginalAudio.wav").
|
||||
int hzFromFilename(const std::string& filename);
|
||||
|
||||
// Computes the lowercase MD5 hex of a file, or an empty string on failure.
|
||||
std::string computeFileMd5(const std::string& path);
|
||||
|
||||
} // namespace radiantwave::audio
|
||||
@@ -0,0 +1,494 @@
|
||||
#include "audio_manager.h"
|
||||
#include "logger/logger.h"
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
|
||||
namespace radiantwave::audio
|
||||
{
|
||||
|
||||
std::string mapToFriendlyDeviceName(const std::string &raw_name)
|
||||
{
|
||||
if (raw_name.empty())
|
||||
return "Default Audio Output";
|
||||
|
||||
std::string lower = raw_name;
|
||||
std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c)
|
||||
{ return std::tolower(c); });
|
||||
|
||||
if (lower.find("hdmi") != std::string::npos ||
|
||||
lower.find("displayport") != std::string::npos ||
|
||||
lower.find("radeon") != std::string::npos ||
|
||||
lower.find("nvidia") != std::string::npos ||
|
||||
lower.find("tv") != std::string::npos)
|
||||
{
|
||||
return "TV / HDMI Monitor";
|
||||
}
|
||||
|
||||
if (lower.find("analog") != std::string::npos ||
|
||||
lower.find("built-in") != std::string::npos ||
|
||||
lower.find("builtin") != std::string::npos ||
|
||||
lower.find("realtek") != std::string::npos ||
|
||||
lower.find("hda") != std::string::npos ||
|
||||
lower.find("speaker") != std::string::npos ||
|
||||
lower.find("pci") != std::string::npos)
|
||||
{
|
||||
return "Built-In Speakers";
|
||||
}
|
||||
|
||||
if (lower.find("usb") != std::string::npos ||
|
||||
lower.find("headset") != std::string::npos ||
|
||||
lower.find("dongle") != std::string::npos)
|
||||
{
|
||||
return "USB Audio Device";
|
||||
}
|
||||
|
||||
if (lower.find("bluez") != std::string::npos ||
|
||||
lower.find("bluetooth") != std::string::npos)
|
||||
{
|
||||
return "Bluetooth Audio";
|
||||
}
|
||||
|
||||
// Fallback cleanly
|
||||
return "Audio Device (" + raw_name.substr(0, 20) + ")";
|
||||
}
|
||||
|
||||
// ─── AudioManager Implementation ───────────────────────────────────────
|
||||
|
||||
AudioManager::AudioManager() = default;
|
||||
|
||||
AudioManager::~AudioManager()
|
||||
{
|
||||
shutdown();
|
||||
}
|
||||
|
||||
bool AudioManager::initialize(config::ConfigManager &config)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
configManager_ = &config;
|
||||
|
||||
auto cfg = config.get();
|
||||
volume_ = cfg.audio.volume;
|
||||
enabled_ = cfg.audio.enabled;
|
||||
currentDevice_ = cfg.audio.output_device;
|
||||
currentTrack_ = cfg.audio.current_track;
|
||||
|
||||
if (!MIX_Init())
|
||||
{
|
||||
std::string err = SDL_GetError();
|
||||
logger::Logger::instance().warning("Audio",
|
||||
"Audio library initialization failed. Sound will be muted until hardware is available (MIX_Init error: " + err + ")");
|
||||
initialized_ = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create mixer device with default audio device
|
||||
mixer_ = MIX_CreateMixerDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, nullptr);
|
||||
if (!mixer_)
|
||||
{
|
||||
std::string err = SDL_GetError();
|
||||
logger::Logger::instance().warning("Audio",
|
||||
"Could not open default audio playback device. Check speaker connections (MIX_CreateMixerDevice error: " + err + ")");
|
||||
initialized_ = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
slots_[0].track = MIX_CreateTrack(mixer_);
|
||||
slots_[1].track = MIX_CreateTrack(mixer_);
|
||||
initialized_ = true;
|
||||
|
||||
// Apply initial volume
|
||||
float gain = enabled_ ? (volume_ / 100.0f) : 0.0f;
|
||||
MIX_SetMixerGain(mixer_, gain);
|
||||
|
||||
logger::Logger::instance().info("Audio", "SDL3_mixer audio system initialized successfully");
|
||||
return true;
|
||||
}
|
||||
|
||||
float AudioManager::effectiveGain() const
|
||||
{
|
||||
return enabled_ ? (volume_ / 100.0f) : 0.0f;
|
||||
}
|
||||
|
||||
void AudioManager::shutdown()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
|
||||
// Tracks are owned by the mixer: MIX_DestroyMixer() stops and destroys
|
||||
// every track created for it, so MIX_DestroyTrack must not be called
|
||||
// here (it can race the mixer thread's deferred-destroy path). Only the
|
||||
// MIX_Audio objects are ours to release; each track holds its own
|
||||
// reference to the audio attached to it, and any audio left behind is
|
||||
// reclaimed by MIX_Quit().
|
||||
for (int i = 0; i < 2; ++i)
|
||||
{
|
||||
if (slots_[i].audio)
|
||||
{
|
||||
MIX_DestroyAudio(slots_[i].audio);
|
||||
slots_[i].audio = nullptr;
|
||||
}
|
||||
slots_[i].track = nullptr;
|
||||
}
|
||||
|
||||
if (mixer_)
|
||||
{
|
||||
MIX_DestroyMixer(mixer_);
|
||||
mixer_ = nullptr;
|
||||
}
|
||||
|
||||
if (initialized_)
|
||||
{
|
||||
MIX_Quit();
|
||||
initialized_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<AudioDeviceItem> AudioManager::getPlaybackDevices() const
|
||||
{
|
||||
std::vector<AudioDeviceItem> list;
|
||||
|
||||
int count = 0;
|
||||
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count);
|
||||
|
||||
if (devices && count > 0)
|
||||
{
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
const char *name = SDL_GetAudioDeviceName(devices[i]);
|
||||
std::string raw = name ? name : "";
|
||||
list.push_back({raw, mapToFriendlyDeviceName(raw)});
|
||||
}
|
||||
SDL_free(devices);
|
||||
}
|
||||
else
|
||||
{
|
||||
list.push_back({"", "Built-In Speakers"});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
void AudioManager::setVolume(int volume_0_to_100)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
volume_ = std::clamp(volume_0_to_100, 0, 100);
|
||||
|
||||
if (mixer_)
|
||||
{
|
||||
float gain = enabled_ ? (volume_ / 100.0f) : 0.0f;
|
||||
MIX_SetMixerGain(mixer_, gain);
|
||||
}
|
||||
|
||||
if (configManager_)
|
||||
{
|
||||
configManager_->update([this](config::AppConfig &cfg)
|
||||
{ cfg.audio.volume = volume_; });
|
||||
}
|
||||
}
|
||||
|
||||
int AudioManager::getVolume() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return volume_;
|
||||
}
|
||||
|
||||
void AudioManager::setAudioEnabled(bool enabled)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
enabled_ = enabled;
|
||||
|
||||
if (mixer_)
|
||||
{
|
||||
float gain = enabled_ ? (volume_ / 100.0f) : 0.0f;
|
||||
MIX_SetMixerGain(mixer_, gain);
|
||||
}
|
||||
|
||||
if (enabled_)
|
||||
{
|
||||
logger::Logger::instance().info("Audio", "Audio unmuted");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger::Logger::instance().info("Audio", "Audio muted");
|
||||
}
|
||||
|
||||
if (configManager_)
|
||||
{
|
||||
configManager_->update([enabled](config::AppConfig &cfg)
|
||||
{ cfg.audio.enabled = enabled; });
|
||||
}
|
||||
}
|
||||
|
||||
bool AudioManager::isAudioEnabled() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return enabled_;
|
||||
}
|
||||
|
||||
bool AudioManager::switchDevice(const std::string &raw_device_name)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
currentDevice_ = raw_device_name;
|
||||
|
||||
logger::Logger::instance().info("Audio", "Switched audio output device to: " + mapToFriendlyDeviceName(raw_device_name));
|
||||
|
||||
if (configManager_)
|
||||
{
|
||||
configManager_->update([raw_device_name](config::AppConfig &cfg)
|
||||
{ cfg.audio.output_device = raw_device_name; });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string AudioManager::getCurrentDevice() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return currentDevice_;
|
||||
}
|
||||
|
||||
bool AudioManager::loadTrackInto(int slotIndex, const std::string &wav_filename)
|
||||
{
|
||||
if (!initialized_ || !mixer_ || !slots_[slotIndex].track)
|
||||
return false;
|
||||
|
||||
// Look for audio file in ~/.local/radiantwave/audio/ or assets/audio/
|
||||
std::string path1 = std::string(std::getenv("HOME") ? std::getenv("HOME") : "/tmp") + "/.local/radiantwave/audio/" + wav_filename;
|
||||
std::string path2 = "assets/audio/" + wav_filename;
|
||||
|
||||
std::string chosen_path = std::filesystem::exists(path1) ? path1 : path2;
|
||||
|
||||
if (!std::filesystem::exists(chosen_path))
|
||||
{
|
||||
logger::Logger::instance().warning("Audio",
|
||||
"Audio track file is missing. Please ensure audio assets are downloaded (File not found: " + wav_filename + ")");
|
||||
return false;
|
||||
}
|
||||
|
||||
MIX_Audio *audio = MIX_LoadAudio(mixer_, chosen_path.c_str(), true);
|
||||
if (!audio)
|
||||
{
|
||||
logger::Logger::instance().error("Audio",
|
||||
"Failed to load audio track (MIX_LoadAudio error: " + std::string(SDL_GetError()) + ")");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stop and free whatever was on this slot before.
|
||||
MIX_StopTrack(slots_[slotIndex].track, 0);
|
||||
if (slots_[slotIndex].audio)
|
||||
{
|
||||
MIX_DestroyAudio(slots_[slotIndex].audio);
|
||||
}
|
||||
slots_[slotIndex].audio = audio;
|
||||
slots_[slotIndex].fadeInDuration = 0.0f;
|
||||
slots_[slotIndex].fadeInElapsed = 0.0f;
|
||||
slots_[slotIndex].fadeOutRemaining = 0.0f;
|
||||
|
||||
MIX_SetTrackAudio(slots_[slotIndex].track, audio);
|
||||
|
||||
currentTrack_ = wav_filename;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void AudioManager::startFadeIn(int slotIndex, float seconds)
|
||||
{
|
||||
TrackSlot &slot = slots_[slotIndex];
|
||||
slot.fadeInDuration = seconds;
|
||||
slot.fadeInElapsed = 0.0f;
|
||||
MIX_SetTrackGain(slot.track, 0.0f);
|
||||
MIX_PlayTrack(slot.track, 0); // Loop playback
|
||||
logger::Logger::instance().info("Audio", "Started playback of " + currentTrack_ + " (fade-in)");
|
||||
}
|
||||
|
||||
bool AudioManager::playTrack(const std::string &wav_filename)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!initialized_ || !mixer_ || !slots_[activeSlot_].track)
|
||||
return false;
|
||||
|
||||
if (!loadTrackInto(activeSlot_, wav_filename))
|
||||
return false;
|
||||
|
||||
MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f);
|
||||
MIX_PlayTrack(slots_[activeSlot_].track, 0); // Loop playback
|
||||
|
||||
// Explicit user selection: persist the manual track so it survives a
|
||||
// restart and is used whenever dynamic audio is turned off. Dynamic
|
||||
// switches never write here, so they cannot clobber this choice.
|
||||
if (configManager_)
|
||||
{
|
||||
configManager_->update([wav_filename](config::AppConfig &cfg)
|
||||
{ cfg.audio.current_track = wav_filename; });
|
||||
}
|
||||
|
||||
logger::Logger::instance().info("Audio", "Started playback of " + wav_filename);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string AudioManager::currentTrack() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return currentTrack_;
|
||||
}
|
||||
|
||||
bool AudioManager::playTrackFadeIn(const std::string &wav_filename, float seconds)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!initialized_ || !mixer_ || !slots_[activeSlot_].track)
|
||||
return false;
|
||||
|
||||
// Same track already loaded in the active slot: bring it straight back
|
||||
// to full volume instead of reloading. This avoids an audible glitch
|
||||
// when re-entering the scroller over a track that is still loaded.
|
||||
if (!wav_filename.empty() && wav_filename == currentTrack_ && slots_[activeSlot_].audio)
|
||||
{
|
||||
TrackSlot &slot = slots_[activeSlot_];
|
||||
slot.fadeInDuration = 0.0f;
|
||||
slot.fadeInElapsed = 0.0f;
|
||||
slot.fadeOutRemaining = 0.0f;
|
||||
MIX_SetTrackGain(slot.track, 1.0f);
|
||||
if (!MIX_TrackPlaying(slot.track))
|
||||
{
|
||||
MIX_PlayTrack(slot.track, 0); // Loop playback
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!loadTrackInto(activeSlot_, wav_filename))
|
||||
return false;
|
||||
|
||||
startFadeIn(activeSlot_, seconds > 0.0f ? seconds : 1.0f);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AudioManager::playTrackCrossfade(const std::string &wav_filename, float seconds)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!initialized_ || !mixer_)
|
||||
return false;
|
||||
|
||||
// Not if the track is the same: the current track keeps playing
|
||||
// untouched instead of being crossfaded back into itself.
|
||||
if (!wav_filename.empty() && wav_filename == currentTrack_)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float fade = seconds > 0.0f ? seconds : 1.0f;
|
||||
int otherSlot = 1 - activeSlot_;
|
||||
TrackSlot &other = slots_[otherSlot];
|
||||
if (!other.track)
|
||||
return false;
|
||||
|
||||
// Load the new track into the inactive slot first; on failure keep the
|
||||
// current track playing untouched.
|
||||
if (!loadTrackInto(otherSlot, wav_filename))
|
||||
return false;
|
||||
|
||||
// Fade out whatever is currently playing on the active slot.
|
||||
TrackSlot &active = slots_[activeSlot_];
|
||||
if (active.audio)
|
||||
{
|
||||
MIX_StopTrack(active.track, MIX_TrackMSToFrames(active.track, static_cast<Sint64>(fade * 1000.0)));
|
||||
active.fadeOutRemaining = fade;
|
||||
}
|
||||
|
||||
activeSlot_ = otherSlot;
|
||||
startFadeIn(activeSlot_, fade);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AudioManager::stopTrackFadeOut(float seconds)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!initialized_)
|
||||
return false;
|
||||
|
||||
// Fade both slots so an in-flight crossfade cannot leave the other
|
||||
// track audible after the fade. Returns false when nothing is playing,
|
||||
// letting callers skip any waiting tied to the fade.
|
||||
float fade = seconds > 0.0f ? seconds : 1.0f;
|
||||
bool faded = false;
|
||||
for (int i = 0; i < 2; ++i)
|
||||
{
|
||||
TrackSlot &slot = slots_[i];
|
||||
if (!slot.track || !slot.audio)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
MIX_StopTrack(slot.track, MIX_TrackMSToFrames(slot.track, static_cast<Sint64>(fade * 1000.0)));
|
||||
slot.fadeOutRemaining = fade;
|
||||
faded = true;
|
||||
}
|
||||
return faded;
|
||||
}
|
||||
|
||||
void AudioManager::stopTrack()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (slots_[activeSlot_].track)
|
||||
{
|
||||
MIX_StopTrack(slots_[activeSlot_].track, 0);
|
||||
}
|
||||
if (slots_[activeSlot_].audio)
|
||||
{
|
||||
MIX_DestroyAudio(slots_[activeSlot_].audio);
|
||||
slots_[activeSlot_].audio = nullptr;
|
||||
}
|
||||
slots_[activeSlot_].fadeInDuration = 0.0f;
|
||||
slots_[activeSlot_].fadeInElapsed = 0.0f;
|
||||
slots_[activeSlot_].fadeOutRemaining = 0.0f;
|
||||
}
|
||||
|
||||
void AudioManager::update(float dt)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!initialized_)
|
||||
return;
|
||||
|
||||
for (int i = 0; i < 2; ++i)
|
||||
{
|
||||
TrackSlot &slot = slots_[i];
|
||||
if (!slot.track)
|
||||
continue;
|
||||
|
||||
if (slot.fadeInDuration > 0.0f)
|
||||
{
|
||||
slot.fadeInElapsed += dt;
|
||||
float t = std::min(1.0f, slot.fadeInElapsed / slot.fadeInDuration);
|
||||
MIX_SetTrackGain(slot.track, t);
|
||||
if (t >= 1.0f)
|
||||
{
|
||||
slot.fadeInDuration = 0.0f;
|
||||
}
|
||||
}
|
||||
else if (i == activeSlot_)
|
||||
{
|
||||
// Keep the active track at full per-track gain; the mixer gain
|
||||
// carries the user's volume / mute state.
|
||||
MIX_SetTrackGain(slot.track, 1.0f);
|
||||
}
|
||||
|
||||
if (slot.fadeOutRemaining > 0.0f)
|
||||
{
|
||||
slot.fadeOutRemaining -= dt;
|
||||
if (slot.fadeOutRemaining <= 0.0f)
|
||||
{
|
||||
slot.fadeOutRemaining = 0.0f;
|
||||
// Free the audio once the fade-out has finished; the track
|
||||
// object itself is reused.
|
||||
if (slot.audio)
|
||||
{
|
||||
MIX_DestroyAudio(slot.audio);
|
||||
slot.audio = nullptr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace radiantwave::audio
|
||||
@@ -0,0 +1,93 @@
|
||||
#pragma once
|
||||
|
||||
#include "config/config_manager.h"
|
||||
#include <SDL3_mixer/SDL_mixer.h>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace radiantwave::audio {
|
||||
|
||||
struct AudioDeviceItem {
|
||||
std::string raw_name; // e.g. alsa_output.pci-0000_00_1f.3.analog-stereo
|
||||
std::string friendly_name; // e.g. "Built-In Speakers"
|
||||
};
|
||||
|
||||
// Maps raw technical audio device strings to user-friendly labels
|
||||
std::string mapToFriendlyDeviceName(const std::string& raw_name);
|
||||
|
||||
class AudioManager {
|
||||
public:
|
||||
AudioManager();
|
||||
~AudioManager();
|
||||
|
||||
// Non-copyable
|
||||
AudioManager(const AudioManager&) = delete;
|
||||
AudioManager& operator=(const AudioManager&) = delete;
|
||||
|
||||
// Initialize SDL3_mixer audio system
|
||||
bool initialize(config::ConfigManager& config);
|
||||
|
||||
// Shutdown audio subsystem
|
||||
void shutdown();
|
||||
|
||||
// Enumerate playback devices with friendly names
|
||||
std::vector<AudioDeviceItem> getPlaybackDevices() const;
|
||||
|
||||
// Master volume control (0 - 100)
|
||||
void setVolume(int volume_0_to_100);
|
||||
int getVolume() const;
|
||||
|
||||
// Master audio enable / mute toggle
|
||||
void setAudioEnabled(bool enabled);
|
||||
bool isAudioEnabled() const;
|
||||
|
||||
// Audio device selection
|
||||
bool switchDevice(const std::string& raw_device_name);
|
||||
std::string getCurrentDevice() const;
|
||||
|
||||
// Playback control
|
||||
bool playTrack(const std::string& wav_filename);
|
||||
void stopTrack();
|
||||
|
||||
// Filename of the track currently loaded in the active slot ("" if none).
|
||||
std::string currentTrack() const;
|
||||
|
||||
// Fade playback control (used by the scroller page)
|
||||
bool playTrackFadeIn(const std::string& wav_filename, float seconds);
|
||||
bool playTrackCrossfade(const std::string& wav_filename, float seconds);
|
||||
// Fades any playing track out over `seconds`. Returns false when nothing
|
||||
// was playing, so callers can skip teardown delays tied to the fade.
|
||||
bool stopTrackFadeOut(float seconds);
|
||||
|
||||
// Drive in-progress fade ramps; call once per frame.
|
||||
void update(float dt);
|
||||
|
||||
private:
|
||||
struct TrackSlot {
|
||||
MIX_Track* track = nullptr;
|
||||
MIX_Audio* audio = nullptr;
|
||||
float fadeInDuration = 0.0f; // >0 while ramping in (seconds)
|
||||
float fadeInElapsed = 0.0f;
|
||||
float fadeOutRemaining = 0.0f; // >0 while fading out (seconds)
|
||||
};
|
||||
|
||||
float effectiveGain() const;
|
||||
bool loadTrackInto(int slotIndex, const std::string& wav_filename);
|
||||
void startFadeIn(int slotIndex, float seconds);
|
||||
|
||||
config::ConfigManager* configManager_ = nullptr;
|
||||
|
||||
bool initialized_ = false;
|
||||
bool enabled_ = true;
|
||||
int volume_ = 80; // 0 - 100
|
||||
std::string currentDevice_ = "";
|
||||
std::string currentTrack_ = "";
|
||||
|
||||
MIX_Mixer* mixer_ = nullptr;
|
||||
TrackSlot slots_[2];
|
||||
int activeSlot_ = 0;
|
||||
mutable std::mutex mutex_;
|
||||
};
|
||||
|
||||
} // namespace radiantwave::audio
|
||||
Reference in New Issue
Block a user