mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-18 18:01:39 +02:00
1 parent
5be2f68a55
commit
537828acc2
7 files changed
+547
-10
No files matched your search
@@ -0,0 +1,64 @@
|
||||
#ifndef MOCKTAIL_AUDIO_FMOD_THREAD_FLOATING_POINT_H_
|
||||
#define MOCKTAIL_AUDIO_FMOD_THREAD_FLOATING_POINT_H_
|
||||
|
||||
#include <string_view>
|
||||
|
||||
#if defined(__x86_64__)
|
||||
#include <xmmintrin.h>
|
||||
#endif
|
||||
|
||||
namespace mocktail::audio {
|
||||
|
||||
// FMOD's Android threads identify themselves through JavaVMAttachArgs before
|
||||
// entering their mix loop. Voice DSP filters can accumulate subnormal values
|
||||
// and spend an entire x86 core on floating-point assists (issue #128).
|
||||
// Limit FTZ/DAZ to these audio threads; retain the caller's other FP controls.
|
||||
class FmodThreadFloatingPointMode final {
|
||||
public:
|
||||
FmodThreadFloatingPointMode() = default;
|
||||
~FmodThreadFloatingPointMode() { Restore(); }
|
||||
FmodThreadFloatingPointMode(const FmodThreadFloatingPointMode&) = delete;
|
||||
FmodThreadFloatingPointMode& operator=(const FmodThreadFloatingPointMode&) =
|
||||
delete;
|
||||
|
||||
bool Enable(const char* thread_name) noexcept {
|
||||
#if defined(__x86_64__)
|
||||
if (active_ || thread_name == nullptr) return false;
|
||||
const std::string_view name(thread_name);
|
||||
if (name != "FMOD mixer thread" && name != "FMOD feeder thread" &&
|
||||
name != "FMOD Convolution thread" && name != "FMOD Worker Thread") {
|
||||
return false;
|
||||
}
|
||||
const unsigned int control = _mm_getcsr();
|
||||
original_mode_ = control & kModeMask;
|
||||
_mm_setcsr(control | kModeMask);
|
||||
active_ = true;
|
||||
return true;
|
||||
#else
|
||||
(void)thread_name;
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void Restore() noexcept {
|
||||
#if defined(__x86_64__)
|
||||
if (active_) {
|
||||
// Restore only our two bits, preserving rounding, exception masks and
|
||||
// status flags set by the guest while the thread was attached.
|
||||
_mm_setcsr((_mm_getcsr() & ~kModeMask) | original_mode_);
|
||||
active_ = false;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private:
|
||||
#if defined(__x86_64__)
|
||||
static constexpr unsigned int kModeMask = (1U << 15) | (1U << 6);
|
||||
unsigned int original_mode_ = 0;
|
||||
bool active_ = false;
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace mocktail::audio
|
||||
|
||||
#endif // MOCKTAIL_AUDIO_FMOD_THREAD_FLOATING_POINT_H_
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
@@ -13,13 +14,15 @@
|
||||
|
||||
namespace mocktail::audio {
|
||||
|
||||
class NativeInputCapture;
|
||||
|
||||
// Replaces the profiled input/output-device query/select slots of the exact
|
||||
// Build-ID-scoped FmodAudioDevice vtable. Roblox keeps owning its FMOD engine,
|
||||
// while the existing settings UI sees and selects the SDL host routes that
|
||||
// consume Android AudioTrack PCM.
|
||||
class RobloxOutputDeviceBridge final {
|
||||
public:
|
||||
RobloxOutputDeviceBridge() = default;
|
||||
RobloxOutputDeviceBridge();
|
||||
~RobloxOutputDeviceBridge();
|
||||
|
||||
RobloxOutputDeviceBridge(const RobloxOutputDeviceBridge&) = delete;
|
||||
@@ -58,6 +61,9 @@ class RobloxOutputDeviceBridge final {
|
||||
void SelectDevice(int index, bool input = false);
|
||||
void ConstructGuestString(void* destination, std::string_view value) const;
|
||||
|
||||
std::unique_ptr<NativeInputCapture> capture_;
|
||||
bool capture_profile_supported_ = false;
|
||||
std::array<std::uintptr_t, 6> original_capture_methods_{};
|
||||
mutable std::mutex mutex_;
|
||||
compat::FmodOutputDeviceBridgeProfile profile_;
|
||||
std::vector<MenuDevice> devices_;
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
#ifndef MOCKTAIL_AUDIO_NATIVE_INPUT_CAPTURE_H_
|
||||
#define MOCKTAIL_AUDIO_NATIVE_INPUT_CAPTURE_H_
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
|
||||
#include "mocktail/audio/sdl_audio_capture.h"
|
||||
|
||||
namespace mocktail::audio {
|
||||
class NativeInputCapture;
|
||||
|
||||
struct NativePcmFormat {
|
||||
std::uint32_t rate = 48000;
|
||||
std::uint8_t channels = 1;
|
||||
std::uint8_t bytes_per_sample = 2;
|
||||
std::uint8_t flags = 0;
|
||||
std::uint8_t padding = 0;
|
||||
};
|
||||
struct NativeOptionalFormat {
|
||||
NativePcmFormat format{};
|
||||
std::uint64_t present = 0;
|
||||
};
|
||||
struct NativeSharedSink { void* object; void* owner; };
|
||||
static_assert(sizeof(NativeOptionalFormat) == 16);
|
||||
static_assert(sizeof(NativePcmFormat) == 8);
|
||||
inline std::atomic<NativeInputCapture*> g_native_capture{nullptr};
|
||||
|
||||
class NativeInputCapture {
|
||||
public:
|
||||
using DeliverPcm = std::size_t (*)(void*, const void*, std::size_t,
|
||||
std::uint64_t, const NativePcmFormat*);
|
||||
using Destroy = void (*)(NativeSharedSink*);
|
||||
using OriginalLatency = void (*)(void*, std::uint32_t*, std::uint32_t*);
|
||||
struct SinkAbi {
|
||||
std::uintptr_t vtable;
|
||||
DeliverPcm deliver;
|
||||
Destroy release;
|
||||
OriginalLatency latency;
|
||||
};
|
||||
NativeInputCapture(SinkAbi abi, bool enabled)
|
||||
: abi_(abi), enabled_(enabled) {}
|
||||
~NativeInputCapture() { Shutdown(); }
|
||||
|
||||
static NativeOptionalFormat Format(void*) {
|
||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
||||
return {{}, capture && capture->enabled_ ? 1ULL : 0ULL};
|
||||
}
|
||||
static void Latency(void* self, std::uint32_t* playback,
|
||||
std::uint32_t* recording) {
|
||||
// Preserve the native playback latency and report the host capture queue.
|
||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
||||
if (!capture) return;
|
||||
capture->abi_.latency(self, playback, recording);
|
||||
if (recording) {
|
||||
std::lock_guard lock(capture->mutex_);
|
||||
*recording = 10;
|
||||
auto it = capture->sessions_.find(self);
|
||||
if (it != capture->sessions_.end()) {
|
||||
std::lock_guard queue_lock(it->second->queue_mutex);
|
||||
*recording += static_cast<std::uint32_t>(it->second->count * 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
static bool Start(void* self, NativeSharedSink* sink) {
|
||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
||||
return capture && capture->StartSink(self, sink);
|
||||
}
|
||||
static void Stop(void* self, NativeSharedSink* sink) {
|
||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
||||
if (capture) capture->StopSink(self, sink);
|
||||
}
|
||||
static bool Recording(void* self) {
|
||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
||||
if (!capture) return false;
|
||||
std::lock_guard lock(capture->mutex_);
|
||||
return capture->sessions_.find(self) != capture->sessions_.end();
|
||||
}
|
||||
static void Poll(void* self) {
|
||||
auto* capture = g_native_capture.load(std::memory_order_acquire);
|
||||
if (capture) capture->Deliver(self);
|
||||
}
|
||||
void Shutdown() {
|
||||
std::lock_guard lock(mutex_);
|
||||
for (auto& [self, session] : sessions_) Close(*session);
|
||||
sessions_.clear();
|
||||
}
|
||||
|
||||
private:
|
||||
struct Session {
|
||||
std::unique_ptr<AudioCapture> input;
|
||||
std::vector<NativeSharedSink> sinks;
|
||||
std::mutex queue_mutex;
|
||||
// At most 200 ms; a stalled consumer must not accumulate stale speech.
|
||||
std::array<std::array<std::int16_t, 480>, 20> frames{};
|
||||
std::size_t head = 0, count = 0;
|
||||
std::uint64_t delivered = 0, dropped = 0;
|
||||
bool audible = false;
|
||||
};
|
||||
static void OnData(void* context, std::size_t bytes) {
|
||||
auto& session = *static_cast<Session*>(context);
|
||||
if (bytes != sizeof(session.frames[0])) return;
|
||||
std::lock_guard lock(session.queue_mutex);
|
||||
if (session.count == session.frames.size()) {
|
||||
session.head = (session.head + 1) % session.frames.size();
|
||||
--session.count;
|
||||
++session.dropped;
|
||||
}
|
||||
std::memcpy(session.frames[(session.head + session.count) %
|
||||
session.frames.size()].data(),
|
||||
session.input->buffer_data(), bytes);
|
||||
++session.count;
|
||||
}
|
||||
bool StartSink(void* self, NativeSharedSink* sink) {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (!enabled_ || !self || !sink || !sink->object || !sink->owner)
|
||||
return false;
|
||||
auto* table = *reinterpret_cast<std::uintptr_t**>(sink->object);
|
||||
if (reinterpret_cast<std::uintptr_t>(table) != abi_.vtable ||
|
||||
table[2] != reinterpret_cast<std::uintptr_t>(abi_.deliver)) {
|
||||
std::fprintf(stderr, " [audio-input] unsupported native PCM sink ABI\n");
|
||||
return false;
|
||||
}
|
||||
auto it = sessions_.find(self);
|
||||
if (it != sessions_.end()) {
|
||||
for (const auto& existing : it->second->sinks)
|
||||
if (existing.object == sink->object) return true;
|
||||
} else {
|
||||
auto session = std::make_unique<Session>();
|
||||
SdlAudioCaptureOptions options;
|
||||
options.output_spec.channels = 1;
|
||||
options.data_callback = &OnData;
|
||||
options.data_context = session.get();
|
||||
auto status = CreateSdlAudioCapture(options, &session->input);
|
||||
if (status.ok()) status = session->input->Start();
|
||||
if (!status.ok()) {
|
||||
std::fprintf(stderr, " [audio-input] capture start failed: %s\n",
|
||||
status.message().c_str());
|
||||
return false;
|
||||
}
|
||||
it = sessions_.emplace(self, std::move(session)).first;
|
||||
std::fprintf(stderr,
|
||||
" [audio-input] native capture started: 48000 Hz mono signed16\n");
|
||||
}
|
||||
// The native parameter is a by-value shared_ptr. Retain its existing
|
||||
// ownership by moving it, leaving the caller's temporary empty.
|
||||
it->second->sinks.push_back(*sink);
|
||||
*sink = {};
|
||||
return true;
|
||||
}
|
||||
void Release(NativeSharedSink& sink) {
|
||||
abi_.release(&sink);
|
||||
sink = {};
|
||||
}
|
||||
void Close(Session& session) {
|
||||
session.input->Shutdown();
|
||||
for (auto& sink : session.sinks) Release(sink);
|
||||
session.sinks.clear();
|
||||
std::fprintf(stderr,
|
||||
" [audio-input] native capture stopped: delivered=%llu dropped=%llu\n",
|
||||
static_cast<unsigned long long>(session.delivered),
|
||||
static_cast<unsigned long long>(session.dropped));
|
||||
}
|
||||
void StopSink(void* self, NativeSharedSink* sink) {
|
||||
std::lock_guard lock(mutex_);
|
||||
auto it = sessions_.find(self);
|
||||
if (it == sessions_.end() || !sink) return;
|
||||
auto& session = *it->second;
|
||||
auto existing = std::find_if(session.sinks.begin(), session.sinks.end(),
|
||||
[&](const auto& entry) { return entry.object == sink->object; });
|
||||
if (existing == session.sinks.end()) return;
|
||||
Release(*existing);
|
||||
session.sinks.erase(existing);
|
||||
if (session.sinks.empty()) {
|
||||
Close(session);
|
||||
sessions_.erase(it);
|
||||
}
|
||||
}
|
||||
void Deliver(void* self) {
|
||||
// Invoked by Roblox's recording thread. Guest code and allocator TLS must
|
||||
// never run on SDL's capture callback thread.
|
||||
std::lock_guard lock(mutex_);
|
||||
auto it = sessions_.find(self);
|
||||
if (it == sessions_.end()) return;
|
||||
auto& session = *it->second;
|
||||
for (std::size_t n = 0; n < session.frames.size(); ++n) {
|
||||
std::array<std::int16_t, 480> pcm;
|
||||
std::uint64_t delay;
|
||||
{
|
||||
std::lock_guard queue_lock(session.queue_mutex);
|
||||
if (!session.count) break;
|
||||
pcm = session.frames[session.head];
|
||||
delay = session.count * 10000000ULL;
|
||||
session.head = (session.head + 1) % session.frames.size();
|
||||
--session.count;
|
||||
}
|
||||
const NativePcmFormat format;
|
||||
for (const auto& sink : session.sinks)
|
||||
abi_.deliver(
|
||||
sink.object, pcm.data(), pcm.size(), delay, &format);
|
||||
if (++session.delivered == 1)
|
||||
std::fprintf(stderr, " [audio-input] PCM delivered to native voice sink\n");
|
||||
if (!session.audible && std::any_of(pcm.begin(), pcm.end(),
|
||||
[](auto value) { return value != 0; })) {
|
||||
session.audible = true;
|
||||
std::fprintf(stderr, " [audio-input] nonzero microphone PCM delivered\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
SinkAbi abi_;
|
||||
bool enabled_;
|
||||
std::mutex mutex_;
|
||||
std::map<void*, std::unique_ptr<Session>> sessions_;
|
||||
};
|
||||
|
||||
|
||||
} // namespace mocktail::audio
|
||||
#endif
|
||||
@@ -21,6 +21,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "audio/roblox_output_device_bridge_internal.h"
|
||||
#include "audio/native_input_capture.h"
|
||||
#include "linker/linker.h"
|
||||
#include "mocktail/audio/sdl_audio_capture.h"
|
||||
#include "mocktail/audio/sdl_audio_sink.h"
|
||||
@@ -105,7 +106,7 @@ bool SetVtableWritable(std::uintptr_t* vtable, std::size_t select_index,
|
||||
}
|
||||
const std::uintptr_t page_size = static_cast<std::uintptr_t>(page_size_value);
|
||||
const std::uintptr_t first =
|
||||
reinterpret_cast<std::uintptr_t>(vtable + kOutputCountVtableIndex);
|
||||
reinterpret_cast<std::uintptr_t>(vtable + 4);
|
||||
const std::uintptr_t last =
|
||||
reinterpret_cast<std::uintptr_t>(vtable + select_index + 1);
|
||||
if (last <= first) {
|
||||
@@ -129,6 +130,16 @@ std::uintptr_t FunctionAddress(Function function) {
|
||||
|
||||
} // namespace
|
||||
|
||||
// ABI verified for Roblox 2.738.1397. Keep capture separate from the menu
|
||||
// profile: relocating query methods alone does not establish a recording ABI.
|
||||
namespace {
|
||||
constexpr std::array<std::size_t, 6> kCaptureSlots{4, 13, 14, 15, 16, 17};
|
||||
constexpr std::array<std::uintptr_t, 6> kCaptureRvas{
|
||||
0x320c630, 0x320bcf6, 0x320d4b4, 0x320dd94, 0x320b844, 0x320e49a};
|
||||
} // namespace
|
||||
|
||||
RobloxOutputDeviceBridge::RobloxOutputDeviceBridge() = default;
|
||||
|
||||
namespace internal {
|
||||
|
||||
bool HasExpectedFmodStringConstructorContract(const std::uint8_t* code,
|
||||
@@ -215,6 +226,10 @@ Status RobloxOutputDeviceBridge::Install(const compat::BuildProfile& profile) {
|
||||
"another Roblox output-device bridge owns the process");
|
||||
}
|
||||
profile_ = *profile.fmod_output_device_bridge;
|
||||
capture_profile_supported_ =
|
||||
profile.elf_build_id == "5f0704edd9064f566ee3d6df2bd2fabbcc709f03" &&
|
||||
profile_.vtable_rva == 0x6cd3ce0 &&
|
||||
profile_.vtable_layout_version == 2 && profile_.has_input_devices();
|
||||
if (!linker::SetAndroidLibraryLoadObserver(&ObserveAndroidLibrary, this)) {
|
||||
g_active_bridge.store(nullptr, std::memory_order_release);
|
||||
profile_ = {};
|
||||
@@ -240,6 +255,8 @@ void RobloxOutputDeviceBridge::Shutdown() {
|
||||
" [audio-menu] failed to restore FmodAudioDevice "
|
||||
"vtable protection\n");
|
||||
}
|
||||
g_native_capture.store(nullptr, std::memory_order_release);
|
||||
capture_.reset();
|
||||
active_ = false;
|
||||
devices_.clear();
|
||||
input_devices_.clear();
|
||||
@@ -408,9 +425,9 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
||||
return FailedPrecondition("FMOD output-device vtable range overflows");
|
||||
}
|
||||
const std::uintptr_t first_slot_rva =
|
||||
profile_.vtable_rva + kOutputCountVtableIndex * sizeof(std::uintptr_t);
|
||||
profile_.vtable_rva + 4 * sizeof(std::uintptr_t);
|
||||
const std::size_t slot_range_size =
|
||||
(kSelectOutputVtableIndex - kOutputCountVtableIndex + 1) *
|
||||
(kSelectOutputVtableIndex - 4 + 1) *
|
||||
sizeof(std::uintptr_t);
|
||||
if (!IsRelroImageRange(library_base_, first_slot_rva, slot_range_size) ||
|
||||
!IsExecutableImageRange(library_base_, profile_.string_constructor_rva,
|
||||
@@ -444,6 +461,29 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
||||
"FmodAudioDevice string ABI contract validation failed");
|
||||
}
|
||||
|
||||
if (capture_profile_supported_) {
|
||||
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i) {
|
||||
if (!IsExecutableImageRange(library_base_, kCaptureRvas[i], 1) ||
|
||||
vtable_[kCaptureSlots[i]] != library_base_ + kCaptureRvas[i])
|
||||
return FailedPrecondition("native input capture vtable ABI mismatch");
|
||||
original_capture_methods_[i] = vtable_[kCaptureSlots[i]];
|
||||
}
|
||||
if (!IsExecutableImageRange(library_base_, 0x3213154, 1) ||
|
||||
!IsExecutableImageRange(library_base_, 0x1dc4cd0, 1) ||
|
||||
!IsRelroImageRange(library_base_, 0x6cd4550, 24))
|
||||
return FailedPrecondition("native input capture sink ABI outside image");
|
||||
capture_ = std::make_unique<NativeInputCapture>(
|
||||
NativeInputCapture::SinkAbi{
|
||||
library_base_ + 0x6cd4550,
|
||||
reinterpret_cast<NativeInputCapture::DeliverPcm>(
|
||||
library_base_ + 0x3213154),
|
||||
reinterpret_cast<NativeInputCapture::Destroy>(
|
||||
library_base_ + 0x1dc4cd0),
|
||||
reinterpret_cast<NativeInputCapture::OriginalLatency>(
|
||||
library_base_ + kCaptureRvas[1])},
|
||||
!input_devices_.empty());
|
||||
}
|
||||
|
||||
original_methods_ = {
|
||||
vtable_[kOutputCountVtableIndex],
|
||||
vtable_[kOutputInfoVtableIndex],
|
||||
@@ -478,6 +518,18 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
||||
for (std::size_t i = 0; i < indexes.size(); ++i)
|
||||
__atomic_store_n(&vtable_[indexes[i]], methods[i], __ATOMIC_RELEASE);
|
||||
}
|
||||
if (capture_) {
|
||||
g_native_capture.store(capture_.get(), std::memory_order_release);
|
||||
const std::array<std::uintptr_t, 6> methods{
|
||||
FunctionAddress(&NativeInputCapture::Format),
|
||||
FunctionAddress(&NativeInputCapture::Latency),
|
||||
FunctionAddress(&NativeInputCapture::Start),
|
||||
FunctionAddress(&NativeInputCapture::Stop),
|
||||
FunctionAddress(&NativeInputCapture::Poll),
|
||||
FunctionAddress(&NativeInputCapture::Recording)};
|
||||
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i)
|
||||
__atomic_store_n(&vtable_[kCaptureSlots[i]], methods[i], __ATOMIC_RELEASE);
|
||||
}
|
||||
if (!SetVtableWritable(vtable_, kSelectOutputVtableIndex, false)) {
|
||||
__atomic_store_n(&vtable_[kOutputCountVtableIndex], original_methods_[0],
|
||||
__ATOMIC_RELEASE);
|
||||
@@ -493,6 +545,13 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
|
||||
__atomic_store_n(&vtable_[indexes[i]], original_methods_[4 + i],
|
||||
__ATOMIC_RELEASE);
|
||||
}
|
||||
if (capture_) {
|
||||
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i)
|
||||
__atomic_store_n(&vtable_[kCaptureSlots[i]], original_capture_methods_[i],
|
||||
__ATOMIC_RELEASE);
|
||||
g_native_capture.store(nullptr, std::memory_order_release);
|
||||
capture_.reset();
|
||||
}
|
||||
(void)SetVtableWritable(vtable_, kSelectOutputVtableIndex, false);
|
||||
return Status::Error(StatusCode::kPlatformError,
|
||||
"cannot restore FmodAudioDevice RELRO protection");
|
||||
@@ -521,6 +580,11 @@ bool RobloxOutputDeviceBridge::RestoreVtableLocked() {
|
||||
__atomic_store_n(&vtable_[indexes[i]], original_methods_[4 + i],
|
||||
__ATOMIC_RELEASE);
|
||||
}
|
||||
if (capture_) {
|
||||
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i)
|
||||
__atomic_store_n(&vtable_[kCaptureSlots[i]], original_capture_methods_[i],
|
||||
__ATOMIC_RELEASE);
|
||||
}
|
||||
return SetVtableWritable(vtable_, kSelectOutputVtableIndex, false);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ struct CaptureSession {
|
||||
void* data_context = nullptr;
|
||||
std::unique_ptr<AudioCapture> capture;
|
||||
std::shared_ptr<std::atomic<bool>> microphone_muted;
|
||||
std::atomic<std::uint64_t> delivered_buffers{0};
|
||||
std::atomic<std::uint64_t> audible_buffers{0};
|
||||
std::atomic<std::uint64_t> muted_buffers{0};
|
||||
};
|
||||
|
||||
struct PlayoutSession {
|
||||
@@ -112,22 +115,40 @@ void DisposeManager(void* opaque_context, const void* identity) {
|
||||
void SetMicrophoneMute(void* opaque_context, bool muted) {
|
||||
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
|
||||
if (context == nullptr) return;
|
||||
context->microphone_muted->store(muted, std::memory_order_release);
|
||||
if (context->microphone_muted->exchange(muted, std::memory_order_acq_rel) != muted) {
|
||||
std::cerr << " [mocktail][audio] WebRTC microphone muted=" << muted << '\n';
|
||||
}
|
||||
}
|
||||
|
||||
void OnCapturedAudio(void* opaque_session, std::size_t size_bytes) {
|
||||
auto* session = static_cast<CaptureSession*>(opaque_session);
|
||||
if (session != nullptr && session->data_callback != nullptr) {
|
||||
if (session->capture == nullptr ||
|
||||
size_bytes > session->capture->buffer_size_bytes()) {
|
||||
return;
|
||||
}
|
||||
const auto* samples = static_cast<const unsigned char*>(
|
||||
session->capture->buffer_data());
|
||||
int peak = 0;
|
||||
for (std::size_t i = 0; i + 1 < size_bytes; i += 2) {
|
||||
const int word = samples[i] | (static_cast<int>(samples[i + 1]) << 8);
|
||||
peak = std::max(peak, std::abs(word >= 32768 ? word - 65536 : word));
|
||||
}
|
||||
if (peak != 0 && session->audible_buffers.fetch_add(1) == 0) {
|
||||
std::cerr << " [mocktail][audio] WebRTC microphone first nonzero PCM"
|
||||
<< " peak=" << peak << "/32768\n";
|
||||
}
|
||||
if (session->microphone_muted != nullptr &&
|
||||
session->microphone_muted->load(std::memory_order_acquire)) {
|
||||
if (session->capture == nullptr ||
|
||||
size_bytes > session->capture->buffer_size_bytes()) {
|
||||
return;
|
||||
}
|
||||
session->muted_buffers.fetch_add(1, std::memory_order_relaxed);
|
||||
std::memset(session->capture->buffer_data(), 0, size_bytes);
|
||||
}
|
||||
session->data_callback(session->data_context, session->identity,
|
||||
size_bytes);
|
||||
if (session->delivered_buffers.fetch_add(1) == 0) {
|
||||
std::cerr << " [mocktail][audio] WebRTC microphone first JNI delivery"
|
||||
<< " bytes=" << size_bytes << '\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +158,10 @@ void CloseCaptureSession(const std::shared_ptr<CaptureSession>& session) {
|
||||
}
|
||||
(void)session->capture->Stop();
|
||||
session->capture->Shutdown();
|
||||
std::cerr << " [mocktail][audio] WebRTC microphone capture summary"
|
||||
<< " buffers=" << session->delivered_buffers.load()
|
||||
<< " nonzero=" << session->audible_buffers.load()
|
||||
<< " muted=" << session->muted_buffers.load() << '\n';
|
||||
}
|
||||
|
||||
int Init(void* opaque_context, const void* identity, int sample_rate_hz,
|
||||
|
||||
+14
-1
@@ -1,5 +1,7 @@
|
||||
#include "jnivm/jnivm.h"
|
||||
|
||||
#include "mocktail/audio/fmod_thread_floating_point.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
@@ -56,6 +58,7 @@ namespace {
|
||||
thread_local JNIEnv* g_thread_local_env = nullptr;
|
||||
thread_local JNIEnv g_thread_env_storage = {};
|
||||
thread_local VM* g_thread_vm_instance = nullptr;
|
||||
thread_local mocktail::audio::FmodThreadFloatingPointMode g_thread_audio_fp_mode;
|
||||
thread_local std::vector<std::vector<jobject>> g_local_frames;
|
||||
|
||||
std::recursive_mutex g_jni_state_mutex;
|
||||
@@ -4970,6 +4973,7 @@ VM::~VM() {
|
||||
g_live_vms.erase(std::remove(g_live_vms.begin(), g_live_vms.end(), this),
|
||||
g_live_vms.end());
|
||||
if (g_thread_vm_instance == this) {
|
||||
g_thread_audio_fp_mode.Restore();
|
||||
g_thread_vm_instance = nullptr;
|
||||
g_thread_local_env = nullptr;
|
||||
g_thread_env_storage.functions = nullptr;
|
||||
@@ -6323,6 +6327,7 @@ JNIEnv* VM::GetJNIEnv() {
|
||||
}
|
||||
return g_thread_local_env;
|
||||
}
|
||||
g_thread_audio_fp_mode.Restore();
|
||||
g_thread_vm_instance = this;
|
||||
g_thread_env_storage.functions = &native_interface_;
|
||||
g_thread_local_env = &g_thread_env_storage;
|
||||
@@ -6372,7 +6377,7 @@ std::shared_ptr<Class> VM::FindClass(const std::string& class_name) const {
|
||||
|
||||
void VM::InitJNIFunctionTables() {
|
||||
invoke_interface_.AttachCurrentThread =
|
||||
[](JavaVM* vm, void** env, void* /*args*/) -> jint {
|
||||
[](JavaVM* vm, void** env, void* args) -> jint {
|
||||
if (JniVmTraceEnabled()) {
|
||||
std::cout << " [JNI] AttachCurrentThread enter vm=" << vm
|
||||
<< " env_out=" << env << '\n';
|
||||
@@ -6389,6 +6394,7 @@ void VM::InitJNIFunctionTables() {
|
||||
return JNI_EINVAL;
|
||||
}
|
||||
if (g_thread_vm_instance != owner || !IsThreadLocalEnvValid()) {
|
||||
g_thread_audio_fp_mode.Restore();
|
||||
g_thread_vm_instance = owner;
|
||||
if (!owner->jni_env_) {
|
||||
owner->jni_env_ = &owner->jni_env_storage_;
|
||||
@@ -6398,6 +6404,12 @@ void VM::InitJNIFunctionTables() {
|
||||
owner->jni_env_->functions ? owner->jni_env_->functions
|
||||
: &owner->native_interface_;
|
||||
g_thread_local_env = &g_thread_env_storage;
|
||||
const auto* attach_args = static_cast<const JavaVMAttachArgs*>(args);
|
||||
if (attach_args != nullptr &&
|
||||
g_thread_audio_fp_mode.Enable(attach_args->name)) {
|
||||
std::fprintf(stderr, " [mocktail][audio] %s: enabled FTZ/DAZ\n",
|
||||
attach_args->name);
|
||||
}
|
||||
}
|
||||
// Reattaching to the same VM must retain any guest JNI table wrapper.
|
||||
*env = g_thread_local_env;
|
||||
@@ -6421,6 +6433,7 @@ void VM::InitJNIFunctionTables() {
|
||||
if (g_thread_vm_instance != owner || !IsThreadLocalEnvValid()) {
|
||||
return JNI_EDETACHED;
|
||||
}
|
||||
g_thread_audio_fp_mode.Restore();
|
||||
g_thread_local_env = nullptr;
|
||||
g_thread_vm_instance = nullptr;
|
||||
g_thread_env_storage.functions = nullptr;
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#if defined(__x86_64__)
|
||||
#include <xmmintrin.h>
|
||||
#endif
|
||||
|
||||
namespace jnivm {
|
||||
namespace {
|
||||
|
||||
@@ -376,6 +380,142 @@ TEST_F(JniVmTest, JniEnvPointerIsNotNull) {
|
||||
EXPECT_NE(vm_->GetJNIEnv(), nullptr);
|
||||
}
|
||||
|
||||
#if defined(__x86_64__)
|
||||
constexpr unsigned int kAudioFpMode = (1U << 15) | (1U << 6);
|
||||
|
||||
class ScopedMxcsr final {
|
||||
public:
|
||||
ScopedMxcsr() : saved_(_mm_getcsr()) {}
|
||||
~ScopedMxcsr() { _mm_setcsr(saved_); }
|
||||
|
||||
private:
|
||||
unsigned int saved_;
|
||||
};
|
||||
|
||||
// Volatile input ensures the multiply executes in SSE with the current MXCSR,
|
||||
// rather than being constant-folded using the compiler's default FP model.
|
||||
float MultiplyAudioSample(volatile float* sample, float gain) {
|
||||
return _mm_cvtss_f32(_mm_mul_ss(_mm_set_ss(*sample), _mm_set_ss(gain)));
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, FmodMixerFlushesSubnormalAudioWithoutChangingParentThread) {
|
||||
ScopedMxcsr restore;
|
||||
_mm_setcsr(0x1f80); // IEEE defaults; FTZ/DAZ disabled.
|
||||
JavaVM* java_vm = vm_->GetJavaVM();
|
||||
std::thread mixer([java_vm]() {
|
||||
ScopedMxcsr restore_worker;
|
||||
volatile float tail = 0x1p-140f;
|
||||
volatile float quiet_normal = 0x1p-126f;
|
||||
volatile float audible = 0.25f;
|
||||
EXPECT_GT(MultiplyAudioSample(&tail, 1.0f), 0.0f);
|
||||
EXPECT_GT(MultiplyAudioSample(&quiet_normal, 0.5f), 0.0f);
|
||||
const unsigned int before = _mm_getcsr();
|
||||
char name[] = "FMOD mixer thread";
|
||||
JavaVMAttachArgs args{JNI_VERSION_1_6, name, nullptr};
|
||||
void* env = nullptr;
|
||||
ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK);
|
||||
EXPECT_EQ(_mm_getcsr(), before | kAudioFpMode);
|
||||
EXPECT_EQ(MultiplyAudioSample(&tail, 1.0f), 0.0f);
|
||||
EXPECT_EQ(MultiplyAudioSample(&quiet_normal, 0.5f), 0.0f);
|
||||
EXPECT_EQ(MultiplyAudioSample(&audible, 0.5f), 0.125f);
|
||||
// Reattaching with no name must not lose the mixer's saved FP state.
|
||||
ASSERT_EQ(java_vm->AttachCurrentThread(&env, nullptr), JNI_OK);
|
||||
EXPECT_EQ(_mm_getcsr() & kAudioFpMode, kAudioFpMode);
|
||||
ASSERT_EQ(java_vm->DetachCurrentThread(), JNI_OK);
|
||||
EXPECT_EQ(_mm_getcsr() & kAudioFpMode, before & kAudioFpMode);
|
||||
EXPECT_GT(MultiplyAudioSample(&tail, 1.0f), 0.0f);
|
||||
});
|
||||
mixer.join();
|
||||
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, FmodDaemonThreadsRestoreExistingModesAndOtherControls) {
|
||||
ScopedMxcsr restore;
|
||||
JavaVM* java_vm = vm_->GetJavaVM();
|
||||
for (const char* name : {"FMOD mixer thread", "FMOD feeder thread",
|
||||
"FMOD Convolution thread", "FMOD Worker Thread"}) {
|
||||
for (const unsigned int mode : {0U, 1U << 6, 1U << 15, kAudioFpMode}) {
|
||||
_mm_setcsr(0x1f80 | mode | _MM_ROUND_DOWN);
|
||||
const unsigned int before = _mm_getcsr();
|
||||
JavaVMAttachArgs args{JNI_VERSION_1_6, const_cast<char*>(name), nullptr};
|
||||
void* env = nullptr;
|
||||
ASSERT_EQ(java_vm->AttachCurrentThreadAsDaemon(&env, &args), JNI_OK);
|
||||
EXPECT_EQ(_mm_getcsr(), before | kAudioFpMode);
|
||||
// Guest changes to other controls/status must survive detach.
|
||||
_mm_setcsr((_mm_getcsr() & ~_MM_ROUND_MASK) | _MM_ROUND_UP | 0x20U);
|
||||
const unsigned int after_guest = _mm_getcsr();
|
||||
ASSERT_EQ(java_vm->DetachCurrentThread(), JNI_OK);
|
||||
EXPECT_EQ(_mm_getcsr(), (after_guest & ~kAudioFpMode) | mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, OrdinaryJniThreadsKeepIeeeFloatingPointBehavior) {
|
||||
ScopedMxcsr restore;
|
||||
_mm_setcsr(0x1f80);
|
||||
JavaVM* java_vm = vm_->GetJavaVM();
|
||||
for (const char* name : {"Main", "RBX Worker", "FMOD mixer thread extra",
|
||||
"FMOD file thread", "", static_cast<const char*>(nullptr)}) {
|
||||
JavaVMAttachArgs args{JNI_VERSION_1_6, const_cast<char*>(name), nullptr};
|
||||
void* env = nullptr;
|
||||
ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK);
|
||||
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
|
||||
// JNI ignores new names when a thread is already attached.
|
||||
args.name = const_cast<char*>("FMOD mixer thread");
|
||||
ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK);
|
||||
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
|
||||
ASSERT_EQ(java_vm->DetachCurrentThread(), JNI_OK);
|
||||
}
|
||||
void* env = nullptr;
|
||||
ASSERT_EQ(java_vm->AttachCurrentThread(&env, nullptr), JNI_OK);
|
||||
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
|
||||
EXPECT_EQ(java_vm->DetachCurrentThread(), JNI_OK);
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, RejectedFmodAttachDoesNotChangeFloatingPointMode) {
|
||||
ScopedMxcsr restore;
|
||||
_mm_setcsr(0x1f80);
|
||||
JavaVM* java_vm = vm_->GetJavaVM();
|
||||
char name[] = "FMOD mixer thread";
|
||||
JavaVMAttachArgs args{JNI_VERSION_1_6, name, nullptr};
|
||||
EXPECT_EQ(java_vm->AttachCurrentThread(nullptr, &args), JNI_EINVAL);
|
||||
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
|
||||
JavaVM foreign = {java_vm->functions};
|
||||
void* env = nullptr;
|
||||
EXPECT_EQ(foreign.AttachCurrentThread(&env, &args), JNI_EINVAL);
|
||||
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, ChangingVmRestoresFmodFloatingPointMode) {
|
||||
ScopedMxcsr restore;
|
||||
_mm_setcsr(0x1f80);
|
||||
JavaVM* java_vm = vm_->GetJavaVM();
|
||||
char name[] = "FMOD mixer thread";
|
||||
JavaVMAttachArgs args{JNI_VERSION_1_6, name, nullptr};
|
||||
void* env = nullptr;
|
||||
ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK);
|
||||
{
|
||||
VM temporary;
|
||||
// Merely constructing/destroying another VM must not change this thread.
|
||||
}
|
||||
EXPECT_EQ(_mm_getcsr() & kAudioFpMode, kAudioFpMode);
|
||||
{
|
||||
VM other;
|
||||
ASSERT_EQ(other.GetJavaVM()->AttachCurrentThread(&env, nullptr), JNI_OK);
|
||||
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
|
||||
}
|
||||
ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK);
|
||||
{
|
||||
VM other;
|
||||
ASSERT_NE(other.GetJNIEnv(), nullptr);
|
||||
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
|
||||
}
|
||||
ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK);
|
||||
vm_.reset();
|
||||
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
|
||||
}
|
||||
#endif
|
||||
|
||||
TEST_F(JniVmTest, DiscardedTemporaryVmKeepsOriginalUsable) {
|
||||
JavaVM* java_vm = vm_->GetJavaVM();
|
||||
JNIEnv* original_env = vm_->GetJNIEnv();
|
||||
|
||||
Reference in new issue
Block a user