mirror of
https://github.com/spice2x/spice2x.github.io.git
synced 2026-08-01 22:30:42 -07:00
audio: implement wrapped IAudioClient3 (#511)
## Link to GitHub Issue, if one exists Fixes #509 ## Description of change Implement `WrappedIAudioClient3`. For now, we have only seen SDVX use it (EG final) and only when the shared mode patch is enabled. Exclusive still goes through version 1. This means that for V3 we can forego implementing backend conversion logic (for now). Only significant addition is `InitializeSharedAudioStream` and this IS called by SDVX when shared mode patch is enabled. ## Testing Tested EG final, tested year 1 data, also IIDX shared/exclusive wasapi.
This commit is contained in:
@@ -82,7 +82,10 @@ HRESULT STDMETHODCALLTYPE WrappedIMMDevice::Activate(
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (iid == IID_IAudioClient) {
|
// almost all games request IAudioClient
|
||||||
|
// so far we have not seen any games request IAudioClient2
|
||||||
|
// SDVX EG Final uses IID_IAudioClient3, but only when shared mode patch is on
|
||||||
|
if (iid == IID_IAudioClient || iid == IID_IAudioClient3) {
|
||||||
|
|
||||||
// prevent initialization recursion when using some ASIO backends that proxy to DirectSound, WASAPI, or WDM
|
// prevent initialization recursion when using some ASIO backends that proxy to DirectSound, WASAPI, or WDM
|
||||||
// like ASIO4All or FlexASIO
|
// like ASIO4All or FlexASIO
|
||||||
@@ -92,23 +95,18 @@ HRESULT STDMETHODCALLTYPE WrappedIMMDevice::Activate(
|
|||||||
}
|
}
|
||||||
std::lock_guard initialize_guard(hooks::audio::INITIALIZE_LOCK, std::adopt_lock);
|
std::lock_guard initialize_guard(hooks::audio::INITIALIZE_LOCK, std::adopt_lock);
|
||||||
|
|
||||||
auto client = reinterpret_cast<IAudioClient *>(*ppInterface);
|
|
||||||
|
|
||||||
// release old audio client if initialized
|
// release old audio client if initialized
|
||||||
if (hooks::audio::CLIENT) {
|
if (hooks::audio::CLIENT) {
|
||||||
hooks::audio::CLIENT->Release();
|
hooks::audio::CLIENT->Release();
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
IAudioClient *client = nullptr;
|
||||||
ret = wrap_audio_client(pReal, dwClsCtx, pActivationParams, &client);
|
if (iid == IID_IAudioClient) {
|
||||||
if (FAILED(ret)) {
|
client = wrap_audio_client(reinterpret_cast<IAudioClient *>(*ppInterface));
|
||||||
return ret;
|
} else { // IID_IAudioClient3
|
||||||
|
client = wrap_audio_client3(reinterpret_cast<IAudioClient3 *>(*ppInterface));
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
client = wrap_audio_client(client);
|
|
||||||
|
|
||||||
*ppInterface = client;
|
*ppInterface = client;
|
||||||
|
|
||||||
// persist the audio client
|
// persist the audio client
|
||||||
hooks::audio::CLIENT = client;
|
hooks::audio::CLIENT = client;
|
||||||
hooks::audio::CLIENT->AddRef();
|
hooks::audio::CLIENT->AddRef();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
#include "hooks/audio/implementations/asio.h"
|
#include "hooks/audio/implementations/asio.h"
|
||||||
#include "hooks/audio/implementations/wave_out.h"
|
#include "hooks/audio/implementations/wave_out.h"
|
||||||
//#include "util/co_task_mem_ptr.h"
|
//#include "util/co_task_mem_ptr.h"
|
||||||
|
#include "util/utils.h"
|
||||||
|
|
||||||
#include "defs.h"
|
#include "defs.h"
|
||||||
#include "dummy_audio_client.h"
|
#include "dummy_audio_client.h"
|
||||||
@@ -40,114 +41,9 @@ static void fix_rec_format(WAVEFORMATEX *pFormat) {
|
|||||||
pFormat->nAvgBytesPerSec = pFormat->nSamplesPerSec * pFormat->nBlockAlign;
|
pFormat->nAvgBytesPerSec = pFormat->nSamplesPerSec * pFormat->nBlockAlign;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(felix): is it appropriate to automatically switch to shared mode? should we do a
|
|
||||||
// `MessageBox` to notify the user?
|
|
||||||
/*
|
|
||||||
static bool check_for_exclusive_access(IAudioClient *client) {
|
|
||||||
static bool checked_once = false;
|
|
||||||
static bool previous_check_result = false;
|
|
||||||
|
|
||||||
CoTaskMemPtr<WAVEFORMATEX> mix_format;
|
|
||||||
REFERENCE_TIME requested_duration = 0;
|
|
||||||
|
|
||||||
if (checked_once) {
|
|
||||||
return previous_check_result;
|
|
||||||
}
|
|
||||||
if (audio::BACKEND.has_value()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// scope function so it has access to the local static variables
|
|
||||||
auto set_result = [](bool result) {
|
|
||||||
checked_once = true;
|
|
||||||
previous_check_result = result;
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
HRESULT ret = client->GetMixFormat(mix_format.ppv());
|
|
||||||
if (FAILED(ret)) {
|
|
||||||
PRINT_FAILED_RESULT("IAudioClient::GetMixFormat", ret);
|
|
||||||
return set_result(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
log_info("audio::wasapi", "Mix Format:");
|
|
||||||
print_format(mix_format.data());
|
|
||||||
|
|
||||||
ret = client->IsFormatSupported(AUDCLNT_SHAREMODE_EXCLUSIVE, mix_format.data(), nullptr);
|
|
||||||
if (ret == AUDCLNT_E_UNSUPPORTED_FORMAT) {
|
|
||||||
auto mix_format_ex = reinterpret_cast<WAVEFORMATEXTENSIBLE *>(mix_format.data());
|
|
||||||
|
|
||||||
log_warning("audio::wasapi", "device does not natively support the mix format, converting to PCM");
|
|
||||||
|
|
||||||
if (mix_format->wFormatTag == WAVE_FORMAT_EXTENSIBLE &&
|
|
||||||
IsEqualGUID(GUID_KSDATAFORMAT_SUBTYPE_IEEE_FLOAT, mix_format_ex->SubFormat))
|
|
||||||
{
|
|
||||||
mix_format_ex->Format.wBitsPerSample = 16;
|
|
||||||
mix_format_ex->Format.nBlockAlign = mix_format_ex->Format.nChannels * (mix_format_ex->Format.wBitsPerSample / 8);
|
|
||||||
mix_format_ex->Format.nAvgBytesPerSec = mix_format_ex->Format.nSamplesPerSec * mix_format_ex->Format.nBlockAlign;
|
|
||||||
mix_format_ex->Samples.wValidBitsPerSample = 16;
|
|
||||||
mix_format_ex->SubFormat = GUID_KSDATAFORMAT_SUBTYPE_PCM;
|
|
||||||
} else if (mix_format->wFormatTag == WAVE_FORMAT_IEEE_FLOAT) {
|
|
||||||
mix_format->wBitsPerSample = 16;
|
|
||||||
mix_format->nBlockAlign = mix_format->nChannels * (mix_format->wBitsPerSample / 8);
|
|
||||||
mix_format->nAvgBytesPerSec = mix_format->nSamplesPerSec * mix_format->nBlockAlign;
|
|
||||||
mix_format->wFormatTag = WAVE_FORMAT_PCM;
|
|
||||||
} else {
|
|
||||||
log_warning("audio::wasapi", "mix format is not a floating point format");
|
|
||||||
return set_result(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
ret = client->IsFormatSupported(AUDCLNT_SHAREMODE_EXCLUSIVE, mix_format.data(), nullptr);
|
|
||||||
if (FAILED(ret)) {
|
|
||||||
log_warning("audio::wasapi", "mix format is not supported");
|
|
||||||
return set_result(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ret = client->GetDevicePeriod(nullptr, &requested_duration);
|
|
||||||
if (FAILED(ret)) {
|
|
||||||
PRINT_FAILED_RESULT("IAudioClient::GetDevicePeriod", ret);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
ret = client->Initialize(
|
|
||||||
AUDCLNT_SHAREMODE_EXCLUSIVE,
|
|
||||||
AUDCLNT_STREAMFLAGS_EVENTCALLBACK,
|
|
||||||
requested_duration,
|
|
||||||
requested_duration,
|
|
||||||
mix_format.data(),
|
|
||||||
nullptr);
|
|
||||||
|
|
||||||
if (ret == AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED || SUCCEEDED(ret)) {
|
|
||||||
log_info("audio::wasapi", "exclusive mode is available, disabling backend");
|
|
||||||
return set_result(true);
|
|
||||||
} else {
|
|
||||||
log_warning("audio::wasapi", "exclusive mode is not available, enabling backend, hr={}", FMT_HRESULT(ret));
|
|
||||||
}
|
|
||||||
|
|
||||||
return set_result(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
HRESULT wrap_audio_client(
|
|
||||||
IMMDevice *device,
|
|
||||||
DWORD cls_ctx,
|
|
||||||
PROPVARIANT *activation_params,
|
|
||||||
IAudioClient **audio_client)
|
|
||||||
{
|
|
||||||
auto exclusive_available = check_for_exclusive_access(*audio_client);
|
|
||||||
(*audio_client)->Stop();
|
|
||||||
(*audio_client)->Reset();
|
|
||||||
(*audio_client)->Release();
|
|
||||||
*audio_client = nullptr;
|
|
||||||
|
|
||||||
SAFE_CALL("IMMDevice", "Activate", device->Activate(
|
|
||||||
IID_IAudioClient,
|
|
||||||
cls_ctx,
|
|
||||||
activation_params,
|
|
||||||
reinterpret_cast<void **>(audio_client)));
|
|
||||||
*/
|
|
||||||
IAudioClient *wrap_audio_client(IAudioClient *audio_client) {
|
IAudioClient *wrap_audio_client(IAudioClient *audio_client) {
|
||||||
|
log_misc("audio::wasapi", "wrapping IAudioClient");
|
||||||
|
|
||||||
AudioBackend *backend = nullptr;
|
AudioBackend *backend = nullptr;
|
||||||
bool requires_dummy = false;
|
bool requires_dummy = false;
|
||||||
|
|
||||||
@@ -164,9 +60,6 @@ IAudioClient *wrap_audio_client(IAudioClient *audio_client) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//} else if (!exclusive_available) {
|
|
||||||
// backend = new WaveOutBackend();
|
|
||||||
//}
|
|
||||||
|
|
||||||
IAudioClient *new_client;
|
IAudioClient *new_client;
|
||||||
|
|
||||||
@@ -182,6 +75,23 @@ IAudioClient *wrap_audio_client(IAudioClient *audio_client) {
|
|||||||
|
|
||||||
return new_client;
|
return new_client;
|
||||||
}
|
}
|
||||||
|
IAudioClient3 *wrap_audio_client3(IAudioClient3 *audio_client) {
|
||||||
|
// TODO: ASIO backend for IAudioClient3, if there is a game that needs it
|
||||||
|
log_misc("audio::wasapi", "wrapping IAudioClient3");
|
||||||
|
|
||||||
|
if (hooks::audio::BACKEND.has_value()) {
|
||||||
|
log_fatal(
|
||||||
|
"audio::wasapi",
|
||||||
|
"IAudioClient3 does not currently support backends! clear -audiobackend and try again");
|
||||||
|
}
|
||||||
|
if (hooks::audio::USE_DUMMY) {
|
||||||
|
log_fatal(
|
||||||
|
"audio::wasapi",
|
||||||
|
"IAudioClient3 does not currently support dummy context, clear -audiodummy and try again");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WrappedIAudioClient(audio_client, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
// IUnknown
|
// IUnknown
|
||||||
HRESULT STDMETHODCALLTYPE WrappedIAudioClient::QueryInterface(REFIID riid, void **ppvObj) {
|
HRESULT STDMETHODCALLTYPE WrappedIAudioClient::QueryInterface(REFIID riid, void **ppvObj) {
|
||||||
@@ -190,8 +100,8 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::QueryInterface(REFIID riid, void
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (riid == IID_WrappedIAudioClient ||
|
if (riid == IID_WrappedIAudioClient ||
|
||||||
riid == IID_IAudioClient)
|
riid == IID_IAudioClient ||
|
||||||
{
|
riid == IID_IAudioClient3) {
|
||||||
this->AddRef();
|
this->AddRef();
|
||||||
*ppvObj = this;
|
*ppvObj = this;
|
||||||
|
|
||||||
@@ -282,20 +192,6 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::Initialize(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log_info("audio::wasapi", "IAudioClient::Initialize success, hr={}", FMT_HRESULT(ret));
|
log_info("audio::wasapi", "IAudioClient::Initialize success, hr={}", FMT_HRESULT(ret));
|
||||||
|
|
||||||
/*
|
|
||||||
if (ShareMode == AUDCLNT_SHAREMODE_SHARED) {
|
|
||||||
IAudioClockAdjustment *clock = nullptr;
|
|
||||||
|
|
||||||
SAFE_CALL("IAudioClient", "GetService", pReal->GetService(
|
|
||||||
IID_IAudioClockAdjustment,
|
|
||||||
reinterpret_cast<void **>(&clock)));
|
|
||||||
|
|
||||||
SAFE_CALL("IAudioClockAdjustment", "SetSampleRate", clock->SetSampleRate(
|
|
||||||
static_cast<float>(pFormat->nSamplesPerSec)));
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
copy_wave_format(&hooks::audio::FORMAT, pFormat);
|
copy_wave_format(&hooks::audio::FORMAT, pFormat);
|
||||||
|
|
||||||
return ret;
|
return ret;
|
||||||
@@ -482,3 +378,93 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::GetService(REFIID riid, void **pp
|
|||||||
|
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HRESULT STDMETHODCALLTYPE WrappedIAudioClient::IsOffloadCapable(
|
||||||
|
AUDIO_STREAM_CATEGORY Category,
|
||||||
|
BOOL *pbOffloadCapable) {
|
||||||
|
WRAP_VERBOSE;
|
||||||
|
|
||||||
|
CHECK_RESULT(pReal3->IsOffloadCapable(Category, pbOffloadCapable));
|
||||||
|
}
|
||||||
|
|
||||||
|
HRESULT STDMETHODCALLTYPE WrappedIAudioClient::SetClientProperties(
|
||||||
|
const AudioClientProperties *pProperties) {
|
||||||
|
|
||||||
|
WRAP_VERBOSE;
|
||||||
|
CHECK_RESULT(pReal3->SetClientProperties(pProperties));
|
||||||
|
}
|
||||||
|
|
||||||
|
HRESULT STDMETHODCALLTYPE WrappedIAudioClient::GetBufferSizeLimits(
|
||||||
|
const WAVEFORMATEX *pFormat,
|
||||||
|
BOOL bEventDriven,
|
||||||
|
REFERENCE_TIME *phnsMinBufferDuration,
|
||||||
|
REFERENCE_TIME *phnsMaxBufferDuration) {
|
||||||
|
|
||||||
|
WRAP_VERBOSE;
|
||||||
|
CHECK_RESULT(pReal3->GetBufferSizeLimits(
|
||||||
|
pFormat,
|
||||||
|
bEventDriven,
|
||||||
|
phnsMinBufferDuration,
|
||||||
|
phnsMaxBufferDuration));
|
||||||
|
}
|
||||||
|
|
||||||
|
HRESULT STDMETHODCALLTYPE WrappedIAudioClient::GetSharedModeEnginePeriod(
|
||||||
|
const WAVEFORMATEX *pFormat,
|
||||||
|
UINT32 *pDefaultPeriodInFrames,
|
||||||
|
UINT32 *pFundamentalPeriodInFrames,
|
||||||
|
UINT32 *pMinPeriodInFrames,
|
||||||
|
UINT32 *pMaxPeriodInFrames) {
|
||||||
|
|
||||||
|
WRAP_VERBOSE;
|
||||||
|
CHECK_RESULT(pReal3->GetSharedModeEnginePeriod(
|
||||||
|
pFormat,
|
||||||
|
pDefaultPeriodInFrames,
|
||||||
|
pFundamentalPeriodInFrames,
|
||||||
|
pMinPeriodInFrames,
|
||||||
|
pMaxPeriodInFrames));
|
||||||
|
}
|
||||||
|
|
||||||
|
HRESULT STDMETHODCALLTYPE WrappedIAudioClient::GetCurrentSharedModeEnginePeriod(
|
||||||
|
WAVEFORMATEX **ppFormat,
|
||||||
|
UINT32 *pCurrentPeriodInFrames) {
|
||||||
|
|
||||||
|
WRAP_VERBOSE;
|
||||||
|
CHECK_RESULT(pReal3->GetCurrentSharedModeEnginePeriod(
|
||||||
|
ppFormat,
|
||||||
|
pCurrentPeriodInFrames));
|
||||||
|
}
|
||||||
|
|
||||||
|
HRESULT STDMETHODCALLTYPE WrappedIAudioClient::InitializeSharedAudioStream(
|
||||||
|
DWORD StreamFlags,
|
||||||
|
UINT32 PeriodInFrames,
|
||||||
|
const WAVEFORMATEX *pFormat,
|
||||||
|
LPCGUID AudioSessionGuid) {
|
||||||
|
|
||||||
|
if (!pFormat) {
|
||||||
|
return E_POINTER;
|
||||||
|
}
|
||||||
|
|
||||||
|
// verbose output
|
||||||
|
log_info("audio::wasapi", "IAudioClient3::InitializeSharedAudioStream hook hit");
|
||||||
|
log_info("audio::wasapi", "... ShareMode : {}", share_mode_str(AUDCLNT_SHAREMODE_SHARED));
|
||||||
|
log_info("audio::wasapi", "... StreamFlags : {}", stream_flags_str(StreamFlags));
|
||||||
|
log_info("audio::wasapi", "... PeriodInFrames : {}", PeriodInFrames);
|
||||||
|
print_format(pFormat);
|
||||||
|
|
||||||
|
// call next
|
||||||
|
HRESULT ret = pReal3->InitializeSharedAudioStream(
|
||||||
|
StreamFlags,
|
||||||
|
PeriodInFrames,
|
||||||
|
pFormat,
|
||||||
|
AudioSessionGuid);
|
||||||
|
|
||||||
|
// check for failure
|
||||||
|
if (FAILED(ret)) {
|
||||||
|
PRINT_FAILED_RESULT("IAudioClient3", "InitializeSharedAudioStream", ret);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
log_info("audio::wasapi", "IAudioClient3::InitializeSharedAudioStream success, hr={}", FMT_HRESULT(ret));
|
||||||
|
copy_wave_format(&hooks::audio::FORMAT, pFormat);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,9 +16,16 @@ static const GUID IID_WrappedIAudioClient = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
IAudioClient *wrap_audio_client(IAudioClient *client);
|
IAudioClient *wrap_audio_client(IAudioClient *client);
|
||||||
|
IAudioClient3 *wrap_audio_client3(IAudioClient3 *client);
|
||||||
|
|
||||||
struct WrappedIAudioClient : IAudioClient {
|
struct WrappedIAudioClient : IAudioClient3 {
|
||||||
explicit WrappedIAudioClient(IAudioClient *orig, AudioBackend *backend) : pReal(orig), backend(backend) {
|
|
||||||
|
explicit WrappedIAudioClient(IAudioClient3 *orig3, AudioBackend *backend) :
|
||||||
|
pReal(orig3), pReal3(orig3), backend(backend) {
|
||||||
|
}
|
||||||
|
|
||||||
|
explicit WrappedIAudioClient(IAudioClient *orig, AudioBackend *backend) :
|
||||||
|
pReal(orig), pReal3(nullptr), backend(backend) {
|
||||||
}
|
}
|
||||||
|
|
||||||
WrappedIAudioClient(const WrappedIAudioClient &) = delete;
|
WrappedIAudioClient(const WrappedIAudioClient &) = delete;
|
||||||
@@ -47,9 +54,43 @@ struct WrappedIAudioClient : IAudioClient {
|
|||||||
HRESULT STDMETHODCALLTYPE GetService(REFIID riid, void **ppv) override;
|
HRESULT STDMETHODCALLTYPE GetService(REFIID riid, void **ppv) override;
|
||||||
#pragma endregion
|
#pragma endregion
|
||||||
|
|
||||||
IAudioClient *const pReal;
|
#pragma region IAudioClient2
|
||||||
AudioBackend *const backend;
|
HRESULT STDMETHODCALLTYPE IsOffloadCapable(
|
||||||
|
AUDIO_STREAM_CATEGORY Category,
|
||||||
|
BOOL *pbOffloadCapable) override;
|
||||||
|
|
||||||
|
HRESULT STDMETHODCALLTYPE SetClientProperties(
|
||||||
|
const AudioClientProperties *pProperties) override;
|
||||||
|
|
||||||
|
HRESULT STDMETHODCALLTYPE GetBufferSizeLimits(
|
||||||
|
const WAVEFORMATEX *pFormat,
|
||||||
|
BOOL bEventDriven,
|
||||||
|
REFERENCE_TIME *phnsMinBufferDuration,
|
||||||
|
REFERENCE_TIME *phnsMaxBufferDuration) override;
|
||||||
|
#pragma endregion
|
||||||
|
|
||||||
|
#pragma region IAudioClient3
|
||||||
|
HRESULT STDMETHODCALLTYPE GetSharedModeEnginePeriod(
|
||||||
|
const WAVEFORMATEX *pFormat,
|
||||||
|
UINT32 *pDefaultPeriodInFrames,
|
||||||
|
UINT32 *pFundamentalPeriodInFrames,
|
||||||
|
UINT32 *pMinPeriodInFrames,
|
||||||
|
UINT32 *pMaxPeriodInFrames) override;
|
||||||
|
|
||||||
|
HRESULT STDMETHODCALLTYPE GetCurrentSharedModeEnginePeriod(
|
||||||
|
WAVEFORMATEX **ppFormat,
|
||||||
|
UINT32 *pCurrentPeriodInFrames) override;
|
||||||
|
|
||||||
|
HRESULT STDMETHODCALLTYPE InitializeSharedAudioStream(
|
||||||
|
DWORD StreamFlags,
|
||||||
|
UINT32 PeriodInFrames,
|
||||||
|
const WAVEFORMATEX *pFormat,
|
||||||
|
LPCGUID AudioSessionGuid) override;
|
||||||
|
#pragma endregion
|
||||||
|
|
||||||
|
IAudioClient *const pReal;
|
||||||
|
IAudioClient3 *const pReal3;
|
||||||
|
AudioBackend *const backend;
|
||||||
bool exclusive_mode = false;
|
bool exclusive_mode = false;
|
||||||
int frame_size = 0;
|
int frame_size = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user