Audio: per-game persisted format overrides + auto-learn
Persist audio overrides keyed by game image name (coop_audio_overrides.ini next to the exe) so a known-bad game is auto-corrected on its next launch: on attach to a guessed stream the host applies any saved override, and a manual Override now saves too. A format the hook catches exactly at IAudioClient::Initialize is auto-saved as that game's override (ground truth); if it overwrites a differing stored value, a warning is logged. Host-originated log lines now reach the Log window via IpcServer::host_log (color-coded). Validated live (harness): a pre-seeded override for coop_tone is auto-applied over the guess (state -> manual override, 48000/2/16). audio_overrides_test covers persist/reload/case-insensitive lookup/differing-overwrite. Trim README. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
153
host/src/audio/audio_overrides.cpp
Normal file
153
host/src/audio/audio_overrides.cpp
Normal file
@@ -0,0 +1,153 @@
|
||||
#include "audio/audio_overrides.hpp"
|
||||
|
||||
#include <cctype>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <mmreg.h> // WAVE_FORMAT_* (needs windows.h first)
|
||||
|
||||
#include "coop/tool_paths.hpp"
|
||||
|
||||
namespace coop
|
||||
{
|
||||
namespace
|
||||
{
|
||||
std::wstring to_lower(std::wstring s)
|
||||
{
|
||||
for (wchar_t& c : s)
|
||||
{
|
||||
c = static_cast<wchar_t>(::towlower(c));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string narrow(const std::wstring& w)
|
||||
{
|
||||
std::string s;
|
||||
s.reserve(w.size());
|
||||
for (wchar_t c : w) // image names + our format tokens are ASCII
|
||||
{
|
||||
s.push_back(static_cast<char>(c & 0x7F));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::wstring widen(const std::string& s)
|
||||
{
|
||||
std::wstring w;
|
||||
w.reserve(s.size());
|
||||
for (char c : s)
|
||||
{
|
||||
w.push_back(static_cast<wchar_t>(static_cast<unsigned char>(c)));
|
||||
}
|
||||
return w;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
AudioOverrideStore::AudioOverrideStore(std::wstring path) : path_(std::move(path))
|
||||
{
|
||||
if (path_.empty())
|
||||
{
|
||||
path_ = exe_directory() + L"coop_audio_overrides.ini";
|
||||
}
|
||||
}
|
||||
|
||||
std::wstring AudioOverrideStore::key_of(const std::wstring& image_name)
|
||||
{
|
||||
const std::size_t slash = image_name.find_last_of(L"\\/");
|
||||
return to_lower(slash == std::wstring::npos ? image_name : image_name.substr(slash + 1));
|
||||
}
|
||||
|
||||
void AudioOverrideStore::load()
|
||||
{
|
||||
map_.clear();
|
||||
std::ifstream f(path_.c_str());
|
||||
if (!f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
std::string line;
|
||||
while (std::getline(f, line))
|
||||
{
|
||||
// "<image> = <rate> <ch> <bits> <pcm|float>"; skip blank lines and # comments.
|
||||
const std::size_t hash = line.find('#');
|
||||
if (hash != std::string::npos)
|
||||
{
|
||||
line.resize(hash);
|
||||
}
|
||||
const std::size_t eq = line.find('=');
|
||||
if (eq == std::string::npos)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
std::string name = line.substr(0, eq);
|
||||
// trim trailing/leading whitespace from the name
|
||||
while (!name.empty() && std::isspace(static_cast<unsigned char>(name.back())))
|
||||
{
|
||||
name.pop_back();
|
||||
}
|
||||
std::size_t b = 0;
|
||||
while (b < name.size() && std::isspace(static_cast<unsigned char>(name[b])))
|
||||
{
|
||||
++b;
|
||||
}
|
||||
name = name.substr(b);
|
||||
if (name.empty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
std::istringstream vs(line.substr(eq + 1));
|
||||
AudioFormatOverride fmt;
|
||||
std::string tag;
|
||||
vs >> fmt.rate >> fmt.channels >> fmt.bits >> tag;
|
||||
if (!fmt.valid())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
fmt.format_tag = (tag == "float") ? WAVE_FORMAT_IEEE_FLOAT : WAVE_FORMAT_PCM;
|
||||
map_[key_of(widen(name))] = fmt;
|
||||
}
|
||||
}
|
||||
|
||||
bool AudioOverrideStore::find(const std::wstring& image_name, AudioFormatOverride& out) const
|
||||
{
|
||||
const auto it = map_.find(key_of(image_name));
|
||||
if (it == map_.end())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
out = it->second;
|
||||
return true;
|
||||
}
|
||||
|
||||
void AudioOverrideStore::set(const std::wstring& image_name, const AudioFormatOverride& fmt, bool* differed)
|
||||
{
|
||||
const std::wstring key = key_of(image_name);
|
||||
if (differed != nullptr)
|
||||
{
|
||||
const auto it = map_.find(key);
|
||||
*differed = (it != map_.end() && it->second != fmt);
|
||||
}
|
||||
map_[key] = fmt;
|
||||
save();
|
||||
}
|
||||
|
||||
void AudioOverrideStore::save() const
|
||||
{
|
||||
std::ofstream f(path_.c_str(), std::ios::trunc);
|
||||
if (!f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
f << "# CoopAllTheThings per-game audio format overrides (auto-managed)\n";
|
||||
f << "# <image.exe> = <rate> <channels> <bits> <pcm|float>\n";
|
||||
for (const auto& [name, fmt] : map_)
|
||||
{
|
||||
f << narrow(name) << " = " << fmt.rate << ' ' << fmt.channels << ' ' << fmt.bits << ' '
|
||||
<< (fmt.format_tag == WAVE_FORMAT_IEEE_FLOAT ? "float" : "pcm") << '\n';
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace coop
|
||||
68
host/src/audio/audio_overrides.hpp
Normal file
68
host/src/audio/audio_overrides.hpp
Normal file
@@ -0,0 +1,68 @@
|
||||
// Per-game audio-format overrides, persisted next to the exe so a game whose hooked
|
||||
// format was wrong/unrecoverable is auto-corrected on its next launch. Keyed by the
|
||||
// game's image name (case-insensitive). Two writers: the operator's manual Override, and
|
||||
// auto-learning a format the hook caught exactly at IAudioClient::Initialize (ground
|
||||
// truth). A human-readable one-line-per-game text file:
|
||||
//
|
||||
// # CoopAllTheThings per-game audio overrides
|
||||
// brotato.exe = 44100 2 32 float
|
||||
// snb.exe = 48000 2 16 pcm
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
namespace coop
|
||||
{
|
||||
|
||||
struct AudioFormatOverride
|
||||
{
|
||||
std::uint32_t rate = 0;
|
||||
std::uint32_t channels = 0;
|
||||
std::uint32_t bits = 0;
|
||||
std::uint32_t format_tag = 0; // WAVE_FORMAT_PCM (1) / WAVE_FORMAT_IEEE_FLOAT (3)
|
||||
|
||||
[[nodiscard]] bool valid() const
|
||||
{
|
||||
return rate != 0 && channels != 0 && bits != 0;
|
||||
}
|
||||
bool operator==(const AudioFormatOverride& o) const
|
||||
{
|
||||
return rate == o.rate && channels == o.channels && bits == o.bits && format_tag == o.format_tag;
|
||||
}
|
||||
bool operator!=(const AudioFormatOverride& o) const
|
||||
{
|
||||
return !(*this == o);
|
||||
}
|
||||
};
|
||||
|
||||
class AudioOverrideStore
|
||||
{
|
||||
public:
|
||||
// `path` empty -> default (exe_dir/coop_audio_overrides.ini). Does not load yet.
|
||||
explicit AudioOverrideStore(std::wstring path = {});
|
||||
|
||||
void load(); // (re)read the file; missing file is fine (empty store)
|
||||
|
||||
// Look up an override by image name (case-insensitive, basename). False if none.
|
||||
[[nodiscard]] bool find(const std::wstring& image_name, AudioFormatOverride& out) const;
|
||||
|
||||
// Store image_name -> fmt and persist immediately. Sets *differed = true when an
|
||||
// existing entry for that game differed from `fmt` (caller warns the operator).
|
||||
void set(const std::wstring& image_name, const AudioFormatOverride& fmt, bool* differed = nullptr);
|
||||
|
||||
[[nodiscard]] const std::wstring& path() const
|
||||
{
|
||||
return path_;
|
||||
}
|
||||
|
||||
private:
|
||||
static std::wstring key_of(const std::wstring& image_name); // lowercased basename
|
||||
void save() const;
|
||||
|
||||
std::wstring path_;
|
||||
std::map<std::wstring, AudioFormatOverride> map_;
|
||||
};
|
||||
|
||||
} // namespace coop
|
||||
Reference in New Issue
Block a user