host: cross-platform audio decode (Android has sound)

Replace the macOS-only AudioToolbox decoder with mt_host::decode_audio: a
RIFF/WAVE PCM parser plus vendored dr_mp3, resampled/remixed to 44.1 kHz
stereo float with SDL_ConvertAudioSamples. macOS and Android now share one
path; the AudioToolbox link is gone.

BGM is no longer cached in clips_ (a 5-minute track is ~100 MB of float):
only the playing track is kept, and a new one decodes on a worker thread
(the pack read stays on the main thread) so map changes do not stall.

host.audio_decode test: synthetic wav layouts + all 1578 pack .wav, 9 pack
.mp3 and 25 Client/BGM .mp3 decode (7060 s of audio in ~3.6 s on M2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-29 21:22:11 +09:00
co-authored by Claude Opus 5.5
parent 75d2dd8545
commit eab5938072
11 changed files with 5889 additions and 107 deletions
+237
View File
@@ -0,0 +1,237 @@
// host/audio_decode against synthetic .wav layouts and the real 40250 audio: every .wav/.mp3 in Client/pack
// (through CEterPackManager, as the live client reads them) and every Client/BGM/*.mp3 must decode to
// non-empty 44.1 kHz stereo float whose length matches the source duration.
//
// host_audio_decode_test <40250 Client dir>
//
// The pack/BGM half exits 77 (ctest SKIP) when the client is missing, unless MT_ASSETS_STRICT=1.
#include "EterPack/StdAfx.h"
#include "EterPack/EterPackManager.h"
#include "../../src/platform/UserInterface/UserInterface.h"
#include "audio_decode.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iterator>
#include <string>
#include <vector>
#include <dirent.h>
#include <strings.h>
#include <unistd.h>
using mt_host::decode_audio;
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::fprintf(stderr, "%s:%d: CHECK(%s)\n", __FILE__, __LINE__, #cond); \
++g_failures; \
} \
} while (0)
static void put16(std::vector<std::uint8_t>& v, unsigned x) { v.push_back(x & 0xff); v.push_back(x >> 8 & 0xff); }
static void put32(std::vector<std::uint8_t>& v, unsigned x) { put16(v, x & 0xffff); put16(v, x >> 16); }
// A RIFF/WAVE file with a LIST chunk before "fmt " (odd-sized, so padded) and `frames` frames of a ramp.
static std::vector<std::uint8_t> make_wav(unsigned tag, unsigned channels, unsigned rate, unsigned bits,
unsigned frames, bool extensible = false)
{
std::vector<std::uint8_t> fmt;
put16(fmt, extensible ? 0xFFFE : tag);
put16(fmt, channels);
put32(fmt, rate);
put32(fmt, rate * channels * bits / 8);
put16(fmt, channels * bits / 8);
put16(fmt, bits);
if (extensible)
{
put16(fmt, 22);
put16(fmt, bits);
put32(fmt, 0);
put16(fmt, tag);
const std::uint8_t guid_tail[14] = {0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71};
fmt.insert(fmt.end(), guid_tail, guid_tail + 14);
}
std::vector<std::uint8_t> pcm;
for (unsigned i = 0; i < frames * channels; ++i)
{
const double s = std::sin(i * 0.01) * 0.5;
if (bits == 8) pcm.push_back(std::uint8_t(128 + s * 127));
else if (bits == 16) put16(pcm, unsigned(std::int16_t(s * 32767)));
else if (bits == 24) { const unsigned x = unsigned(std::int32_t(s * 8388607)); put16(pcm, x); pcm.push_back(x >> 16 & 0xff); }
else if (tag == 3) { float f = float(s); unsigned x; std::memcpy(&x, &f, 4); put32(pcm, x); }
else put32(pcm, unsigned(std::int32_t(s * 2147483647.0)));
}
std::vector<std::uint8_t> out = {'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'A', 'V', 'E', 'L', 'I', 'S', 'T', 3, 0, 0, 0, 'a', 'b', 'c', 0};
out.insert(out.end(), {'f', 'm', 't', ' '});
put32(out, unsigned(fmt.size()));
out.insert(out.end(), fmt.begin(), fmt.end());
out.insert(out.end(), {'d', 'a', 't', 'a'});
put32(out, unsigned(pcm.size()));
out.insert(out.end(), pcm.begin(), pcm.end());
const unsigned riff = unsigned(out.size() - 8);
std::memcpy(&out[4], &riff, 4);
return out;
}
// Output frame count within a few frames of the resampled source length.
static bool frames_match(const std::vector<float>& out, double src_frames, double src_rate)
{
const double expected = src_frames * mt_host::kMixRate / src_rate;
return out.size() % 2 == 0 && std::fabs(double(out.size() / 2) - expected) <= 8 + expected * 0.001;
}
static bool peak_ok(const std::vector<float>& out)
{
float peak = 0;
for (float f : out)
{
if (!std::isfinite(f)) return false;
peak = std::max(peak, std::fabs(f));
}
return peak <= 1.5f;
}
static void synthetic()
{
struct Case { unsigned tag, channels, rate, bits; bool ext; };
const Case cases[] = {
{1, 1, 22050, 16, false}, {1, 1, 32000, 16, false}, {1, 1, 44100, 16, false}, {1, 2, 44100, 16, false},
{1, 1, 22000, 16, false}, {1, 1, 11025, 8, false}, {1, 2, 48000, 24, false}, {1, 2, 44100, 32, false},
{3, 1, 44100, 32, false}, {1, 2, 22050, 16, true}, {3, 2, 48000, 32, true},
};
for (const Case& c : cases)
{
const unsigned frames = c.rate / 2;
std::vector<float> out;
const std::vector<std::uint8_t> wav = make_wav(c.tag, c.channels, c.rate, c.bits, frames, c.ext);
const bool ok = decode_audio(wav.data(), wav.size(), out);
if (!ok || !frames_match(out, frames, c.rate) || !peak_ok(out))
std::fprintf(stderr, "synthetic tag %u ch %u %u Hz %u bit ext %d: ok %d frames %zu\n", c.tag, c.channels,
c.rate, c.bits, c.ext, ok, out.size() / 2);
CHECK(ok && frames_match(out, frames, c.rate) && peak_ok(out));
}
// Mono is duplicated to both channels.
{
std::vector<float> out;
const std::vector<std::uint8_t> wav = make_wav(1, 1, 44100, 16, 1000);
CHECK(decode_audio(wav.data(), wav.size(), out) && out.size() == 2000);
bool same = out.size() == 2000;
for (size_t i = 0; same && i < out.size(); i += 2)
same = out[i] == out[i + 1];
CHECK(same);
}
// The length cap.
{
std::vector<float> out;
const std::vector<std::uint8_t> wav = make_wav(1, 1, 44100, 16, 44100 * 3);
CHECK(decode_audio(wav.data(), wav.size(), out, 1) && out.size() == 44100 * 2);
}
// Garbage, truncation, unsupported formats.
{
std::vector<float> out;
std::vector<std::uint8_t> junk(4096, 0x5a);
CHECK(!decode_audio(junk.data(), junk.size(), out) && out.empty());
CHECK(!decode_audio(nullptr, 0, out));
std::vector<std::uint8_t> wav = make_wav(1, 1, 22050, 16, 1000);
wav.resize(40);
CHECK(!decode_audio(wav.data(), wav.size(), out));
const std::vector<std::uint8_t> adpcm = make_wav(2, 1, 22050, 16, 1000);
CHECK(!decode_audio(adpcm.data(), adpcm.size(), out));
}
}
static bool has_ext(const std::string& name, const char* ext)
{
const size_t n = std::strlen(ext);
return name.size() > n && strcasecmp(name.c_str() + name.size() - n, ext) == 0;
}
int main(int argc, char** argv)
{
synthetic();
const char* strict = std::getenv("MT_ASSETS_STRICT");
const bool is_strict = strict && std::string(strict) == "1";
const std::string client = argc > 1 ? argv[1] : "";
if (client.empty() || chdir(client.c_str()) != 0 || access("pack/Index", 0) != 0)
{
std::fprintf(stderr, "host_audio_decode_test: no 40250 Client/pack at '%s'\n", client.c_str());
if (g_failures) return 1;
return is_strict ? 1 : 77;
}
PackSingletons();
CHECK(PackInitialize("pack"));
struct Access : CEterPackManager
{
static const CEterFileDict::TDict& dict(CEterPackManager& m) { return static_cast<Access&>(m).m_FileDict.GetDict(); }
};
CEterPackManager& mgr = CEterPackManager::Instance();
std::vector<std::string> names;
for (const auto& kv : Access::dict(mgr))
{
const std::string name = kv.second.pkInfo->filename;
if ((has_ext(name, ".wav") || has_ext(name, ".mp3")) && (names.empty() || names.back() != name))
names.push_back(name);
}
std::sort(names.begin(), names.end());
names.erase(std::unique(names.begin(), names.end()), names.end());
size_t wav = 0, mp3 = 0, bgm = 0, failed = 0;
double seconds = 0, decode_ms = 0;
auto decode_one = [&](const std::string& label, const std::uint8_t* data, size_t size) {
std::vector<float> out;
const auto t0 = std::chrono::steady_clock::now();
const bool ok = decode_audio(data, size, out) && peak_ok(out);
decode_ms += std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
if (!ok)
{
if (failed < 10) std::fprintf(stderr, "cannot decode %s (%zu bytes)\n", label.c_str(), size);
++failed;
}
seconds += out.size() / 2.0 / mt_host::kMixRate;
};
for (const std::string& name : names)
{
CMappedFile file;
LPCVOID data = nullptr;
if (!mgr.Get(file, name.c_str(), &data))
{
std::fprintf(stderr, "cannot read %s\n", name.c_str());
++failed;
continue;
}
(has_ext(name, ".wav") ? wav : mp3) += 1;
decode_one(name, static_cast<const std::uint8_t*>(data), file.Size());
}
if (DIR* d = opendir("BGM"))
{
while (dirent* e = readdir(d))
{
if (!has_ext(e->d_name, ".mp3")) continue;
std::ifstream in(std::string("BGM/") + e->d_name, std::ios::binary);
const std::vector<std::uint8_t> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
++bgm;
decode_one(std::string("BGM/") + e->d_name, bytes.data(), bytes.size());
}
closedir(d);
}
// 2026-09-29 survey (tools/epk_scan): 1601 PCM16 .wav entries (1578 distinct paths) and 9 .mp3 in the packs, 25 .mp3 in Client/BGM.
CHECK(wav == 1578);
CHECK(mp3 == 9);
CHECK(bgm == 25);
CHECK(failed == 0);
std::printf("pack wav %zu, pack mp3 %zu, BGM mp3 %zu, failed %zu, %.0f s of audio decoded in %.0f ms\n", wav, mp3,
bgm, failed, seconds, decode_ms);
if (g_failures)
std::fprintf(stderr, "%d failure(s)\n", g_failures);
return g_failures ? 1 : 0;
}