diff --git a/Source/Android/app/src/main/AndroidManifest.xml b/Source/Android/app/src/main/AndroidManifest.xml
index 6d33d6158a..a14e13b135 100644
--- a/Source/Android/app/src/main/AndroidManifest.xml
+++ b/Source/Android/app/src/main/AndroidManifest.xml
@@ -28,6 +28,9 @@
+
=
HashSet(listOf(*NOT_RUNTIME_EDITABLE_ARRAY))
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsAdapter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsAdapter.kt
index bcf68e67ec..6a8132d32d 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsAdapter.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsAdapter.kt
@@ -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) {
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentPresenter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentPresenter.kt
index ff82f4c63c..b961fed382 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentPresenter.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentPresenter.kt
@@ -892,6 +892,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) {
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt
index fc575ead48..ddcf1d39b5 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt
@@ -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)
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ActivityTracker.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ActivityTracker.kt
index 83a34e3e8c..3d56dcd1ba 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ActivityTracker.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ActivityTracker.kt
@@ -9,12 +9,15 @@ class ActivityTracker : ActivityLifecycleCallbacks {
private val resumedActivities = HashSet()
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
}
@@ -28,6 +31,7 @@ class ActivityTracker : ActivityLifecycleCallbacks {
}
override fun onActivityResumed(activity: Activity) {
+ currentActivity = activity
resumedActivities.add(activity)
if (!backgroundExecutionAllowed && !resumedActivities.isEmpty()) {
backgroundExecutionAllowed = true
@@ -36,6 +40,9 @@ class ActivityTracker : ActivityLifecycleCallbacks {
}
override fun onActivityPaused(activity: Activity) {
+ if (currentActivity === activity) {
+ currentActivity = null
+ }
resumedActivities.remove(activity)
if (backgroundExecutionAllowed && resumedActivities.isEmpty()) {
backgroundExecutionAllowed = false
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/PermissionsHandler.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/PermissionsHandler.java
index a4c69281f2..527139ada6 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/PermissionsHandler.java
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/PermissionsHandler.java
@@ -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);
+ }
}
diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml
index 1b08fc6475..7676a4ccb5 100644
--- a/Source/Android/app/src/main/res/values/strings.xml
+++ b/Source/Android/app/src/main/res/values/strings.xml
@@ -941,4 +941,8 @@ It can efficiently compress both junk data and encrypted Wii data.
Incompatible Figure Selected
Please select a compatible figure file
+ Wii Speak
+ Mute Wii Speak
+ Missing Microphone Permission
+ Wii Speak emulation requires microphone permission. You might need to restart the game for the permission to be effective.
diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp
index ed382745c0..08ec2c9804 100644
--- a/Source/Android/jni/AndroidCommon/IDCache.cpp
+++ b/Source/Android/jni/AndroidCommon/IDCache.cpp
@@ -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(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);
}
}
diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h
index 0b01d14b42..3cc8c11b5e 100644
--- a/Source/Android/jni/AndroidCommon/IDCache.h
+++ b/Source/Android/jni/AndroidCommon/IDCache.h
@@ -115,6 +115,10 @@ jmethodID GetCoreDeviceControlConstructor();
jclass GetInputDetectorClass();
jfieldID GetInputDetectorPointer();
+jclass GetPermissionHandlerClass();
+jmethodID GetPermissionHandlerHasRecordAudioPermission();
+jmethodID GetPermissionHandlerRequestRecordAudioPermission();
+
jmethodID GetRunnableRun();
} // namespace IDCache
diff --git a/Source/Core/AudioCommon/CubebUtils.cpp b/Source/Core/AudioCommon/CubebUtils.cpp
index 75b78f687f..9910234a80 100644
--- a/Source/Core/AudioCommon/CubebUtils.cpp
+++ b/Source/Core/AudioCommon/CubebUtils.cpp
@@ -16,6 +16,13 @@
#include
+#ifdef _WIN32
+#include
+
+#include "Common/Event.h"
+#include "Common/ScopeGuard.h"
+#endif
+
static ptrdiff_t s_path_cutoff_point = 0;
static void LogCallback(const char* format, ...)
@@ -49,7 +56,9 @@ static void DestroyContext(cubeb* ctx)
}
}
-std::shared_ptr CubebUtils::GetContext()
+namespace CubebUtils
+{
+std::shared_ptr GetContext()
{
static std::weak_ptr weak;
@@ -82,3 +91,146 @@ std::shared_ptr CubebUtils::GetContext()
weak = shared = {ctx, DestroyContext};
return shared;
}
+
+std::vector> ListInputDevices()
+{
+ std::vector> devices;
+
+ cubeb_device_collection collection;
+ auto cubeb_ctx = GetContext();
+ 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++)
+ {
+ auto& info = collection.device[i];
+ 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?";
+ }
+ }();
+
+ INFO_LOG_FMT(AUDIO,
+ "[{}] Device ID: {}\n"
+ "\tName: {}\n"
+ "\tGroup ID: {}\n"
+ "\tVendor: {}\n"
+ "\tState: {}",
+ i, info.device_id, info.friendly_name, info.group_id,
+ (info.vendor_name == nullptr) ? "(null)" : info.vendor_name, state_name);
+ if (info.state == CUBEB_DEVICE_STATE_ENABLED)
+ {
+ devices.emplace_back(info.device_id, 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 = GetContext();
+ 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++)
+ {
+ 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_view worker_name)
+#ifdef _WIN32
+ : m_work_queue
+{
+ worker_name, [](const CoInitSyncWorker::FunctionType& f) { f(); }
+}
+#endif
+{
+#ifdef _WIN32
+ Common::Event sync_event;
+ m_work_queue.EmplaceItem([this, &sync_event] {
+ Common::ScopeGuard sync_event_guard([&sync_event] { sync_event.Set(); });
+ auto result = ::CoInitializeEx(nullptr, COINIT_MULTITHREADED | COINIT_DISABLE_OLE1DDE);
+ m_coinit_success = result == S_OK;
+ m_should_couninit = result == S_OK || result == S_FALSE;
+ });
+ sync_event.Wait();
+#endif
+}
+
+CoInitSyncWorker::~CoInitSyncWorker()
+{
+#ifdef _WIN32
+ if (m_should_couninit)
+ {
+ Common::Event sync_event;
+ m_work_queue.EmplaceItem([this, &sync_event] {
+ Common::ScopeGuard sync_event_guard([&sync_event] { sync_event.Set(); });
+ m_should_couninit = false;
+ CoUninitialize();
+ });
+ sync_event.Wait();
+ }
+ m_coinit_success = false;
+#endif
+}
+
+bool CoInitSyncWorker::Execute(FunctionType f)
+{
+#ifdef _WIN32
+ if (!m_coinit_success)
+ return false;
+
+ Common::Event sync_event;
+ m_work_queue.EmplaceItem([&sync_event, f] {
+ Common::ScopeGuard sync_event_guard([&sync_event] { sync_event.Set(); });
+#endif
+ f();
+#ifdef _WIN32
+ });
+ sync_event.Wait();
+#endif
+ return true;
+}
+} // namespace CubebUtils
diff --git a/Source/Core/AudioCommon/CubebUtils.h b/Source/Core/AudioCommon/CubebUtils.h
index f0effc2e8f..ac60fd434f 100644
--- a/Source/Core/AudioCommon/CubebUtils.h
+++ b/Source/Core/AudioCommon/CubebUtils.h
@@ -5,10 +5,39 @@
#include
#include
+#include
+#include
+#include
+#include
+
+#ifdef _WIN32
+#include "Common/WorkQueueThread.h"
+#endif
struct cubeb;
namespace CubebUtils
{
std::shared_ptr GetContext();
+std::vector> 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;
+
+ CoInitSyncWorker(std::string_view worker_name);
+ ~CoInitSyncWorker();
+
+ bool Execute(FunctionType f);
+
+#ifdef _WIN32
+private:
+ Common::WorkQueueThread m_work_queue;
+ bool m_coinit_success = false;
+ bool m_should_couninit = false;
+#endif
+};
} // namespace CubebUtils
diff --git a/Source/Core/Core/CMakeLists.txt b/Source/Core/Core/CMakeLists.txt
index 6036777bbb..6c1e39bac3 100644
--- a/Source/Core/Core/CMakeLists.txt
+++ b/Source/Core/Core/CMakeLists.txt
@@ -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
diff --git a/Source/Core/Core/Config/MainSettings.cpp b/Source/Core/Core/Config/MainSettings.cpp
index 9f4d1cce17..f1b14be930 100644
--- a/Source/Core/Core/Config/MainSettings.cpp
+++ b/Source/Core/Core/Config/MainSettings.cpp
@@ -594,6 +594,16 @@ const Info MAIN_EMULATE_SKYLANDER_PORTAL{
const Info MAIN_EMULATE_INFINITY_BASE{
{System::Main, "EmulatedUSBDevices", "EmulateInfinityBase"}, false};
+const Info MAIN_EMULATE_WII_SPEAK{{System::Main, "EmulatedUSBDevices", "EmulateWiiSpeak"},
+ false};
+
+const Info MAIN_WII_SPEAK_MICROPHONE{
+ {System::Main, "EmulatedUSBDevices", "WiiSpeakMicrophone"}, ""};
+
+const Info MAIN_WII_SPEAK_MUTED{{System::Main, "EmulatedUSBDevices", "WiiSpeakMuted"}, true};
+const Info 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)
diff --git a/Source/Core/Core/Config/MainSettings.h b/Source/Core/Core/Config/MainSettings.h
index 28a044d253..1944b90f87 100644
--- a/Source/Core/Core/Config/MainSettings.h
+++ b/Source/Core/Core/Config/MainSettings.h
@@ -362,6 +362,10 @@ void SetUSBDeviceWhitelist(const std::set>& devices);
extern const Info MAIN_EMULATE_SKYLANDER_PORTAL;
extern const Info MAIN_EMULATE_INFINITY_BASE;
+extern const Info MAIN_EMULATE_WII_SPEAK;
+extern const Info MAIN_WII_SPEAK_MICROPHONE;
+extern const Info MAIN_WII_SPEAK_MUTED;
+extern const Info MAIN_WII_SPEAK_VOLUME_MODIFIER;
// GameCube path utility functions
diff --git a/Source/Core/Core/IOS/USB/Emulated/Microphone.cpp b/Source/Core/Core/IOS/USB/Emulated/Microphone.cpp
new file mode 100644
index 0000000000..e58812488d
--- /dev/null
+++ b/Source/Core/Core/IOS/USB/Emulated/Microphone.cpp
@@ -0,0 +1,370 @@
+// Copyright 2025 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "Core/IOS/USB/Emulated/Microphone.h"
+
+#include
+
+#include
+
+#ifdef HAVE_CUBEB
+#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
+#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 state_callback(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(), ¶ms, &minimum_latency) != CUBEB_OK)
+ {
+ WARN_LOG_FMT(IOS_USB, "Error getting minimum latency");
+ }
+
+ 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, ¶ms, nullptr, nullptr,
+ std::max(16, minimum_latency), DataCallback, state_callback,
+ 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::DataCallback(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(user_data);
+ const auto& sampler = mic->GetSampler();
+
+ // Skip data if sampling is off or mute is on
+ if (!sampler.sample_on || sampler.mute)
+ return nframes;
+
+ std::lock_guard lk(mic->m_ring_lock);
+ std::span buff_in(static_cast(input_buffer), nframes);
+ const auto gain = mic->ComputeGain(Config::Get(Config::MAIN_WII_SPEAK_VOLUME_MODIFIER));
+ auto processed_buff_in = buff_in | std::views::transform([gain](s16 sample) {
+ return MathUtil::SaturatingCast(sample * gain);
+ });
+
+ mic->UpdateLoudness(processed_buff_in);
+
+ for (s16 le_sample : processed_buff_in)
+ {
+ mic->m_stream_buffer[mic->m_stream_wpos] = Common::swap16(le_sample);
+ mic->m_stream_wpos = (mic->m_stream_wpos + 1) % mic->STREAM_SIZE;
+ }
+
+ mic->m_samples_avail += nframes;
+ if (mic->m_samples_avail > mic->STREAM_SIZE)
+ {
+ WARN_LOG_FMT(IOS_USB, "Wii Speak ring buffer is full, data will be lost!");
+ mic->m_samples_avail = 0;
+ }
+
+ 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 lk(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(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(std::ranges::input_range auto&& samples)
+{
+ // Based on MH3 graphical cues, let's use a 0x4000 window
+ static const u32 WINDOW = 0x4000;
+ static const float UNIT = (m_loudness.DB_MAX - m_loudness.DB_MIN) / WINDOW;
+
+ m_loudness.Update(samples);
+
+ if (m_loudness.samples_count >= m_loudness.SAMPLES_NEEDED)
+ {
+ const float amp_db = m_loudness.GetAmplitudeDb();
+ m_loudness_level = static_cast((amp_db - m_loudness.DB_MIN) / UNIT);
+
+#ifdef WII_SPEAK_LOG_STATS
+ m_loudness.LogStats();
+#else
+ DEBUG_LOG_FMT(IOS_USB,
+ "Wii Speak loudness stats (sample count: {}/{}):\n"
+ " - min={} max={} amplitude={} dB\n"
+ " - level={:04x}",
+ m_loudness.samples_count, m_loudness.SAMPLES_NEEDED, m_loudness.peak_min,
+ m_loudness.peak_max, amp_db, m_loudness_level);
+#endif
+
+ m_loudness.Reset();
+ }
+}
+
+bool Microphone::HasData(u32 sample_count = BUFF_SIZE_SAMPLES) const
+{
+ return m_samples_avail >= sample_count;
+}
+
+const WiiSpeakState& Microphone::GetSampler() const
+{
+ return m_sampler;
+}
+
+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));
+
+Microphone::Loudness::SampleType Microphone::Loudness::GetPeak() const
+{
+ return std::max(std::abs(peak_min), std::abs(peak_max));
+}
+
+Microphone::FloatType Microphone::Loudness::GetDecibel(FloatType value) const
+{
+ 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) const
+{
+ 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
diff --git a/Source/Core/Core/IOS/USB/Emulated/Microphone.h b/Source/Core/Core/IOS/USB/Emulated/Microphone.h
new file mode 100644
index 0000000000..4bb8229db4
--- /dev/null
+++ b/Source/Core/Core/IOS/USB/Emulated/Microphone.h
@@ -0,0 +1,131 @@
+// Copyright 2025 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#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;
+
+ Microphone(const WiiSpeakState& sampler);
+ ~Microphone();
+
+ bool HasData(u32 sample_count) const;
+ u16 ReadIntoBuffer(u8* ptr, u32 size);
+ u16 GetLoudnessLevel() const;
+ void UpdateLoudness(std::ranges::input_range auto&& samples);
+ const WiiSpeakState& GetSampler() const;
+ FloatType ComputeGain(FloatType relative_db) const;
+ void SetSamplingRate(u32 sampling_rate);
+
+private:
+#ifdef HAVE_CUBEB
+ static long DataCallback(cubeb_stream* stream, void* user_data, const void* input_buffer,
+ void* output_buffer, long nframes);
+#endif
+
+ void StreamInit();
+ void StreamTerminate();
+ void StreamStart(u32 sampling_rate);
+ void StreamStop();
+
+ using SampleType = s16;
+ static constexpr u32 BUFF_SIZE_SAMPLES = 32;
+ static constexpr u32 STREAM_SIZE = BUFF_SIZE_SAMPLES * 500;
+
+ std::array 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
+ u16 m_loudness_level = 0;
+ struct Loudness
+ {
+ using SampleType = s16;
+ using UnsignedSampleType = std::make_unsigned_t;
+
+ void Update(const auto& samples)
+ {
+ samples_count += static_cast(samples.size());
+
+ const auto [min_element, max_element] = std::ranges::minmax_element(samples);
+ peak_min = std::min(*min_element, peak_min);
+ peak_max = std::max(*max_element, peak_max);
+
+ const auto begin = samples.begin();
+ const auto end = samples.end();
+ absolute_sum = std::reduce(begin, end, absolute_sum,
+ [](u32 a, SampleType b) { return a + std::abs(b); });
+ square_sum = std::reduce(begin, end, square_sum, [](FloatType a, s16 b) {
+ return a + std::pow(FloatType(b), FloatType(2));
+ });
+ }
+
+ SampleType GetPeak() const;
+ FloatType GetDecibel(FloatType value) const;
+ FloatType GetAmplitude() const;
+ FloatType GetAmplitudeDb() const;
+ FloatType GetAbsoluteMean() const;
+ FloatType GetAbsoluteMeanDb() const;
+ FloatType GetRootMeanSquare() const;
+ FloatType GetRootMeanSquareDb() const;
+ FloatType GetCrestFactor() const;
+ FloatType GetCrestFactorDb() const;
+ FloatType ComputeGain(FloatType db) const;
+
+ 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 =
+ FloatType{std::numeric_limits::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;
+
+ std::mutex m_ring_lock;
+
+ const WiiSpeakState& m_sampler;
+
+#ifdef HAVE_CUBEB
+ std::shared_ptr m_cubeb_ctx = nullptr;
+ cubeb_stream* m_cubeb_stream = nullptr;
+ CubebUtils::CoInitSyncWorker m_worker{"Wii Speak Worker"};
+#endif
+};
+} // namespace IOS::HLE::USB
diff --git a/Source/Core/Core/IOS/USB/Emulated/WiiSpeak.cpp b/Source/Core/Core/IOS/USB/Emulated/WiiSpeak.cpp
new file mode 100644
index 0000000000..686906fd55
--- /dev/null
+++ b/Source/Core/Core/IOS/USB/Emulated/WiiSpeak.cpp
@@ -0,0 +1,423 @@
+// Copyright 2025 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "Core/IOS/USB/Emulated/WiiSpeak.h"
+
+#include
+
+#include "Core/HW/Memmap.h"
+#include "Core/System.h"
+
+namespace IOS::HLE::USB
+{
+WiiSpeak::WiiSpeak()
+{
+ m_id = u64(m_vid) << 32 | u64(m_pid) << 16 | u64(9) << 8 | u64(1);
+}
+
+WiiSpeak::~WiiSpeak() = default;
+
+DeviceDescriptor WiiSpeak::GetDeviceDescriptor() const
+{
+ return m_device_descriptor;
+}
+
+std::vector WiiSpeak::GetConfigurations() const
+{
+ return m_config_descriptor;
+}
+
+std::vector WiiSpeak::GetInterfaces(u8 config) const
+{
+ return m_interface_descriptor;
+}
+
+std::vector WiiSpeak::GetEndpoints(u8 config, u8 interface, u8 alt) const
+{
+ return m_endpoint_descriptor;
+}
+
+bool WiiSpeak::Attach()
+{
+ if (m_device_attached)
+ return true;
+
+ DEBUG_LOG_FMT(IOS_USB, "[{:04x}:{:04x}] Opening device", m_vid, m_pid);
+ if (!m_microphone)
+ m_microphone = std::make_unique(m_sampler);
+ m_device_attached = true;
+ return true;
+}
+
+bool WiiSpeak::AttachAndChangeInterface(const u8 interface)
+{
+ if (!Attach())
+ return false;
+
+ if (interface != m_active_interface)
+ return ChangeInterface(interface) == 0;
+
+ return true;
+}
+
+int WiiSpeak::CancelTransfer(const u8 endpoint)
+{
+ INFO_LOG_FMT(IOS_USB, "[{:04x}:{:04x} {}] Cancelling transfers (endpoint {:#x})", m_vid, m_pid,
+ m_active_interface, endpoint);
+
+ return IPC_SUCCESS;
+}
+
+int WiiSpeak::ChangeInterface(const u8 interface)
+{
+ DEBUG_LOG_FMT(IOS_USB, "[{:04x}:{:04x} {}] Changing interface to {}", m_vid, m_pid,
+ m_active_interface, interface);
+ m_active_interface = interface;
+ return 0;
+}
+
+int WiiSpeak::GetNumberOfAltSettings(u8 interface)
+{
+ return 0;
+}
+
+int WiiSpeak::SetAltSetting(u8 alt_setting)
+{
+ return 0;
+}
+
+int WiiSpeak::SubmitTransfer(std::unique_ptr cmd)
+{
+ DEBUG_LOG_FMT(IOS_USB,
+ "[{:04x}:{:04x} {}] Control: bRequestType={:02x} bRequest={:02x} wValue={:04x}"
+ " wIndex={:04x} wLength={:04x}",
+ m_vid, m_pid, m_active_interface, cmd->request_type, cmd->request, cmd->value,
+ cmd->index, cmd->length);
+
+ switch (cmd->request_type << 8 | cmd->request)
+ {
+ case USBHDR(DIR_DEVICE2HOST, TYPE_STANDARD, REC_INTERFACE, REQUEST_GET_INTERFACE):
+ {
+ constexpr u8 data{1};
+ cmd->FillBuffer(&data, sizeof(data));
+ cmd->ScheduleTransferCompletion(1, 100);
+ break;
+ }
+ case USBHDR(DIR_HOST2DEVICE, TYPE_STANDARD, REC_INTERFACE, REQUEST_SET_INTERFACE):
+ {
+ INFO_LOG_FMT(IOS_USB, "[{:04x}:{:04x} {}] REQUEST_SET_INTERFACE index={:04x} value={:04x}",
+ m_vid, m_pid, m_active_interface, cmd->index, cmd->value);
+ if (static_cast(cmd->index) != m_active_interface)
+ {
+ const int ret = ChangeInterface(static_cast(cmd->index));
+ if (ret < 0)
+ {
+ ERROR_LOG_FMT(IOS_USB, "[{:04x}:{:04x} {}] Failed to change interface to {}", m_vid, m_pid,
+ m_active_interface, cmd->index);
+ return ret;
+ }
+ }
+ const int ret = SetAltSetting(static_cast(cmd->value));
+ if (ret == 0)
+ cmd->GetEmulationKernel().EnqueueIPCReply(cmd->ios_request, cmd->length);
+ return ret;
+ }
+ case USBHDR(DIR_HOST2DEVICE, TYPE_VENDOR, REC_INTERFACE, 0):
+ {
+ init = false;
+ cmd->GetEmulationKernel().EnqueueIPCReply(cmd->ios_request, IPC_SUCCESS);
+ break;
+ }
+ case USBHDR(DIR_DEVICE2HOST, TYPE_VENDOR, REC_INTERFACE, REQUEST_GET_DESCRIPTOR):
+ {
+ if (!init)
+ {
+ constexpr u8 data{0};
+ cmd->FillBuffer(&data, sizeof(data));
+ cmd->GetEmulationKernel().EnqueueIPCReply(cmd->ios_request, IPC_SUCCESS);
+ init = true;
+ }
+ else
+ {
+ constexpr u8 data{1};
+ cmd->FillBuffer(&data, sizeof(data));
+ cmd->GetEmulationKernel().EnqueueIPCReply(cmd->ios_request, IPC_SUCCESS);
+ }
+ break;
+ }
+ case USBHDR(DIR_HOST2DEVICE, TYPE_VENDOR, REC_INTERFACE, 1):
+ SetRegister(cmd);
+ cmd->GetEmulationKernel().EnqueueIPCReply(cmd->ios_request, IPC_SUCCESS);
+ break;
+ case USBHDR(DIR_DEVICE2HOST, TYPE_VENDOR, REC_INTERFACE, 2):
+ GetRegister(cmd);
+ cmd->GetEmulationKernel().EnqueueIPCReply(cmd->ios_request, IPC_SUCCESS);
+ break;
+ default:
+ NOTICE_LOG_FMT(IOS_USB, "Unknown command");
+ cmd->GetEmulationKernel().EnqueueIPCReply(cmd->ios_request, IPC_SUCCESS);
+ }
+
+ return IPC_SUCCESS;
+}
+
+int WiiSpeak::SubmitTransfer(std::unique_ptr cmd)
+{
+ cmd->GetEmulationKernel().EnqueueIPCReply(cmd->ios_request, IPC_SUCCESS);
+ return IPC_SUCCESS;
+}
+
+int WiiSpeak::SubmitTransfer(std::unique_ptr cmd)
+{
+ cmd->GetEmulationKernel().EnqueueIPCReply(cmd->ios_request, IPC_SUCCESS);
+ return IPC_SUCCESS;
+}
+
+int WiiSpeak::SubmitTransfer(std::unique_ptr cmd)
+{
+ auto& system = cmd->GetEmulationKernel().GetSystem();
+ auto& memory = system.GetMemory();
+
+ u8* packets = memory.GetPointerForRange(cmd->data_address, cmd->length);
+ if (packets == nullptr)
+ {
+ ERROR_LOG_FMT(IOS_USB, "Wii Speak command invalid");
+ return IPC_EINVAL;
+ }
+
+ switch (cmd->endpoint)
+ {
+ case ENDPOINT_AUDIO_IN:
+ {
+ // Transfer: Wii Speak -> Wii
+ u16 size = 0;
+ if (m_microphone && m_microphone->HasData(cmd->length / sizeof(s16)))
+ size = m_microphone->ReadIntoBuffer(packets, cmd->length);
+ for (std::size_t i = 0; i < cmd->num_packets; i++)
+ {
+ cmd->SetPacketReturnValue(i, std::min(size, cmd->packet_sizes[i]));
+ size = (size > cmd->packet_sizes[i]) ? (size - cmd->packet_sizes[i]) : 0;
+ }
+ break;
+ }
+ case ENDPOINT_AUDIO_OUT:
+ // Transfer: Wii -> Wii Speak
+ break;
+ default:
+ WARN_LOG_FMT(IOS_USB, "Wii Speak unsupported isochronous transfer (endpoint={:02x})",
+ cmd->endpoint);
+ break;
+ }
+
+ // Transferring too slow causes the visual cue to not appear,
+ // while transferring too fast results in more choppy audio.
+ DEBUG_LOG_FMT(IOS_USB,
+ "Wii Speak isochronous transfer: length={:04x} endpoint={:02x} num_packets={:02x}",
+ cmd->length, cmd->endpoint, cmd->num_packets);
+
+ // According to the Wii Speak specs on wiibrew, it's "USB 2.0 Full-speed Device Module",
+ // so the length of a single frame should be 1 ms.
+ //
+ // Monster Hunter 3 and the Wii Speak Channel use cmd->length=0x100, allowing 256/2 samples
+ // (i.e. 128 samples in 16-bit mono) per frame transfer. The Microphone class is using cubeb
+ // configured with a sample rate of 8000.
+ //
+ // Based on USB sniffing using Wireshark + Dolphin USB passthrough:
+ // - 125 frames are received per second (i.e. timing 8 ms per frame)
+ // - however, the cmd->length=0x80 which doesn't match the HLE emulation
+ // - each frame having 8 packets of 0x10 bytes
+ //
+ // Let's sample at a reasonable speed.
+ const u32 transfer_timing = 2000;
+ cmd->ScheduleTransferCompletion(IPC_SUCCESS, transfer_timing);
+ return IPC_SUCCESS;
+}
+
+void WiiSpeak::SetRegister(const std::unique_ptr& cmd)
+{
+ auto& system = cmd->GetEmulationKernel().GetSystem();
+ auto& memory = system.GetMemory();
+ const u8 reg = memory.Read_U8(cmd->data_address + 1) & ~1;
+ const u16 arg1 = memory.Read_U16(cmd->data_address + 2);
+ const u16 arg2 = memory.Read_U16(cmd->data_address + 4);
+
+ DEBUG_LOG_FMT(IOS_USB, "Wii Speak register set (reg={:02x}, arg1={:04x}, arg2={:04x})", reg, arg1,
+ arg2);
+
+ switch (reg)
+ {
+ case SAMPLER_STATE:
+ m_sampler.sample_on = !!arg1;
+ break;
+ case SAMPLER_FREQ:
+ switch (arg1)
+ {
+ case FREQ_8KHZ:
+ m_sampler.freq = 8000;
+ break;
+ case FREQ_11KHZ:
+ m_sampler.freq = 11025;
+ break;
+ case FREQ_RESERVED:
+ default:
+ WARN_LOG_FMT(IOS_USB,
+ "Wii Speak unsupported SAMPLER_FREQ set (arg1={:04x}, arg2={:04x}) defaulting "
+ "to FREQ_16KHZ",
+ arg1, arg2);
+ [[fallthrough]];
+ case FREQ_16KHZ:
+ m_sampler.freq = 16000;
+ break;
+ }
+ if (m_microphone)
+ m_microphone->SetSamplingRate(m_sampler.freq);
+ break;
+ case SAMPLER_GAIN:
+ WARN_LOG_FMT(IOS_USB, "Wii Speak SAMPLER_GAIN set (arg1={:04x}, arg2={:04x}) not implemented",
+ arg1, arg2);
+ switch (arg1 & ~0x300)
+ {
+ case GAIN_00dB:
+ m_sampler.gain = 0;
+ break;
+ case GAIN_15dB:
+ m_sampler.gain = 15;
+ break;
+ case GAIN_30dB:
+ m_sampler.gain = 30;
+ break;
+ default:
+ WARN_LOG_FMT(IOS_USB,
+ "Wii Speak unsupported SAMPLER_GAIN set (arg1={:04x}, arg2={:04x}) defaulting "
+ "to GAIN_36dB",
+ arg1, arg2);
+ [[fallthrough]];
+ case GAIN_36dB:
+ m_sampler.gain = 36;
+ break;
+ }
+ break;
+ case EC_STATE:
+ m_sampler.ec_reset = !!arg1;
+ break;
+ case SP_STATE:
+ switch (arg1)
+ {
+ case SP_ENABLE:
+ m_sampler.sp_on = arg2 == 0;
+ break;
+ case SP_SIN:
+ case SP_SOUT:
+ case SP_RIN:
+ break;
+ default:
+ WARN_LOG_FMT(IOS_USB, "Wii Speak unsupported SP_STATE set (arg1={:04x}, arg2={:04x})", arg1,
+ arg2);
+ break;
+ }
+ break;
+ case SAMPLER_MUTE:
+ m_sampler.mute = !!arg1;
+ break;
+ default:
+ WARN_LOG_FMT(IOS_USB,
+ "Wii Speak unsupported register set (reg={:02x}, arg1={:04x}, arg2={:04x})", reg,
+ arg1, arg2);
+ break;
+ }
+}
+
+void WiiSpeak::GetRegister(const std::unique_ptr& cmd) const
+{
+ auto& system = cmd->GetEmulationKernel().GetSystem();
+ auto& memory = system.GetMemory();
+ const u8 reg = memory.Read_U8(cmd->data_address + 1) & ~1;
+ const u32 arg1 = cmd->data_address + 2;
+ const u32 arg2 = cmd->data_address + 4;
+
+ DEBUG_LOG_FMT(IOS_USB, "Wii Speak register get (reg={:02x}, arg1={:08x}, arg2={:08x})", reg, arg1,
+ arg2);
+
+ switch (reg)
+ {
+ case SAMPLER_STATE:
+ memory.Write_U16(m_sampler.sample_on ? 1 : 0, arg1);
+ break;
+ case SAMPLER_FREQ:
+ switch (m_sampler.freq)
+ {
+ case 8000:
+ memory.Write_U16(FREQ_8KHZ, arg1);
+ break;
+ case 11025:
+ memory.Write_U16(FREQ_11KHZ, arg1);
+ break;
+ default:
+ WARN_LOG_FMT(IOS_USB,
+ "Wii Speak unsupported SAMPLER_FREQ get (arg1={:04x}, arg2={:04x}) defaulting "
+ "to FREQ_16KHZ",
+ arg1, arg2);
+ [[fallthrough]];
+ case 16000:
+ memory.Write_U16(FREQ_16KHZ, arg1);
+ break;
+ }
+ break;
+ case SAMPLER_GAIN:
+ switch (m_sampler.gain)
+ {
+ case 0:
+ memory.Write_U16(0x300 | GAIN_00dB, arg1);
+ break;
+ case 15:
+ memory.Write_U16(0x300 | GAIN_15dB, arg1);
+ break;
+ case 30:
+ memory.Write_U16(0x300 | GAIN_30dB, arg1);
+ break;
+ default:
+ WARN_LOG_FMT(IOS_USB,
+ "Wii Speak unsupported SAMPLER_GAIN get (arg1={:04x}, arg2={:04x}) defaulting "
+ "to GAIN_36dB",
+ arg1, arg2);
+ [[fallthrough]];
+ case 36:
+ memory.Write_U16(0x300 | GAIN_36dB, arg1);
+ break;
+ }
+ break;
+ case EC_STATE:
+ memory.Write_U16(m_sampler.ec_reset ? 1 : 0, arg1);
+ break;
+ case SP_STATE:
+ switch (memory.Read_U16(arg1))
+ {
+ case SP_ENABLE:
+ memory.Write_U16(1, arg2);
+ break;
+ case SP_SIN:
+ break;
+ case SP_SOUT:
+ // TODO: Find how it was measured and how accurate it was
+ // memory.Write_U16(0x39B0, arg2); // 6dB
+ memory.Write_U16(m_microphone->GetLoudnessLevel(), arg2);
+ break;
+ case SP_RIN:
+ break;
+ default:
+ WARN_LOG_FMT(IOS_USB, "Wii Speak unsupported SP_STATE get (arg1={:04x}, arg2={:04x})", arg1,
+ arg2);
+ break;
+ }
+ break;
+ case SAMPLER_MUTE:
+ memory.Write_U16(m_sampler.mute ? 1 : 0, arg1);
+ break;
+ default:
+ WARN_LOG_FMT(IOS_USB,
+ "Wii Speak unsupported register get (reg={:02x}, arg1={:08x}, arg2={:08x})", reg,
+ arg1, arg2);
+ break;
+ }
+}
+} // namespace IOS::HLE::USB
diff --git a/Source/Core/Core/IOS/USB/Emulated/WiiSpeak.h b/Source/Core/Core/IOS/USB/Emulated/WiiSpeak.h
new file mode 100644
index 0000000000..b4d298f778
--- /dev/null
+++ b/Source/Core/Core/IOS/USB/Emulated/WiiSpeak.h
@@ -0,0 +1,101 @@
+// Copyright 2025 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include
+#include
+
+#include "Common/CommonTypes.h"
+#include "Core/IOS/USB/Common.h"
+#include "Core/IOS/USB/Emulated/Microphone.h"
+
+namespace IOS::HLE::USB
+{
+struct WiiSpeakState
+{
+ bool sample_on;
+ bool mute;
+ int freq;
+ int gain;
+ bool ec_reset;
+ bool sp_on;
+
+ static constexpr u32 DEFAULT_SAMPLING_RATE = 16000;
+};
+
+class WiiSpeak final : public Device
+{
+public:
+ WiiSpeak();
+ ~WiiSpeak();
+
+ DeviceDescriptor GetDeviceDescriptor() const override;
+ std::vector GetConfigurations() const override;
+ std::vector GetInterfaces(u8 config) const override;
+ std::vector GetEndpoints(u8 config, u8 interface, u8 alt) const override;
+ bool Attach() override;
+ bool AttachAndChangeInterface(u8 interface) override;
+ int CancelTransfer(u8 endpoint) override;
+ int ChangeInterface(u8 interface) override;
+ int GetNumberOfAltSettings(u8 interface) override;
+ int SetAltSetting(u8 alt_setting) override;
+ int SubmitTransfer(std::unique_ptr message) override;
+ int SubmitTransfer(std::unique_ptr message) override;
+ int SubmitTransfer(std::unique_ptr message) override;
+ int SubmitTransfer(std::unique_ptr message) override;
+
+private:
+ WiiSpeakState m_sampler{};
+
+ enum Registers
+ {
+ SAMPLER_STATE = 0,
+ SAMPLER_MUTE = 0x0c,
+
+ SAMPLER_FREQ = 2,
+ FREQ_8KHZ = 0,
+ FREQ_11KHZ = 1,
+ FREQ_RESERVED = 2,
+ FREQ_16KHZ = 3, // default
+
+ SAMPLER_GAIN = 4,
+ GAIN_00dB = 0,
+ GAIN_15dB = 1,
+ GAIN_30dB = 2,
+ GAIN_36dB = 3, // default
+
+ EC_STATE = 0x14,
+
+ SP_STATE = 0x38,
+ SP_ENABLE = 0x1010,
+ SP_SIN = 0x2001,
+ SP_SOUT = 0x2004,
+ SP_RIN = 0x200d
+ };
+
+ void GetRegister(const std::unique_ptr& cmd) const;
+ void SetRegister(const std::unique_ptr& cmd);
+
+ const u16 m_vid = 0x057E;
+ const u16 m_pid = 0x0308;
+ u8 m_active_interface = 0;
+ bool m_device_attached = false;
+ bool init = false;
+ std::unique_ptr m_microphone{};
+ const DeviceDescriptor m_device_descriptor{0x12, 0x1, 0x200, 0, 0, 0, 0x10,
+ 0x57E, 0x0308, 0x0214, 0x1, 0x2, 0x0, 0x1};
+ const std::vector m_config_descriptor{
+ {0x9, 0x2, 0x0030, 0x1, 0x1, 0x0, 0x80, 0x32}};
+ const std::vector m_interface_descriptor{
+ {0x9, 0x4, 0x0, 0x0, 0x0, 0xFF, 0xFF, 0xFF, 0x0},
+ {0x9, 0x4, 0x0, 0x01, 0x03, 0xFF, 0xFF, 0xFF, 0x0}};
+ static constexpr u8 ENDPOINT_AUDIO_IN = 0x81;
+ static constexpr u8 ENDPOINT_AUDIO_OUT = 0x3;
+ static constexpr u8 ENDPOINT_DATA_OUT = 0x2;
+ const std::vector m_endpoint_descriptor{
+ {0x7, 0x5, ENDPOINT_AUDIO_IN, 0x1, 0x0020, 0x1},
+ {0x7, 0x5, ENDPOINT_DATA_OUT, 0x2, 0x0020, 0},
+ {0x7, 0x5, ENDPOINT_AUDIO_OUT, 0x1, 0x0040, 0x1}};
+};
+} // namespace IOS::HLE::USB
diff --git a/Source/Core/Core/IOS/USB/USBScanner.cpp b/Source/Core/Core/IOS/USB/USBScanner.cpp
index 8a8ef2da0b..128e59f13c 100644
--- a/Source/Core/Core/IOS/USB/USBScanner.cpp
+++ b/Source/Core/Core/IOS/USB/USBScanner.cpp
@@ -23,6 +23,7 @@
#include "Core/IOS/USB/Common.h"
#include "Core/IOS/USB/Emulated/Infinity.h"
#include "Core/IOS/USB/Emulated/Skylanders/Skylander.h"
+#include "Core/IOS/USB/Emulated/WiiSpeak.h"
#include "Core/IOS/USB/Host.h"
#include "Core/IOS/USB/LibusbDevice.h"
#include "Core/NetPlayProto.h"
@@ -177,6 +178,11 @@ void USBScanner::AddEmulatedDevices(DeviceMap* new_devices)
auto infinity_base = std::make_unique();
AddDevice(std::move(infinity_base), new_devices);
}
+ if (Config::Get(Config::MAIN_EMULATE_WII_SPEAK) && !NetPlay::IsNetPlayRunning())
+ {
+ auto wii_speak = std::make_unique();
+ AddDevice(std::move(wii_speak), new_devices);
+ }
}
void USBScanner::AddDevice(std::unique_ptr device, DeviceMap* new_devices)
diff --git a/Source/Core/DolphinLib.props b/Source/Core/DolphinLib.props
index 67e3a15f4c..bd68021aaf 100644
--- a/Source/Core/DolphinLib.props
+++ b/Source/Core/DolphinLib.props
@@ -1,9 +1,12 @@
-
-
+
+
+
+
+
@@ -289,7 +292,6 @@
-
@@ -404,9 +406,11 @@
+
+
@@ -771,10 +775,13 @@
-
-
+
+
+
+
+
@@ -958,7 +965,6 @@
-
@@ -1069,9 +1075,11 @@
+
+
diff --git a/Source/Core/DolphinQt/CMakeLists.txt b/Source/Core/DolphinQt/CMakeLists.txt
index b6e1659d4f..f903773797 100644
--- a/Source/Core/DolphinQt/CMakeLists.txt
+++ b/Source/Core/DolphinQt/CMakeLists.txt
@@ -247,6 +247,8 @@ add_executable(dolphin-emu
DiscordHandler.h
DiscordJoinRequestDialog.cpp
DiscordJoinRequestDialog.h
+ EmulatedUSB/WiiSpeakWindow.cpp
+ EmulatedUSB/WiiSpeakWindow.h
FIFO/FIFOAnalyzer.cpp
FIFO/FIFOAnalyzer.h
FIFO/FIFOPlayerWindow.cpp
diff --git a/Source/Core/DolphinQt/DolphinQt.vcxproj b/Source/Core/DolphinQt/DolphinQt.vcxproj
index 41349a4cee..454208f066 100644
--- a/Source/Core/DolphinQt/DolphinQt.vcxproj
+++ b/Source/Core/DolphinQt/DolphinQt.vcxproj
@@ -157,6 +157,7 @@
+
@@ -374,6 +375,7 @@
+
diff --git a/Source/Core/DolphinQt/EmulatedUSB/WiiSpeakWindow.cpp b/Source/Core/DolphinQt/EmulatedUSB/WiiSpeakWindow.cpp
new file mode 100644
index 0000000000..f22fe93117
--- /dev/null
+++ b/Source/Core/DolphinQt/EmulatedUSB/WiiSpeakWindow.cpp
@@ -0,0 +1,149 @@
+// Copyright 2025 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "DolphinQt/EmulatedUSB/WiiSpeakWindow.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#ifdef HAVE_CUBEB
+#include "AudioCommon/CubebUtils.h"
+#endif
+#include "Core/Config/MainSettings.h"
+#include "Core/Core.h"
+#include "Core/System.h"
+#include "DolphinQt/Settings.h"
+
+WiiSpeakWindow::WiiSpeakWindow(QWidget* parent) : QWidget(parent)
+{
+ // i18n: Window for managing the Wii Speak microphone
+ setWindowTitle(tr("Wii Speak Manager"));
+ setObjectName(QStringLiteral("wii_speak_manager"));
+ setMinimumSize(QSize(700, 200));
+
+ CreateMainWindow();
+
+ connect(&Settings::Instance(), &Settings::EmulationStateChanged, this,
+ &WiiSpeakWindow::OnEmulationStateChanged);
+
+ installEventFilter(this);
+
+ OnEmulationStateChanged(Core::GetState(Core::System::GetInstance()));
+};
+
+WiiSpeakWindow::~WiiSpeakWindow() = default;
+
+void WiiSpeakWindow::CreateMainWindow()
+{
+ auto* main_layout = new QVBoxLayout();
+ auto* label = new QLabel();
+ label->setText(QStringLiteral("%1")
+ .arg(tr("Some settings cannot be changed when emulation is running.")));
+ main_layout->addWidget(label);
+
+ auto* checkbox_group = new QGroupBox();
+ auto* checkbox_layout = new QHBoxLayout();
+ checkbox_layout->setAlignment(Qt::AlignHCenter);
+ m_checkbox_enabled = new QCheckBox(tr("Emulate Wii Speak"), this);
+ m_checkbox_enabled->setChecked(Config::Get(Config::MAIN_EMULATE_WII_SPEAK));
+ connect(m_checkbox_enabled, &QCheckBox::toggled, this, &WiiSpeakWindow::EmulateWiiSpeak);
+ checkbox_layout->addWidget(m_checkbox_enabled);
+ checkbox_group->setLayout(checkbox_layout);
+ main_layout->addWidget(checkbox_group);
+
+ auto* config_group = new QGroupBox(tr("Microphone Configuration"));
+ auto* config_layout = new QHBoxLayout();
+
+ auto checkbox_mic_muted = new QCheckBox(tr("Mute"), this);
+ checkbox_mic_muted->setChecked(Config::Get(Config::MAIN_WII_SPEAK_MUTED));
+ connect(checkbox_mic_muted, &QCheckBox::toggled, this,
+ &WiiSpeakWindow::SetWiiSpeakConnectionState);
+ checkbox_mic_muted->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
+ config_layout->addWidget(checkbox_mic_muted);
+
+ auto* volume_layout = new QGridLayout();
+ static constexpr int FILTER_MIN = -50;
+ static constexpr int FILTER_MAX = 50;
+ const int volume_modifier =
+ std::clamp(Config::Get(Config::MAIN_WII_SPEAK_VOLUME_MODIFIER), FILTER_MIN, FILTER_MAX);
+ auto filter_slider = new QSlider(Qt::Horizontal, this);
+ auto slider_label = new QLabel(tr("Volume modifier (value: %1dB)").arg(volume_modifier));
+ connect(filter_slider, &QSlider::valueChanged, this, [slider_label](int value) {
+ Config::SetBaseOrCurrent(Config::MAIN_WII_SPEAK_VOLUME_MODIFIER, value);
+ slider_label->setText(tr("Volume modifier (value: %1dB)").arg(value));
+ });
+ filter_slider->setMinimum(FILTER_MIN);
+ filter_slider->setMaximum(FILTER_MAX);
+ filter_slider->setValue(volume_modifier);
+ filter_slider->setTickPosition(QSlider::TicksBothSides);
+ filter_slider->setTickInterval(10);
+ filter_slider->setSingleStep(1);
+ volume_layout->addWidget(new QLabel(QStringLiteral("%1dB").arg(FILTER_MIN)), 0, 0, Qt::AlignLeft);
+ volume_layout->addWidget(slider_label, 0, 1, Qt::AlignCenter);
+ volume_layout->addWidget(new QLabel(QStringLiteral("%1dB").arg(FILTER_MAX)), 0, 2,
+ Qt::AlignRight);
+ volume_layout->addWidget(filter_slider, 1, 0, 1, 3);
+ config_layout->addLayout(volume_layout);
+ config_layout->setStretch(1, 3);
+
+ m_combobox_microphones = new QComboBox();
+#ifndef HAVE_CUBEB
+ m_combobox_microphones->addItem(QLatin1String("(%1)").arg(tr("Audio backend unsupported")),
+ QString{});
+#else
+ m_combobox_microphones->addItem(QLatin1String("(%1)").arg(tr("Autodetect preferred microphone")),
+ QString{});
+ for (auto& [device_id, device_name] : CubebUtils::ListInputDevices())
+ {
+ const auto user_data = QString::fromStdString(device_id);
+ m_combobox_microphones->addItem(QString::fromStdString(device_name), user_data);
+ }
+#endif
+ connect(m_combobox_microphones, &QComboBox::currentIndexChanged, this,
+ &WiiSpeakWindow::OnInputDeviceChange);
+
+ auto current_device_id = QString::fromStdString(Config::Get(Config::MAIN_WII_SPEAK_MICROPHONE));
+ m_combobox_microphones->setCurrentIndex(m_combobox_microphones->findData(current_device_id));
+ config_layout->addWidget(m_combobox_microphones);
+
+ config_group->setLayout(config_layout);
+ main_layout->addWidget(config_group);
+
+ setLayout(main_layout);
+}
+
+void WiiSpeakWindow::EmulateWiiSpeak(bool emulate)
+{
+ Config::SetBaseOrCurrent(Config::MAIN_EMULATE_WII_SPEAK, emulate);
+}
+
+void WiiSpeakWindow::SetWiiSpeakConnectionState(bool muted)
+{
+ Config::SetBaseOrCurrent(Config::MAIN_WII_SPEAK_MUTED, muted);
+}
+
+void WiiSpeakWindow::OnEmulationStateChanged(Core::State state)
+{
+ const bool running = state != Core::State::Uninitialized;
+
+ m_checkbox_enabled->setEnabled(!running);
+ m_combobox_microphones->setEnabled(!running);
+}
+
+void WiiSpeakWindow::OnInputDeviceChange()
+{
+ auto user_data = m_combobox_microphones->currentData();
+ if (!user_data.isValid())
+ return;
+
+ const std::string device_id = user_data.toString().toStdString();
+ Config::SetBaseOrCurrent(Config::MAIN_WII_SPEAK_MICROPHONE, device_id);
+}
diff --git a/Source/Core/DolphinQt/EmulatedUSB/WiiSpeakWindow.h b/Source/Core/DolphinQt/EmulatedUSB/WiiSpeakWindow.h
new file mode 100644
index 0000000000..2da4887cc9
--- /dev/null
+++ b/Source/Core/DolphinQt/EmulatedUSB/WiiSpeakWindow.h
@@ -0,0 +1,29 @@
+// Copyright 2025 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include
+
+#include "Core/Core.h"
+
+class QCheckBox;
+class QComboBox;
+
+class WiiSpeakWindow : public QWidget
+{
+ Q_OBJECT
+public:
+ explicit WiiSpeakWindow(QWidget* parent = nullptr);
+ ~WiiSpeakWindow() override;
+
+private:
+ void CreateMainWindow();
+ void OnEmulationStateChanged(Core::State state);
+ void EmulateWiiSpeak(bool emulate);
+ void SetWiiSpeakConnectionState(bool connected);
+ void OnInputDeviceChange();
+
+ QCheckBox* m_checkbox_enabled;
+ QComboBox* m_combobox_microphones;
+};
diff --git a/Source/Core/DolphinQt/MainWindow.cpp b/Source/Core/DolphinQt/MainWindow.cpp
index 8a766dc6b7..31d589349a 100644
--- a/Source/Core/DolphinQt/MainWindow.cpp
+++ b/Source/Core/DolphinQt/MainWindow.cpp
@@ -94,6 +94,7 @@
#include "DolphinQt/Debugger/ThreadWidget.h"
#include "DolphinQt/Debugger/WatchWidget.h"
#include "DolphinQt/DiscordHandler.h"
+#include "DolphinQt/EmulatedUSB/WiiSpeakWindow.h"
#include "DolphinQt/FIFO/FIFOPlayerWindow.h"
#include "DolphinQt/GCMemcardManager.h"
#include "DolphinQt/GameList/GameList.h"
@@ -579,6 +580,7 @@ void MainWindow::ConnectMenuBar()
connect(m_menu_bar, &MenuBar::ShowFIFOPlayer, this, &MainWindow::ShowFIFOPlayer);
connect(m_menu_bar, &MenuBar::ShowSkylanderPortal, this, &MainWindow::ShowSkylanderPortal);
connect(m_menu_bar, &MenuBar::ShowInfinityBase, this, &MainWindow::ShowInfinityBase);
+ connect(m_menu_bar, &MenuBar::ShowWiiSpeakWindow, this, &MainWindow::ShowWiiSpeakWindow);
connect(m_menu_bar, &MenuBar::ConnectWiiRemote, this, &MainWindow::OnConnectWiiRemote);
#ifdef USE_RETRO_ACHIEVEMENTS
@@ -1438,6 +1440,18 @@ void MainWindow::ShowInfinityBase()
m_infinity_window->activateWindow();
}
+void MainWindow::ShowWiiSpeakWindow()
+{
+ if (!m_wii_speak_window)
+ {
+ m_wii_speak_window = new WiiSpeakWindow();
+ }
+
+ m_wii_speak_window->show();
+ m_wii_speak_window->raise();
+ m_wii_speak_window->activateWindow();
+}
+
void MainWindow::StateLoad()
{
QString dialog_path = (Config::Get(Config::MAIN_CURRENT_STATE_PATH).empty()) ?
diff --git a/Source/Core/DolphinQt/MainWindow.h b/Source/Core/DolphinQt/MainWindow.h
index a43f98ead0..536b30133f 100644
--- a/Source/Core/DolphinQt/MainWindow.h
+++ b/Source/Core/DolphinQt/MainWindow.h
@@ -52,6 +52,7 @@ class ThreadWidget;
class ToolBar;
class WatchWidget;
class WiiTASInputWindow;
+class WiiSpeakWindow;
struct WindowSystemInfo;
namespace Core
@@ -173,6 +174,7 @@ private:
void ShowFIFOPlayer();
void ShowSkylanderPortal();
void ShowInfinityBase();
+ void ShowWiiSpeakWindow();
void ShowMemcardManager();
void ShowResourcePackManager();
void ShowCheatsManager();
@@ -246,6 +248,7 @@ private:
FIFOPlayerWindow* m_fifo_window = nullptr;
SkylanderPortalWindow* m_skylander_window = nullptr;
InfinityBaseWindow* m_infinity_window = nullptr;
+ WiiSpeakWindow* m_wii_speak_window = nullptr;
MappingWindow* m_hotkey_window = nullptr;
FreeLookWindow* m_freelook_window = nullptr;
diff --git a/Source/Core/DolphinQt/MenuBar.cpp b/Source/Core/DolphinQt/MenuBar.cpp
index 232ea1e337..6f6919a142 100644
--- a/Source/Core/DolphinQt/MenuBar.cpp
+++ b/Source/Core/DolphinQt/MenuBar.cpp
@@ -279,6 +279,7 @@ void MenuBar::AddToolsMenu()
auto* usb_device_menu = new QMenu(tr("Emulated USB Devices"), tools_menu);
usb_device_menu->addAction(tr("&Skylanders Portal"), this, &MenuBar::ShowSkylanderPortal);
usb_device_menu->addAction(tr("&Infinity Base"), this, &MenuBar::ShowInfinityBase);
+ usb_device_menu->addAction(tr("&Wii Speak"), this, &MenuBar::ShowWiiSpeakWindow);
tools_menu->addMenu(usb_device_menu);
tools_menu->addSeparator();
diff --git a/Source/Core/DolphinQt/MenuBar.h b/Source/Core/DolphinQt/MenuBar.h
index c3309a2f54..8a73caf97c 100644
--- a/Source/Core/DolphinQt/MenuBar.h
+++ b/Source/Core/DolphinQt/MenuBar.h
@@ -94,6 +94,7 @@ signals:
void ShowResourcePackManager();
void ShowSkylanderPortal();
void ShowInfinityBase();
+ void ShowWiiSpeakWindow();
void ConnectWiiRemote(int id);
#ifdef USE_RETRO_ACHIEVEMENTS
diff --git a/Source/VSProps/Base.Dolphin.props b/Source/VSProps/Base.Dolphin.props
index 3410b9fef6..f2f213b009 100644
--- a/Source/VSProps/Base.Dolphin.props
+++ b/Source/VSProps/Base.Dolphin.props
@@ -3,6 +3,7 @@
$(ProjectName)$(TargetSuffix)
+ true
@@ -47,7 +48,7 @@
USE_RETRO_ACHIEVEMENTS;%(PreprocessorDefinitions)
RC_CLIENT_SUPPORTS_HASH;%(PreprocessorDefinitions)
RC_CLIENT_SUPPORTS_RAINTEGRATION;%(PreprocessorDefinitions)
- HAVE_CUBEB;%(PreprocessorDefinitions)
+ HAVE_CUBEB;%(PreprocessorDefinitions)