[PATCH v10 0/2] MR10906: sapi: Implement ISpVoice::SetPriority and GetPriority.
sapi: Implement ISpVoice::SetPriority and GetPriority. Previously both methods returned E_NOTIMPL. Store the priority on the speech_voice struct and validate the value against SPVPRI_NORMAL,\ SPVPRI_ALERT and SPVPRI_OVER. This unblocks Overwatch 2's Audio Captions accessibility TTS, which calls\ SetPriority during voice setup and aborts on any non-S_OK result. Add tests covering priority round-trips between SPVPRI_NORMAL, SPVPRI_ALERT and SPVPRI_OVER, and verify the default is SPVPRI_NORMAL. Wine-Bug: https://bugs.winehq.org/show_bug.cgi?id=59749 -- v10: https://gitlab.winehq.org/wine/wine/-/merge_requests/10906
From: Bohdan Tkachenko <bohdan@tkachenko.dev> Previously this method returned E_NOTIMPL. Populate SPVOICESTATUS from speech_voice state: - ulCurrentStream tracks the stream currently being processed by speak_proc (set at the start of the worker so it reflects the in-flight stream, and sticks at the last processed value when idle, per MSDN). Stored as last_stream_num on speech_voice. - ulLastStreamQueued reuses the existing cur_stream_num counter, which is incremented before each Speak queues a task. - hrLastResult is the HRESULT of the most recently completed speak (set at the end of speak_proc). - dwRunningState is derived from async_wait_queue_empty(&queue, 0) so it correctly reports SPRS_DONE both when idle and after a purge clears queued tasks. This unblocks Overwatch 2's Audio Captions accessibility TTS, which tight-polls GetStatus for SPRS_DONE between Speak calls; the previous E_NOTIMPL stub left the status struct untouched, leaving the game wedged forever waiting for SPRS_DONE. Add tests covering GetStatus with a NULL pointer and status fields before, during and after sync and async Speak calls. Wine-Bug: https://bugs.winehq.org/show_bug.cgi?id=59749 --- dlls/sapi/tests/tts.c | 40 ++++++++++++++++++++++++++++++++++++++++ dlls/sapi/tts.c | 38 ++++++++++++++++++++++++++++++++------ 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/dlls/sapi/tests/tts.c b/dlls/sapi/tests/tts.c index c89bffae5a0..429a2541528 100644 --- a/dlls/sapi/tests/tts.c +++ b/dlls/sapi/tests/tts.c @@ -494,6 +494,7 @@ static void test_spvoice(void) ISpDataKey *attrs_key; LONG rate; USHORT volume; + SPVOICESTATUS status; ULONG stream_num; DWORD regid; WAVEFORMATEX wfx; @@ -651,6 +652,17 @@ static void test_spvoice(void) hr = ISpVoice_SetVolume(voice, 101); ok(hr == E_INVALIDARG, "got %#lx.\n", hr); + hr = ISpVoice_GetStatus(voice, NULL, NULL); + ok(hr == S_OK, "got %#lx.\n", hr); + + memset(&status, 0xcc, sizeof(status)); + hr = ISpVoice_GetStatus(voice, &status, NULL); + ok(hr == S_OK, "got %#lx.\n", hr); + ok(status.dwRunningState == SPRS_DONE, "got %#lx.\n", status.dwRunningState); + ok(status.ulLastStreamQueued == 0, "got %lu.\n", status.ulLastStreamQueued); + ok(status.ulCurrentStream == 0, "got %lu.\n", status.ulCurrentStream); + ok(status.hrLastResult == S_OK, "got %#lx.\n", status.hrLastResult); + hr = CoRegisterClassObject(&CLSID_TestEngine, (IUnknown *)&test_engine_cf, CLSCTX_INPROC_SERVER, REGCLS_MULTIPLEUSE, ®id); ok(hr == S_OK, "got %#lx.\n", hr); @@ -732,6 +744,16 @@ static void test_spvoice(void) ok(hr == S_OK, "got %#lx.\n", hr); ok(duration < 200, "took %lu ms.\n", duration); + memset(&status, 0xcc, sizeof(status)); + hr = ISpVoice_GetStatus(voice, &status, NULL); + ok(hr == S_OK, "got %#lx.\n", hr); + ok(status.dwRunningState == SPRS_DONE, "got %#lx.\n", status.dwRunningState); + ok(status.ulLastStreamQueued == stream_num, "got %lu vs %lu.\n", + status.ulLastStreamQueued, stream_num); + ok(status.ulCurrentStream == stream_num, "got %lu vs %lu.\n", + status.ulCurrentStream, stream_num); + ok(status.hrLastResult == S_OK, "got %#lx.\n", status.hrLastResult); + reset_engine_params(&test_engine); test_engine.output_data = wave_data; test_engine.output_len = wave_len; @@ -744,6 +766,16 @@ static void test_spvoice(void) todo_wine ok(stream_num == 1, "got %lu.\n", stream_num); ok(duration < 500, "took %lu ms.\n", duration); + memset(&status, 0xcc, sizeof(status)); + hr = ISpVoice_GetStatus(voice, &status, NULL); + ok(hr == S_OK, "got %#lx.\n", hr); + /* dwRunningState may be 0 transiently on Windows before the worker + * picks up the queued task. */ + ok(status.dwRunningState == SPRS_IS_SPEAKING || status.dwRunningState == 0, + "got %#lx.\n", status.dwRunningState); + ok(status.ulLastStreamQueued == stream_num, "got %lu vs %lu.\n", + status.ulLastStreamQueued, stream_num); + hr = ISpVoice_WaitUntilDone(voice, 100); ok(hr == S_FALSE, "got %#lx.\n", hr); @@ -752,6 +784,14 @@ static void test_spvoice(void) ok(hr == S_OK, "got %#lx.\n", hr); ok(duration > 800 && duration < 3500, "took %lu ms.\n", duration); + memset(&status, 0xcc, sizeof(status)); + hr = ISpVoice_GetStatus(voice, &status, NULL); + ok(hr == S_OK, "got %#lx.\n", hr); + ok(status.dwRunningState == SPRS_DONE, "got %#lx.\n", status.dwRunningState); + ok(status.ulCurrentStream == stream_num, "got %lu vs %lu.\n", + status.ulCurrentStream, stream_num); + ok(status.hrLastResult == S_OK, "got %#lx.\n", status.hrLastResult); + ok(test_engine.speak_called, "ISpTTSEngine::Speak was not called.\n"); ok(test_engine.flags == SPF_NLP_SPEAK_PUNC, "got %#lx.\n", test_engine.flags); ok(test_engine.frag_count == 1, "got %Iu.\n", test_engine.frag_count); diff --git a/dlls/sapi/tts.c b/dlls/sapi/tts.c index 159635a4559..bf6bf0e0d0b 100644 --- a/dlls/sapi/tts.c +++ b/dlls/sapi/tts.c @@ -58,6 +58,8 @@ struct speech_voice LONG rate; SPVSTATE state; struct async_queue queue; + ULONG last_stream_num; + HRESULT last_hr; CRITICAL_SECTION cs; }; @@ -959,6 +961,11 @@ static void speak_proc(struct async_task *task) EnterCriticalSection(&This->cs); + /* Update ulCurrentStream tracking now, before doing any work — per MSDN + * SPVOICESTATUS.ulCurrentStream reports the stream currently being + * processed, and sticks at the last processed value once idle. */ + This->last_stream_num = site->stream_num; + if (This->actions & SPVES_ABORT) { LeaveCriticalSection(&This->cs); @@ -997,6 +1004,11 @@ done: ISpAudio_Release(audio); } CoTaskMemFree(wfx); + + EnterCriticalSection(&This->cs); + This->last_hr = hr; + LeaveCriticalSection(&This->cs); + ISpTTSEngine_Release(speak_task->engine); free_frag_list(speak_task->frag_list); ISpTTSEngineSite_Release(speak_task->site); @@ -1211,14 +1223,26 @@ static HRESULT WINAPI spvoice_SpeakStream(ISpVoice *iface, IStream *stream, DWOR static HRESULT WINAPI spvoice_GetStatus(ISpVoice *iface, SPVOICESTATUS *status, WCHAR **bookmark) { - static unsigned int once; + struct speech_voice *This = impl_from_ISpVoice(iface); - if (!once++) - FIXME("(%p, %p, %p): stub.\n", iface, status, bookmark); - else - WARN("(%p, %p, %p): stub.\n", iface, status, bookmark); + TRACE("(%p, %p, %p).\n", iface, status, bookmark); - return E_NOTIMPL; + if (bookmark) *bookmark = NULL; + + /* Windows accepts a NULL status pointer as a no-op returning S_OK. */ + if (!status) return S_OK; + + memset(status, 0, sizeof(*status)); + + EnterCriticalSection(&This->cs); + status->ulCurrentStream = This->last_stream_num; + status->ulLastStreamQueued = This->cur_stream_num; + status->hrLastResult = This->last_hr; + status->dwRunningState = (async_wait_queue_empty(&This->queue, 0) == WAIT_OBJECT_0) ? + SPRS_DONE : SPRS_IS_SPEAKING; + LeaveCriticalSection(&This->cs); + + return S_OK; } static HRESULT WINAPI spvoice_Skip(ISpVoice *iface, const WCHAR *type, LONG items, ULONG *skipped) @@ -1714,6 +1738,8 @@ HRESULT speech_voice_create(IUnknown *outer, REFIID iid, void **obj) This->actions = SPVES_CONTINUE; This->volume = 100; This->rate = 0; + This->last_stream_num = 0; + This->last_hr = S_OK; memset(&This->state, 0, sizeof(This->state)); This->state.Volume = 100; -- GitLab https://gitlab.winehq.org/wine/wine/-/merge_requests/10906
From: Bohdan Tkachenko <bohdan@tkachenko.dev> Previously both methods returned E_NOTIMPL. Store the priority on the speech_voice struct and validate the value against SPVPRI_NORMAL, SPVPRI_ALERT and SPVPRI_OVER. This unblocks Overwatch 2's Audio Captions accessibility TTS, which calls SetPriority during voice setup and aborts on any non-S_OK result. Add tests covering priority round-trips between SPVPRI_NORMAL, SPVPRI_ALERT and SPVPRI_OVER, and verify the default is SPVPRI_NORMAL. Wine-Bug: https://bugs.winehq.org/show_bug.cgi?id=59749 --- dlls/sapi/tests/tts.c | 24 ++++++++++++++++++++++++ dlls/sapi/tts.c | 29 +++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/dlls/sapi/tests/tts.c b/dlls/sapi/tests/tts.c index 429a2541528..3e374357ab1 100644 --- a/dlls/sapi/tests/tts.c +++ b/dlls/sapi/tests/tts.c @@ -495,6 +495,7 @@ static void test_spvoice(void) LONG rate; USHORT volume; SPVOICESTATUS status; + SPVPRIORITY priority; ULONG stream_num; DWORD regid; WAVEFORMATEX wfx; @@ -663,6 +664,29 @@ static void test_spvoice(void) ok(status.ulCurrentStream == 0, "got %lu.\n", status.ulCurrentStream); ok(status.hrLastResult == S_OK, "got %#lx.\n", status.hrLastResult); + priority = 0xdead; + hr = ISpVoice_GetPriority(voice, &priority); + ok(hr == S_OK, "got %#lx.\n", hr); + ok(priority == SPVPRI_NORMAL, "got %d.\n", priority); + + hr = ISpVoice_SetPriority(voice, SPVPRI_ALERT); + ok(hr == S_OK, "got %#lx.\n", hr); + + priority = 0xdead; + hr = ISpVoice_GetPriority(voice, &priority); + ok(hr == S_OK, "got %#lx.\n", hr); + ok(priority == SPVPRI_ALERT, "got %d.\n", priority); + + hr = ISpVoice_SetPriority(voice, SPVPRI_OVER); + ok(hr == S_OK, "got %#lx.\n", hr); + + priority = 0xdead; + hr = ISpVoice_GetPriority(voice, &priority); + ok(hr == S_OK, "got %#lx.\n", hr); + ok(priority == SPVPRI_OVER, "got %d.\n", priority); + + hr = ISpVoice_SetPriority(voice, SPVPRI_NORMAL); + ok(hr == S_OK, "got %#lx.\n", hr); hr = CoRegisterClassObject(&CLSID_TestEngine, (IUnknown *)&test_engine_cf, CLSCTX_INPROC_SERVER, REGCLS_MULTIPLEUSE, ®id); ok(hr == S_OK, "got %#lx.\n", hr); diff --git a/dlls/sapi/tts.c b/dlls/sapi/tts.c index bf6bf0e0d0b..11de8a7b2fa 100644 --- a/dlls/sapi/tts.c +++ b/dlls/sapi/tts.c @@ -60,6 +60,7 @@ struct speech_voice struct async_queue queue; ULONG last_stream_num; HRESULT last_hr; + SPVPRIORITY priority; CRITICAL_SECTION cs; }; @@ -1004,11 +1005,9 @@ done: ISpAudio_Release(audio); } CoTaskMemFree(wfx); - EnterCriticalSection(&This->cs); This->last_hr = hr; LeaveCriticalSection(&This->cs); - ISpTTSEngine_Release(speak_task->engine); free_frag_list(speak_task->frag_list); ISpTTSEngineSite_Release(speak_task->site); @@ -1254,16 +1253,33 @@ static HRESULT WINAPI spvoice_Skip(ISpVoice *iface, const WCHAR *type, LONG item static HRESULT WINAPI spvoice_SetPriority(ISpVoice *iface, SPVPRIORITY priority) { - FIXME("(%p, %d): stub.\n", iface, priority); + struct speech_voice *This = impl_from_ISpVoice(iface); - return E_NOTIMPL; + TRACE("(%p, %d).\n", iface, priority); + + if (priority != SPVPRI_NORMAL && priority != SPVPRI_ALERT && priority != SPVPRI_OVER) + return E_INVALIDARG; + + EnterCriticalSection(&This->cs); + This->priority = priority; + LeaveCriticalSection(&This->cs); + + return S_OK; } static HRESULT WINAPI spvoice_GetPriority(ISpVoice *iface, SPVPRIORITY *priority) { - FIXME("(%p, %p): stub.\n", iface, priority); + struct speech_voice *This = impl_from_ISpVoice(iface); - return E_NOTIMPL; + TRACE("(%p, %p).\n", iface, priority); + + if (!priority) return E_POINTER; + + EnterCriticalSection(&This->cs); + *priority = This->priority; + LeaveCriticalSection(&This->cs); + + return S_OK; } static HRESULT WINAPI spvoice_SetAlertBoundary(ISpVoice *iface, SPEVENTENUM boundary) @@ -1740,6 +1756,7 @@ HRESULT speech_voice_create(IUnknown *outer, REFIID iid, void **obj) This->rate = 0; This->last_stream_num = 0; This->last_hr = S_OK; + This->priority = SPVPRI_NORMAL; memset(&This->state, 0, sizeof(This->state)); This->state.Volume = 100; -- GitLab https://gitlab.winehq.org/wine/wine/-/merge_requests/10906
On Tue May 26 03:51:01 2026 +0000, Alexandre Julliard wrote:
The patches conflict, you should keep them both in the same MR, with one applying on top of the other. Moved both commits back into this MR and closed the other one.
-- https://gitlab.winehq.org/wine/wine/-/merge_requests/10906#note_141292
participants (2)
-
Bohdan Tkachenko -
Bohdan Tkachenko (@BohdanTkachenko)