From c24885dac8270a73f3d58c349961317b772504cf Mon Sep 17 00:00:00 2001 From: tbreitenfeldt Date: Fri, 26 Nov 2021 11:11:26 -0800 Subject: [PATCH 1/5] Implemented apple script solution for talking to VoiceOver when active, and a default solution for default speech output if not active. --- accessible_output2/outputs/__init__.py | 1 + .../outputs/system_voiceover.py | 69 +++++++++++++++++++ accessible_output2/outputs/voiceover.py | 27 +++++--- readme.rst | 14 ++++ 4 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 accessible_output2/outputs/system_voiceover.py diff --git a/accessible_output2/outputs/__init__.py b/accessible_output2/outputs/__init__.py index 4bff6eb..354869d 100644 --- a/accessible_output2/outputs/__init__.py +++ b/accessible_output2/outputs/__init__.py @@ -33,6 +33,7 @@ def _load_com(*names): if platform.system() == "Darwin": from . import voiceover + from . import system_voiceover if platform.system() == "Linux": from . import speech_dispatcher diff --git a/accessible_output2/outputs/system_voiceover.py b/accessible_output2/outputs/system_voiceover.py new file mode 100644 index 0000000..bf29a2c --- /dev/null +++ b/accessible_output2/outputs/system_voiceover.py @@ -0,0 +1,69 @@ +from __future__ import absolute_import +import platform +from collections import OrderedDict + +from .base import Output, OutputError + +class SystemVoiceOver(Output): + """Default speech output supporting the Apple VoiceOver screen reader.""" + + name = "VoiceOver" + priority = 101 + system_output = True + + def __init__(self, *args, **kwargs): + from AppKit import NSSpeechSynthesizer + self.NSSpeechSynthesizer = NSSpeechSynthesizer + self.voiceover = NSSpeechSynthesizer.alloc().init() + self.voices = self._available_voices() + + def _available_voices(self): + voices = OrderedDict() + + for voice in self.NSSpeechSynthesizer.availableVoices(): + voice_attr = self.NSSpeechSynthesizer.attributesForVoice_(voice) + voice_name = voice_attr["VoiceName"] + voice_identifier = voice_attr["VoiceIdentifier"] + voices[voice_name] = voice_identifier + + return voices + + def list_voices(self): + return list(self.voices.keys()) + + def get_voice(self): + voice_attr = self.NSSpeechSynthesizer.attributesForVoice_(self.voiceover.voice()) + return voice_attr["VoiceName"] + + def set_voice(self, voice_name): + voice_identifier = self.voices[voice_name] + self.voiceover.setVoice_(voice_identifier) + + def get_rate(self): + return self.voiceover.rate() + + def set_rate(self, rate): + self.voiceover.setRate_(rate) + + def get_volume(self): + return self.voiceover.volume() + + def set_volume(self, volume): + self.voiceover.setVolume_(volume) + + def is_speaking(self): + return self.NSSpeechSynthesizer.isAnyApplicationSpeaking() + + def speak(self, text, interrupt=False): + if interrupt: + self.silence() + + return self.voiceover.startSpeakingString_(text) + + def silence(self): + self.voiceover.stopSpeaking() + + def is_active(self): + return self.voiceover is not None + +output_class = SystemVoiceOver \ No newline at end of file diff --git a/accessible_output2/outputs/voiceover.py b/accessible_output2/outputs/voiceover.py index 5c1de4b..20389e2 100644 --- a/accessible_output2/outputs/voiceover.py +++ b/accessible_output2/outputs/voiceover.py @@ -1,25 +1,32 @@ -from __future__ import absolute_import +import subprocess, psutil -from .base import Output +from accessible_output2.outputs.base import Output class VoiceOver(Output): - """Speech output supporting the Apple VoiceOver screen reader.""" name = "VoiceOver" - def __init__(self, *args, **kwargs): - import appscript - self.app = appscript.app("voiceover") + def run_apple_script(self, command, process = "voiceover"): + return subprocess.Popen(["osascript", "-e", + f"tell application \"{process}\"\n{command}\nend tell"], + stdout = subprocess.PIPE).communicate()[0] def speak(self, text, interrupt=False): - self.app.output(text) + # apple script output command seems to interrupt by default + # if an empty string is provided itseems to force voiceover to not interrupt + if not interrupt: + self.silence() + self.run_apple_script(f"output \"{text}\"") - def silence(self): - self.app.output(u"") + def silence (self): + self.run_apple_script("output \"\"") def is_active(self): - return self.app.isrunning() + for process in psutil.process_iter(): + if process.name().lower() == "voiceover": + return True + return False output_class = VoiceOver diff --git a/readme.rst b/readme.rst index 9774bf3..a86e60d 100644 --- a/readme.rst +++ b/readme.rst @@ -25,6 +25,8 @@ Speech: - Supernova and other Dolphin products - PC Talker - Microsoft Speech API +- VoiceOver +- E-Speak Braille: @@ -35,3 +37,15 @@ Braille: - System Access - Supernova and other Dolphin products +Note for Apple Users: +------------------ +VoiceOver is supported by accessible_output2 in two different ways. + +The first way is through Apple Script, which requires the user to enable the VoiceOver setting "Allow Voiceover to be controled by Apple Script". This method will provide output to the running instance of voiceover. This no longer checks if VoiceOver has this setting enabled or not due to the expensive cost of running an Apple Script query everytime is_active is called. This means that if the VoiceOver setting is disabled, and VoiceOver is running, an error will be thrown by VoiceOver if you attempt to speak with VoiceOver, rather than automaticly switching to the secondary speech output system. Application developers that are providing support for VoiceOver are encouraged to provide some notification to the user about enabling Voiceover to be controled by Apple Script, or to just disable VoiceOver altogether to use the default speech output. + +If Voiceover is not running, The NSSpeechSynthesizer object is used. This will use a separate instance of VoiceOver, using default VoiceOver settings which are customizable from the provided class similar to SAPI5 for Windows. + +The error thrown by VoiceOver if Apple Script is disabled is: +Note: This error can not be caught in python +.. code-block + execution error: VoiceOver got an error: AppleEvent handler failed. From 76f21e3c0abecbacad553f5961e40179c888dc30 Mon Sep 17 00:00:00 2001 From: tbreitenfeldt Date: Fri, 26 Nov 2021 11:18:05 -0800 Subject: [PATCH 2/5] Updated readme. --- readme.rst | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/readme.rst b/readme.rst index a86e60d..ff89cb5 100644 --- a/readme.rst +++ b/readme.rst @@ -45,7 +45,6 @@ The first way is through Apple Script, which requires the user to enable the Voi If Voiceover is not running, The NSSpeechSynthesizer object is used. This will use a separate instance of VoiceOver, using default VoiceOver settings which are customizable from the provided class similar to SAPI5 for Windows. -The error thrown by VoiceOver if Apple Script is disabled is: -Note: This error can not be caught in python -.. code-block - execution error: VoiceOver got an error: AppleEvent handler failed. +Error thrown by VoiceOver if Apple Script is disabled: (This error can not be caught in python ) + +execution error: VoiceOver got an error: AppleEvent handler failed. From bdc1670f3e0ccce33a7c4ab7e4983534565d75ba Mon Sep 17 00:00:00 2001 From: tbreitenfeldt Date: Fri, 26 Nov 2021 12:15:52 -0800 Subject: [PATCH 3/5] Added function to VoiceOver class to support is_speaking. --- accessible_output2/outputs/voiceover.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/accessible_output2/outputs/voiceover.py b/accessible_output2/outputs/voiceover.py index 20389e2..4826284 100644 --- a/accessible_output2/outputs/voiceover.py +++ b/accessible_output2/outputs/voiceover.py @@ -7,6 +7,13 @@ class VoiceOver(Output): name = "VoiceOver" + def __init__(self, *args, **kwargs): + from AppKit import NSSpeechSynthesizer + self.NSSpeechSynthesizer = NSSpeechSynthesizer + + def is_speaking(self): + return self.NSSpeechSynthesizer.isAnyApplicationSpeaking() + def run_apple_script(self, command, process = "voiceover"): return subprocess.Popen(["osascript", "-e", f"tell application \"{process}\"\n{command}\nend tell"], From e894a636f5159105617104a9dab5d296fc30a480 Mon Sep 17 00:00:00 2001 From: tbreitenfeldt Date: Sun, 5 Dec 2021 08:05:16 -0800 Subject: [PATCH 4/5] Added sanitize function to VoiceOver class to prevent script injection through the speak method. Also changed how VoiceOver.is_active checks the running process by using pgrep. --- accessible_output2/outputs/voiceover.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/accessible_output2/outputs/voiceover.py b/accessible_output2/outputs/voiceover.py index 4826284..9945dc6 100644 --- a/accessible_output2/outputs/voiceover.py +++ b/accessible_output2/outputs/voiceover.py @@ -1,4 +1,4 @@ -import subprocess, psutil +import subprocess from accessible_output2.outputs.base import Output @@ -11,29 +11,32 @@ def __init__(self, *args, **kwargs): from AppKit import NSSpeechSynthesizer self.NSSpeechSynthesizer = NSSpeechSynthesizer - def is_speaking(self): - return self.NSSpeechSynthesizer.isAnyApplicationSpeaking() - def run_apple_script(self, command, process = "voiceover"): return subprocess.Popen(["osascript", "-e", f"tell application \"{process}\"\n{command}\nend tell"], stdout = subprocess.PIPE).communicate()[0] + def sanitize(self, str): + return str.replace("\\", "\\\\") \ + .replace("\"", "\\\"") + + def is_speaking(self): + return self.NSSpeechSynthesizer.isAnyApplicationSpeaking() + def speak(self, text, interrupt=False): # apple script output command seems to interrupt by default # if an empty string is provided itseems to force voiceover to not interrupt if not interrupt: self.silence() - self.run_apple_script(f"output \"{text}\"") + + sanitized_text = sanitize(text) + self.run_apple_script(f"output \"{sanitized_text}\"") def silence (self): self.run_apple_script("output \"\"") def is_active(self): - for process in psutil.process_iter(): - if process.name().lower() == "voiceover": - return True - - return False + return subprocess.Popen(["pgrep", "--count", "--ignore-case", "--exact", "voiceover"], + stdout = subprocess.PIPE).communicate()[0].startswith(b"0") output_class = VoiceOver From 0f0b34993fb4b271f88b5cb2581d194704d1c70f Mon Sep 17 00:00:00 2001 From: tbreitenfeldt Date: Sat, 19 Feb 2022 14:01:40 -0800 Subject: [PATCH 5/5] Modified voiceover class to sanitize incoming text before being passed to osascript to avoid script injection. Also changed how Voiceover is determined to be active or not using pgrep. --- accessible_output2/outputs/voiceover.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/accessible_output2/outputs/voiceover.py b/accessible_output2/outputs/voiceover.py index 9945dc6..6091eab 100644 --- a/accessible_output2/outputs/voiceover.py +++ b/accessible_output2/outputs/voiceover.py @@ -20,23 +20,24 @@ def sanitize(self, str): return str.replace("\\", "\\\\") \ .replace("\"", "\\\"") - def is_speaking(self): - return self.NSSpeechSynthesizer.isAnyApplicationSpeaking() - def speak(self, text, interrupt=False): - # apple script output command seems to interrupt by default - # if an empty string is provided itseems to force voiceover to not interrupt - if not interrupt: - self.silence() + sanitized_text = self.sanitize(text) + # The silence function does not seem to work. + # osascript takes time to execute, so voiceover usually starts talking before being silenced + if interrupt: + self.silence() - sanitized_text = sanitize(text) self.run_apple_script(f"output \"{sanitized_text}\"") def silence (self): self.run_apple_script("output \"\"") + def is_speaking(self): + return self.NSSpeechSynthesizer.isAnyApplicationSpeaking() + def is_active(self): - return subprocess.Popen(["pgrep", "--count", "--ignore-case", "--exact", "voiceover"], - stdout = subprocess.PIPE).communicate()[0].startswith(b"0") + # If no process is found, an empty string is returned + return bool(subprocess.Popen(["pgrep", "-x", "VoiceOver"], + stdout = subprocess.PIPE).communicate()[0]) output_class = VoiceOver