nvdaportable/NVDA/_synthDrivers32/sapi4.py

1268 lines
44 KiB
Python

# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2006-2025 NV Access Limited, Leonard de Ruijter, gexgd0419
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.
from datetime import datetime
from enum import IntEnum
from functools import wraps
import locale
from collections import OrderedDict, deque
import queue
import threading
import time
import winreg
import winBindings.ole32
from winBindings import user32
import winBindings.winmm
from winBindings.mmeapi import WAVEFORMATEX
from comtypes import CoCreateInstance, CoInitialize, COMObject, COMError, GUID, hresult, ReturnHRESULT
from ctypes import (
addressof,
byref,
c_ulong,
c_ulonglong,
POINTER,
c_void_p,
c_wchar,
cast,
create_string_buffer,
memmove,
string_at,
sizeof,
)
from ctypes.wintypes import BOOL, DWORD, FILETIME, HANDLE, MSG, WORD
from typing import TYPE_CHECKING, Callable, NamedTuple, Optional
import nvwave
from synthDriverHandler import (
SynthDriver,
VoiceInfo,
synthIndexReached,
synthDoneSpeaking,
isDebugForSynthDriver,
)
from logHandler import log
from ._sapi4 import (
MMSYSERR_NOERROR,
AudioError,
SDATA,
CLSID_MMAudioDest,
CLSID_TTSEnumerator,
DriverMessage,
IAudio,
IAudioDest,
IAudioDestNotifySink,
IAudioMultiMediaDevice,
ITTSAttributes,
ITTSBufNotifySink,
ITTSCentralW,
ITTSEnumW,
ITTSNotifySinkW,
TextSDATA,
TTSATTR_MAXPITCH,
TTSATTR_MAXSPEED,
TTSATTR_MAXVOLUME,
TTSATTR_MINPITCH,
TTSATTR_MINSPEED,
TTSATTR_MINVOLUME,
TTSDATAFLAG_TAGGED,
TTSFEATURE_PITCH,
TTSFEATURE_SPEED,
TTSFEATURE_VOLUME,
TTSMODEINFO,
VOICECHARSET,
)
import config
import weakref
from speech.commands import (
IndexCommand,
SpeechCommand,
CharacterModeCommand,
BreakCommand,
PitchCommand,
RateCommand,
SynthCommand,
VolumeCommand,
BaseProsodyCommand,
)
from speech.types import SpeechSequence
class SynthDriverBufSink(COMObject):
_com_interfaces_ = [ITTSBufNotifySink]
def __init__(self, synthRef: weakref.ReferenceType):
self.synthRef = synthRef
self._allowDelete = True
super().__init__()
def ITTSBufNotifySink_BookMark(self, this: int, qTimeStamp: int, dwMarkNum: int):
synth = self.synthRef()
if synth is None:
log.debugWarning(
"Called ITTSBufNotifySink_BookMark method on ITTSBufNotifySink while driver is dead",
)
return
synthIndexReached.notify(synth=synth, index=dwMarkNum)
if synth._finalIndex == dwMarkNum:
synth._finalIndex = None
synthDoneSpeaking.notify(synth=synth)
# remove already triggered bookmarks
while synth._bookmarks:
if synth._bookmarks.popleft() == dwMarkNum:
break
def IUnknown_Release(self, this: int, *args, **kwargs):
if not self._allowDelete and self._refcnt.value == 1:
log.debugWarning("ITTSBufNotifySink::Release called too many times by engine")
return 1
return super().IUnknown_Release(this, *args, **kwargs)
if TYPE_CHECKING:
from ctypes import _Pointer
c_ulonglong_p = _Pointer[c_ulonglong]
LP_IAudioDestNotifySink = _Pointer[IAudioDestNotifySink]
else:
c_ulonglong_p = POINTER(c_ulonglong)
LP_IAudioDestNotifySink = POINTER(IAudioDestNotifySink)
_Bookmark = NamedTuple("Bookmark", [("bytePos", int), ("id", int)])
_lastLoggedTimes: dict[Callable, float] = dict()
def _logTrace(logAll: bool = False, format: str = ""):
"""
Decorator that wraps the COM methods, logs the calls,
and converts COMError exceptions to silent ReturnHRESULTs.
:param logAll: If true, logs every call. If false (default), omits frequent calls to reduce logs. Errors are always logged.
:param format: Format specifier for log messages. Provided format arguments are: `args`, `kwargs`, and `result`.
"""
def _decorator(func):
@wraps(func)
def _wrapper(*args, **kwargs):
global _lastLoggedTimes
funcname = func.__name__.split("_")[1]
try:
result = func(*args, **kwargs)
if isDebugForSynthDriver():
if logAll:
_lastLoggedTimes.clear()
logTime = time.time()
# filter out calls to the same function within 10ms
if logAll or func not in _lastLoggedTimes or logTime - _lastLoggedTimes[func] > 0.01:
log.debug(
f"SAPI4: {funcname} {format.format(args=args, kwargs=kwargs, result=result)}",
)
_lastLoggedTimes[func] = logTime
return result
except COMError as e:
errcode = e.hresult
errtext = e.text
except ReturnHRESULT as e:
errcode, errtext = e.args
if isDebugForSynthDriver():
try:
err = AudioError(errcode).name
except ValueError:
err = f"{errcode:#x}"
log.debug(f"SAPI4: {funcname} failed with {err}")
raise ReturnHRESULT(errcode, errtext)
return _wrapper
return _decorator
class _AudioState(IntEnum):
INVALID = 0
UNCLAIMED = 1
CLAIMED = 2
STARTED = 3
UNCLAIMING = 4 # will change to CLAIMED after audio completes
RECLAIMING = 5 # will change to STARTED after audio completes
class _ComThreadTask:
def __init__(self, func: Callable, *args, **kwargs):
self.func = func
self.args = args
self.kwargs = kwargs
self.completed = threading.Event()
self.result = None
self.exception = None
class _ComThread(threading.Thread):
"""Thread dedicated to run all SAPI 4 COM-related code."""
def __init__(self):
super().__init__(name="Sapi4ComThread")
self._tasks: queue.SimpleQueue[_ComThreadTask] = queue.SimpleQueue()
self._ready = threading.Event()
self.start() # Start the thread immediately
self._ready.wait() # Wait for message queue to be created
def run(self):
msg = MSG()
# Force the message queue to be created first
PM_NOREMOVE = 0
user32.PeekMessage(byref(msg), None, 0, 0, PM_NOREMOVE)
CoInitialize()
self._ready.set()
# Run a message loop, as it's required by SAPI 4.
# When queueing a new task, post a message to this thread to wake it up.
# When done, post WM_QUIT to this thread.
while user32.GetMessage(byref(msg), None, 0, 0):
user32.TranslateMessage(byref(msg))
user32.DispatchMessage(byref(msg))
# Process queued tasks outside window procedures
# to avoid COM error RPC_E_CANTCALLOUT_INEXTERNALCALL
# (-2147418107, 0x80010005).
try:
while True:
task = self._tasks.get_nowait()
try:
task.result = task.func(*task.args, **task.kwargs)
except BaseException as e:
task.exception = e
finally:
completed = task.completed
del task
completed.set()
except queue.Empty:
pass
def stop(self):
WM_QUIT = 18
user32.PostThreadMessage(self.native_id, WM_QUIT, 0, 0)
self.join()
def submit(self, func: Callable, *args, **kwargs) -> _ComThreadTask:
"""Queue a function to be executed on this thread."""
if not self.is_alive():
raise RuntimeError("Thread has been stopped")
task = _ComThreadTask(func, *args, **kwargs)
self._tasks.put(task)
# post a message to wake up the thread
user32.PostThreadMessage(self.native_id, 0, 0, 0)
return task
def invoke(self, func: Callable, *args, **kwargs):
"""Invoke a function on this thread synchronously, and return its result."""
if threading.current_thread() is self:
# Call directly
return func(*args, **kwargs)
task = self.submit(func, *args, **kwargs)
task.completed.wait()
if task.exception is not None:
try:
raise task.exception
finally:
del task
return task.result
class _ComProxy:
"""Proxy for SAPI 4 COM object pointers that invokes all COM methods on the specified `_ComThread`.
All SAPI 4 COM objects should be wrapped by _ComProxy and run on the same _ComThread."""
def __init__(self, obj, thread: _ComThread):
"""Constructor.
:param obj: The COM object pointer to wrap.
:param thread: The COM thread to run all its COM method calls on. The object should be created on the same thread."""
self._obj = obj
self._thread = thread
def __getattr__(self, name: str):
attr = getattr(self._obj, name)
if not callable(attr):
return attr
@wraps(attr)
def _wrapper(*args, **kwargs):
return self._thread.invoke(attr, *args, **kwargs)
return _wrapper
def __del__(self):
# Release the object on the ComThread as well.
def _deleter():
self._obj = None
self._thread.invoke(_deleter)
BUFFER_LENGTH_S = 2
"""Length of SynthDriverAudio's internal buffer, in seconds.
SAPI4 requires the buffer to be at least 2 seconds."""
class SynthDriverAudio(COMObject):
"""
Implements IAudio and IAudioDest to receive streamed in audio data.
An instance of this class will be passed to,
and be used by the TTS engine.
Typically, an engine does the following things to output audio.
(Note that different engines may have different implementations)
- Initialize, such as setting wave format with `WaveFormatSet`, setting notify sink with `PassNotify`, etc.
- Call `Claim` to prepare the audio output.
- Call `DataSet` to prepare some initial audio data.
- Call `Start` to start playing.
- Call `DataSet` to provide more audio data,
and call `BookMark` when the engine want to know when audio reaches a specific point.
- Call `UnClaim` when all the audio has been written. The audio will still be played to the end.
- When pausing the audio, it calls `Stop` and `UnClaim`.
- When unpausing the audio, it calls `Claim` and `Start`.
- When resetting the audio, it calls `Stop`, `Flush`, and `UnClaim`.
`Stop` and `UnClaim` will not clear the buffer, but `Flush` will.
"""
_com_interfaces_ = [IAudio, IAudioDest]
def __init__(self, comThread: _ComThread):
"""Constructor.
:param comThread: The COM thread that `IAudioDestNotifySink` methods will be called on."""
if isDebugForSynthDriver():
log.debug("SAPI4: Initializing WASAPI implementation")
self._allowDelete = False
self._notifySink: LP_IAudioDestNotifySink | None = None
self._deviceState = _AudioState.INVALID
self._waveFormat: WAVEFORMATEX | None = None
self._player: nvwave.WavePlayer | None = None
self._writtenBytes = 0
self._playedBytes = 0
self._startTime = datetime.now()
self._startBytes = 0
self._freeBytes = 0
self._audioQueue: deque[bytes] = deque()
self._bookmarkQueue: deque[_Bookmark] = deque()
self._audioCond = threading.Condition()
self._audioStopped = False
self._audioThread = threading.Thread(target=self._audioThreadFunc, name="Sapi4AudioThread")
self._level = 0xFFFFFFFF # defaults to maximum value (0xFFFF) for both channels (low and high word)
self._comThread = comThread
def IUnknown_Release(self, this: int, *args, **kwargs) -> int:
if not self._allowDelete and self._refcnt.value == 1:
log.debugWarning("SynthDriverAudio was released too many times")
return 1
return super().IUnknown_Release(this, *args, **kwargs)
def terminate(self):
if isDebugForSynthDriver():
log.debug("SAPI4: Terminating audio")
with self._audioCond:
self._audioStopped = True
self._audioCond.notify()
if self._audioThread is not threading.current_thread() and self._audioThread.is_alive():
self._audioThread.join()
self._notifySink = None
self._allowDelete = True
def _queueNotification(self, func: Callable, *args, **kwargs) -> None:
"""Queue a notification to be sent to the engine via IAudioDestNotifySink.
:param func: The IAudioDestNotifySink member function to call.
:param ...: The arguments required by the member function.
"""
def _notify(*args, **kwargs):
try:
func(*args, **kwargs)
except COMError:
pass # Ignore returned HRESULT errors
self._comThread.submit(_notify, *args, **kwargs)
def _setLevel(self, level: int) -> None:
self._level = level
self._player.setVolume(
left=float(level & 0xFFFF) / 0xFFFF,
right=float(level >> 16) / 0xFFFF,
)
def _initPlayer(self) -> None:
"""Initialize audio playback based on the wave format provided by the engine."""
if isDebugForSynthDriver():
log.debug("SAPI4: Creating wave player")
self._player = nvwave.WavePlayer(
channels=self._waveFormat.nChannels,
samplesPerSec=self._waveFormat.nSamplesPerSec,
bitsPerSample=self._waveFormat.wBitsPerSample,
outputDevice=config.conf["audio"]["outputDevice"],
)
self._setLevel(self._level)
@_logTrace(logAll=True)
def IAudio_Flush(self) -> None:
"""Clears the object's internal buffer and resets the audio device,
but does not stop playing the audio data afterwards."""
if self._deviceState == _AudioState.INVALID:
raise ReturnHRESULT(AudioError.NEED_WAVE_FORMAT, None)
self._player.stop()
with self._audioCond:
if self._notifySink:
while self._bookmarkQueue:
bookmark = self._bookmarkQueue.popleft()
# Flush all untriggered bookmarks.
# 1 (TRUE) means that the bookmark is sent because of flushing.
self._queueNotification(self._notifySink.BookMark, bookmark.id, 1)
self._audioQueue.clear()
self._bookmarkQueue.clear()
self._freeBytes = self._waveFormat.nAvgBytesPerSec * BUFFER_LENGTH_S
# As byte positions can only increase,
# set _playedBytes to the current _writtenBytes
# to make sure that bookmarks that use byte positions still work.
self._playedBytes = self._writtenBytes
@_logTrace()
def IAudio_LevelGet(self) -> int:
"""Returns the volume level, ranging from 0x0000 to 0xFFFF.
Low word is for the left (or mono) channel, and high word is for the right channel."""
if self._deviceState == _AudioState.INVALID:
raise ReturnHRESULT(AudioError.NEED_WAVE_FORMAT, None)
return self._level
@_logTrace(format="{args[1]:#010x}")
def IAudio_LevelSet(self, dwLevel: int) -> None:
"""Sets the volume level, ranging from 0x0000 to 0xFFFF.
Low word is for the left (or mono) channel, and high word is for the right channel."""
if self._deviceState == _AudioState.INVALID:
raise ReturnHRESULT(AudioError.NEED_WAVE_FORMAT, None)
self._setLevel(dwLevel)
@_logTrace()
def IAudio_PassNotify(self, pNotifyInterface: c_void_p, IIDNotifyInterface: GUID) -> None:
"""Passes in an implementation of IAudioDestNotifySink to receive notifications.
The previous sink, if exists, will be released and replaced.
Allows specifying NULL for no sink."""
if IIDNotifyInterface != IAudioDestNotifySink._iid_:
raise ReturnHRESULT(AudioError.INVALID_NOTIFY_SINK, None)
if self._notifySink:
self._notifySink = None
if pNotifyInterface:
self._notifySink = cast(pNotifyInterface, LP_IAudioDestNotifySink)
@_logTrace()
def IAudio_PosnGet(self) -> int:
"""Returns the byte position currently being played,
which should increase monotonically and never reset."""
if self._deviceState == _AudioState.INVALID:
raise ReturnHRESULT(AudioError.NEED_WAVE_FORMAT, None)
return self._playedBytes
@_logTrace(logAll=True)
def IAudio_Claim(self) -> None:
"""Acquires (opens) the multimedia device.
Called before the engine wants to write audio data.
`IAudioDestNotifySink::AudioStart()` will be called to notify the engine.
Previous buffer should not be cleared.
If Claim is called before unclaiming completes, unclaiming is canceled,
and neither AudioStop nor AudioStart is notified."""
if self._deviceState == _AudioState.INVALID:
raise ReturnHRESULT(AudioError.NEED_WAVE_FORMAT, None)
elif self._deviceState == _AudioState.UNCLAIMING:
# cancels unclaiming
if isDebugForSynthDriver():
log.debug("SAPI4: Unclaiming cancelled")
self._deviceState = _AudioState.RECLAIMING
return
elif self._deviceState != _AudioState.UNCLAIMED:
raise ReturnHRESULT(AudioError.ALREADY_CLAIMED, None)
self._deviceState = _AudioState.CLAIMED
if self._notifySink:
self._queueNotification(self._notifySink.AudioStart)
@_logTrace(logAll=True)
def IAudio_UnClaim(self) -> None:
"""Releases the multimedia device asynchronously.
Called after the engine completes writing all audio data.
If there is audio in the buffer, it should still be played till the end.
`IAudioDestNotifySink::AudioStop()` will be called after the audio completely stops."""
if self._deviceState == _AudioState.INVALID:
raise ReturnHRESULT(AudioError.NEED_WAVE_FORMAT, None)
elif self._deviceState == _AudioState.CLAIMED:
# When not playing, this can finish immediately.
if self._writtenBytes == self._playedBytes and not self._audioQueue:
# If all audio is done playing, stop the player.
self._player.stop()
self._deviceState = _AudioState.UNCLAIMED
if self._notifySink:
self._queueNotification(self._notifySink.AudioStop, 0) # IANSRSN_NODATA
if isDebugForSynthDriver():
log.debug("SAPI4: UnClaim finished")
elif self._deviceState in (_AudioState.STARTED, _AudioState.RECLAIMING):
# When playing, wait for the playback to finish.
if isDebugForSynthDriver():
log.debug("SAPI4: UnClaiming")
with self._audioCond:
self._deviceState = _AudioState.UNCLAIMING
self._audioCond.notify()
else:
raise ReturnHRESULT(AudioError.NOT_CLAIMED, None)
@_logTrace(logAll=True)
def IAudio_Start(self) -> None:
"""Starts (or resumes) playing the audio in the buffer."""
if self._deviceState == _AudioState.INVALID:
raise ReturnHRESULT(AudioError.NEED_WAVE_FORMAT, None)
elif self._deviceState == _AudioState.STARTED:
raise ReturnHRESULT(AudioError.ALREADY_STARTED, None)
elif self._deviceState not in (_AudioState.CLAIMED, _AudioState.RECLAIMING):
raise ReturnHRESULT(AudioError.NOT_CLAIMED, None)
self._startTime = datetime.now()
self._startBytes = self._playedBytes
try:
self._player.pause(False)
except OSError:
log.debugWarning("Error starting audio", exc_info=True)
with self._audioCond:
self._deviceState = _AudioState.STARTED
self._audioCond.notify()
@_logTrace(logAll=True)
def IAudio_Stop(self) -> None:
"""Stops (or pauses) playing, without clearing the buffer.
If there is audio in the buffer, calling Stop and UnClaim should keep the buffer
and only pause the playback."""
if self._deviceState == _AudioState.INVALID:
raise ReturnHRESULT(AudioError.NEED_WAVE_FORMAT, None)
elif self._deviceState == _AudioState.STARTED:
self._deviceState = _AudioState.CLAIMED
elif self._deviceState not in (_AudioState.UNCLAIMING, _AudioState.RECLAIMING):
return
try:
self._player.pause(True)
except OSError:
log.debugWarning("Error stopping audio", exc_info=True)
with self._audioCond:
self._audioCond.notify()
@_logTrace()
def IAudio_TotalGet(self) -> int:
"""Returns the total number of bytes written,
including the unplayed bytes in the buffer,
which should increase monotonically and never reset."""
if self._deviceState == _AudioState.INVALID:
raise ReturnHRESULT(AudioError.NEED_WAVE_FORMAT, None)
return self._writtenBytes
@_logTrace()
def IAudio_ToFileTime(self, pqWord: c_ulonglong_p) -> FILETIME:
"""Converts a byte position to UTC FILETIME."""
if not pqWord:
raise ReturnHRESULT(hresult.E_INVALIDARG, None)
if self._deviceState == _AudioState.INVALID:
raise ReturnHRESULT(AudioError.NEED_WAVE_FORMAT, None)
UNIX_TIME_CONV = 1_1644_473_600
filetime_ticks = int((self._startTime.timestamp() + UNIX_TIME_CONV) * 10_000_000)
filetime_ticks += (pqWord[0] - self._startBytes) * 10_000_000 // self._waveFormat.nAvgBytesPerSec
return FILETIME(filetime_ticks & 0xFFFFFFFF, filetime_ticks >> 32)
@_logTrace()
def IAudio_WaveFormatGet(self) -> SDATA:
"""Gets a copy of the current wave format.
:returns: A pointer to the WAVEFORMATEX structure.
Should be freed by the caller using CoTaskMemFree."""
if self._deviceState == _AudioState.INVALID:
raise ReturnHRESULT(AudioError.NEED_WAVE_FORMAT, None)
size = sizeof(WAVEFORMATEX)
ptr = winBindings.ole32.CoTaskMemAlloc(size)
if not ptr:
raise COMError(hresult.E_OUTOFMEMORY, "CoTaskMemAlloc failed", (None, None, None, None, None))
memmove(ptr, addressof(self._waveFormat), size)
return SDATA(ptr, size)
@_logTrace()
def IAudio_WaveFormatSet(self, dWFEX: SDATA) -> None:
"""Sets the current wave format. Only integer PCM formats are supported."""
size = 18 # SAPI4 uses 18 bytes without the final padding
if not dWFEX.pData or dWFEX.dwSize < size:
raise ReturnHRESULT(hresult.E_INVALIDARG, None)
wfx = WAVEFORMATEX()
memmove(addressof(wfx), dWFEX.pData, size)
if self._deviceState != _AudioState.INVALID:
# Setting wave format more than once is not allowed.
if bytes(wfx) == bytes(self._waveFormat):
return # Format not changed, do nothing
else:
raise ReturnHRESULT(AudioError.WAVE_DEVICE_BUSY)
if wfx.wFormatTag != nvwave.WAVE_FORMAT_PCM:
log.debugWarning("Wave format not supported. Only integer PCM formats are supported.")
raise ReturnHRESULT(AudioError.WAVE_FORMAT_NOT_SUPPORTED, None)
self._waveFormat = wfx
self._initPlayer()
self._deviceState = _AudioState.UNCLAIMED
self._freeBytes = wfx.nAvgBytesPerSec * BUFFER_LENGTH_S
self._audioThread.start()
@_logTrace(format="{result[0]} bytes free")
def IAudioDest_FreeSpace(self) -> tuple[DWORD, BOOL]:
"""Returns the number of bytes that are free in the object's internal buffer.
:returns: Tuple (dwBytes, fEOF).
dwBytes: number of bytes available.
fEOF: TRUE if end-of-file is reached and no more data can be sent.
For wave-out devices, this should always be FALSE."""
if self._deviceState == _AudioState.INVALID:
raise ReturnHRESULT(AudioError.NEED_WAVE_FORMAT, None)
return (self._freeBytes, 0)
@_logTrace(format="{args[2]} bytes written")
def IAudioDest_DataSet(self, pBuffer: c_void_p, dwSize: int) -> None:
"""Writes audio data to the end of the object's internal buffer.
This should not block.
When data cannot fit in the buffer, this should return AudioError.NOT_ENOUGH_DATA immediately."""
if not pBuffer:
raise ReturnHRESULT(hresult.E_INVALIDARG, None)
if self._deviceState == _AudioState.INVALID:
raise ReturnHRESULT(AudioError.NEED_WAVE_FORMAT, None)
elif self._deviceState in (_AudioState.UNCLAIMED, _AudioState.UNCLAIMING):
log.debugWarning("Audio data written when device is not claimed")
raise ReturnHRESULT(AudioError.NOT_CLAIMED, None)
elif self._freeBytes < dwSize:
raise ReturnHRESULT(AudioError.NOT_ENOUGH_DATA, None)
with self._audioCond:
self._audioQueue.append(string_at(pBuffer, dwSize))
self._writtenBytes += dwSize
self._freeBytes -= dwSize
self._audioCond.notify()
@_logTrace()
def IAudioDest_BookMark(self, dwMarkID: int) -> None:
"""Attaches a bookmark to the most recent data in the audio-destination object's internal buffer.
When the bookmark is reached, `IAudioDestNotifySink::BookMark` is called.
When Flush is called, untriggered bookmarks should also be triggered."""
if self._deviceState == _AudioState.INVALID:
raise ReturnHRESULT(AudioError.NEED_WAVE_FORMAT, None)
with self._audioCond:
self._bookmarkQueue.append(_Bookmark(self._writtenBytes, dwMarkID))
self._audioCond.notify()
def _audioThreadFunc(self):
"""Audio thread function that feeds the audio data from queue to WavePlayer."""
while not self._audioStopped:
with self._audioCond:
self._checkBookmarksAndState()
if self._deviceState not in (
_AudioState.STARTED,
_AudioState.UNCLAIMING,
_AudioState.RECLAIMING,
):
self._audioCond.wait()
continue
if not self._audioQueue:
# Since WavePlayer.feed returns before the audio finishes,
# in order not to lose the final callbacks
# when there's no more audio to feed,
# wait with a timeout to give WavePlayer a chance
# to check the callbacks periodically.
self._audioCond.wait(0.01)
item = self._audioQueue.popleft() if self._audioQueue else None
if item:
size = len(item)
self._player.feed(item, size, lambda size=size: self._onChunkFinished(size))
else:
# Call feed to let WavePlayer check the callbacks
self._player.feed(None, 0, None)
def _onChunkFinished(self, size: int):
self._playedBytes += size
self._freeBytes += size
if self._notifySink:
self._queueNotification(self._notifySink.FreeSpace, self._freeBytes, 0)
def _checkBookmarksAndState(self):
if self._deviceState not in (
_AudioState.STARTED,
_AudioState.UNCLAIMING,
_AudioState.RECLAIMING,
):
return
while self._bookmarkQueue:
bookmark = self._bookmarkQueue[0]
if bookmark.bytePos > self._playedBytes:
break
if self._notifySink:
self._queueNotification(self._notifySink.BookMark, bookmark.id, 0)
self._bookmarkQueue.popleft()
if self._playedBytes == self._writtenBytes and self._deviceState in (
_AudioState.UNCLAIMING,
_AudioState.RECLAIMING,
):
self._finishUnClaim()
def _finishUnClaim(self):
"""Finishes the asynchronous UnClaim call."""
if self._deviceState == _AudioState.UNCLAIMING:
self._deviceState = _AudioState.UNCLAIMED
if isDebugForSynthDriver():
log.debug("SAPI4: UnClaim finished")
elif self._deviceState == _AudioState.RECLAIMING:
self._deviceState = _AudioState.CLAIMED
return
else:
return
self._player.stop()
if self._notifySink:
# Notify when the device is finally closed
self._queueNotification(self._notifySink.AudioStop, 0) # IANSRSN_NODATA
class SynthDriverMMAudio(COMObject):
"""
Wrapper around SAPI4's built-in MMAudioDest,
which can log the interactions between MMAudioDest and the TTS engine.
"""
_com_interfaces_ = [IAudio, IAudioDest]
def __init__(self):
if isDebugForSynthDriver():
log.debug("SAPI4: Initializing WinMM implementation")
self._allowDelete = False
self.mmdev = CoCreateInstance(CLSID_MMAudioDest, IAudioMultiMediaDevice)
self.mmdev.DeviceNumSet(_mmDeviceEndpointIdToWaveOutId(config.conf["audio"]["outputDevice"]))
self.audio = self.mmdev.QueryInterface(IAudio)
self.audiodest = self.mmdev.QueryInterface(IAudioDest)
def IUnknown_Release(self, this: int, *args, **kwargs) -> int:
if not self._allowDelete and self._refcnt.value == 1:
log.debugWarning("SynthDriverMMAudio was released too many times")
return 1
return super().IUnknown_Release(this, *args, **kwargs)
def terminate(self):
self._allowDelete = True
@_logTrace(logAll=True)
def IAudio_Flush(self) -> None:
self.audio.Flush()
@_logTrace()
def IAudio_LevelGet(self) -> int:
return self.audio.LevelGet()
@_logTrace(format="{args[1]:#010x}")
def IAudio_LevelSet(self, dwLevel: int) -> None:
return self.audio.LevelSet(dwLevel)
@_logTrace()
def IAudio_PassNotify(self, pNotifyInterface: c_void_p, IIDNotifyInterface: GUID) -> None:
return self.audio.PassNotify(pNotifyInterface, IIDNotifyInterface)
@_logTrace()
def IAudio_PosnGet(self) -> int:
return self.audio.PosnGet()
@_logTrace(logAll=True)
def IAudio_Claim(self) -> None:
self.audio.Claim()
@_logTrace(logAll=True)
def IAudio_UnClaim(self) -> None:
self.audio.UnClaim()
@_logTrace(logAll=True)
def IAudio_Start(self) -> None:
self.audio.Start()
@_logTrace(logAll=True)
def IAudio_Stop(self) -> None:
self.audio.Stop()
@_logTrace()
def IAudio_TotalGet(self) -> int:
return self.audio.TotalGet()
@_logTrace()
def IAudio_ToFileTime(self, pqWord: c_ulonglong_p) -> FILETIME:
return self.audio.ToFileTime(pqWord)
@_logTrace()
def IAudio_WaveFormatGet(self) -> SDATA:
return self.audio.WaveFormatGet()
@_logTrace()
def IAudio_WaveFormatSet(self, dWFEX: SDATA) -> None:
self.audio.WaveFormatSet(dWFEX)
@_logTrace(format="{result[0]} bytes free")
def IAudioDest_FreeSpace(self) -> tuple[DWORD, BOOL]:
return self.audiodest.FreeSpace()
@_logTrace(format="{args[2]} bytes written")
def IAudioDest_DataSet(self, pBuffer: c_void_p, dwSize: int) -> None:
self.audiodest.DataSet(pBuffer, dwSize)
@_logTrace()
def IAudioDest_BookMark(self, dwMarkID: int) -> None:
self.audiodest.BookMark(dwMarkID)
class SynthDriverSink(COMObject):
_com_interfaces_ = [ITTSNotifySinkW]
def __init__(self, synthRef: weakref.ReferenceType):
self.synthRef = synthRef
self._allowDelete = True
super().__init__()
def ITTSNotifySinkW_AudioStart(self, this: int, qTimeStamp: int):
if isDebugForSynthDriver():
log.debug("SAPI4: TTSNotifySink AudioStart")
synth = self.synthRef()
if synth is None:
log.debugWarning(
"Called ITTSNotifySinkW_AudioStart method on ITTSNotifySinkW while driver is dead",
)
return
if synth._bookmarkLists:
# take the first bookmark list
synth._bookmarks = synth._bookmarkLists.popleft()
def ITTSNotifySinkW_AudioStop(self, this: int, qTimeStamp: int):
if isDebugForSynthDriver():
log.debug("SAPI4: TTSNotifySink AudioStop")
synth = self.synthRef()
if synth is None:
log.debugWarning(
"Called ITTSNotifySinkW_AudioStop method on ITTSNotifySinkW while driver is dead",
)
return
# trigger all untriggered bookmarks
if synth._bookmarks:
while synth._bookmarks:
synthIndexReached.notify(synth=synth, index=synth._bookmarks.popleft())
# if there are untriggered bookmarks, synthDoneSpeaking hasn't been triggered yet.
# Trigger synthDoneSpeaking after triggering all bookmarks
synth._finalIndex = None
synthDoneSpeaking.notify(synth=synth)
synth._bookmarks = None
def IUnknown_Release(self, this: int, *args, **kwargs):
if not self._allowDelete and self._refcnt.value == 1:
log.debugWarning("ITTSNotifySinkW::Release called too many times by engine")
return 1
return super().IUnknown_Release(this, *args, **kwargs)
class SynthDriver(SynthDriver):
name = "sapi4"
description = "Microsoft Speech API version 4"
supportedSettings = [SynthDriver.VoiceSetting()]
supportedCommands: set[type[SynthCommand]] = {
IndexCommand,
CharacterModeCommand,
BreakCommand,
}
supportedNotifications = {synthIndexReached, synthDoneSpeaking}
@classmethod
def check(cls):
try:
winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"CLSID\%s" % CLSID_TTSEnumerator).Close()
return True
except WindowsError:
return False
def _fetchEnginesList(self):
enginesList = []
self._ttsEngines.Reset()
while True:
mode = TTSMODEINFO()
fetched = c_ulong()
try:
self._ttsEngines.Next(1, byref(mode), byref(fetched))
except: # noqa: E722
log.error("can't get next engine", exc_info=True)
break
if fetched.value == 0:
break
enginesList.append(mode)
return enginesList
def __init__(self):
self._comThread = _ComThread()
self._finalIndex: Optional[int] = None
self._ttsCentral = None
self._ttsAudio = None
self._sinkRegKey = DWORD()
self._bookmarks = None
self._bookmarkLists = deque()
self._sink = SynthDriverSink(weakref.ref(self))
self._sinkPtr = self._sink.QueryInterface(ITTSNotifySinkW)
self._bufSink = SynthDriverBufSink(weakref.ref(self))
self._bufSinkPtr = self._bufSink.QueryInterface(ITTSBufNotifySink)
# HACK: Some buggy engines call Release() too many times on our buf sink.
# Therefore, don't let the buf sink be deleted before we release it ourselves.
self._bufSink._allowDelete = False
# Create COM objects on the dedicated COM thread,
# and wrap them with _ComProxy so that method calls will happen on the same thread.
self._ttsEngines = self._comThread.invoke(CoCreateInstance, CLSID_TTSEnumerator, ITTSEnumW)
self._ttsEngines = _ComProxy(self._ttsEngines, self._comThread)
self._enginesList = self._fetchEnginesList()
if len(self._enginesList) == 0:
raise RuntimeError("No Sapi4 engines available")
self._rateDelta = 0
self._pitchDelta = 0
self._volume = 100
self._paused = False
self.voice = str(self._enginesList[0].gModeID)
def terminate(self):
self._bufSink._allowDelete = True
self._sink._allowDelete = True
# Release all COM objects before stopping the COM thread.
self._ttsAttrs = None
self._ttsCentral = None
if self._ttsAudio:
self._ttsAudio.terminate()
self._ttsAudio = None
self._ttsEngines = None
self._comThread.stop()
def speak(self, speechSequence: SpeechSequence):
textList = []
charMode = False
unprocessedSequence = speechSequence
bookmarks = deque()
# #15500: Some SAPI4 voices reset all prosody when they receive any prosody command,
# whereas other voices never undo prosody changes when a sequence is interrupted.
# Add all default values to the start and end of the sequence,
# but avoid duplicating the first command, if any,
# And only add the defaults when there is a prosody command in the sequence.
supportedProsody = [c for c in self.supportedCommands if issubclass(c, BaseProsodyCommand)]
prosodyToAdd = []
if any(type(i) in supportedProsody for i in unprocessedSequence):
prosodyToAdd.extend(c() for c in supportedProsody)
speechSequence = [c for c in prosodyToAdd if not isinstance(unprocessedSequence[0], type(c))]
speechSequence.extend(unprocessedSequence)
# To be sure, add all default values to the end of the sequence.
# This might cause multiple cases of prosody resets, but better safe than sorry.
speechSequence.extend(prosodyToAdd)
lastHandledIndexInSequence = 0
for item in speechSequence:
if isinstance(item, str):
textList.append(item.replace("\\", "\\\\"))
elif isinstance(item, IndexCommand):
textList.append("\\mrk=%d\\" % item.index)
bookmarks.append(item.index)
lastHandledIndexInSequence = item.index
elif isinstance(item, CharacterModeCommand):
textList.append("\\RmS=1\\" if item.state else "\\RmS=0\\")
charMode = item.state
elif isinstance(item, BreakCommand):
textList.append(f"\\Pau={item.time}\\")
elif isinstance(item, PitchCommand):
val = self._percentToParam(item.newValue, self._minPitch, self._maxPitch)
textList.append(f"\\Pit={val}\\")
elif isinstance(item, RateCommand):
val = self._percentToParam(item.newValue, self._minRate, self._maxRate)
textList.append(f"\\Spd={val}\\")
elif isinstance(item, VolumeCommand):
val = self._percentToParam(item.newValue, self._minVolume, self._maxVolume)
# If you specify a value greater than 65535, the engine assumes that you want to set the
# left and right channels separately and converts the value to a double word,
# using the low word for the left channel and the high word for the right channel.
# However, some voices don't handle values greater than 65535 properly in Vol tags,
# so here only 0~65535 are used.
textList.append(f"\\Vol={val}\\")
elif isinstance(item, SpeechCommand):
log.debugWarning("Unsupported speech command: %s" % item)
else:
log.error("Unknown speech: %s" % item)
# lastHandledIndexInSequence is the index denoting the end of the speech sequence.
# store it on the driver to support the synthDoneSpeaking notification.
self._finalIndex = lastHandledIndexInSequence
if charMode:
# Some synths stay in character mode if we don't explicitly disable it.
textList.append("\\RmS=0\\")
# Some SAPI4 synthesizers complete speech sequence just after the last text
# and ignore any indexes passed after it
# Therefore we add the pause of 1ms at the end
textList.append("\\PAU=1\\")
text = "".join(textList)
self._bookmarkLists.append(bookmarks)
flags = TTSDATAFLAG_TAGGED
self._ttsCentral.TextData(
VOICECHARSET.CHARSET_TEXT,
flags,
TextSDATA(text),
self._bufSinkPtr,
ITTSBufNotifySink._iid_,
)
def cancel(self):
if isDebugForSynthDriver():
log.debug("SAPI4: Cancelling")
try:
# cancel all pending bookmarks
self._bookmarkLists.clear()
self._bookmarks = None
if self._paused:
# Unpause the voice before resetting,
# because some voices keep the pausing state
# even after resetting.
self._ttsCentral.AudioResume()
self._paused = False
self._ttsCentral.AudioReset()
except COMError:
log.debugWarning("Error cancelling speech", exc_info=True)
finally:
self._finalIndex = None
def pause(self, switch: bool):
if isDebugForSynthDriver():
if switch:
log.debug("SAPI4: Pausing")
else:
log.debug("SAPI4: Unpausing")
if switch:
try:
self._ttsCentral.AudioPause()
except COMError:
log.debugWarning("Error pausing speech", exc_info=True)
else:
self._ttsCentral.AudioResume()
self._paused = switch
def removeSetting(self, name):
# Putting it here because currently no other synths make use of it. OrderedDict, where you are?
for i, s in enumerate(self.supportedSettings):
if s.id == name:
del self.supportedSettings[i]
return
def _set_voice(self, val):
try:
val = GUID(val)
except: # noqa: E722
val = self._enginesList[0].gModeID
mode = None
for mode in self._enginesList:
if mode.gModeID == val:
break
if mode is None:
raise ValueError("no such mode: %s" % val)
self._currentMode = mode
if self._ttsCentral:
try:
# Some SAPI4 synthesizers may fail this call.
self._ttsCentral.UnRegister(self._sinkRegKey)
except COMError:
log.debugWarning("Error unregistering ITTSCentral sink", exc_info=True)
# Some SAPI4 synthesizers assume that only one instance of ITTSCentral
# will be created by the client, and will stop working if more are created.
# Here we make sure that the previous _ttsCentral is released
# before the next _ttsCentral is created.
self._ttsAttrs = None
self._ttsCentral = None
self._ttsAudio.terminate()
self._ttsAudio = None
if config.conf["speech"]["useWASAPIForSAPI4"]:
self._ttsAudio = self._comThread.invoke(SynthDriverAudio, self._comThread)
else:
self._ttsAudio = self._comThread.invoke(SynthDriverMMAudio)
self._ttsCentral = POINTER(ITTSCentralW)()
self._ttsEngines.Select(self._currentMode.gModeID, byref(self._ttsCentral), self._ttsAudio)
self._ttsCentral = _ComProxy(self._ttsCentral, self._comThread)
self._ttsCentral.Register(self._sinkPtr, ITTSNotifySinkW._iid_, byref(self._sinkRegKey))
self._ttsAttrs = _ComProxy(self._ttsCentral.QueryInterface(ITTSAttributes), self._comThread)
# Find out rate limits
hasRate = bool(mode.dwFeatures & TTSFEATURE_SPEED)
if hasRate:
try:
oldVal = DWORD()
self._ttsAttrs.SpeedGet(byref(oldVal))
self._defaultRate = oldVal.value
self._ttsAttrs.SpeedSet(TTSATTR_MINSPEED)
newVal = DWORD()
self._ttsAttrs.SpeedGet(byref(newVal))
self._minRate = newVal.value
self._ttsAttrs.SpeedSet(TTSATTR_MAXSPEED)
self._ttsAttrs.SpeedGet(byref(newVal))
# ViaVoice (and perhaps other synths) doesn't seem to like the speed being set to maximum.
self._maxRate = newVal.value - 1
val = max(self._minRate, min(self._maxRate, self._defaultRate + self._rateDelta))
self._ttsAttrs.SpeedSet(val)
if self._maxRate <= self._minRate:
hasRate = False
except COMError:
hasRate = False
if hasRate:
if not self.isSupported("rate"):
self.supportedSettings.insert(1, SynthDriver.RateSetting())
self.supportedCommands.add(RateCommand)
else:
if self.isSupported("rate"):
self.removeSetting("rate")
if RateCommand in self.supportedCommands:
self.supportedCommands.remove(RateCommand)
# Find out pitch limits
hasPitch = bool(mode.dwFeatures & TTSFEATURE_PITCH)
if hasPitch:
try:
oldVal = WORD()
self._ttsAttrs.PitchGet(byref(oldVal))
self._defaultPitch = oldVal.value
self._ttsAttrs.PitchSet(TTSATTR_MINPITCH)
newVal = WORD()
self._ttsAttrs.PitchGet(byref(newVal))
self._minPitch = newVal.value
self._ttsAttrs.PitchSet(TTSATTR_MAXPITCH)
self._ttsAttrs.PitchGet(byref(newVal))
self._maxPitch = newVal.value
val = max(self._minPitch, min(self._maxPitch, self._defaultPitch + self._pitchDelta))
self._ttsAttrs.PitchSet(val)
if self._maxPitch <= self._minPitch:
hasPitch = False
except COMError:
hasPitch = False
if hasPitch:
if not self.isSupported("pitch"):
self.supportedSettings.insert(2, SynthDriver.PitchSetting())
self.supportedCommands.add(PitchCommand)
else:
if self.isSupported("pitch"):
self.removeSetting("pitch")
if PitchCommand in self.supportedCommands:
self.supportedCommands.remove(PitchCommand)
# Find volume limits
hasVolume = bool(mode.dwFeatures & TTSFEATURE_VOLUME)
if hasVolume:
try:
oldVal = DWORD()
self._ttsAttrs.VolumeGet(byref(oldVal))
self._ttsAttrs.VolumeSet(TTSATTR_MINVOLUME)
newVal = DWORD()
self._ttsAttrs.VolumeGet(byref(newVal))
self._minVolume = newVal.value & 0xFFFF
self._ttsAttrs.VolumeSet(TTSATTR_MAXVOLUME)
self._ttsAttrs.VolumeGet(byref(newVal))
self._maxVolume = newVal.value & 0xFFFF
self._set_volume(self._volume)
if self._maxVolume <= self._minVolume:
hasVolume = False
except COMError:
hasVolume = False
if hasVolume:
if not self.isSupported("volume"):
self.supportedSettings.insert(3, SynthDriver.VolumeSetting())
self.supportedCommands.add(VolumeCommand)
else:
if self.isSupported("volume"):
self.removeSetting("volume")
if VolumeCommand in self.supportedCommands:
self.supportedCommands.remove(VolumeCommand)
def _get_voice(self):
return str(self._currentMode.gModeID)
def _getAvailableVoices(self):
voices = OrderedDict()
for mode in self._enginesList:
ID = str(mode.gModeID)
name = "%s - %s" % (mode.szModeName, mode.szProductName)
try:
language = locale.windows_locale[mode.language.LanguageID]
except KeyError:
language = None
voices[ID] = VoiceInfo(ID, name, language)
return voices
def _get_rate(self) -> int:
val = DWORD()
self._ttsAttrs.SpeedGet(byref(val))
# Sometimes the raw value can drift outside the min and max value.
val.value = max(min(val.value, self._maxRate), self._minRate)
return self._paramToPercent(val.value, self._minRate, self._maxRate)
def _set_rate(self, val: int):
val = self._percentToParam(val, self._minRate, self._maxRate)
self._ttsAttrs.SpeedSet(val)
self._rateDelta = val - self._defaultRate
def _get_pitch(self) -> int:
val = WORD()
self._ttsAttrs.PitchGet(byref(val))
# Sometimes the raw value can drift outside the min and max value.
val.value = max(min(val.value, self._maxPitch), self._minPitch)
return self._paramToPercent(val.value, self._minPitch, self._maxPitch)
def _set_pitch(self, val: int):
val = self._percentToParam(val, self._minPitch, self._maxPitch)
self._ttsAttrs.PitchSet(val)
self._pitchDelta = val - self._defaultPitch
def _get_volume(self) -> int:
val = DWORD()
self._ttsAttrs.VolumeGet(byref(val))
# Sometimes the raw value can drift outside the min and max value.
val.value &= 0xFFFF
val.value = max(min(val.value, self._maxVolume), self._minVolume)
return self._paramToPercent(val.value, self._minVolume, self._maxVolume)
def _set_volume(self, val: int):
self._volume = val
val = self._percentToParam(val, self._minVolume, self._maxVolume)
# If you specify a value greater than 65535, the engine assumes that you want to set the
# left and right channels separately and converts the value to a double word,
# using the low word for the left channel and the high word for the right channel.
val |= val << 16
self._ttsAttrs.VolumeSet(val)
def _mmDeviceEndpointIdToWaveOutId(targetEndpointId: str) -> int:
"""Translate from an MMDevice Endpoint ID string to a WaveOut Device ID number.
:param targetEndpointId: MMDevice endpoint ID string to translate from, or the default value of the `audio.outputDevice` configuration key for the default output device.
:return: An integer WaveOut device ID for use with SAPI4.
If no matching device is found, or the default output device is requested, `-1` is returned, which means output will be handled by Microsoft Sound Mapper.
"""
if targetEndpointId != config.conf.getConfigValidation(("audio", "outputDevice")).default:
targetEndpointIdByteCount = (len(targetEndpointId) + 1) * sizeof(c_wchar)
currEndpointId = create_string_buffer(targetEndpointIdByteCount)
currEndpointIdByteCount = DWORD()
# Defined in mmeapi.h
waveOutMessage = winBindings.winmm.waveOutMessage
waveOutGetNumDevs = winBindings.winmm.waveOutGetNumDevs
for devID in range(waveOutGetNumDevs()):
# Get the length of this device's endpoint ID string.
mmr = waveOutMessage(
HANDLE(devID),
DriverMessage.QUERY_INSTANCE_ID_SIZE,
byref(currEndpointIdByteCount),
None,
)
if (mmr != MMSYSERR_NOERROR) or (currEndpointIdByteCount.value != targetEndpointIdByteCount):
# ID lengths don't match, so this device can't be a match.
continue
# Get the device's endpoint ID string.
mmr = waveOutMessage(
HANDLE(devID),
DriverMessage.QUERY_INSTANCE_ID,
byref(currEndpointId),
currEndpointIdByteCount,
)
if mmr != MMSYSERR_NOERROR:
continue
# Decode the endpoint ID string to a python string, and strip the null terminator.
if (
currEndpointId.raw[: targetEndpointIdByteCount - sizeof(c_wchar)].decode("utf-16")
== targetEndpointId
):
return devID
# No matching device found, or default requested explicitly.
# Return the ID of Microsoft Sound Mapper
return -1