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)