diff --git a/configure.ac b/configure.ac index bd3705446..7858a10b0 100644 --- a/configure.ac +++ b/configure.ac @@ -224,7 +224,7 @@ AM_CONDITIONAL(ENABLE_DOC, test "$enable_doc" != "no") # Check for python support. # Python 3 or greater required AC_ARG_ENABLE([python], - [AS_HELP_STRING([--disable-python], [do not install python bindings])], + [AS_HELP_STRING([--disable-python], [do not install python bindings and python output-module helpers])], [], [enable_python=check]) AS_IF([test $enable_python != "no"], @@ -233,6 +233,11 @@ AS_IF([test $enable_python != "no"], [AS_IF([test $enable_python = "yes"], [AC_MSG_FAILURE([python 3 or greater is not available])])])]) AM_CONDITIONAL([HAVE_PYTHON], [test $enable_python = "yes"]) +AS_IF([test "$enable_python" = "yes"], + [TEST_PYTHON_ENABLED=yes], + [TEST_PYTHON_ENABLED=no]) +AC_SUBST([TEST_PYTHON_ENABLED]) +AC_SUBST([PYTHON]) output_modules="cicero dummy festival openjtalk generic" # checks for output modules @@ -721,6 +726,7 @@ AC_CONFIG_FILES([Makefile src/common/Makefile src/modules/Makefile src/server/Makefile + src/tests/atlocal src/tests/Makefile], [chmod +x run-speechd run-spd-say]) AC_OUTPUT diff --git a/src/modules/Makefile.am b/src/modules/Makefile.am index 33dc78daf..5cc53d537 100644 --- a/src/modules/Makefile.am +++ b/src/modules/Makefile.am @@ -26,6 +26,14 @@ EXTRA_DIST += dummy-message-default.wav EXTRA_DIST += dummy-message.txt CLEANFILES = dummy-message.wav +if HAVE_PYTHON +speechd_python_modules_pythondir = $(pythondir)/speechd_python_modules +dist_speechd_python_modules_python_PYTHON = \ + speechd_python_modules/__init__.py \ + speechd_python_modules/module_utils.py \ + speechd_python_modules/speechd_types.py +endif + inc_local = -I$(top_srcdir)/include -I$(top_srcdir)/src/common if DARWIN_HOST diff --git a/src/modules/speechd_python_modules/__init__.py b/src/modules/speechd_python_modules/__init__.py new file mode 100644 index 000000000..52ca92913 --- /dev/null +++ b/src/modules/speechd_python_modules/__init__.py @@ -0,0 +1,30 @@ +# +# __init__.py - Python output module helpers package +# +# Copyright (C) 2026 Jean-François David +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +# + + +"""Helpers for Python Speech Dispatcher output modules.""" diff --git a/src/modules/speechd_python_modules/module_utils.py b/src/modules/speechd_python_modules/module_utils.py new file mode 100644 index 000000000..a4b26d6a9 --- /dev/null +++ b/src/modules/speechd_python_modules/module_utils.py @@ -0,0 +1,77 @@ +# +# module_utils.py - Utilities for Python output modules +# +# Copyright (C) 2026 Jean-François David +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +# + + +def module_strip_ssml(message: str) -> str: + out = [] + append = out.append + omit = False + i = 0 + length = len(message) + + while i < length: + char = message[i] + + if char == "<": + omit = True + i += 1 + continue + if char == ">": + omit = False + i += 1 + continue + if omit: + i += 1 + continue + + if char == "&": + if message.startswith("<", i): + append("<") + i += 4 + continue + if message.startswith(">", i): + append(">") + i += 4 + continue + if message.startswith("&", i): + append("&") + i += 5 + continue + if message.startswith(""", i): + append('"') + i += 6 + continue + if message.startswith("'", i): + append("'") + i += 6 + continue + + append(char) + i += 1 + + return "".join(out) diff --git a/src/modules/speechd_python_modules/speechd_types.py b/src/modules/speechd_python_modules/speechd_types.py new file mode 100644 index 000000000..86090632b --- /dev/null +++ b/src/modules/speechd_python_modules/speechd_types.py @@ -0,0 +1,53 @@ +# +# speechd_types.py - Types for Python output modules +# +# Copyright (C) 2026 Jean-François David +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +# + + +SPD_MSGTYPE_TEXT = 0 +SPD_MSGTYPE_SOUND_ICON = 1 +SPD_MSGTYPE_CHAR = 2 +SPD_MSGTYPE_KEY = 3 +SPD_MSGTYPE_SPELL = 99 + +SPD_AUDIO_LE = 0 +SPD_AUDIO_BE = 1 + + +class SPDVoice: + def __init__(self, name=None, language=None, variant=None): + self.name = name + self.language = language + self.variant = variant + + +class AudioTrack: + def __init__(self, bits, num_channels, sample_rate, num_samples, samples): + self.bits = bits + self.num_channels = num_channels + self.sample_rate = sample_rate + self.num_samples = num_samples + self.samples = samples diff --git a/src/tests/.gitignore b/src/tests/.gitignore index c9d9cb2e0..e83232ddf 100644 --- a/src/tests/.gitignore +++ b/src/tests/.gitignore @@ -21,6 +21,7 @@ /Makefile.in /TAGS /atconfig +/atlocal /clibrary /clibrary2 /clibrary3 diff --git a/src/tests/Makefile.am b/src/tests/Makefile.am index bcbf4da48..e03091eeb 100644 --- a/src/tests/Makefile.am +++ b/src/tests/Makefile.am @@ -17,7 +17,7 @@ ## Process this file with automake to produce Makefile.in -DISTCLEANFILES = atconfig $(TESTSUITE) +DISTCLEANFILES = atconfig atlocal $(TESTSUITE) c_api = $(top_builddir)/src/api/c @@ -42,7 +42,7 @@ atconfig: $(top_builddir)/config.status AUTOM4TE = autom4te AUTOTEST = $(AUTOM4TE) --language=autotest -TESTSUITE_AT = c_api.at +TESTSUITE_AT = c_api.at python_module.at TESTSUITE = ./testsuite $(TESTSUITE): package.m4 testsuite.at $(TESTSUITE_AT) $(AUTOTEST) -I '$(srcdir)' -o $@.tmp $@.at @@ -79,7 +79,7 @@ run_test_LDADD = $(c_api)/libspeechd.la $(GLIB_LIBS) $(EXTRA_SOCKET_LIBS) EXTRA_DIST= basic.test general.test keys.test priority_progress.test \ pronunciation.test punctuation.test sound_icons.test spelling.test \ ssml.test stop_and_pause.test voices.test yo.wav \ - testsuite.at $(TESTSUITE_AT) sayfortune.sh + atlocal.in testsuite.at $(TESTSUITE_AT) sayfortune.sh clean-local: test ! -f $(TESTSUITE) || $(SHELL) $(TESTSUITE) --clean @@ -88,7 +88,7 @@ clean-local: # Run the test suite on the *installed* tree. testinstall: atconfig $(TESTSUITE) - $(SHELL) $(TESTSUITE) AUTOTEST_PATH="$(bindir)" $(TESTSUITEFLAGS) + $(SHELL) $(TESTSUITE) AUTOTEST_PATH="$(bindir)" TEST_PYTHONPATH="$(DESTDIR)$(pythondir)" $(TESTSUITEFLAGS) CLEANFILES = package.m4 diff --git a/src/tests/atlocal.in b/src/tests/atlocal.in new file mode 100644 index 000000000..cb7c89fcf --- /dev/null +++ b/src/tests/atlocal.in @@ -0,0 +1,2 @@ +TEST_PYTHON_ENABLED='@TEST_PYTHON_ENABLED@' +PYTHON='@PYTHON@' diff --git a/src/tests/python_module.at b/src/tests/python_module.at new file mode 100644 index 000000000..3bc74c3a2 --- /dev/null +++ b/src/tests/python_module.at @@ -0,0 +1,138 @@ +# python_module.at - python module tests +# +# Copyright (C) 2026 Jean-François David +# +# This is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2, or (at your option) +# any later version. +# +# This software is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +AT_BANNER([Python module]) + +AT_SETUP([Python module imports]) +AT_KEYWORDS([python import]) + +AT_SKIP_IF([test "x$TEST_PYTHON_ENABLED" != xyes]) + +AT_CHECK([[test_pythonpath=${TEST_PYTHONPATH-"$abs_top_srcdir/src/modules"} +PYTHONDONTWRITEBYTECODE=1 \ +PYTHONNOUSERSITE=1 \ +PYTHONPATH="$test_pythonpath" \ +"$PYTHON" - "$test_pythonpath" <<'PY' +import os +import sys + +from speechd_python_modules import module_utils, speechd_types + +pythonpath_dir = os.path.realpath(sys.argv[1]) +module_dir = os.path.join(pythonpath_dir, "speechd_python_modules") +modules = [module_utils, speechd_types] + + +def check_module_path(module): + module_file = os.path.realpath(module.__file__) + if os.path.dirname(module_file) != module_dir: + raise SystemExit( + "%s imported from %r, expected %r" + % (module.__name__, module_file, module_dir) + ) + + +for module in modules: + check_module_path(module) +PY +]], [0], [], []) + +AT_CLEANUP + +AT_SETUP([module_strip_ssml]) +AT_KEYWORDS([python module_utils ssml]) + +AT_SKIP_IF([test "x$TEST_PYTHON_ENABLED" != xyes]) + +AT_CHECK([[test_pythonpath=${TEST_PYTHONPATH-"$abs_top_srcdir/src/modules"} +PYTHONDONTWRITEBYTECODE=1 \ +PYTHONNOUSERSITE=1 \ +PYTHONPATH="$test_pythonpath" \ +"$PYTHON" - <<'PY' +from speechd_python_modules import module_utils + +strip_ssml = module_utils.module_strip_ssml + + +def check(message, expected): + actual = strip_ssml(message) + if actual != expected: + raise SystemExit( + "strip_ssml(%r) returned %r, expected %r" + % (message, actual, expected) + ) + + +check("Plain text.", "Plain text.") +check("Hello world.", "Hello world.") +check('Hello world', "Hello world") +check('text', "text") +check( + "Use <tag>, &, "quotes" and 'apostrophes'.", + "Use , &, \"quotes\" and 'apostrophes'.", +) +check("Keep