diff --git a/.github/static/SpeechJokey.spec b/.github/static/SpeechJokey.spec index 95e3b1e..45f5bfc 100644 --- a/.github/static/SpeechJokey.spec +++ b/.github/static/SpeechJokey.spec @@ -4,7 +4,7 @@ from kivymd import hooks_path as kivymd_hooks_path block_cipher = None app_name = 'SpeechJokey' -#win_icon = '.\icon.ico' +win_icon = '.\assets\speech-jokey.png' a = Analysis( ['src\\main.py'], diff --git a/.gitignore b/.gitignore index 859cd7d..1364f60 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # VSCode environment .vscode/.history - +tmp +app_settings.json # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..93f6bac --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: SpeechJokey", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/src/main.py", + "console": "integratedTerminal", + "justMyCode": true + } + ] +} \ No newline at end of file diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..b6d1b2c --- /dev/null +++ b/NOTES.md @@ -0,0 +1,3 @@ +# Relevant links +- (SSML support in voice platforms)[http://ssml.guru/] +- (Speech Markdown Docs)[https://www.speechmarkdown.org/basics/what/] \ No newline at end of file diff --git a/natasa-speech-synthesis.code-workspace b/natasa-speech-synthesis.code-workspace index 8e17d52..0232e30 100644 --- a/natasa-speech-synthesis.code-workspace +++ b/natasa-speech-synthesis.code-workspace @@ -23,8 +23,7 @@ "recommendations": [ "ms-python.python", "ms-python.vscode-pylance", - "Gruntfuggly.todo-tree", - "zeshuaro.vscode-python-poetry" + "Gruntfuggly.todo-tree" ] } } \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index ae86b27..0becade 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11,6 +11,35 @@ files = [ {file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"}, ] +[[package]] +name = "annotated-types" +version = "0.6.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, + {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, +] + +[[package]] +name = "asttokens" +version = "2.4.1" +description = "Annotate AST trees with source code positions" +optional = false +python-versions = "*" +files = [ + {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, + {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, +] + +[package.dependencies] +six = ">=1.12.0" + +[package.extras] +astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] +test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] + [[package]] name = "buildozer" version = "1.5.0" @@ -137,6 +166,28 @@ files = [ {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, ] +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.5" +files = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] + [[package]] name = "distlib" version = "0.3.7" @@ -159,6 +210,51 @@ files = [ {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"}, ] +[[package]] +name = "elevenlabs" +version = "0.2.26" +description = "The official elevenlabs python package." +optional = false +python-versions = "*" +files = [ + {file = "elevenlabs-0.2.26-py3-none-any.whl", hash = "sha256:a3cf8a09157b490cef9a1298957ac120a3e51fbd52282834c463030ba7ccd717"}, + {file = "elevenlabs-0.2.26.tar.gz", hash = "sha256:1bb4f1074ac8b1c2a7d440dcb43dd122c0f183381901ecbcbccfe7e165c0a3aa"}, +] + +[package.dependencies] +ipython = ">=7.0" +pydantic = ">=2.0" +requests = ">=2.20" +websockets = ">=11.0" + +[[package]] +name = "exceptiongroup" +version = "1.2.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, + {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "executing" +version = "2.0.1" +description = "Get the currently executing AST node of a frame, and other information" +optional = false +python-versions = ">=3.5" +files = [ + {file = "executing-2.0.1-py2.py3-none-any.whl", hash = "sha256:eac49ca94516ccc753f9fb5ce82603156e590b27525a8bc32cce8ae302eb61bc"}, + {file = "executing-2.0.1.tar.gz", hash = "sha256:35afe2ce3affba8ee97f2d69927fa823b08b472b7b994e36a52a964b93d16147"}, +] + +[package.extras] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] + [[package]] name = "filelock" version = "3.13.1" @@ -186,6 +282,62 @@ files = [ {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, ] +[[package]] +name = "ipython" +version = "8.18.1" +description = "IPython: Productive Interactive Computing" +optional = false +python-versions = ">=3.9" +files = [ + {file = "ipython-8.18.1-py3-none-any.whl", hash = "sha256:e8267419d72d81955ec1177f8a29aaa90ac80ad647499201119e2f05e99aa397"}, + {file = "ipython-8.18.1.tar.gz", hash = "sha256:ca6f079bb33457c66e233e4580ebfc4128855b4cf6370dddd73842a9563e8a27"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} +prompt-toolkit = ">=3.0.41,<3.1.0" +pygments = ">=2.4.0" +stack-data = "*" +traitlets = ">=5" +typing-extensions = {version = "*", markers = "python_version < \"3.10\""} + +[package.extras] +all = ["black", "curio", "docrepr", "exceptiongroup", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.22)", "pandas", "pickleshare", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio (<0.22)", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"] +black = ["black"] +doc = ["docrepr", "exceptiongroup", "ipykernel", "matplotlib", "pickleshare", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio (<0.22)", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "typing-extensions"] +kernel = ["ipykernel"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["pickleshare", "pytest (<7.1)", "pytest-asyncio (<0.22)", "testpath"] +test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.22)", "pandas", "pickleshare", "pytest (<7.1)", "pytest-asyncio (<0.22)", "testpath", "trio"] + +[[package]] +name = "jedi" +version = "0.19.1" +description = "An autocompletion tool for Python that can be used for text editors." +optional = false +python-versions = ">=3.6" +files = [ + {file = "jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"}, + {file = "jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd"}, +] + +[package.dependencies] +parso = ">=0.8.3,<0.9.0" + +[package.extras] +docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] + [[package]] name = "kivy" version = "2.2.1" @@ -346,6 +498,35 @@ files = [ [package.dependencies] altgraph = ">=0.17" +[[package]] +name = "matplotlib-inline" +version = "0.1.6" +description = "Inline Matplotlib backend for Jupyter" +optional = false +python-versions = ">=3.5" +files = [ + {file = "matplotlib-inline-0.1.6.tar.gz", hash = "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304"}, + {file = "matplotlib_inline-0.1.6-py3-none-any.whl", hash = "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311"}, +] + +[package.dependencies] +traitlets = "*" + +[[package]] +name = "parso" +version = "0.8.3" +description = "A Python Parser" +optional = false +python-versions = ">=3.6" +files = [ + {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, + {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, +] + +[package.extras] +qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] +testing = ["docopt", "pytest (<6.0.0)"] + [[package]] name = "pefile" version = "2023.2.7" @@ -453,6 +634,20 @@ files = [ docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] +[[package]] +name = "prompt-toolkit" +version = "3.0.41" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "prompt_toolkit-3.0.41-py3-none-any.whl", hash = "sha256:f36fe301fafb7470e86aaf90f036eef600a3210be4decf461a5b1ca8403d3cb2"}, + {file = "prompt_toolkit-3.0.41.tar.gz", hash = "sha256:941367d97fc815548822aa26c2a269fdc4eb21e9ec05fc5d447cf09bad5d75f0"}, +] + +[package.dependencies] +wcwidth = "*" + [[package]] name = "ptyprocess" version = "0.7.0" @@ -464,6 +659,179 @@ files = [ {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, ] +[[package]] +name = "pure-eval" +version = "0.2.2" +description = "Safely evaluate AST nodes without side effects" +optional = false +python-versions = "*" +files = [ + {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, + {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, +] + +[package.extras] +tests = ["pytest"] + +[[package]] +name = "pyaudio" +version = "0.2.14" +description = "Cross-platform audio I/O with PortAudio" +optional = false +python-versions = "*" +files = [ + {file = "PyAudio-0.2.14-cp310-cp310-win32.whl", hash = "sha256:126065b5e82a1c03ba16e7c0404d8f54e17368836e7d2d92427358ad44fefe61"}, + {file = "PyAudio-0.2.14-cp310-cp310-win_amd64.whl", hash = "sha256:2a166fc88d435a2779810dd2678354adc33499e9d4d7f937f28b20cc55893e83"}, + {file = "PyAudio-0.2.14-cp311-cp311-win32.whl", hash = "sha256:506b32a595f8693811682ab4b127602d404df7dfc453b499c91a80d0f7bad289"}, + {file = "PyAudio-0.2.14-cp311-cp311-win_amd64.whl", hash = "sha256:bbeb01d36a2f472ae5ee5e1451cacc42112986abe622f735bb870a5db77cf903"}, + {file = "PyAudio-0.2.14-cp312-cp312-win32.whl", hash = "sha256:5fce4bcdd2e0e8c063d835dbe2860dac46437506af509353c7f8114d4bacbd5b"}, + {file = "PyAudio-0.2.14-cp312-cp312-win_amd64.whl", hash = "sha256:12f2f1ba04e06ff95d80700a78967897a489c05e093e3bffa05a84ed9c0a7fa3"}, + {file = "PyAudio-0.2.14-cp38-cp38-win32.whl", hash = "sha256:858caf35b05c26d8fc62f1efa2e8f53d5fa1a01164842bd622f70ddc41f55000"}, + {file = "PyAudio-0.2.14-cp38-cp38-win_amd64.whl", hash = "sha256:2dac0d6d675fe7e181ba88f2de88d321059b69abd52e3f4934a8878e03a7a074"}, + {file = "PyAudio-0.2.14-cp39-cp39-win32.whl", hash = "sha256:f745109634a7c19fa4d6b8b7d6967c3123d988c9ade0cd35d4295ee1acdb53e9"}, + {file = "PyAudio-0.2.14-cp39-cp39-win_amd64.whl", hash = "sha256:009f357ee5aa6bc8eb19d69921cd30e98c42cddd34210615d592a71d09c4bd57"}, + {file = "PyAudio-0.2.14.tar.gz", hash = "sha256:78dfff3879b4994d1f4fc6485646a57755c6ee3c19647a491f790a0895bd2f87"}, +] + +[package.extras] +test = ["numpy"] + +[[package]] +name = "pydantic" +version = "2.5.2" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pydantic-2.5.2-py3-none-any.whl", hash = "sha256:80c50fb8e3dcecfddae1adbcc00ec5822918490c99ab31f6cf6140ca1c1429f0"}, + {file = "pydantic-2.5.2.tar.gz", hash = "sha256:ff177ba64c6faf73d7afa2e8cad38fd456c0dbe01c9954e71038001cd15a6edd"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.14.5" +typing-extensions = ">=4.6.1" + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.14.5" +description = "" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pydantic_core-2.14.5-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:7e88f5696153dc516ba6e79f82cc4747e87027205f0e02390c21f7cb3bd8abfd"}, + {file = "pydantic_core-2.14.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4641e8ad4efb697f38a9b64ca0523b557c7931c5f84e0fd377a9a3b05121f0de"}, + {file = "pydantic_core-2.14.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:774de879d212db5ce02dfbf5b0da9a0ea386aeba12b0b95674a4ce0593df3d07"}, + {file = "pydantic_core-2.14.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ebb4e035e28f49b6f1a7032920bb9a0c064aedbbabe52c543343d39341a5b2a3"}, + {file = "pydantic_core-2.14.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b53e9ad053cd064f7e473a5f29b37fc4cc9dc6d35f341e6afc0155ea257fc911"}, + {file = "pydantic_core-2.14.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aa1768c151cf562a9992462239dfc356b3d1037cc5a3ac829bb7f3bda7cc1f9"}, + {file = "pydantic_core-2.14.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eac5c82fc632c599f4639a5886f96867ffced74458c7db61bc9a66ccb8ee3113"}, + {file = "pydantic_core-2.14.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2ae91f50ccc5810b2f1b6b858257c9ad2e08da70bf890dee02de1775a387c66"}, + {file = "pydantic_core-2.14.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6b9ff467ffbab9110e80e8c8de3bcfce8e8b0fd5661ac44a09ae5901668ba997"}, + {file = "pydantic_core-2.14.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:61ea96a78378e3bd5a0be99b0e5ed00057b71f66115f5404d0dae4819f495093"}, + {file = "pydantic_core-2.14.5-cp310-none-win32.whl", hash = "sha256:bb4c2eda937a5e74c38a41b33d8c77220380a388d689bcdb9b187cf6224c9720"}, + {file = "pydantic_core-2.14.5-cp310-none-win_amd64.whl", hash = "sha256:b7851992faf25eac90bfcb7bfd19e1f5ffa00afd57daec8a0042e63c74a4551b"}, + {file = "pydantic_core-2.14.5-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:4e40f2bd0d57dac3feb3a3aed50f17d83436c9e6b09b16af271b6230a2915459"}, + {file = "pydantic_core-2.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ab1cdb0f14dc161ebc268c09db04d2c9e6f70027f3b42446fa11c153521c0e88"}, + {file = "pydantic_core-2.14.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aae7ea3a1c5bb40c93cad361b3e869b180ac174656120c42b9fadebf685d121b"}, + {file = "pydantic_core-2.14.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:60b7607753ba62cf0739177913b858140f11b8af72f22860c28eabb2f0a61937"}, + {file = "pydantic_core-2.14.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2248485b0322c75aee7565d95ad0e16f1c67403a470d02f94da7344184be770f"}, + {file = "pydantic_core-2.14.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:823fcc638f67035137a5cd3f1584a4542d35a951c3cc68c6ead1df7dac825c26"}, + {file = "pydantic_core-2.14.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96581cfefa9123accc465a5fd0cc833ac4d75d55cc30b633b402e00e7ced00a6"}, + {file = "pydantic_core-2.14.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a33324437018bf6ba1bb0f921788788641439e0ed654b233285b9c69704c27b4"}, + {file = "pydantic_core-2.14.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9bd18fee0923ca10f9a3ff67d4851c9d3e22b7bc63d1eddc12f439f436f2aada"}, + {file = "pydantic_core-2.14.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:853a2295c00f1d4429db4c0fb9475958543ee80cfd310814b5c0ef502de24dda"}, + {file = "pydantic_core-2.14.5-cp311-none-win32.whl", hash = "sha256:cb774298da62aea5c80a89bd58c40205ab4c2abf4834453b5de207d59d2e1651"}, + {file = "pydantic_core-2.14.5-cp311-none-win_amd64.whl", hash = "sha256:e87fc540c6cac7f29ede02e0f989d4233f88ad439c5cdee56f693cc9c1c78077"}, + {file = "pydantic_core-2.14.5-cp311-none-win_arm64.whl", hash = "sha256:57d52fa717ff445cb0a5ab5237db502e6be50809b43a596fb569630c665abddf"}, + {file = "pydantic_core-2.14.5-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:e60f112ac88db9261ad3a52032ea46388378034f3279c643499edb982536a093"}, + {file = "pydantic_core-2.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6e227c40c02fd873c2a73a98c1280c10315cbebe26734c196ef4514776120aeb"}, + {file = "pydantic_core-2.14.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0cbc7fff06a90bbd875cc201f94ef0ee3929dfbd5c55a06674b60857b8b85ed"}, + {file = "pydantic_core-2.14.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:103ef8d5b58596a731b690112819501ba1db7a36f4ee99f7892c40da02c3e189"}, + {file = "pydantic_core-2.14.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c949f04ecad823f81b1ba94e7d189d9dfb81edbb94ed3f8acfce41e682e48cef"}, + {file = "pydantic_core-2.14.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c1452a1acdf914d194159439eb21e56b89aa903f2e1c65c60b9d874f9b950e5d"}, + {file = "pydantic_core-2.14.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb4679d4c2b089e5ef89756bc73e1926745e995d76e11925e3e96a76d5fa51fc"}, + {file = "pydantic_core-2.14.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf9d3fe53b1ee360e2421be95e62ca9b3296bf3f2fb2d3b83ca49ad3f925835e"}, + {file = "pydantic_core-2.14.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:70f4b4851dbb500129681d04cc955be2a90b2248d69273a787dda120d5cf1f69"}, + {file = "pydantic_core-2.14.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:59986de5710ad9613ff61dd9b02bdd2f615f1a7052304b79cc8fa2eb4e336d2d"}, + {file = "pydantic_core-2.14.5-cp312-none-win32.whl", hash = "sha256:699156034181e2ce106c89ddb4b6504c30db8caa86e0c30de47b3e0654543260"}, + {file = "pydantic_core-2.14.5-cp312-none-win_amd64.whl", hash = "sha256:5baab5455c7a538ac7e8bf1feec4278a66436197592a9bed538160a2e7d11e36"}, + {file = "pydantic_core-2.14.5-cp312-none-win_arm64.whl", hash = "sha256:e47e9a08bcc04d20975b6434cc50bf82665fbc751bcce739d04a3120428f3e27"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:af36f36538418f3806048f3b242a1777e2540ff9efaa667c27da63d2749dbce0"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:45e95333b8418ded64745f14574aa9bfc212cb4fbeed7a687b0c6e53b5e188cd"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e47a76848f92529879ecfc417ff88a2806438f57be4a6a8bf2961e8f9ca9ec7"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d81e6987b27bc7d101c8597e1cd2bcaa2fee5e8e0f356735c7ed34368c471550"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34708cc82c330e303f4ce87758828ef6e457681b58ce0e921b6e97937dd1e2a3"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c1988019752138b974c28f43751528116bcceadad85f33a258869e641d753"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e4d090e73e0725b2904fdbdd8d73b8802ddd691ef9254577b708d413bf3006e"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5c7d5b5005f177764e96bd584d7bf28d6e26e96f2a541fdddb934c486e36fd59"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a71891847f0a73b1b9eb86d089baee301477abef45f7eaf303495cd1473613e4"}, + {file = "pydantic_core-2.14.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a717aef6971208f0851a2420b075338e33083111d92041157bbe0e2713b37325"}, + {file = "pydantic_core-2.14.5-cp37-none-win32.whl", hash = "sha256:de790a3b5aa2124b8b78ae5faa033937a72da8efe74b9231698b5a1dd9be3405"}, + {file = "pydantic_core-2.14.5-cp37-none-win_amd64.whl", hash = "sha256:6c327e9cd849b564b234da821236e6bcbe4f359a42ee05050dc79d8ed2a91588"}, + {file = "pydantic_core-2.14.5-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:ef98ca7d5995a82f43ec0ab39c4caf6a9b994cb0b53648ff61716370eadc43cf"}, + {file = "pydantic_core-2.14.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6eae413494a1c3f89055da7a5515f32e05ebc1a234c27674a6956755fb2236f"}, + {file = "pydantic_core-2.14.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcf4e6d85614f7a4956c2de5a56531f44efb973d2fe4a444d7251df5d5c4dcfd"}, + {file = "pydantic_core-2.14.5-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6637560562134b0e17de333d18e69e312e0458ee4455bdad12c37100b7cad706"}, + {file = "pydantic_core-2.14.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77fa384d8e118b3077cccfcaf91bf83c31fe4dc850b5e6ee3dc14dc3d61bdba1"}, + {file = "pydantic_core-2.14.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16e29bad40bcf97aac682a58861249ca9dcc57c3f6be22f506501833ddb8939c"}, + {file = "pydantic_core-2.14.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:531f4b4252fac6ca476fbe0e6f60f16f5b65d3e6b583bc4d87645e4e5ddde331"}, + {file = "pydantic_core-2.14.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:074f3d86f081ce61414d2dc44901f4f83617329c6f3ab49d2bc6c96948b2c26b"}, + {file = "pydantic_core-2.14.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:c2adbe22ab4babbca99c75c5d07aaf74f43c3195384ec07ccbd2f9e3bddaecec"}, + {file = "pydantic_core-2.14.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0f6116a558fd06d1b7c2902d1c4cf64a5bd49d67c3540e61eccca93f41418124"}, + {file = "pydantic_core-2.14.5-cp38-none-win32.whl", hash = "sha256:fe0a5a1025eb797752136ac8b4fa21aa891e3d74fd340f864ff982d649691867"}, + {file = "pydantic_core-2.14.5-cp38-none-win_amd64.whl", hash = "sha256:079206491c435b60778cf2b0ee5fd645e61ffd6e70c47806c9ed51fc75af078d"}, + {file = "pydantic_core-2.14.5-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:a6a16f4a527aae4f49c875da3cdc9508ac7eef26e7977952608610104244e1b7"}, + {file = "pydantic_core-2.14.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:abf058be9517dc877227ec3223f0300034bd0e9f53aebd63cf4456c8cb1e0863"}, + {file = "pydantic_core-2.14.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49b08aae5013640a3bfa25a8eebbd95638ec3f4b2eaf6ed82cf0c7047133f03b"}, + {file = "pydantic_core-2.14.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c2d97e906b4ff36eb464d52a3bc7d720bd6261f64bc4bcdbcd2c557c02081ed2"}, + {file = "pydantic_core-2.14.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3128e0bbc8c091ec4375a1828d6118bc20404883169ac95ffa8d983b293611e6"}, + {file = "pydantic_core-2.14.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88e74ab0cdd84ad0614e2750f903bb0d610cc8af2cc17f72c28163acfcf372a4"}, + {file = "pydantic_core-2.14.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c339dabd8ee15f8259ee0f202679b6324926e5bc9e9a40bf981ce77c038553db"}, + {file = "pydantic_core-2.14.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3387277f1bf659caf1724e1afe8ee7dbc9952a82d90f858ebb931880216ea955"}, + {file = "pydantic_core-2.14.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ba6b6b3846cfc10fdb4c971980a954e49d447cd215ed5a77ec8190bc93dd7bc5"}, + {file = "pydantic_core-2.14.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ca61d858e4107ce5e1330a74724fe757fc7135190eb5ce5c9d0191729f033209"}, + {file = "pydantic_core-2.14.5-cp39-none-win32.whl", hash = "sha256:ec1e72d6412f7126eb7b2e3bfca42b15e6e389e1bc88ea0069d0cc1742f477c6"}, + {file = "pydantic_core-2.14.5-cp39-none-win_amd64.whl", hash = "sha256:c0b97ec434041827935044bbbe52b03d6018c2897349670ff8fe11ed24d1d4ab"}, + {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:79e0a2cdbdc7af3f4aee3210b1172ab53d7ddb6a2d8c24119b5706e622b346d0"}, + {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:678265f7b14e138d9a541ddabbe033012a2953315739f8cfa6d754cc8063e8ca"}, + {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95b15e855ae44f0c6341ceb74df61b606e11f1087e87dcb7482377374aac6abe"}, + {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09b0e985fbaf13e6b06a56d21694d12ebca6ce5414b9211edf6f17738d82b0f8"}, + {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3ad873900297bb36e4b6b3f7029d88ff9829ecdc15d5cf20161775ce12306f8a"}, + {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2d0ae0d8670164e10accbeb31d5ad45adb71292032d0fdb9079912907f0085f4"}, + {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d37f8ec982ead9ba0a22a996129594938138a1503237b87318392a48882d50b7"}, + {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:35613015f0ba7e14c29ac6c2483a657ec740e5ac5758d993fdd5870b07a61d8b"}, + {file = "pydantic_core-2.14.5-pp37-pypy37_pp73-macosx_10_7_x86_64.whl", hash = "sha256:ab4ea451082e684198636565224bbb179575efc1658c48281b2c866bfd4ddf04"}, + {file = "pydantic_core-2.14.5-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ce601907e99ea5b4adb807ded3570ea62186b17f88e271569144e8cca4409c7"}, + {file = "pydantic_core-2.14.5-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb2ed8b3fe4bf4506d6dab3b93b83bbc22237e230cba03866d561c3577517d18"}, + {file = "pydantic_core-2.14.5-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:70f947628e074bb2526ba1b151cee10e4c3b9670af4dbb4d73bc8a89445916b5"}, + {file = "pydantic_core-2.14.5-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4bc536201426451f06f044dfbf341c09f540b4ebdb9fd8d2c6164d733de5e634"}, + {file = "pydantic_core-2.14.5-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f4791cf0f8c3104ac668797d8c514afb3431bc3305f5638add0ba1a5a37e0d88"}, + {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:038c9f763e650712b899f983076ce783175397c848da04985658e7628cbe873b"}, + {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:27548e16c79702f1e03f5628589c6057c9ae17c95b4c449de3c66b589ead0520"}, + {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c97bee68898f3f4344eb02fec316db93d9700fb1e6a5b760ffa20d71d9a46ce3"}, + {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9b759b77f5337b4ea024f03abc6464c9f35d9718de01cfe6bae9f2e139c397e"}, + {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:439c9afe34638ace43a49bf72d201e0ffc1a800295bed8420c2a9ca8d5e3dbb3"}, + {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:ba39688799094c75ea8a16a6b544eb57b5b0f3328697084f3f2790892510d144"}, + {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ccd4d5702bb90b84df13bd491be8d900b92016c5a455b7e14630ad7449eb03f8"}, + {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:81982d78a45d1e5396819bbb4ece1fadfe5f079335dd28c4ab3427cd95389944"}, + {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:7f8210297b04e53bc3da35db08b7302a6a1f4889c79173af69b72ec9754796b8"}, + {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:8c8a8812fe6f43a3a5b054af6ac2d7b8605c7bcab2804a8a7d68b53f3cd86e00"}, + {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:206ed23aecd67c71daf5c02c3cd19c0501b01ef3cbf7782db9e4e051426b3d0d"}, + {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2027d05c8aebe61d898d4cffd774840a9cb82ed356ba47a90d99ad768f39789"}, + {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40180930807ce806aa71eda5a5a5447abb6b6a3c0b4b3b1b1962651906484d68"}, + {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:615a0a4bff11c45eb3c1996ceed5bdaa2f7b432425253a7c2eed33bb86d80abc"}, + {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5e412d717366e0677ef767eac93566582518fe8be923361a5c204c1a62eaafe"}, + {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:513b07e99c0a267b1d954243845d8a833758a6726a3b5d8948306e3fe14675e3"}, + {file = "pydantic_core-2.14.5.tar.gz", hash = "sha256:6d30226dfc816dd0fdf120cae611dd2215117e4f9b124af8c60ab9093b6e8e71"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + [[package]] name = "pygments" version = "2.17.2" @@ -630,6 +998,51 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "stack-data" +version = "0.6.3" +description = "Extract data from python stack frames and tracebacks for informative displays" +optional = false +python-versions = "*" +files = [ + {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, + {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + +[[package]] +name = "traitlets" +version = "5.14.0" +description = "Traitlets Python configuration system" +optional = false +python-versions = ">=3.8" +files = [ + {file = "traitlets-5.14.0-py3-none-any.whl", hash = "sha256:f14949d23829023013c47df20b4a76ccd1a85effb786dc060f34de7948361b33"}, + {file = "traitlets-5.14.0.tar.gz", hash = "sha256:fcdaa8ac49c04dfa0ed3ee3384ef6dfdb5d6f3741502be247279407679296772"}, +] + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<7.5)", "pytest-mock", "pytest-mypy-testing"] + +[[package]] +name = "typing-extensions" +version = "4.8.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, + {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, +] + [[package]] name = "urllib3" version = "2.1.0" @@ -666,7 +1079,99 @@ platformdirs = ">=3.9.1,<5" docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] +[[package]] +name = "wcwidth" +version = "0.2.12" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.12-py2.py3-none-any.whl", hash = "sha256:f26ec43d96c8cbfed76a5075dac87680124fa84e0855195a6184da9c187f133c"}, + {file = "wcwidth-0.2.12.tar.gz", hash = "sha256:f01c104efdf57971bcb756f054dd58ddec5204dd15fa31d6503ea57947d97c02"}, +] + +[[package]] +name = "websockets" +version = "12.0" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "websockets-12.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374"}, + {file = "websockets-12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be"}, + {file = "websockets-12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eb809e816916a3b210bed3c82fb88eaf16e8afcf9c115ebb2bacede1797d2547"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c588f6abc13f78a67044c6b1273a99e1cf31038ad51815b3b016ce699f0d75c2"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5aa9348186d79a5f232115ed3fa9020eab66d6c3437d72f9d2c8ac0c6858c558"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6350b14a40c95ddd53e775dbdbbbc59b124a5c8ecd6fbb09c2e52029f7a9f480"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:70ec754cc2a769bcd218ed8d7209055667b30860ffecb8633a834dde27d6307c"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6e96f5ed1b83a8ddb07909b45bd94833b0710f738115751cdaa9da1fb0cb66e8"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4d87be612cbef86f994178d5186add3d94e9f31cc3cb499a0482b866ec477603"}, + {file = "websockets-12.0-cp310-cp310-win32.whl", hash = "sha256:befe90632d66caaf72e8b2ed4d7f02b348913813c8b0a32fae1cc5fe3730902f"}, + {file = "websockets-12.0-cp310-cp310-win_amd64.whl", hash = "sha256:363f57ca8bc8576195d0540c648aa58ac18cf85b76ad5202b9f976918f4219cf"}, + {file = "websockets-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4"}, + {file = "websockets-12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f"}, + {file = "websockets-12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53"}, + {file = "websockets-12.0-cp311-cp311-win32.whl", hash = "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402"}, + {file = "websockets-12.0-cp311-cp311-win_amd64.whl", hash = "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b"}, + {file = "websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df"}, + {file = "websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc"}, + {file = "websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113"}, + {file = "websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d"}, + {file = "websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f"}, + {file = "websockets-12.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5f6ffe2c6598f7f7207eef9a1228b6f5c818f9f4d53ee920aacd35cec8110438"}, + {file = "websockets-12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9edf3fc590cc2ec20dc9d7a45108b5bbaf21c0d89f9fd3fd1685e223771dc0b2"}, + {file = "websockets-12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8572132c7be52632201a35f5e08348137f658e5ffd21f51f94572ca6c05ea81d"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:604428d1b87edbf02b233e2c207d7d528460fa978f9e391bd8aaf9c8311de137"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a9d160fd080c6285e202327aba140fc9a0d910b09e423afff4ae5cbbf1c7205"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87b4aafed34653e465eb77b7c93ef058516cb5acf3eb21e42f33928616172def"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b2ee7288b85959797970114deae81ab41b731f19ebcd3bd499ae9ca0e3f1d2c8"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7fa3d25e81bfe6a89718e9791128398a50dec6d57faf23770787ff441d851967"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a571f035a47212288e3b3519944f6bf4ac7bc7553243e41eac50dd48552b6df7"}, + {file = "websockets-12.0-cp38-cp38-win32.whl", hash = "sha256:3c6cc1360c10c17463aadd29dd3af332d4a1adaa8796f6b0e9f9df1fdb0bad62"}, + {file = "websockets-12.0-cp38-cp38-win_amd64.whl", hash = "sha256:1bf386089178ea69d720f8db6199a0504a406209a0fc23e603b27b300fdd6892"}, + {file = "websockets-12.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab3d732ad50a4fbd04a4490ef08acd0517b6ae6b77eb967251f4c263011a990d"}, + {file = "websockets-12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1d9697f3337a89691e3bd8dc56dea45a6f6d975f92e7d5f773bc715c15dde28"}, + {file = "websockets-12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1df2fbd2c8a98d38a66f5238484405b8d1d16f929bb7a33ed73e4801222a6f53"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23509452b3bc38e3a057382c2e941d5ac2e01e251acce7adc74011d7d8de434c"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e5fc14ec6ea568200ea4ef46545073da81900a2b67b3e666f04adf53ad452ec"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46e71dbbd12850224243f5d2aeec90f0aaa0f2dde5aeeb8fc8df21e04d99eff9"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b81f90dcc6c85a9b7f29873beb56c94c85d6f0dac2ea8b60d995bd18bf3e2aae"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a02413bc474feda2849c59ed2dfb2cddb4cd3d2f03a2fedec51d6e959d9b608b"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bbe6013f9f791944ed31ca08b077e26249309639313fff132bfbf3ba105673b9"}, + {file = "websockets-12.0-cp39-cp39-win32.whl", hash = "sha256:cbe83a6bbdf207ff0541de01e11904827540aa069293696dd528a6640bd6a5f6"}, + {file = "websockets-12.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc4e7fa5414512b481a2483775a8e8be7803a35b30ca805afa4998a84f9fd9e8"}, + {file = "websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b"}, + {file = "websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30"}, + {file = "websockets-12.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2"}, + {file = "websockets-12.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468"}, + {file = "websockets-12.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611"}, + {file = "websockets-12.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370"}, + {file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"}, + {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, +] + [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.12" -content-hash = "77b31aa2dc212057e5657babda1c5fbe45963bd1fff354ce4b32715fbf2aefd7" +content-hash = "9b338d697376c9a632cb6dcb53d8d752ebc5ee1ee2e6e0be63d29bdff000d0cb" diff --git a/pyproject.toml b/pyproject.toml index 2343e0f..1f4bd77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,8 @@ six = "^1.16.0" buildozer = "^1.5.0" pyinstaller = "^5.6.2" kivymd = "^1.1.1" +elevenlabs = "^0.2.26" +pyaudio = "^0.2.14" [build-system] requires = ["poetry-core"] diff --git a/src/SpeechJokey.kv b/src/SpeechJokey.kv index e799254..258e6e9 100644 --- a/src/SpeechJokey.kv +++ b/src/SpeechJokey.kv @@ -1,5 +1,6 @@ : orientation: 'vertical' + text_input: text_input BoxLayout: orientation: 'vertical' @@ -17,45 +18,49 @@ text: 'Load File' on_press: root.load_file() + Button: + text: 'Save File' + on_press: root.save_file() + Button: text: 'Listen' on_press: root.play_audio() Button: - text: 'Save' - on_press: root.save_file() + text: 'Generate' + on_press: root.generate_audio() + + Button: + text: 'Settings' + on_press: root.open_settings() # Adjustable Split View for the text input and additional functionalities Splitter: sizable_from: 'top' - min_size: 100 - max_size: 500 + orientation: 'vertical' + + TextInput: + id: text_input + multiline: True + hint_text: 'Enter your text here or load from a file...' + # on_text: root.adjust_split_view() + # SSML tag editing tools within the split view BoxLayout: - orientation: 'vertical' - - TextInput: - id: text_input - multiline: True - hint_text: 'Enter your text here or load from a file...' - on_text: root.adjust_split_view() - - # SSML tag editing tools within the split view - BoxLayout: - size_hint_y: None - height: '50dp' - - Button: - text: 'Add Break' - on_press: root.insert_ssml_tag('break') - - Button: - text: 'Change Pitch' - on_press: root.insert_ssml_tag('pitch') - - Button: - text: 'Emphasize' - on_press: root.insert_ssml_tag('emphasis') + size_hint_y: None + height: '50dp' + + Button: + text: 'Add Break' + on_press: root.insert_ssml_tag('break') + + Button: + text: 'Change Pitch' + on_press: root.insert_ssml_tag('pitch') + + Button: + text: 'Emphasize' + on_press: root.insert_ssml_tag('emphasis') # Playback controls BoxLayout: diff --git a/src/api/README.md b/src/api/README.md new file mode 100644 index 0000000..41f3912 --- /dev/null +++ b/src/api/README.md @@ -0,0 +1,101 @@ +# Supported APIs + +This directory contains the client wrappers for the supported APIs. + +A client wrapper is a class that implements the API calls and provides a settings widget for the application to use for configuration. +Any settings that are required for the API shall be stored in the settings class. + +New APIs can be added by following the instructions below and **must** follow the described structure. + +For a reference implementation, see the [example API](exampleapi/). + +## Structure + +An API module **must** be stored in a directory under `src/api/`, using `` as the directory name. + +API modules **must** consist of three classes, stored in the `.py` file: +- `Widget`: The widget for the API to view and edit settings in the application settings popup. This class should use CamelCase naming. +- `Settings`: The settings class for the API, which **must** inherit from `BaseApiSettings`. This class should use CamelCase naming. +- ``: The API implementation class. This class should use CamelCase naming. + +Example for the naming scheme: +``: `exampleapi` +``: `ExampleApi` (should be a CamelCase version of ``) +`Widget`: `ExampleApiWidget` +`Settings`: `ExampleApiSettings` + +The `BaseApiSettings` class implements the required Singleton pattern for the settings class. It ensures proper dynamic loading of the API during application startup by declaring methods that **must** be implemented by the settings class. + +Additionally, the API module may contain a `.kv` file, which contains the kivy rules for the settings view of the specific API. + +The widget for the settings holds the reference to the singleton instance of the settings class for the API. +Whenever settings are changed in the settings widget, the settings class should be updated accordingly. +Whenever settings are used, they shall be retrieved via the settings widget or via the settings class. + +The settings class **must** implement the following methods: +- `isSupported()`: Returns a boolean indicating whether the API is functionally supported by the current environment. Setting this to false will ignore the API during the application startup. +- `get_settings_widget()`: Returns an instance of the settings widget for the API. +- `load_settings()`: Loads the settings from the global settings instance into the internal state of the API settings. Internally this shall call `global_settings.get_setting(api_name, setting_name)` for each setting that is required by your API. +- `save_settings()`: Saves the internal state of the API settings into the global settings instance. Internally this shall call `global_settings.update_setting(api_name, setting_name, value)` for each setting that is required to be stored for your API. + +## How to add a new API + +1. Create a new directory for the API under `src/api/`. +2. Add these files into the created directory `__init__.py`, `.py`, `.kv`. +3. Update `.py` with the following content: + +```python +from kivy.app import App +from ..base_settings import BaseApiSettings + +class Widget(): + # Add the properties for the settings widget here + # Example: example_setting = StringProperty('') + settings = ObjectProperty(None) + + def __init__(self, **kwargs): + super(Widget, self).__init__(**kwargs) + self.add_widget(Label(text="Hello World!")) + self.settings = Settings() + self.settings.load_settings() + +class Settings(BaseApiSettings): + api_name = '' + # Add relevant settings for the API here + # Example: example_setting = 'Foo' + + def __init__(self, **kwargs): + super(ExampleAPISettings, self).__init__(**kwargs) + self.load_settings() + + @classmethod + def isSupported(cls): + return False # Set to true once the API is functionally supported + + @classmethod + def get_settings_widget(cls): + return Widget() + + def load_settings(self): + app_instance = App.get_running_app() + # Update the internal settings state for the API here + # Example: token = app_instance.global_settings.get_setting(self.api_name, "example_setting") + + def save_settings(self): + app_instance = App.get_running_app() + # Save the internal settings state from the API here + # Example: app_instance.global_settings.update_setting(self.api_name, "example_setting", self.example_setting) + +class (): + # The API implementation goes here +``` + +4. Update `.kv` with the following content: + +```kivy +Widget: + # Add the settings view for your specific API here + # Example: TextInput: + # text: root.example_setting + # on_text_validate: root.save_settings() +``` \ No newline at end of file diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/base_settings.py b/src/api/base_settings.py new file mode 100644 index 0000000..1c0a937 --- /dev/null +++ b/src/api/base_settings.py @@ -0,0 +1,50 @@ +from abc import ABC, abstractmethod +from kivy.event import EventDispatcher +from kivy.clock import Clock + +class BaseApiSettings(ABC, EventDispatcher): + _instance = None + + @classmethod + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super(BaseApiSettings, cls).__new__(cls) + return cls._instance + + def __init__(self, **kwargs): + super(BaseApiSettings, self).__init__(**kwargs) + Clock.schedule_once(lambda dt: self.load_settings, 1.5) # Do an initial load of settings + + @classmethod + @abstractmethod + def isSupported(cls): + """ + This property must be overridden in derived classes. + It should return a boolean indicating if the API is functionally supported yet. + """ + pass + + @classmethod + @abstractmethod + def get_settings_widget(cls): + """ + This method must be overridden in derived classes. + It should return the widget that will be displayed in the settings popup. + """ + pass + + @abstractmethod + def load_settings(self): + """ + This method must be overridden in derived classes. + It should load the API specific settings into the application. + """ + pass + + @abstractmethod + def save_settings(self): + """ + This method must be overridden in derived classes. + It should return the settings from the API in JSON format. + """ + pass diff --git a/src/api/elevenlabsapi/__init__.py b/src/api/elevenlabsapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/elevenlabsapi/elevenlabsapi.kv b/src/api/elevenlabsapi/elevenlabsapi.kv new file mode 100644 index 0000000..0ab0cc5 --- /dev/null +++ b/src/api/elevenlabsapi/elevenlabsapi.kv @@ -0,0 +1,26 @@ +: + orientation: 'vertical' + api_key_input: api_key_input + voice_selection: voice_name_spinner + model_selection: model_spinner + + Label: + text: 'ElevenLabs API Key:' + TextInput: + id: api_key_input + text: '' + multiline: False + + Label: + text: 'Voice Name:' + Spinner: + id: voice_name_spinner + text: root.voice_names[0] if root.voice_names else 'No voices available' + values: root.voice_names + + Label: + text: 'Model:' + Spinner: + id: model_spinner + text: root.model_names[0] if root.model_names else 'No models available' + values: root.model_names \ No newline at end of file diff --git a/src/api/elevenlabsapi/elevenlabsapi.py b/src/api/elevenlabsapi/elevenlabsapi.py new file mode 100644 index 0000000..8b836a7 --- /dev/null +++ b/src/api/elevenlabsapi/elevenlabsapi.py @@ -0,0 +1,173 @@ +try: + from elevenlabs import voices, generate, play, save, set_api_key, get_api_key +except ImportError: + raise ImportError("Please install elevenlabs module: pip install elevenlabs (for installation details: https://github.com/elevenlabs/elevenlabs-python)") + +if __name__ == '__main__': + import argparse +from kivy.app import App +from kivy.properties import StringProperty, ListProperty, ObjectProperty +from kivy.logger import Logger as log +from kivy.uix.boxlayout import BoxLayout +import pyaudio +from typing import Iterator, List +from ..base_settings import BaseApiSettings + +class ElevenLabsWidget(BoxLayout): + api_key_input = ObjectProperty(None) + voice_selection = ObjectProperty(None) + model_selection = ObjectProperty(None) + voice_names = ListProperty() + model_names = ListProperty() + settings = ObjectProperty(None) + + def __init__(self, **kwargs): + super(ElevenLabsWidget, self).__init__(**kwargs) + self.voice_names = ElevenLabsTTS.get_voices() + self.voice_names.sort() + self.model_names = ElevenLabsTTS.get_models() + self.settings = ElevenLabsSettings(self) + # Two-way bind api-key + self.api_key_input.bind(text=self.settings.setter('api_key_text')) + self.settings.bind(api_key_text=self.api_key_input.setter('text')) + self.api_key_input.bind(on_text_validate=self.update_key) # Set environment variable for token + # Two-way bind voice + self.voice_selection.bind(text=self.settings.setter('voice_text')) + self.settings.bind(voice_text=self.voice_selection.setter('text')) + # Two-way bind model + self.model_selection.bind(text=self.settings.setter('model_text')) + self.settings.bind(model_text=self.model_selection.setter('text')) + + def update_key(self, instance, value): + set_api_key(value) + instance.text = value + +class ElevenLabsSettings(BaseApiSettings): + api_name = 'ElevenLabs' + api_key_text = StringProperty('') + voice_text = StringProperty('') + model_text = StringProperty('') + widget: ElevenLabsWidget + + @classmethod + def isSupported(cls): + return True + + @classmethod + def get_settings_widget(cls): + return ElevenLabsWidget() + + def __init__(self, widget: ElevenLabsWidget, **kwargs): + super(ElevenLabsSettings, self).__init__(**kwargs) + self.api = ElevenLabsTTS(self) + self.widget = widget + # Done by the schedule_once in super() + # self.load_settings() + + def load_settings(self): + # FIXME Two-way-binding still doesn't update UI upon load + app_instance = App.get_running_app() + self.api_key_text = app_instance.global_settings.get_setting(self.api_name, "api_key", default="") + # self.dispatch("api_key_text") + self.voice_text = app_instance.global_settings.get_setting(self.api_name, "voice", default="") + # self.dispatch("voice_text") + self.model_text = app_instance.global_settings.get_setting(self.api_name, "model", default="") + # self.dispatch("model_text") + app_instance.api = self.api + + def save_settings(self): + app_instance = App.get_running_app() + app_instance.global_settings.update_setting(self.api_name, "api_key", self.api_key_text) + app_instance.global_settings.update_setting(self.api_name, "voice", self.voice_text) + app_instance.global_settings.update_setting(self.api_name, "model", self.model_text) + +# NOTE This is not very functionally solid, just a template for the API integration that can be iterated upon +class ElevenLabsTTS(): + """ + This is a TTS implementation for the ElevenLabs TTS API. + """ + _models = [ + "eleven_multilingual_v2", + "eleven_monolingual_v1" + ] + def __init__(self, settings: ElevenLabsSettings = None, api_key: str = None, voice_name: str = 'Serena', model: str ="eleven_multilingual_v2"): + if settings is None: + if model not in self._models: + raise ValueError(f'Model not supported: {model} (must be one of: {", ".join(self._models)})') + if(not api_key): + api_key = get_api_key() + if(not api_key): + raise ValueError("No API key provided and no API key found in environment variable (ELEVENLABS_API_KEY)"); + else: + set_api_key(api_key) + self.voice = next((v for v in voices() if v.name == voice_name), None) + if(not self.voice): + raise ValueError(f'Voice not found: {voice_name} (available voices: {", ".join(v.name for v in voices())})') + self.model = model + else: + self.settings = settings + + def synthesize(self, input: str, out_filename: str = None): + """ + Synthesize an input using the ElevenLabs TTS API. + + Args: + sentence (str): sentence to be synthesized + out_filename (str): output filename (Optional, if not provided, the audio will be played instead of saved) + """ + if(not input): + raise ValueError("Input must not be empty") + shouldStream = True if not out_filename else False + if self.settings is None: + audio = generate(text=input, voice=self.voice, model=self.model, stream=shouldStream) + if(shouldStream): + play(audio) # FIXME returns a bytes error at the moment + else: + save(audio, out_filename) + else: + self.voice = next((v for v in voices() if v.name == self.settings.voice_text), None) + self.model = self._models[0] + set_api_key(self.settings.api_key_text) + audio = generate(text=input, voice=self.voice, model=self.model, stream=shouldStream) + if(shouldStream): + play(audio) + else: + save(audio, out_filename) + + @staticmethod + def get_config(): + return { + "api_key": str, + "language": str, + "voice": str + } + + @staticmethod + def get_models() -> List[str]: + return ElevenLabsTTS._models + + @staticmethod + def get_voices() -> List[str]: + return [v.name for v in voices()] + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("-f", "--filename", help="Output filename", default=None) + args = parser.parse_args() + tts = ElevenLabsTTS("1935142dfef1ff0488bddbf191a26a94") + tts.synthesize(input=""" +Dear Natascha, + +I hope you are doing well! I just wanted to give you a quick update on our project, the SpeechJokey application. We have a small yet exciting update! + +We've been working on a class template that communicates with the ElevenLabs API. It's still in an early phase and more of a playground at the moment, but it's a step in the right direction. The aim is for you to find your own path as a DJ using a synthetic voice with this application. + +It's a small progress, but an important one. We are still experimenting and trying to fine-tune everything for you. Your thoughts and ideas on this are, as always, very welcome. + +Look forward to more as we make further advancements! + +Best regards, + +Serena the AI voice + """, out_filename=args.filename) \ No newline at end of file diff --git a/src/api/exampleapi/__init__.py b/src/api/exampleapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/exampleapi/exampleapi.kv b/src/api/exampleapi/exampleapi.kv new file mode 100644 index 0000000..16da097 --- /dev/null +++ b/src/api/exampleapi/exampleapi.kv @@ -0,0 +1,6 @@ +: + example_setting_input: example_setting_input + TextInput: + id: example_setting_input + text_hint: "Hello World!" + on_text_validate: root.save_settings diff --git a/src/api/exampleapi/exampleapi.py b/src/api/exampleapi/exampleapi.py new file mode 100644 index 0000000..3d81804 --- /dev/null +++ b/src/api/exampleapi/exampleapi.py @@ -0,0 +1,55 @@ +from kivy.app import App +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.label import Label +from kivy.properties import StringProperty, ObjectProperty +from ..base_settings import BaseApiSettings + +# NOTE This class holds the widget properties and logic for the specific API settings view. It also holds the instance of the specific API settings class. +class ExampleAPIWidget(BoxLayout): + settings = ObjectProperty(None) + example_setting_input = ObjectProperty(None) + + def __init__(self, **kwargs): + super(ExampleAPIWidget, self).__init__(**kwargs) + self.add_widget(Label(text="Hello World!")) # Example initialization for the widget + self.settings = ExampleAPISettings(self) # The settings instance must be created here + self.example_setting_input.bind(text=self.settings.setter('example_setting')) # Bind text input to update example_setting + self.settings.bind(example_setting=self.example_setting_input.setter('text')) # Bind example_setting to update text input + self.settings.load_settings() # An initial loading of the settings is recommended + + def update(self, instance, value): + self.example_setting_input.text = value + +# NOTE This class holds the state of the specific API settings and must be derived from BaseApiSettings, which implements the required Singleton pattern for you. +class ExampleAPISettings(BaseApiSettings): + api_name = 'ExampleAPI' + example_setting = StringProperty('') + + def __init__(self, widget, **kwargs): + super(ExampleAPISettings, self).__init__(**kwargs) + self.load_settings() + self.widget = widget + + @classmethod + def isSupported(cls): + return True + + @classmethod + def get_settings_widget(cls): + return ExampleAPIWidget() + + def load_settings(self): # Settings are loaded using the global settings instance + app_instance = App.get_running_app() + self.example_setting = app_instance.global_settings.get_setting(self.api_name, "example_setting") + + def save_settings(self): # Settings are stored using the global settings instance + app_instance = App.get_running_app() + app_instance.global_settings.update_setting(self.api_name, "example_setting", self.example_setting) + +# NOTE This class holds the API logic and performs the API calls. When instantiated, the settings class of the API shall be passed. +class ExampleAPI(): + def __init__(self, api_settings: ExampleAPISettings): + self.api_settings = api_settings + + def do_stuff(self): + print(f"Doing stuff with Example setting: {self.api_settings.example_setting}") \ No newline at end of file diff --git a/src/assets/speech-jokey.png b/src/assets/speech-jokey.png new file mode 100644 index 0000000..6834573 Binary files /dev/null and b/src/assets/speech-jokey.png differ diff --git a/src/main.py b/src/main.py index 1e65707..5d77c61 100644 --- a/src/main.py +++ b/src/main.py @@ -1,19 +1,61 @@ from kivy.app import App +from kivy.lang import Builder from kivy.uix.boxlayout import BoxLayout -# Import other necessary Kivy modules +from kivy.properties import ObjectProperty +from kivy.logger import Logger as log, LOG_LEVELS +from kivy.config import Config +import os + +from modules.dialog import loaddialog, savedialog +from modules.util.widget_loader import load_widget +from api.elevenlabsapi.elevenlabsapi import ElevenLabsTTS +from settings import app_settings class MainScreen(BoxLayout): + text_input = ObjectProperty(None) + + def __init__(self, **kwargs): + super(MainScreen, self).__init__(**kwargs) + self.file_load_popup = loaddialog.LoadDialog(callback=self.load_textfile, title="Load file", size_hint=(0.9, 0.9)) + # self.file_load_popup.size = (400, 400) + self.file_save_popup = savedialog.SaveDialog(callback=self.save_textfile, title="Save file", size_hint=(0.9, 0.9)) + # self.file_save_popup.size = (400, 400) + self.settings_popup = app_settings.AppSettingsPopup() + def load_file(self): - # Logic to load a text file - pass + # Open dialog + self.file_load_popup.open() + + def save_file(self): + self.file_save_popup.open() + + def load_textfile(self, file: str): + with open(os.path.abspath(file), 'r') as file: + text = file.read() + log.info(f"Text: {text[0:40]}...") + self.text_input.text = text + + def save_textfile(self, file: str): + if file is not None: + with open(file, 'w') as file: + file.write(self.text_input.text) def play_audio(self): # Logic to play audio pass - def save_file(self): - # Logic to save audio file - pass + def generate_audio(self): + # Logic to save generated voice audio to file + api = App.get_running_app().api + if isinstance(api, ElevenLabsTTS): + log.debug(f"Synthesizing: {self.text_input.text[0:10]}...") + try: + api.synthesize(self.text_input.text, os.path.join("tmp/", "tmp.wav")) + except Exception as e: + log.error(f"Audio generation failed: {e}") + + def open_settings(self): + self.settings_popup.open() def insert_ssml_tag(self, tag_name): # Logic to insert SSML tags into text @@ -25,6 +67,15 @@ def playback_control(self, action): class SpeechJokey(App): def build(self): + load_widget(os.path.join(os.path.dirname(loaddialog.__file__), 'loaddialog.kv')) + load_widget(os.path.join(os.path.dirname(savedialog.__file__), 'savedialog.kv')) + load_widget(os.path.join(os.path.dirname(app_settings.__file__), 'AppSettingsPopup.kv')) + self.global_settings = app_settings.GlobalSettings() + self.icon = 'assets/speech-jokey.png' + Config.set('kivy','window_icon', os.path.join(os.path.dirname(__file__), self.icon)) + log.setLevel(LOG_LEVELS["debug"]) + self.title = 'Speech Jokey' + self.api = None return MainScreen() if __name__ == '__main__': diff --git a/src/modules/dialog/loaddialog.kv b/src/modules/dialog/loaddialog.kv new file mode 100644 index 0000000..cf4dfb0 --- /dev/null +++ b/src/modules/dialog/loaddialog.kv @@ -0,0 +1,35 @@ +: + load: load_button + cancel: cancel_button + filechooser: filechooser + label: label + + # Providing the orientation + orientation: 'vertical' + + # Creating the File list / icon view + + BoxLayout: + orientation: "vertical" + # Creating Icon view other side + FileChooserIconView: + size_hint_y: 0.9 + id: filechooser + on_selection: root.on_browser_select(filechooser.selection) + filters: ["*.txt"] + + BoxLayout: + size_hint_y: 0.1 + # Adding label + Label: + id: label + + Button: + id: cancel_button + text: "Cancel" + on_release: root.dismiss() + + Button: + id: load_button + text: "Load" + on_release: root.on_fileload() diff --git a/src/modules/dialog/loaddialog.py b/src/modules/dialog/loaddialog.py new file mode 100644 index 0000000..25e60c3 --- /dev/null +++ b/src/modules/dialog/loaddialog.py @@ -0,0 +1,33 @@ +from kivy.uix.popup import Popup +from kivy.properties import ObjectProperty +from kivy.logger import Logger as log +import os + +class LoadDialog(Popup): + # filebrowser_list = ObjectProperty(None) + label = ObjectProperty(None) + filechooser = ObjectProperty(None) + load = ObjectProperty(None) + cancel = ObjectProperty(None) + + def __init__(self, callback, **kwargs): + super(LoadDialog, self).__init__(**kwargs) + self.filechooser.rootpath = os.path.expanduser("~") + self.callback = callback + + def on_browser_select(self, selection): + if len(self.filechooser.selection) == 0: + return + selection = selection[0] + log.debug(f"Selection: {selection}") + self.label.text = selection + + def on_fileload(self): + if len(self.filechooser.selection) == 0: + log.info("No file selected") + return + selection = self.filechooser.selection[0] + log.info(f"Selected file: {selection}") + self.dismiss() + self.callback(selection) + self.label.text = "" \ No newline at end of file diff --git a/src/modules/dialog/savedialog.kv b/src/modules/dialog/savedialog.kv new file mode 100644 index 0000000..f2df9e0 --- /dev/null +++ b/src/modules/dialog/savedialog.kv @@ -0,0 +1,30 @@ +: + filechooser: filechooser + filename_input: filename_input + BoxLayout: + orientation: 'vertical' + FileChooserIconView: + id: filechooser + size_hint_y: 0.9 + on_selection: root.on_browser_select(filechooser.selection) + BoxLayout: + size_hint_y: 0.1 + orientation: "horizontal" + Label: + text: "Filename:" + #size_hint_y: 0.1 + text_size: self.size + halign: "right" + valign: "center" + TextInput: + id: filename_input + #size_hint_y: 0.1 + multiline: False + BoxLayout: + size_hint_y: 0.1 + Button: + text: "Cancel" + on_press: root.dismiss() + Button: + text: "Save" + on_press: root.on_filesave(filechooser.path, filename_input.text) diff --git a/src/modules/dialog/savedialog.py b/src/modules/dialog/savedialog.py new file mode 100644 index 0000000..6816810 --- /dev/null +++ b/src/modules/dialog/savedialog.py @@ -0,0 +1,36 @@ +from kivy.uix.popup import Popup +from kivy.properties import ObjectProperty +from kivy.logger import Logger as log +import os + +class SaveDialog(Popup): + # filebrowser_list = ObjectProperty(None) + filechooser = ObjectProperty(None) + filename_input = ObjectProperty(None) + load = ObjectProperty(None) + cancel = ObjectProperty(None) + + def __init__(self, callback, **kwargs): + super(SaveDialog, self).__init__(**kwargs) + self.filechooser.rootpath = os.path.expanduser("~") + self.callback = callback + + def on_browser_select(self, selection): + if len(selection) == 0: + return + log.debug(f"File selected: {selection[0]}") + self.filename_input.text = os.path.basename(selection[0]) + + def on_filesave(self, path, filename): + if len(path) == 0: + return + elif os.path.isfile(path[0]): + path = os.path.dirname(path[0]) + else: + path = path[0] + full_path = os.path.join(path, filename) + # Implement the actual file saving logic here + print("Saving to:", full_path) + # Close the popup after saving + self.dismiss() + self.callback(full_path) \ No newline at end of file diff --git a/src/modules/speech-markdown/__init__.py b/src/modules/speech-markdown/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/speech-markdown/simple_speech_markdown.py b/src/modules/speech-markdown/simple_speech_markdown.py new file mode 100644 index 0000000..9fa41db --- /dev/null +++ b/src/modules/speech-markdown/simple_speech_markdown.py @@ -0,0 +1,347 @@ +class SimpleSpeechMarkdown: + """ + Simple Speech Markdown + A class for creating Speech Markdown strings with simple methods for each Speech Markdown element. + See Also: + https://www.speechmarkdown.org/syntax/ + + Args: + None + Attributes: + _emphasis_levels (dict): Emphasis levels + _rate_levels (list): Rate levels + _pitch_levels (list): Pitch levels + _time_formats (list): Time formats + _units (list): Units + _volume_levels (list): Volume levels + """ + _emphasis_levels = { + "strong": "++", + "moderate": "+", + "none": "~", + "reduced": "-", + } + _rate_levels = [ + "x-slow", + "slow", + "medium", + "fast", + "x-fast" + ] + _pitch_levels = [ + "x-high", + "high", + "medium", + "low", + "x-low" + ] + _time_formats = [ + "hms12", + "hms24" + ] + _units = [ + "foot", + "ft" + ] + _volume_levels = [ + "silent", + "x-soft", + "soft", + "medium", + "loud", + "x-loud" + ] + def __init__(self): + pass + def address(self, text: str) -> str: + """ + Args: + text (str): Address text + Returns: + str: Speech Markdown address (e.g. "(123 Main Street)[address]") + See Also: + https://www.speechmarkdown.org/syntax/address/ + """ + return f'({text})[address]' + def audio(self, url, caption: str = "", standard: bool = True) -> str: + """ + Args: + url (str): Audio URL + caption (str): Audio caption + standard (bool): Standard or short format + Returns: + str: Speech Markdown audio (e.g. "!({caption})[\"{url}\"]") + See Also: + https://www.speechmarkdown.org/syntax/audio/ + """ + if(standard): + return f'!({caption})[\"{url}\"]' + else: # short format + return f'![\"{url}\"]' + def break_(self, time: int = 500, time_as: str = "ms", strength: str = None) -> str: + """ + Args: + time (int): Time + time_as (str): Time unit + strength (str): Strength + Returns: + str: Speech Markdown break (e.g. "[500ms]" or "[break:\"strong\"]") + See Also: + https://www.speechmarkdown.org/syntax/break/ + """ + if(strength): + return f'[break:\"{strength}\"]' + else: + return f'[{time}{time_as}]' + def cardinal(self, number: int) -> str: + """ + Args: + number (int): Cardinal number + Returns: + str: Speech Markdown cardinal (e.g. "(123)[cardinal]") + See Also: + https://www.speechmarkdown.org/syntax/cardinal/ + """ + return f'({number})[cardinal]' + def characters(self, text: str) -> str: + """ + Args: + text (str): Characters + Returns: + str: Speech Markdown characters (e.g. "(abc)[characters]") + See Also: + https://www.speechmarkdown.org/syntax/characters/ + """ + return f'({text})[characters]' + def date(self, text: str, format: str = "dmy") -> str: + """ + Args: + text (str): Date text + format (str): Date format + Returns: + str: Speech Markdown date (e.g. "(01/01/2020)[date:dmy]") + See Also: + https://www.speechmarkdown.org/syntax/date/ + """ + # TODO date text verification (supports seperators: slash /, dash -, dot .) + return f'({text})[date:{format}]' + def emphasis(self, text: str, level: str, inline: bool = False) -> str: + """ + Args: + text (str): Emphasis text + level (str): Emphasis level (must be one of "strong", "moderate", "none", "reduced") + inline (bool): Inline or standard format + Returns: + str: Speech Markdown emphasis (e.g. "(text)[emphasis:\"strong\"] or "++text++" with inline=True) + Raises: + ValueError: Invalid emphasis level + See Also: + https://www.speechmarkdown.org/syntax/emphasis/ + """ + if(level not in self._emphasis_levels.keys()): + raise ValueError(f'Invalid emphasis level: {level} (must be one of {self._emphasis_levels.keys()})') + if(inline): + return f'{_emphasis_levels[level]}{text}{_emphasis_levels[level]}' + else: + return f'({text})[emphasis:\"{level}\"]' + def expletive(self, text: str) -> str: + """ + Args: + text (str): Expletive text + Returns: + str: Speech Markdown expletive (e.g. "(text)[expletive]") + See Also: + https://www.speechmarkdown.org/syntax/expletive/ + """ + return f'({text})[expletive]' + def fraction(self, numerator, denominator) -> str: + """ + Args: + numerator: Numerator (must be int or str) + denominator: Denominator (must be int or str) + Returns: + str: Speech Markdown fraction (e.g. "(1/2)[fraction]" or "(1+1/2)[fraction]") + Raises: + ValueError: Invalid numerator type (must be int or str) + ValueError: Invalid denominator type (must be int or str) + See Also: + https://www.speechmarkdown.org/syntax/fraction/ + """ + # can be int (e.g. 4) or str (e.g. '1+1') + if(type(numerator) is not int and type(numerator) is not str): + raise ValueError(f'Invalid numerator type: {type(numerator)} (must be int or str)') + if(type(denominator) is not int and type(denominator) is not str): + raise ValueError(f'Invalid denominator type: {type(denominator)} (must be int or str)') + return f'({numerator}/{denominator})[fraction]' + def interjection(self, text: str) -> str: + """ + Args: + text (str): Interjection text + Returns: + str: Speech Markdown interjection (e.g. "(text)[interjection]") + See Also: + https://www.speechmarkdown.org/syntax/interjection/ + """ + return f'({text})[interjection]' + def ipa(self, text: str, phonetic: str, short: bool = False) -> str: + """ + Args: + text (str): Text + phonetic (str): Phonetic + short (bool): Short or standard format + Returns: + str: Speech Markdown IPA (e.g. "(pecan)[ipa:\"pɪˈkɑːn\"]") + See Also: + https://www.speechmarkdown.org/syntax/ipa/ + """ + if(short): + return f'({text})[/{phonetic}/]' + else: + return f'({text})[ipa:\"{phonetic}\"]' + def lang(self, text: str, lang: str) -> str: + """ + Args: + text (str): Text + lang (str): Language + Returns: + str: Speech Markdown lang (e.g. "(text)[lang:\"en-US\"]") + See Also: + https://www.speechmarkdown.org/syntax/lang/ + """ + # TODO Verify language code for lang() + return f'({text})[lang:\"{lang}\"]' + def number(self, number: int) -> str: + """ + Args: + number (int): Number + Returns: + str: Speech Markdown number (e.g. "(123)[number]") + See Also: + https://www.speechmarkdown.org/syntax/number/ + """ + # NOTE number is the same as cardinal + return f'({number})[number]' + def ordinal(self, number: int) -> str: + """ + Args: + number (int): Ordinal number + Returns: + str: Speech Markdown ordinal (e.g. "(1)[ordinal]") + See Also: + https://www.speechmarkdown.org/syntax/ordinal/ + """ + return f'({number})[ordinal]' + def phone(self, text: str, country_code: str = "1") -> str: + """ + Args: + text (str): Text + country_code (str): Country code + Returns: + str: Speech Markdown phone (e.g. "(555-555-5555)[phone]" or "(555-555-5555)[phone:\"1\"]") + See Also: + https://www.speechmarkdown.org/syntax/phone/ + """ + # TODO Verify phone number for phone() + return f'({text})[phone:\"{number}\"]' + def pitch(self, text: str, level: str = "medium") -> str: + """ + Args: + text (str): Pitch text + level (str): Pitch level (must be one of: x-high, high, medium, low, x-low) + Returns: + str: Speech Markdown pitch (e.g. "(text)[pitch:\"x-high\"]") + Raises: + ValueError: Invalid pitch level (must be one of: x-high, high, medium, low, x-low) + See Also: + https://www.speechmarkdown.org/syntax/pitch/ + """ + if(level not in self._pitch_levels): + raise ValueError(f'Invalid pitch level: {level} (must be one of: {", ".join(self._pitch_levels)})') + return f'({text})[pitch:\"{level}\"]' + def rate(self, text: str, speed: str = "medium") -> str: + """ + Args: + text (str): Rate text + speed (str): Rate speed (must be one of: x-slow, slow, medium, fast, x-fast) + Returns: + str: Speech Markdown rate (e.g. "(text)[rate:\"x-slow\"]") + Raises: + ValueError: Invalid rate speed (must be one of: x-slow, slow, medium, fast, x-fast) + See Also: + https://www.speechmarkdown.org/syntax/rate/ + """ + if(speed not in self._rate_levels): + raise ValueError(f'Invalid rate speed: {speed} (must be one of: {", ".join(self._rate_levels)})') + return f'({text})[rate:\"{speed}\"]' + def sub(self, abbreviation: str, text: str, short: bool = False) -> str: + """ + Args: + abbreviation (str): Abbreviation + text (str): Text + Returns: + str: Speech Markdown sub (e.g. "(abbreviation)[sub:\"text\"]" or "(abbreviation)[\"text\"]" if short=True) + See Also: + https://www.speechmarkdown.org/syntax/sub/ + """ + if(short): + return f'({abbreviation})[\"{text}\"]' + else: + return f'({text})[sub:\"{abbreviation}\"]' + def time(self, time: str, format: str = "hms24") -> str: + """ + Args: + time (str): Time + format (str): Time format (must be one of: hms12, hms24) + Returns: + str: Speech Markdown time (e.g. "(1:30pm)[time:\"hms12\"]" or "(13:00)[time:\"hms24\"]") + Raises: + ValueError: Invalid time format (must be one of: hms12, hms24) + See Also: + https://www.speechmarkdown.org/syntax/time/ + """ + # TODO Verify provided time for time() + if(format not in self._time_formats): + raise ValueError(f'Invalid time format: {format} (must be one of: {", ".join(self._time_formats)})') + return f'({time})[time:\"{format}\"]' + def unit(self, number: int, unit: str) -> str: + """ + Args: + number (int): Number + unit (str): Unit (Not all unit types are supported yet, currently only supports: foot, ft) + Returns: + str: Speech Markdown unit (e.g. "(1 ft)[unit]" or "(1 foot)[unit]") + Raises: + ValueError: Invalid unit (must be one of: foot, ft) + See Also: + https://www.speechmarkdown.org/syntax/unit/ + """ + # TODO Add support for more unit types for unit() + # TODO Convert metric units to SSML supported imperial units for unit() + if(unit not in self._units): + raise ValueError(f'Invalid unit: {unit} (must be one of: {", ".join(self._units)})' + return f'({number} {unit})[unit]' + def volume(self, text: str, level: str = "medium") -> str: + """ + Args: + text (str): Volume text + level (str): Volume level (must be one of: silent, x-soft, soft, medium, loud, x-loud) + Returns: + str: Speech Markdown volume (e.g. "(text)[volume:\"silent\"]") + Raises: + ValueError: Invalid volume level (must be one of: silent, x-soft, soft, medium, loud, x-loud) + See Also: + https://www.speechmarkdown.org/syntax/volume/ + """ + if(level not in self._volume_levels): + raise ValueError(f'Invalid volume level: {level} (must be one of: {", ".join(self._volume_levels)})') + return f'({text})[volume:\"{level}\"]' + def whisper(self, text: str) -> str: + """ + Args: + text (str): Whisper text + Returns: + str: Speech Markdown whisper (e.g. "(text)[whisper]") + See Also: + https://www.speechmarkdown.org/syntax/whisper/ + """ + return f'({text})[whisper]' \ No newline at end of file diff --git a/src/modules/ssml/Speech.py b/src/modules/ssml/Speech.py deleted file mode 100644 index 4b16242..0000000 --- a/src/modules/ssml/Speech.py +++ /dev/null @@ -1,234 +0,0 @@ -# Credits: https://github.com/user3301/ssml_builder/blob/master/builder.py -# coding=utf-8 -""" -A utility class for building SSML format text. -@author: user3301 -@date: 2018-02-06 -""" -import re - -from six import string_types - -class Speech: - # @constructor - def __init__(self): - self.content = [] - - # This appends raw text into the tag. - # @param saying The raw text insert into the speak tag. - # returns {self} - def say(self, saying): - self.present(saying, "The saying provided was null") - self.content.append(self.escape(saying)) - return self - - # inserts a paragraph tag. - # @param paragraph The paragraph of text to insert - # @returns {self} - def paragraph(self, paragraph): - self.present(paragraph, "The paragraph was null") - self.content.append("

" + self.escape(paragraph) + "

") - return self - - # insert a sentence tag. - # @param saying The sentence to insert - # @returns {self} - def sentence(self, saying): - self.present(saying, "The sentence was null") - self.content.append("" + self.escape(saying) + "") - return self - - # insert a break tag. - # @param duration the duration for the pause - # @returns {self} - def pause(self, duration): - self.present(duration, "The duration was null") - self.validateDuration(duration) - self.content.append("") - return self - - # create break tag that will pause the audio based upon the strength level. - # @param strength the strength level - # @returns {self} - def pauseByStrength(self, strength): - self.present(strength, "the strength was null") - strength = strength.lower().strip() - if strength in ("none", "x-weak", "weak", "medium", "strong", "x-strong"): - self.content.append("") - return self - else: - raise ValueError("The strength was not valid.") - - # insert a say-as = spell-out tag - # @param word the raw text insert into the say-as tag - # @returns {self} - def spell(self, word): - self.present(word, "The word was null") - self.content.append("" + self.escape(word) + "") - return self - - # insert a say-as = spell-out tag for every single word - # @param words the raw text - # @param delay the interval in each word - # @returns {self} - def spellSlowly(self, words, delay): - self.present(words, "The word was null") - self.validateDuration(delay) - for word in words: - self.content.append("" + self.escape(word) + "") - self.pause(delay) - return self - - # ----------------------------------------------amazon effect ------------------------------------------------------ - - # insert an amazon "whispered" effect tag - # @param saying the raw text - # @returns {self} - def whispered(self, saying): - self.present(saying, "The saying is null") - self.content.append("" + self.escape(saying) + "") - return self - - # TODO tag for timbre - - # insert an amazon "soft phonation" effect tag - # @param saying the raw text - # @returns {self} - def softPhonation(self, saying): - self.present(saying, "The saying is null") - self.content.append("" + self.escape(saying) + "") - return self - - # insert an amazon "dynamic range compression(drc)" effect tag to enhance the volume of certain sounds in a conversation - # @param saying the raw text - # @returns {self} - def drc(self, saying): - self.present(saying, "The saying is null") - self.content.append("" + self.escape(saying) + "") - return self - - # insert a mark tag, this tag provides the user with the ability to place a custom tag within the text. No action is - # taken on the tag by Amazon Polly, but when SSML metadata is returned, the position of this tag will also be returned. - # @param tag_name the custom tag - # returns {self} - # def mark(self, tag_name): - # self.present(tag_name, "Tag name is null") - # self.content.append(""% tag_name) - # return self - - # -----------------------------------------end of amazon effect------------------------------------------------------ - - # insert an emphasis tag - # @param saying the raw text - # @param level the degree of emphasis that you want to place on the text - # @returns {self} - def emphasis(self, saying, level): - self.present(saying, "The saying is null") - if level.lower() not in ["strong", "moderate", "reduced"]: - raise Exception("The level type is invalid") - else: - self.content.append("" % level + self.escape(saying) + "") - return self - - # insert an language tag - # @param saying the raw text - # @param accentType the accent voice attemptted to use - # @returns {self} - def lang(self, saying, accentType): - langSet = {"da-DK", "nl-NL", "en-AU", "en-GB", "en-IN", "en-US", "en-GB-WLS", "fr-FR", "fr-CA", "de-DE", - "is-IS", "it-IT", "ja-JP", "ko-KR", "nb-NO", "pl-PL", "pt-BR", "pt-PT", "ro-RO", "ru-RU", "es-ES", - "es-US", "sv-SE", "tr-TR", "cy-GB"} - self.present(saying, "The saying is null") - if accentType not in langSet: - raise Exception("The language type is invalid") - else: - self.content.append("" % accentType + self.escape(saying) + "") - return self - - # insert a phonetic tag for the indicated text to provide a phonetic pronunciation - # @param word the indicated text - # @param alphabet phoneme system used - # @param ph indicates the phonetic symbols to be used for pronunciation - # returns {self} - def phoneme(self, word, alphabet, ph): - self.present(word, "The word is null") - if alphabet is "ipa": - ipaPhonemes = {"b", "d", "d͡ʒ", "ð", "f", "g", "h", "j", "k", "l", "m", "n", "ŋ", "p", "ɹ", "s", "ʃ", "t", "t͡ʃ", "Θ", "v", "w", "z", "ʒ", "ə", "ɚ", "æ", "aɪ", "aʊ", "ɑ", "eɪ", "ɝ", "ɛ", "i:", "ɪ", "oʊ", "ɔ", "ɔɪ", "u", "ʊ", "ʌ", "\'", ",", "."} - if set(ph).issubset(ipaPhonemes): - phValue = ''.join(str(x) for x in ph) - self.content.append(""%(alphabet, phValue) + self.escape(word) + "") - else: - raise Exception("The phonetic symbols is invalid") - elif alphabet is "x-sampa": - xsampaPhonemes = {"b", "d", "dZ", "D", "f", "g", "h", "j", "k", "l", "m", "n", "N", "p", "r\\", "s", "S", "t", "tS", "T", "v", "w", "z", "Z", "@", "@\'", "{", "aI", "aU", "A", "eI", "3\'", "E", "i", "I", "oU", "O", "OI", "u", "U", "V", "\"", "%", "."} - if set(ph).issubset(xsampaPhonemes): - phValue = ''.join(str(x) for x in ph) - self.content.append("" % (alphabet, phValue) + self.escape(word) + "") - else: - raise Exception("The phonetic symbols is invalid") - else: - raise Exception("The alphabet standard is invalid") - return self - - def prosody(self, word, attributes): - self.present(word, "The word is null") - validRates = {"x-slow", "slow", "medium", "fast", "x-fast"} - validPitches = {"x-slow", "slow", "medium", "fast", "x-fast"} - validVolumns = {"silent", "x-soft", "soft", "medium", "loud", "x-loud"} - #TODO wip - - - # This method escapes any special characters that will cause SSML to be invalid - # @param word the word needs to be examed - # returns {word} the replaced word string - @classmethod - def escape(self, word): - if isinstance(word, string_types): - word = word.replace('&', 'and') - word = word.replace('<', '') - word = word.replace('>', '') - word = word.replace('\"', '') - word = word.replace('\'', '') - return word - if isinstance(word, (int, float, complex, bool)): - return word - raise Exception("received invalid type") - - # check if the duration is in correct format (a positive number followed by 's' or 'ms') and in the legit - # range (0ms - 10000ms) - # @param duration The duration of a pause - # @throws Exception when the duration is not in the correct format or exceed the legit length - @classmethod - def validateDuration(self, duration): - pattern = "^(\d*\.?\d+)(s|ms)$" - if re.match(pattern, duration): - matcher = re.search(pattern, duration) - pauseDuration = int(matcher.group(1)) - pauseType = matcher.group(2) - if pauseType.lower() == 's' and pauseDuration > 10: - raise Exception("The duration exceeds the maximum length.") - elif pauseDuration > 10000: - raise Exception("The duration exceeds the maximum length.") - else: - raise Exception("The format of the duration is incorrect.") - - # Validates that the provided value is not null or undefined. It will throw an exception if it's either. - @classmethod - def present(self, value, msg): - if value is None: - raise Exception(msg) - - # construct an SSML format string - # @param excludeSpeakTag boolean value to determine if root tag is needed - # @returns {string} - def ssml(self, excludeSpeakTag): - if excludeSpeakTag: - return ' '.join(self.content) - else: - return '' + ' '.join(self.content) + '' - - # convert SSML format string into XML format - # returns {xml} - def toXML(self): - xml = "\n" + self.ssml(True) + "" - return xml \ No newline at end of file diff --git a/src/modules/ssml/ssml_wrapper.py b/src/modules/ssml/ssml_wrapper.py new file mode 100644 index 0000000..cc12d4b --- /dev/null +++ b/src/modules/ssml/ssml_wrapper.py @@ -0,0 +1,7 @@ +"""ssml_wrapper.py + +""" + +class SSMLWrapper: + def __init__(self): + pass \ No newline at end of file diff --git a/src/modules/util/widget_loader.py b/src/modules/util/widget_loader.py new file mode 100644 index 0000000..2d122e9 --- /dev/null +++ b/src/modules/util/widget_loader.py @@ -0,0 +1,10 @@ +from kivy.lang import Builder +import os + +def load_widget(kv_file_path: str = None): + # Load kivy file if provided + if kv_file_path is not None and os.path.exists(kv_file_path): + Builder.unload_file(kv_file_path) + Builder.load_file(kv_file_path) + else: + raise ValueError("Invalid kv file path provided: {kv_file_path}") \ No newline at end of file diff --git a/src/settings/AppSettingsPopup.kv b/src/settings/AppSettingsPopup.kv new file mode 100644 index 0000000..1dfd6e8 --- /dev/null +++ b/src/settings/AppSettingsPopup.kv @@ -0,0 +1,46 @@ +: + settings_container: settings_container + title: 'Application Settings' + size_hint: None, None + auto_dismiss: False + size: 600, 600 + + BoxLayout: + orientation: 'vertical' + padding: [10, 10, 10, 10] + spacing: 15 + + BoxLayout: + id: settings_container + orientation: "horizontal" + height: 20 + BoxLayout: + orientation: "vertical" + Label: + text: 'Select API:' + height: 20 + Spinner: + id: api_spinner + text: 'None' + values: root.api_options + on_text: root.on_api_selected(self.text) + height: 20 + + BoxLayout: + id: bottom_button_layout + size_hint_y: None + height: '48dp' + spacing: 10 + + Button: + text: 'Save' + on_release: root.save_settings() + Button: + text: 'Load' + on_release: root.load_settings() + Button: + text: 'Cancel' + on_release: root.dismiss() + Button: + text: 'Reset' + on_release: root.reset_settings() \ No newline at end of file diff --git a/src/settings/__init__.py b/src/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/settings/app_settings.py b/src/settings/app_settings.py new file mode 100644 index 0000000..c33e49d --- /dev/null +++ b/src/settings/app_settings.py @@ -0,0 +1,148 @@ +from kivy.app import App +from kivy.uix.popup import Popup +from kivy.properties import ObjectProperty, ListProperty +from kivy.event import EventDispatcher +from kivy.logger import Logger as log +import os +import importlib +import inspect +import json +from pathlib import Path + +from api.base_settings import BaseApiSettings +from modules.util.widget_loader import load_widget + +def none_settings(): + pass + +class GlobalSettings(EventDispatcher): + _instance = None + _settings_file = "app_settings.json" + _default_settings = {} + + def __new__(cls): + if cls._instance is None: + cls._instance = super(GlobalSettings, cls).__new__(cls) + cls._instance.load_or_initialize_settings() + return cls._instance + + def load_or_initialize_settings(self): + if not os.path.exists(self._settings_file): + self.reset() + else: + with open(self._settings_file, 'r') as file: + self._settings = json.load(file) + + + def save_settings(self): + with open(self._settings_file, 'w') as file: + json.dump(self._settings, file, indent=4) + log.info(f"{self.__class__.__name__}: Settings saved: {self._settings_file}") + + def load_settings(self): + if os.path.exists(self._settings_file): + with open(self._settings_file, 'r') as file: + self._settings = json.load(file) + else: + log.error(f"{self.__class__.__name__}: Settings file does not exist. Reset or save is required.") + + def update_setting(self, api_name, key, value): + log.debug(f"{self.__class__.__name__}: Update {api_name}: {key} to '{value}'.") + if api_name in self._settings.keys(): + self._settings[api_name][key] = value + self.save_settings() + else: + self._settings[api_name] = {key: value} + self.save_settings() + + def get_setting(self, api_name, key, default=None): + value = self._settings.get(api_name, {}).get(key, default) + log.debug(f"{self.__class__.__name__}: Load {key}: {value}") + return value + + def reset(self): + self._settings = self._default_settings.copy() + self.save_settings() + +class AppSettingsPopup(Popup): + settings_container = ObjectProperty(None) + api_settings_container = ObjectProperty(None) + supported_apis = {'None': none_settings} + api_options = ListProperty(supported_apis.keys()) + + def __init__(self, **kwargs): + super(AppSettingsPopup, self).__init__(**kwargs) + self.discover_supported_apis() + + def discover_supported_apis(self): + api_dir = Path(__file__).parent.parent / "api" + skipped = ['__pycache__'] + for api_path in api_dir.iterdir(): + if api_path.is_dir() and api_path.name not in skipped: + api_name = api_path.name.capitalize() + log.debug(f"{self.__class__.__name__}: Potential API {api_name}") + settings_module_name = f"api.{api_name.lower()}.{api_name.lower()}" + try: + settings_module = importlib.import_module(settings_module_name) + # Iterate over all members of the module and find the subclass of BaseApiSettings + for name, obj in inspect.getmembers(settings_module, predicate=inspect.isclass): + if issubclass(obj, BaseApiSettings) and obj is not BaseApiSettings: + if obj.isSupported(): + self.supported_apis[api_name] = lambda api_name=api_name, obj=obj: self.load_api_settings_widget(api_name, obj) + log.info(f"{self.__class__.__name__}: Discovered API {api_name}: {obj.__name__}") + self.api_options = self.supported_apis.keys() + else: + log.debug(f"{self.__class__.__name__}: API {api_name} is not supported yet") + break + else: + log.debug(f"{self.__class__.__name__}: Skipping {name} in {settings_module_name}") + except ImportError as e: + log.error(f"{self.__class__.__name__}: Could not import {settings_module_name}: {e}") + else: + log.debug(f"{self.__class__.__name__}: Skipping {api_path.name}") + + def on_api_selected(self, api_name): + if api_name in self.supported_apis: + self.supported_apis[api_name]() + else: + log.info(f"{self.__class__.__name__}: API {api_name} is not supported yet") + + def load_api_settings_widget(self, api_name: str, settings_class: BaseApiSettings): + try: + # Get the module in which the class is defined + module = inspect.getmodule(settings_class) + if module is None: + raise ImportError(f"Module for class {settings_class.__name__} not found") + + # Load the KV file + kv_file_path = os.path.join(os.path.dirname(module.__file__), f"{module.__file__.replace('.py', '.kv')}") + load_widget(kv_file_path) + + # Load the settings widget + self.load_settings_widget(settings_class.get_settings_widget()) + except (ImportError, AttributeError, ValueError) as e: + log.error(f"{self.__class__.__name__}: Error loading settings for {api_name}: {e}") + + def load_settings_widget(self, settings_widget): + if self.api_settings_container is not None: + self.settings_container.remove_widget(self.api_settings_container) + self.api_settings_container = settings_widget + self.settings_container.add_widget(self.api_settings_container) + self.api_settings_container.settings.load_settings() + + def save_settings(self): + # Logic to save settings + self.api_settings_container.settings.save_settings() + self.dismiss() + + def load_settings(self): + App.get_running_app().global_settings.load_settings() + if self.api_settings_container is not None: + self.api_settings_container.settings.load_settings() + self.settings_container.do_layout() + + def reset_settings(self): + App.get_running_app().global_settings.reset() + if self.api_settings_container is not None: + self.settings_container.remove_widget(self.api_settings_container) + self.api_settings_container = ObjectProperty(None) \ No newline at end of file