Merge pull request #12769 from sepalani/wii-speak

IOS/USB: Emulate Wii Speak using cubeb
This commit is contained in:
JMC47
2025-05-21 13:54:56 -04:00
committed by GitHub
36 changed files with 1592 additions and 11 deletions
@@ -28,6 +28,9 @@
<uses-permission
android:name="android.permission.VIBRATE"
android:required="false"/>
<uses-permission
android:name="android.permission.RECORD_AUDIO"
android:required="false"/>
<application
android:name=".DolphinApplication"
@@ -2,6 +2,7 @@
package org.dolphinemu.dolphinemu;
import android.app.Activity;
import android.app.Application;
import android.content.Context;
import android.hardware.usb.UsbManager;
@@ -15,13 +16,15 @@ import org.dolphinemu.dolphinemu.utils.VolleyUtil;
public class DolphinApplication extends Application
{
private static DolphinApplication application;
private static ActivityTracker sActivityTracker;
@Override
public void onCreate()
{
super.onCreate();
application = this;
registerActivityLifecycleCallbacks(new ActivityTracker());
sActivityTracker = new ActivityTracker();
registerActivityLifecycleCallbacks(sActivityTracker);
VolleyUtil.init(getApplicationContext());
System.loadLibrary("main");
@@ -36,4 +39,9 @@ public class DolphinApplication extends Application
{
return application.getApplicationContext();
}
public static Activity getAppActivity()
{
return sActivityTracker.getCurrentActivity();
}
}
@@ -244,6 +244,18 @@ enum class BooleanSetting(
"EmulateInfinityBase",
false
),
MAIN_EMULATE_WII_SPEAK(
Settings.FILE_DOLPHIN,
Settings.SECTION_EMULATED_USB_DEVICES,
"EmulateWiiSpeak",
false
),
MAIN_WII_SPEAK_MUTED(
Settings.FILE_DOLPHIN,
Settings.SECTION_EMULATED_USB_DEVICES,
"WiiSpeakMuted",
true
),
MAIN_SHOW_GAME_TITLES(
Settings.FILE_DOLPHIN,
Settings.SECTION_INI_ANDROID,
@@ -930,7 +942,8 @@ enum class BooleanSetting(
MAIN_DSP_JIT,
MAIN_TIME_TRACKING,
MAIN_EMULATE_SKYLANDER_PORTAL,
MAIN_EMULATE_INFINITY_BASE
MAIN_EMULATE_INFINITY_BASE,
MAIN_EMULATE_WII_SPEAK
)
private val NOT_RUNTIME_EDITABLE: Set<BooleanSetting> =
HashSet(listOf(*NOT_RUNTIME_EDITABLE_ARRAY))
@@ -15,6 +15,7 @@ import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import com.google.android.material.datepicker.CalendarConstraints
@@ -59,6 +60,9 @@ class SettingsAdapter(
val settings: Settings?
get() = fragmentView.settings
val fragmentActivity: FragmentActivity
get() = fragmentView.fragmentActivity
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
@@ -896,6 +896,22 @@ class SettingsFragmentPresenter(
0
)
)
sl.add(
SwitchSetting(
context,
BooleanSetting.MAIN_EMULATE_WII_SPEAK,
R.string.emulate_wii_speak,
0
)
)
sl.add(
SwitchSetting(
context,
BooleanSetting.MAIN_WII_SPEAK_MUTED,
R.string.mute_wii_speak,
0
)
)
}
private fun addAdvancedSettings(sl: ArrayList<SettingsItem>) {
@@ -2,6 +2,7 @@
package org.dolphinemu.dolphinemu.features.settings.ui.viewholder
import android.app.Activity
import android.view.View
import android.widget.CompoundButton
import org.dolphinemu.dolphinemu.databinding.ListItemSettingSwitchBinding
@@ -10,6 +11,7 @@ import org.dolphinemu.dolphinemu.features.settings.model.view.SettingsItem
import org.dolphinemu.dolphinemu.features.settings.model.view.SwitchSetting
import org.dolphinemu.dolphinemu.features.settings.ui.SettingsAdapter
import org.dolphinemu.dolphinemu.utils.DirectoryInitialization
import org.dolphinemu.dolphinemu.utils.PermissionsHandler
import java.io.File
import java.util.*
@@ -57,6 +59,13 @@ class SwitchSettingViewHolder(
binding.settingSwitch.isEnabled = false
}
if (setting.setting === BooleanSetting.MAIN_EMULATE_WII_SPEAK && isChecked) {
if (!PermissionsHandler.hasRecordAudioPermission(itemView.context)) {
val currentActivity = adapter.fragmentActivity as Activity
PermissionsHandler.requestRecordAudioPermission(currentActivity)
}
}
adapter.onBooleanClick(setting, binding.settingSwitch.isChecked)
setStyle(binding.textSettingName, setting)
@@ -9,12 +9,15 @@ class ActivityTracker : ActivityLifecycleCallbacks {
private val resumedActivities = HashSet<Activity>()
private var backgroundExecutionAllowed = false
private var firstStart = true
var currentActivity : Activity? = null
private set
private fun isMainActivity(activity: Activity): Boolean {
return activity is MainView
}
override fun onActivityCreated(activity: Activity, bundle: Bundle?) {
currentActivity = activity
if (isMainActivity(activity)) {
firstStart = bundle == null
}
@@ -26,6 +29,7 @@ class ActivityTracker : ActivityLifecycleCallbacks {
}
override fun onActivityResumed(activity: Activity) {
currentActivity = activity
resumedActivities.add(activity)
if (!backgroundExecutionAllowed && !resumedActivities.isEmpty()) {
backgroundExecutionAllowed = true
@@ -34,6 +38,9 @@ class ActivityTracker : ActivityLifecycleCallbacks {
}
override fun onActivityPaused(activity: Activity) {
if (currentActivity === activity) {
currentActivity = null
}
resumedActivities.remove(activity)
if (backgroundExecutionAllowed && resumedActivities.isEmpty()) {
backgroundExecutionAllowed = false
@@ -2,6 +2,7 @@
package org.dolphinemu.dolphinemu.utils;
import android.app.Activity;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Build;
@@ -11,10 +12,16 @@ import androidx.core.content.ContextCompat;
import androidx.fragment.app.FragmentActivity;
import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE;
import static android.Manifest.permission.RECORD_AUDIO;
import org.dolphinemu.dolphinemu.R;
import org.dolphinemu.dolphinemu.DolphinApplication;
import org.dolphinemu.dolphinemu.NativeLibrary;
public class PermissionsHandler
{
public static final int REQUEST_CODE_WRITE_PERMISSION = 500;
public static final int REQUEST_CODE_RECORD_AUDIO = 501;
private static boolean sWritePermissionDenied = false;
public static void requestWritePermission(final FragmentActivity activity)
@@ -52,4 +59,32 @@ public class PermissionsHandler
{
return sWritePermissionDenied;
}
public static boolean hasRecordAudioPermission(Context context)
{
if (context == null)
context = DolphinApplication.getAppContext();
int hasRecordPermission = ContextCompat.checkSelfPermission(context, RECORD_AUDIO);
return hasRecordPermission == PackageManager.PERMISSION_GRANTED;
}
public static void requestRecordAudioPermission(Activity activity)
{
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M)
return;
if (activity == null)
{
// Calling from C++ code
activity = DolphinApplication.getAppActivity();
// Since the emulation (and cubeb) has already started, enabling the microphone permission
// now might require restarting the game to be effective. Warn the user about it.
NativeLibrary.displayAlertMsg(
activity.getString(R.string.wii_speak_permission_warning),
activity.getString(R.string.wii_speak_permission_warning_description),
false, true, false);
}
activity.requestPermissions(new String[]{RECORD_AUDIO}, REQUEST_CODE_RECORD_AUDIO);
}
}
@@ -948,4 +948,8 @@ It can efficiently compress both junk data and encrypted Wii data.
<string name="incompatible_figure_selected">Incompatible Figure Selected</string>
<string name="select_compatible_figure">Please select a compatible figure file</string>
<string name="emulate_wii_speak">Wii Speak</string>
<string name="mute_wii_speak">Mute Wii Speak</string>
<string name="wii_speak_permission_warning">Missing Microphone Permission</string>
<string name="wii_speak_permission_warning_description">Wii Speak emulation requires microphone permission. You might need to restart the game for the permission to be effective.</string>
</resources>
@@ -116,6 +116,10 @@ static jmethodID s_core_device_control_constructor;
static jclass s_input_detector_class;
static jfieldID s_input_detector_pointer;
static jclass s_permission_handler_class;
static jmethodID s_permission_handler_has_record_audio_permission;
static jmethodID s_permission_handler_request_record_audio_permission;
static jmethodID s_runnable_run;
namespace IDCache
@@ -538,6 +542,21 @@ jfieldID GetInputDetectorPointer()
return s_input_detector_pointer;
}
jclass GetPermissionHandlerClass()
{
return s_permission_handler_class;
}
jmethodID GetPermissionHandlerHasRecordAudioPermission()
{
return s_permission_handler_has_record_audio_permission;
}
jmethodID GetPermissionHandlerRequestRecordAudioPermission()
{
return s_permission_handler_request_record_audio_permission;
}
jmethodID GetRunnableRun()
{
return s_runnable_run;
@@ -765,6 +784,16 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved)
s_input_detector_pointer = env->GetFieldID(input_detector_class, "pointer", "J");
env->DeleteLocalRef(input_detector_class);
const jclass permission_handler_class =
env->FindClass("org/dolphinemu/dolphinemu/utils/PermissionsHandler");
s_permission_handler_class =
reinterpret_cast<jclass>(env->NewGlobalRef(permission_handler_class));
s_permission_handler_has_record_audio_permission = env->GetStaticMethodID(
permission_handler_class, "hasRecordAudioPermission", "(Landroid/content/Context;)Z");
s_permission_handler_request_record_audio_permission = env->GetStaticMethodID(
permission_handler_class, "requestRecordAudioPermission", "(Landroid/app/Activity;)V");
env->DeleteLocalRef(permission_handler_class);
const jclass runnable_class = env->FindClass("java/lang/Runnable");
s_runnable_run = env->GetMethodID(runnable_class, "run", "()V");
env->DeleteLocalRef(runnable_class);
@@ -804,5 +833,6 @@ JNIEXPORT void JNI_OnUnload(JavaVM* vm, void* reserved)
env->DeleteGlobalRef(s_core_device_class);
env->DeleteGlobalRef(s_core_device_control_class);
env->DeleteGlobalRef(s_input_detector_class);
env->DeleteGlobalRef(s_permission_handler_class);
}
}
@@ -115,6 +115,10 @@ jmethodID GetCoreDeviceControlConstructor();
jclass GetInputDetectorClass();
jfieldID GetInputDetectorPointer();
jclass GetPermissionHandlerClass();
jmethodID GetPermissionHandlerHasRecordAudioPermission();
jmethodID GetPermissionHandlerRequestRecordAudioPermission();
jmethodID GetRunnableRun();
} // namespace IDCache
+147 -1
View File
@@ -13,6 +13,10 @@
#include <cubeb/cubeb.h>
#ifdef _WIN32
#include <Objbase.h>
#endif
static void LogCallback(const char* format, ...)
{
auto* instance = Common::Log::LogManager::GetInstance();
@@ -47,7 +51,9 @@ static void DestroyContext(cubeb* ctx)
}
}
std::shared_ptr<cubeb> CubebUtils::GetContext()
namespace CubebUtils
{
std::shared_ptr<cubeb> GetContext()
{
static std::weak_ptr<cubeb> weak;
@@ -72,3 +78,143 @@ std::shared_ptr<cubeb> CubebUtils::GetContext()
weak = shared = {ctx, DestroyContext};
return shared;
}
std::vector<std::pair<std::string, std::string>> ListInputDevices()
{
std::vector<std::pair<std::string, std::string>> devices;
cubeb_device_collection collection;
auto cubeb_ctx = GetContext();
const int r = cubeb_enumerate_devices(cubeb_ctx.get(), CUBEB_DEVICE_TYPE_INPUT, &collection);
if (r != CUBEB_OK)
{
ERROR_LOG_FMT(AUDIO, "Error listing cubeb input devices");
return devices;
}
INFO_LOG_FMT(AUDIO, "Listing cubeb input devices:");
for (uint32_t i = 0; i < collection.count; i++)
{
const auto& info = collection.device[i];
const auto device_state = info.state;
const char* state_name = [device_state] {
switch (device_state)
{
case CUBEB_DEVICE_STATE_DISABLED:
return "disabled";
case CUBEB_DEVICE_STATE_UNPLUGGED:
return "unplugged";
case CUBEB_DEVICE_STATE_ENABLED:
return "enabled";
default:
return "unknown?";
}
}();
// According to cubeb_device_info definition in cubeb.h:
// > "Optional vendor name, may be NULL."
// In practice, it seems some other fields might be NULL as well.
static constexpr auto fmt_str = [](const char* ptr) constexpr -> const char* {
return (ptr == nullptr) ? "(null)" : ptr;
};
INFO_LOG_FMT(AUDIO,
"[{}] Device ID: {}\n"
"\tName: {}\n"
"\tGroup ID: {}\n"
"\tVendor: {}\n"
"\tState: {}",
i, fmt_str(info.device_id), fmt_str(info.friendly_name), fmt_str(info.group_id),
fmt_str(info.vendor_name), state_name);
if (info.device_id == nullptr)
continue; // Shouldn't happen
if (info.state == CUBEB_DEVICE_STATE_ENABLED)
{
devices.emplace_back(info.device_id, fmt_str(info.friendly_name));
}
}
cubeb_device_collection_destroy(cubeb_ctx.get(), &collection);
return devices;
}
cubeb_devid GetInputDeviceById(std::string_view id)
{
if (id.empty())
return nullptr;
cubeb_device_collection collection;
auto cubeb_ctx = CubebUtils::GetContext();
const int r = cubeb_enumerate_devices(cubeb_ctx.get(), CUBEB_DEVICE_TYPE_INPUT, &collection);
if (r != CUBEB_OK)
{
ERROR_LOG_FMT(AUDIO, "Error enumerating cubeb input devices");
return nullptr;
}
cubeb_devid device_id = nullptr;
for (uint32_t i = 0; i < collection.count; i++)
{
const auto& info = collection.device[i];
if (id.compare(info.device_id) == 0)
{
device_id = info.devid;
break;
}
}
if (device_id == nullptr)
{
WARN_LOG_FMT(AUDIO, "Failed to find selected input device, defaulting to system preferences");
}
cubeb_device_collection_destroy(cubeb_ctx.get(), &collection);
return device_id;
}
CoInitSyncWorker::CoInitSyncWorker([[maybe_unused]] std::string worker_name)
#ifdef _WIN32
: m_work_queue{std::move(worker_name)}
#endif
{
#ifdef _WIN32
m_work_queue.PushBlocking([this] {
const auto result = ::CoInitializeEx(nullptr, COINIT_MULTITHREADED | COINIT_DISABLE_OLE1DDE);
m_coinit_success = result == S_OK;
m_should_couninit = m_coinit_success || result == S_FALSE;
});
#endif
}
CoInitSyncWorker::~CoInitSyncWorker()
{
#ifdef _WIN32
if (m_should_couninit)
{
m_work_queue.PushBlocking([this] {
m_should_couninit = false;
CoUninitialize();
});
}
m_coinit_success = false;
#endif
}
bool CoInitSyncWorker::Execute(FunctionType f)
{
#ifdef _WIN32
if (!m_coinit_success)
return false;
m_work_queue.PushBlocking(f);
#else
f();
#endif
return true;
}
} // namespace CubebUtils
+30
View File
@@ -3,11 +3,41 @@
#pragma once
#include <functional>
#include <memory>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#ifdef _WIN32
#include "Common/WorkQueueThread.h"
#endif
struct cubeb;
namespace CubebUtils
{
std::shared_ptr<cubeb> GetContext();
std::vector<std::pair<std::string, std::string>> ListInputDevices();
const void* GetInputDeviceById(std::string_view id);
// Helper used to handle Windows COM library for cubeb WASAPI backend
class CoInitSyncWorker
{
public:
using FunctionType = std::function<void()>;
CoInitSyncWorker(std::string worker_name);
~CoInitSyncWorker();
bool Execute(FunctionType f);
#ifdef _WIN32
private:
Common::AsyncWorkThread m_work_queue;
bool m_coinit_success = false;
bool m_should_couninit = false;
#endif
};
} // namespace CubebUtils
+4
View File
@@ -429,12 +429,16 @@ add_library(core
IOS/USB/Common.h
IOS/USB/Emulated/Infinity.cpp
IOS/USB/Emulated/Infinity.h
IOS/USB/Emulated/Microphone.cpp
IOS/USB/Emulated/Microphone.h
IOS/USB/Emulated/Skylanders/Skylander.cpp
IOS/USB/Emulated/Skylanders/Skylander.h
IOS/USB/Emulated/Skylanders/SkylanderCrypto.cpp
IOS/USB/Emulated/Skylanders/SkylanderCrypto.h
IOS/USB/Emulated/Skylanders/SkylanderFigure.cpp
IOS/USB/Emulated/Skylanders/SkylanderFigure.h
IOS/USB/Emulated/WiiSpeak.cpp
IOS/USB/Emulated/WiiSpeak.h
IOS/USB/Host.cpp
IOS/USB/Host.h
IOS/USB/OH0/OH0.cpp
+10
View File
@@ -596,6 +596,16 @@ const Info<bool> MAIN_EMULATE_SKYLANDER_PORTAL{
const Info<bool> MAIN_EMULATE_INFINITY_BASE{
{System::Main, "EmulatedUSBDevices", "EmulateInfinityBase"}, false};
const Info<bool> MAIN_EMULATE_WII_SPEAK{{System::Main, "EmulatedUSBDevices", "EmulateWiiSpeak"},
false};
const Info<std::string> MAIN_WII_SPEAK_MICROPHONE{
{System::Main, "EmulatedUSBDevices", "WiiSpeakMicrophone"}, ""};
const Info<bool> MAIN_WII_SPEAK_MUTED{{System::Main, "EmulatedUSBDevices", "WiiSpeakMuted"}, true};
const Info<s16> MAIN_WII_SPEAK_VOLUME_MODIFIER{
{System::Main, "EmulatedUSBDevices", "WiiSpeakVolumeModifier"}, 0};
// The reason we need this function is because some memory card code
// expects to get a non-NTSC-K region even if we're emulating an NTSC-K Wii.
DiscIO::Region ToGameCubeRegion(DiscIO::Region region)
+4
View File
@@ -364,6 +364,10 @@ void SetUSBDeviceWhitelist(const std::set<std::pair<u16, u16>>& devices);
extern const Info<bool> MAIN_EMULATE_SKYLANDER_PORTAL;
extern const Info<bool> MAIN_EMULATE_INFINITY_BASE;
extern const Info<bool> MAIN_EMULATE_WII_SPEAK;
extern const Info<std::string> MAIN_WII_SPEAK_MICROPHONE;
extern const Info<bool> MAIN_WII_SPEAK_MUTED;
extern const Info<s16> MAIN_WII_SPEAK_VOLUME_MODIFIER;
// GameCube path utility functions
+2 -1
View File
@@ -88,6 +88,7 @@ constexpr std::array<const char*, NUM_HOTKEYS> s_hotkey_labels{{
_trans("Connect Balance Board"),
_trans("Toggle SD Card"),
_trans("Toggle USB Keyboard"),
_trans("Toggle Wii Speak Mute"),
_trans("Next Profile"),
_trans("Previous Profile"),
@@ -345,7 +346,7 @@ constexpr std::array<HotkeyGroupInfo, NUM_HOTKEY_GROUPS> s_groups_info = {
{_trans("Stepping"), HK_STEP, HK_SKIP},
{_trans("Program Counter"), HK_SHOW_PC, HK_SET_PC},
{_trans("Breakpoint"), HK_BP_TOGGLE, HK_MBP_ADD},
{_trans("Wii"), HK_TRIGGER_SYNC_BUTTON, HK_TOGGLE_USB_KEYBOARD},
{_trans("Wii"), HK_TRIGGER_SYNC_BUTTON, HK_TOGGLE_WII_SPEAK_MUTE},
{_trans("Controller Profile 1"), HK_NEXT_WIIMOTE_PROFILE_1, HK_PREV_GAME_WIIMOTE_PROFILE_1},
{_trans("Controller Profile 2"), HK_NEXT_WIIMOTE_PROFILE_2, HK_PREV_GAME_WIIMOTE_PROFILE_2},
{_trans("Controller Profile 3"), HK_NEXT_WIIMOTE_PROFILE_3, HK_PREV_GAME_WIIMOTE_PROFILE_3},
+1
View File
@@ -74,6 +74,7 @@ enum Hotkey
HK_BALANCEBOARD_CONNECT,
HK_TOGGLE_SD_CARD,
HK_TOGGLE_USB_KEYBOARD,
HK_TOGGLE_WII_SPEAK_MUTE,
HK_NEXT_WIIMOTE_PROFILE_1,
HK_PREV_WIIMOTE_PROFILE_1,
@@ -0,0 +1,376 @@
// Copyright 2025 Dolphin Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#include "Core/IOS/USB/Emulated/Microphone.h"
#include <algorithm>
#include <cmath>
#include <ranges>
#include <span>
#ifdef HAVE_CUBEB
#include <cubeb/cubeb.h>
#include "AudioCommon/CubebUtils.h"
#endif
#include "Common/Logging/Log.h"
#include "Common/MathUtil.h"
#include "Common/Swap.h"
#include "Core/Config/MainSettings.h"
#include "Core/Core.h"
#include "Core/IOS/USB/Emulated/WiiSpeak.h"
#include "Core/System.h"
#ifdef _WIN32
#include <Objbase.h>
#endif
#ifdef ANDROID
#include "jni/AndroidCommon/IDCache.h"
#endif
namespace IOS::HLE::USB
{
Microphone::Microphone(const WiiSpeakState& sampler) : m_sampler(sampler)
{
StreamInit();
}
Microphone::~Microphone()
{
StreamTerminate();
}
#ifndef HAVE_CUBEB
void Microphone::StreamInit()
{
}
void Microphone::StreamStart([[maybe_unused]] u32 sampling_rate)
{
}
void Microphone::StreamStop()
{
}
void Microphone::StreamTerminate()
{
}
#else
void Microphone::StreamInit()
{
if (!m_worker.Execute([this] { m_cubeb_ctx = CubebUtils::GetContext(); }))
{
ERROR_LOG_FMT(IOS_USB, "Failed to init Wii Speak stream");
return;
}
// TODO: Not here but rather inside the WiiSpeak device if possible?
StreamStart(m_sampler.DEFAULT_SAMPLING_RATE);
}
void Microphone::StreamTerminate()
{
StreamStop();
if (m_cubeb_ctx)
m_worker.Execute([this] { m_cubeb_ctx.reset(); });
}
static void StateCallback(cubeb_stream* stream, void* user_data, cubeb_state state)
{
}
void Microphone::StreamStart(u32 sampling_rate)
{
if (!m_cubeb_ctx)
return;
m_worker.Execute([this, sampling_rate] {
#ifdef ANDROID
JNIEnv* env = IDCache::GetEnvForThread();
if (jboolean result = env->CallStaticBooleanMethod(
IDCache::GetPermissionHandlerClass(),
IDCache::GetPermissionHandlerHasRecordAudioPermission(), nullptr);
result == JNI_FALSE)
{
env->CallStaticVoidMethod(IDCache::GetPermissionHandlerClass(),
IDCache::GetPermissionHandlerRequestRecordAudioPermission(),
nullptr);
}
#endif
cubeb_stream_params params{};
params.format = CUBEB_SAMPLE_S16LE;
params.rate = sampling_rate;
params.channels = 1;
params.layout = CUBEB_LAYOUT_MONO;
u32 minimum_latency;
if (cubeb_get_min_latency(m_cubeb_ctx.get(), &params, &minimum_latency) != CUBEB_OK)
{
WARN_LOG_FMT(IOS_USB, "Error getting minimum latency");
minimum_latency = 16;
}
cubeb_devid input_device =
CubebUtils::GetInputDeviceById(Config::Get(Config::MAIN_WII_SPEAK_MICROPHONE));
if (cubeb_stream_init(m_cubeb_ctx.get(), &m_cubeb_stream, "Dolphin Emulated Wii Speak",
input_device, &params, nullptr, nullptr,
std::max<u32>(16, minimum_latency), CubebDataCallback, StateCallback,
this) != CUBEB_OK)
{
ERROR_LOG_FMT(IOS_USB, "Error initializing cubeb stream");
return;
}
if (cubeb_stream_start(m_cubeb_stream) != CUBEB_OK)
{
ERROR_LOG_FMT(IOS_USB, "Error starting cubeb stream");
return;
}
INFO_LOG_FMT(IOS_USB, "started cubeb stream");
});
}
void Microphone::StreamStop()
{
if (!m_cubeb_stream)
return;
m_worker.Execute([this] {
if (cubeb_stream_stop(m_cubeb_stream) != CUBEB_OK)
ERROR_LOG_FMT(IOS_USB, "Error stopping cubeb stream");
cubeb_stream_destroy(m_cubeb_stream);
m_cubeb_stream = nullptr;
});
}
long Microphone::CubebDataCallback(cubeb_stream* stream, void* user_data, const void* input_buffer,
void* /*output_buffer*/, long nframes)
{
// Skip data when core isn't running
if (Core::GetState(Core::System::GetInstance()) != Core::State::Running)
return nframes;
// Skip data when HLE Wii Speak is muted
// TODO: Update cubeb and use cubeb_stream_set_input_mute
if (Config::Get(Config::MAIN_WII_SPEAK_MUTED))
return nframes;
auto* mic = static_cast<Microphone*>(user_data);
return mic->DataCallback(static_cast<const SampleType*>(input_buffer), nframes);
}
long Microphone::DataCallback(const SampleType* input_buffer, long nframes)
{
std::lock_guard lock(m_ring_lock);
// Skip data if sampling is off or mute is on
if (!m_sampler.sample_on || m_sampler.mute)
return nframes;
std::span<const SampleType> buffer(input_buffer, nframes);
const auto gain = ComputeGain(Config::Get(Config::MAIN_WII_SPEAK_VOLUME_MODIFIER));
const auto apply_gain = [gain](SampleType sample) {
return MathUtil::SaturatingCast<SampleType>(sample * gain);
};
for (const SampleType le_sample : std::ranges::transform_view(buffer, apply_gain))
{
UpdateLoudness(le_sample);
m_stream_buffer[m_stream_wpos] = Common::swap16(le_sample);
m_stream_wpos = (m_stream_wpos + 1) % STREAM_SIZE;
}
m_samples_avail += nframes;
if (m_samples_avail > STREAM_SIZE)
{
WARN_LOG_FMT(IOS_USB, "Wii Speak ring buffer is full, data will be lost!");
m_samples_avail = STREAM_SIZE;
}
return nframes;
}
#endif
u16 Microphone::ReadIntoBuffer(u8* ptr, u32 size)
{
static constexpr u32 SINGLE_READ_SIZE = BUFF_SIZE_SAMPLES * sizeof(SampleType);
// Avoid buffer overflow during memcpy
static_assert((STREAM_SIZE % BUFF_SIZE_SAMPLES) == 0,
"The STREAM_SIZE isn't a multiple of BUFF_SIZE_SAMPLES");
std::lock_guard lock(m_ring_lock);
u8* begin = ptr;
for (u8* end = begin + size; ptr < end; ptr += SINGLE_READ_SIZE, size -= SINGLE_READ_SIZE)
{
if (size < SINGLE_READ_SIZE || m_samples_avail < BUFF_SIZE_SAMPLES)
break;
SampleType* last_buffer = &m_stream_buffer[m_stream_rpos];
std::memcpy(ptr, last_buffer, SINGLE_READ_SIZE);
m_samples_avail -= BUFF_SIZE_SAMPLES;
m_stream_rpos += BUFF_SIZE_SAMPLES;
m_stream_rpos %= STREAM_SIZE;
}
return static_cast<u16>(ptr - begin);
}
u16 Microphone::GetLoudnessLevel() const
{
if (m_sampler.mute || Config::Get(Config::MAIN_WII_SPEAK_MUTED))
return 0;
return m_loudness_level;
}
// Based on graphical cues on Monster Hunter 3, the level seems properly displayed with values
// between 0 and 0x3a00.
//
// TODO: Proper hardware testing, documentation, formulas...
void Microphone::UpdateLoudness(const SampleType sample)
{
// Based on MH3 graphical cues, let's use a 0x4000 window
static const u32 WINDOW = 0x4000;
static const FloatType UNIT = (m_loudness.DB_MAX - m_loudness.DB_MIN) / WINDOW;
m_loudness.Update(sample);
if (m_loudness.samples_count >= m_loudness.SAMPLES_NEEDED)
{
const FloatType amp_db = m_loudness.GetAmplitudeDb();
m_loudness_level = static_cast<u16>((amp_db - m_loudness.DB_MIN) / UNIT);
#ifdef WII_SPEAK_LOG_STATS
m_loudness.LogStats();
#endif
m_loudness.Reset();
}
}
bool Microphone::HasData(u32 sample_count = BUFF_SIZE_SAMPLES) const
{
std::lock_guard lock(m_ring_lock);
return m_samples_avail >= sample_count;
}
Microphone::FloatType Microphone::ComputeGain(FloatType relative_db) const
{
return m_loudness.ComputeGain(relative_db);
}
void Microphone::SetSamplingRate(u32 sampling_rate)
{
StreamStop();
StreamStart(sampling_rate);
}
const Microphone::FloatType Microphone::Loudness::DB_MIN =
20 * std::log10(FloatType(1) / MAX_AMPLITUDE);
const Microphone::FloatType Microphone::Loudness::DB_MAX = 20 * std::log10(FloatType(1));
void Microphone::Loudness::Update(const SampleType sample)
{
++samples_count;
peak_min = std::min(sample, peak_min);
peak_max = std::max(sample, peak_max);
absolute_sum += std::abs(sample);
square_sum += std::pow(FloatType(sample), FloatType(2));
}
Microphone::SampleType Microphone::Loudness::GetPeak() const
{
return std::max(std::abs(peak_min), std::abs(peak_max));
}
Microphone::FloatType Microphone::Loudness::GetDecibel(FloatType value)
{
return 20 * std::log10(value);
}
Microphone::FloatType Microphone::Loudness::GetAmplitude() const
{
return GetPeak() / MAX_AMPLITUDE;
}
Microphone::FloatType Microphone::Loudness::GetAmplitudeDb() const
{
return GetDecibel(GetAmplitude());
}
Microphone::FloatType Microphone::Loudness::GetAbsoluteMean() const
{
return FloatType(absolute_sum) / samples_count;
}
Microphone::FloatType Microphone::Loudness::GetAbsoluteMeanDb() const
{
return GetDecibel(GetAbsoluteMean());
}
Microphone::FloatType Microphone::Loudness::GetRootMeanSquare() const
{
return std::sqrt(square_sum / samples_count);
}
Microphone::FloatType Microphone::Loudness::GetRootMeanSquareDb() const
{
return GetDecibel(GetRootMeanSquare());
}
Microphone::FloatType Microphone::Loudness::GetCrestFactor() const
{
const auto rms = GetRootMeanSquare();
if (rms == 0)
return FloatType(0);
return GetPeak() / rms;
}
Microphone::FloatType Microphone::Loudness::GetCrestFactorDb() const
{
return GetDecibel(GetCrestFactor());
}
Microphone::FloatType Microphone::Loudness::ComputeGain(FloatType db)
{
return std::pow(FloatType(10), db / 20);
}
void Microphone::Loudness::Reset()
{
samples_count = 0;
absolute_sum = 0;
square_sum = FloatType(0);
peak_min = 0;
peak_max = 0;
}
void Microphone::Loudness::LogStats()
{
const auto amplitude = GetAmplitude();
const auto amplitude_db = GetDecibel(amplitude);
const auto rms = GetRootMeanSquare();
const auto rms_db = GetDecibel(rms);
const auto abs_mean = GetAbsoluteMean();
const auto abs_mean_db = GetDecibel(abs_mean);
const auto crest_factor = GetCrestFactor();
const auto crest_factor_db = GetDecibel(crest_factor);
INFO_LOG_FMT(IOS_USB,
"Wii Speak loudness stats (sample count: {}/{}):\n"
" - min={} max={} amplitude={} ({} dB)\n"
" - rms={} ({} dB) \n"
" - abs_mean={} ({} dB)\n"
" - crest_factor={} ({} dB)",
samples_count, SAMPLES_NEEDED, peak_min, peak_max, amplitude, amplitude_db, rms,
rms_db, abs_mean, abs_mean_db, crest_factor, crest_factor_db);
}
} // namespace IOS::HLE::USB
@@ -0,0 +1,113 @@
// Copyright 2025 Dolphin Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <array>
#include <atomic>
#include <limits>
#include <memory>
#include <mutex>
#include <type_traits>
#include "AudioCommon/CubebUtils.h"
#include "Common/CommonTypes.h"
#ifdef HAVE_CUBEB
#include "AudioCommon/CubebUtils.h"
struct cubeb;
struct cubeb_stream;
#endif
namespace IOS::HLE::USB
{
struct WiiSpeakState;
class Microphone final
{
public:
using FloatType = float;
using SampleType = s16;
using UnsignedSampleType = std::make_unsigned_t<SampleType>;
Microphone(const WiiSpeakState& sampler);
~Microphone();
bool HasData(u32 sample_count) const;
u16 ReadIntoBuffer(u8* ptr, u32 size);
u16 GetLoudnessLevel() const;
FloatType ComputeGain(FloatType relative_db) const;
void SetSamplingRate(u32 sampling_rate);
private:
#ifdef HAVE_CUBEB
static long CubebDataCallback(cubeb_stream* stream, void* user_data, const void* input_buffer,
void* output_buffer, long nframes);
#endif
long DataCallback(const SampleType* input_buffer, long nframes);
void UpdateLoudness(SampleType sample);
void StreamInit();
void StreamTerminate();
void StreamStart(u32 sampling_rate);
void StreamStop();
static constexpr u32 BUFF_SIZE_SAMPLES = 32;
static constexpr u32 STREAM_SIZE = BUFF_SIZE_SAMPLES * 500;
std::array<SampleType, STREAM_SIZE> m_stream_buffer{};
u32 m_stream_wpos = 0;
u32 m_stream_rpos = 0;
u32 m_samples_avail = 0;
// TODO: Find how this level is calculated on real hardware
std::atomic<u16> m_loudness_level = 0;
struct Loudness
{
void Update(SampleType sample);
SampleType GetPeak() const;
static FloatType GetDecibel(FloatType value);
FloatType GetAmplitude() const;
FloatType GetAmplitudeDb() const;
FloatType GetAbsoluteMean() const;
FloatType GetAbsoluteMeanDb() const;
FloatType GetRootMeanSquare() const;
FloatType GetRootMeanSquareDb() const;
FloatType GetCrestFactor() const;
FloatType GetCrestFactorDb() const;
static FloatType ComputeGain(FloatType db);
void Reset();
void LogStats();
// Samples used to compute the loudness level (arbitrarily chosen)
static constexpr u16 SAMPLES_NEEDED = 128;
static_assert((SAMPLES_NEEDED % BUFF_SIZE_SAMPLES) == 0);
static constexpr FloatType MAX_AMPLITUDE =
UnsignedSampleType{std::numeric_limits<UnsignedSampleType>::max() / 2};
static const FloatType DB_MIN;
static const FloatType DB_MAX;
u16 samples_count = 0;
u32 absolute_sum = 0;
FloatType square_sum = FloatType(0);
SampleType peak_min = 0;
SampleType peak_max = 0;
};
Loudness m_loudness;
mutable std::mutex m_ring_lock;
const WiiSpeakState& m_sampler;
#ifdef HAVE_CUBEB
std::shared_ptr<cubeb> m_cubeb_ctx = nullptr;
cubeb_stream* m_cubeb_stream = nullptr;
CubebUtils::CoInitSyncWorker m_worker{"Wii Speak Worker"};
#endif
};
} // namespace IOS::HLE::USB

Some files were not shown because too many files have changed in this diff Show More