protonscr

protontts speaks SAPI bookmark names: WoW says start/end around every TTS message

protonclosed
ValveSoftware/Proton#10325 · opened 2026-10-28 by milevase93 · updated 2026-10-28 · 1 comments · github
Mmilevase93 2026-10-28 github

Upstream reporting context

Originally reported at https://github.com/CachyOS/proton-cachyos/issues/333. A CachyOS maintainer recommended reporting upstream: https://github.com/CachyOS/proton-cachyos/issues/333#issuecomment-6476908103.

Testing scope: Runtime reproduction and playback validation were performed on Proton-CachyOS, not on a stock Valve Proton release. Upstream involvement is inferred from the same missing SPVA_Bookmark handling in ValveSoftware/wine's proton_11.0 source. A stock-Proton runtime reproduction remains unverified. This report concerns the engine's bookmark handling and includes a standalone SAPI reproduction, rather than only a WoW compatibility report.

Problem

The Proton TTS engine speaks SAPI bookmark names as ordinary words. In World of Warcraft Retail, every TTS announcement is consequently surrounded by spoken "start" and "end".

This is reproducible outside WoW with a short SAPI script. It appears to be an upstream protontts issue rather than a WoW addon or audio configuration problem.

Environment

  • CachyOS, kernel 7.3.0-4-cachyos
  • Faugus Launcher / UMU
  • Proton-CachyOS cachyos-11.0-20261015-slr (unmodified engine reproduces)
  • 64-bit prefix
  • Proton Voice Files installed; PROTON_VOICE_FILES points to the installed model directory
  • Voice: Proton Voice - English (United States) - libritts-r 3
  • Microsoft native SAPI installed using Winetricks sapi, with the missing English and Universal PhoneConverters entries restored from the same Microsoft package
  • WoW uses winePlatformTTS=1

Native SAPI is relevant: Wine's builtin SAPI previously failed at ISpVoice::SetInterest, before synthesis. The issue reported here is the separate bookmark-synthesis problem after working SAPI is configured.

Minimal reproduction

Run the following .vbs with the 64-bit cscript.exe inside the configured prefix:

Set voice = CreateObject("SAPI.SpVoice")
Set voices = voice.GetVoices()
Set voice.Voice = voices.Item(0)
voice.Speak "You should hear this sentence without extra words.", 16
voice.Speak "<bookmark mark=""start""/>You should hear this sentence without extra words.<bookmark mark=""end""/>", 8

Flags 16 and 8 are SPF_IS_NOT_XML and SPF_IS_XML, respectively.

Expected: both calls speak only the sentence. Bookmarks produce SAPI events, not spoken audio.

Actual: the second call also speaks "start" and "end".

Cause

In dlls/protontts/tts.c, ttsengine_Speak walks SPVTEXTFRAG and sends pTextStart/ulTextLen to Piper without inspecting State.eAction. SPVA_Bookmark fragments therefore enter synthesis.

Microsoft's contract specifies that SPVA_Bookmark represents bookmark contents, which should produce SPEI_TTS_BOOKMARK:
https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ms717279(v=vs.85)

Source:
https://github.com/ValveSoftware/wine/blob/proton_11.0/dlls/protontts/tts.c

Locally tested candidate fix

A separate runner was created with only its 64-bit protontts.dll rebuilt. The existing Unix TTS backend was retained. The fix:

  • handles SPVA_Bookmark before synthesis;
  • copies and terminates the bookmark name because fragment strings have explicit lengths;
  • emits SPEI_TTS_BOOKMARK through ISpTTSEngineSite::AddEvents;
  • tracks written audio bytes for the event's stream offset;
  • skips audio synthesis for the bookmark fragment.

The rebuilt engine used proton_11.0 source commit e60892cc2398b0a83c332a2078c7b85e47bdb42b. This is the source used for the candidate rebuild, not a claim that the original runner was built from that exact commit.

Compilation and source checks passed. The affected user confirmed that the standalone plain/bookmarked speech test and WoW TTS work with the patched runner, without the spoken markers. This is local playback validation; broader regression testing and detailed event-consumer testing are still needed.

An AI coding assistant prepared the candidate patch and this report; the affected user performed the desktop playback tests.

Candidate patch

diff --git a/dlls/protontts/tts.c b/dlls/protontts/tts.c
index 0676293..4e2fe65 100644
--- a/dlls/protontts/tts.c
+++ b/dlls/protontts/tts.c
@@ -38,6 +38,9 @@
 
 WINE_DEFAULT_DEBUG_CHANNEL(protontts);
 
+/* SPEVENTLPARAMTYPE is not yet declared in Wine's SAPI headers. */
+#define SPET_LPARAM_IS_STRING 4
+
 DEFINE_GUID(SPDFID_WaveFormatEx, 0xc31adbae,0x527f,0x4ff5,0xa2,0x30,0xf6,0x2b,0xb6,0x1f,0xf7,0x0c);
 
 struct ttsengine
@@ -208,6 +211,7 @@ static HRESULT WINAPI ttsengine_Speak(ISpTTSEngine *iface, DWORD flags, REFGUID
     HANDLE thread = NULL;
     char *text = NULL;
     DWORD actions;
+    ULONGLONG audio_offset = 0;
     HRESULT hr = S_OK;
 
     TRACE("(%p, %#lx, %s, %p, %p, %p).\n", iface, flags, debugstr_guid(fmtid), wfx, frag_list, site);
@@ -226,7 +230,32 @@ static HRESULT WINAPI ttsengine_Speak(ISpTTSEngine *iface, DWORD flags, REFGUID
 
         actions = ISpTTSEngineSite_GetActions(site);
         if (actions & SPVES_ABORT)
-            return S_OK;
+            goto done;
+
+        if (frag_list->State.eAction == SPVA_Bookmark)
+        {
+            SPEVENT event = {0};
+            WCHAR *name;
+            SIZE_T count = (SIZE_T)frag_list->ulTextLen + 1;
+
+            if (!(name = CoTaskMemAlloc(count * sizeof(*name))))
+            {
+                hr = E_OUTOFMEMORY;
+                goto done;
+            }
+            memcpy(name, frag_list->pTextStart, frag_list->ulTextLen * sizeof(*name));
+            name[frag_list->ulTextLen] = 0;
+            event.eEventId = SPEI_TTS_BOOKMARK;
+            event.elParamType = SPET_LPARAM_IS_STRING;
+            event.ullAudioStreamOffset = audio_offset;
+            event.wParam = wcstol(name, NULL, 10);
+            event.lParam = (LPARAM)name;
+            hr = ISpTTSEngineSite_AddEvents(site, &event, 1);
+            CoTaskMemFree(name);
+            if (FAILED(hr)) goto done;
+            continue;
+        }
+
         if (actions & SPVES_RATE)
             ISpTTSEngineSite_GetRate(site, &global_rate);
 
@@ -271,8 +300,10 @@ static HRESULT WINAPI ttsengine_Speak(ISpTTSEngine *iface, DWORD flags, REFGUID
             tts_voice_audio_lock(This->voice, &buf, &size, &done);
             if (buf)
             {
+                ULONG written = 0;
                 adjust_volume(buf, size / sizeof(int16_t), global_volume * frag_list->State.Volume);
-                hr = ISpTTSEngineSite_Write(site, buf, size, NULL);
+                hr = ISpTTSEngineSite_Write(site, buf, size, &written);
+                audio_offset += written;
             }
             WINE_UNIX_CALL(unix_tts_voice_audio_release, &This->voice);
 
@@ -285,6 +316,8 @@ static HRESULT WINAPI ttsengine_Speak(ISpTTSEngine *iface, DWORD flags, REFGUID
 
         CloseHandle(thread);
         thread = NULL;
+        free(text);
+        text = NULL;
     }
 
 done:
Kkisak-valve maintainer 2026-10-28 github

Hello @milevase93, we're using one issue report per unofficially supported game title, so I've gone ahead and transferred this issue report to https://github.com/ValveSoftware/Proton/issues/8760#issuecomment-6478101023.