├── .devcontainer.json ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── dependabot.yml └── workflows │ ├── hacs_action.yml │ ├── hassfest.yaml │ ├── lint.yml │ ├── release.yml │ ├── validate.yml │ └── validate_with_hassfest.yml ├── .gitignore ├── .mailmap ├── .ruff.toml ├── .vscode └── tasks.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── config └── configuration.yaml ├── custom_components └── chime_tts │ ├── __init__.py │ ├── config.py │ ├── config_flow.py │ ├── const.py │ ├── cover_art.jpg │ ├── helpers │ ├── filesystem.py │ ├── helpers.py │ ├── media_player.py │ ├── media_player_helper.py │ ├── services_helper.py │ └── tts_audio_helper.py │ ├── icons.json │ ├── manifest.json │ ├── mp3s │ ├── README │ ├── ba_dum_tss.mp3 │ ├── bells.mp3 │ ├── bells_2.mp3 │ ├── bright.mp3 │ ├── chirp.mp3 │ ├── choir.mp3 │ ├── chord.mp3 │ ├── classical.mp3 │ ├── crickets.mp3 │ ├── ding_dong.mp3 │ ├── drumroll.mp3 │ ├── dun_dun_dun.mp3 │ ├── error.mp3 │ ├── fanfare.mp3 │ ├── glockenspiel.mp3 │ ├── hail.mp3 │ ├── knock.mp3 │ ├── marimba.mp3 │ ├── mario_coin.mp3 │ ├── microphone_tap.mp3 │ ├── sad_trombone.mp3 │ ├── soft.mp3 │ ├── tada.mp3 │ ├── toast.mp3 │ ├── twenty_four.mp3 │ └── whistle.mp3 │ ├── notify.py │ ├── queue_manager.py │ ├── services.yaml │ └── translations │ └── en.json ├── hacs.json ├── icon.png ├── images ├── call_service_clear_cache_from_ui-dark.png ├── call_service_clear_cache_from_ui-light.png ├── call_service_from_ui-dark.png ├── call_service_from_ui-light.png ├── icon_small.png └── wiki │ ├── chimes │ ├── chime_options.gif │ ├── chime_path.png │ ├── config.png │ ├── config_custom_1.png │ └── custom_1.gif │ ├── home │ ├── no_chime_tts-dark.png │ ├── no_chime_tts-light.png │ ├── with_chime_tts-dark.png │ └── with_chime_tts-light.png │ └── installation │ ├── add_custom_repository-dark.png │ ├── add_custom_repository-light.png │ ├── download_button.png │ ├── setup-dark.png │ ├── setup-light.png │ ├── success-dark.png │ └── success-light.png ├── requirements.txt └── scripts ├── develop ├── lint └── setup /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nimroddolev/chime_tts", 3 | "network": "host", 4 | "image": "mcr.microsoft.com/vscode/devcontainers/python:0-3.10-bullseye", 5 | "postCreateCommand": "scripts/setup", 6 | "forwardPorts": [ 7 | 8123 8 | ], 9 | "portsAttributes": { 10 | "8123": { 11 | "label": "Home Assistant", 12 | "onAutoForward": "notify" 13 | } 14 | }, 15 | "customizations": { 16 | "vscode": { 17 | "extensions": [ 18 | "ms-python.python", 19 | "github.vscode-pull-request-github", 20 | "ryanluker.vscode-coverage-gutters", 21 | "ms-python.vscode-pylance" 22 | ], 23 | "settings": { 24 | "files.eol": "\n", 25 | "editor.tabSize": 4, 26 | "python.pythonPath": "/usr/bin/python3", 27 | "python.analysis.autoSearchPaths": false, 28 | "python.linting.pylintEnabled": true, 29 | "python.linting.enabled": true, 30 | "python.formatting.provider": "black", 31 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 32 | "editor.formatOnPaste": false, 33 | "editor.formatOnSave": true, 34 | "editor.formatOnType": true, 35 | "files.trimTrailingWhitespace": true 36 | } 37 | } 38 | }, 39 | "remoteUser": "vscode", 40 | "features": { 41 | "ghcr.io/devcontainers/features/rust:1": {}, 42 | "ghcr.io/devcontainers-contrib/features/ffmpeg-apt-get:1": {} 43 | } 44 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [nimroddolev] 4 | #patreon: # Replace with a single Patreon username 5 | #open_collective: # Replace with a single Open Collective username 6 | #ko_fi: # Replace with a single Ko-fi username 7 | #tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | #community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | #liberapay: # Replace with a single Liberapay username 10 | #issuehunt: # Replace with a single IssueHunt username 11 | #lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | #polar: # Replace with a single Polar username 13 | buy_me_a_coffee: nimroddolev 14 | #custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Bug report" 3 | description: "Report a bug with the integration" 4 | labels: "Bug" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Before you open a new issue, search through the existing issues to see if others have had the same problem. 9 | - type: textarea 10 | attributes: 11 | label: "System Health details" 12 | description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io//more-info/system-health#github-issues)" 13 | validations: 14 | required: true 15 | - type: checkboxes 16 | attributes: 17 | label: Checklist 18 | options: 19 | - label: I have enabled debug logging for my installation. 20 | required: true 21 | - label: I have filled out the issue template to the best of my ability. 22 | required: true 23 | - label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue). 24 | required: true 25 | - label: This issue is not a duplicate issue of currently [previous issues](https://github.com/nimroddolev/chime_tts/issues?q=is%3Aissue+label%3A%22Bug%22+).. 26 | required: true 27 | - type: textarea 28 | attributes: 29 | label: "Describe the issue" 30 | description: "A clear and concise description of what the issue is." 31 | validations: 32 | required: true 33 | - type: textarea 34 | attributes: 35 | label: Reproduction steps 36 | description: "Without steps to reproduce, it will be hard to fix, it is very important that you fill out this part, issues without it will be closed" 37 | value: | 38 | 1. 39 | 2. 40 | 3. 41 | ... 42 | validations: 43 | required: true 44 | - type: textarea 45 | attributes: 46 | label: "Debug logs" 47 | description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue." 48 | render: text 49 | validations: 50 | required: true 51 | 52 | - type: textarea 53 | attributes: 54 | label: "Diagnostics dump" 55 | description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)" 56 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature request" 3 | description: "Suggest an idea for this project" 4 | labels: "Feature+Request" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea. 9 | - type: checkboxes 10 | attributes: 11 | label: Checklist 12 | options: 13 | - label: I have filled out the template to the best of my ability. 14 | required: true 15 | - label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request). 16 | required: true 17 | - label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/nimroddolev/chime_tts/issues?q=is%3Aissue+label%3A%22Feature+Request%22+). 18 | required: true 19 | 20 | - type: textarea 21 | attributes: 22 | label: "Is your feature request related to a problem? Please describe." 23 | description: "A clear and concise description of what the problem is." 24 | placeholder: "I'm always frustrated when [...]" 25 | validations: 26 | required: true 27 | 28 | - type: textarea 29 | attributes: 30 | label: "Describe the solution you'd like" 31 | description: "A clear and concise description of what you want to happen." 32 | validations: 33 | required: true 34 | 35 | - type: textarea 36 | attributes: 37 | label: "Describe alternatives you've considered" 38 | description: "A clear and concise description of any alternative solutions or features you've considered." 39 | validations: 40 | required: true 41 | 42 | - type: textarea 43 | attributes: 44 | label: "Additional context" 45 | description: "Add any other context or screenshots about the feature request here." 46 | validations: 47 | required: false 48 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | 9 | - package-ecosystem: "pip" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | ignore: 14 | # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json 15 | - dependency-name: "homeassistant" -------------------------------------------------------------------------------- /.github/workflows/hacs_action.yml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - name: HACS Action 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v4" 14 | - uses: home-assistant/actions/hassfest@master 15 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: "Lint" 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | branches: 9 | - "main" 10 | 11 | jobs: 12 | ruff: 13 | name: "Ruff" 14 | runs-on: "ubuntu-latest" 15 | steps: 16 | - name: "Checkout the repository" 17 | uses: "actions/checkout@v4" 18 | 19 | - name: "Set up Python" 20 | uses: actions/setup-python@v5.3.0 21 | with: 22 | python-version: "3.10" 23 | cache: "pip" 24 | 25 | - name: "Install requirements" 26 | run: python3 -m pip install -r requirements.txt 27 | 28 | - name: "Run" 29 | run: python3 -m ruff check . 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release" 2 | 3 | on: 4 | release: 5 | types: 6 | - "published" 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | release: 12 | name: "Release" 13 | runs-on: "ubuntu-latest" 14 | permissions: 15 | contents: write 16 | steps: 17 | - name: "Checkout the repository" 18 | uses: "actions/checkout@v4" 19 | 20 | - name: "Adjust version number" 21 | shell: "bash" 22 | run: | 23 | yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ 24 | "${{ github.workspace }}/custom_components/chime_tts/manifest.json" 25 | 26 | - name: "ZIP the integration directory" 27 | shell: "bash" 28 | run: | 29 | cd "${{ github.workspace }}/custom_components/chime_tts" 30 | zip chime_tts.zip -r ./ 31 | 32 | - name: "Upload the ZIP file to the release" 33 | uses: softprops/action-gh-release@v2.1.0 34 | with: 35 | files: ${{ github.workspace }}/custom_components/chime_tts/chime_tts.zip 36 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: "Validate" 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | push: 8 | branches: 9 | - "main" 10 | pull_request: 11 | branches: 12 | - "main" 13 | 14 | jobs: 15 | hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest 16 | name: "Hassfest Validation" 17 | runs-on: "ubuntu-latest" 18 | steps: 19 | - name: "Checkout the repository" 20 | uses: "actions/checkout@v4" 21 | 22 | - name: "Run hassfest validation" 23 | uses: "home-assistant/actions/hassfest@master" 24 | 25 | hacs: # https://github.com/hacs/action 26 | name: "HACS Validation" 27 | runs-on: "ubuntu-latest" 28 | steps: 29 | - name: "Checkout the repository" 30 | uses: "actions/checkout@v4" 31 | 32 | - name: "Run HACS validation" 33 | uses: "hacs/action@main" 34 | with: 35 | category: "integration" 36 | -------------------------------------------------------------------------------- /.github/workflows/validate_with_hassfest.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v4" 14 | - uses: "home-assistant/actions/hassfest@master" 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # artifacts 2 | __pycache__ 3 | .pytest* 4 | *.egg-info 5 | */build/* 6 | */dist/* 7 | 8 | 9 | # misc 10 | .coverage 11 | .vscode 12 | coverage.xml 13 | 14 | 15 | # Home Assistant configuration 16 | config/* 17 | !config/configuration.yaml 18 | node_modules 19 | build 20 | .docusaurus 21 | package-lock.json 22 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Nimrod Dolev 2 | 3 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml 2 | 3 | target-version = "py310" 4 | 5 | lint.select = [ 6 | "B007", # Loop control variable {name} not used within loop body 7 | "B014", # Exception handler with duplicate exception 8 | "C", # complexity 9 | "D", # docstrings 10 | "E", # pycodestyle 11 | "F", # pyflakes/autoflake 12 | "ICN001", # import concentions; {name} should be imported as {asname} 13 | "PGH004", # Use specific rule codes when using noqa 14 | "PLC0414", # Useless import alias. Import alias does not rename original package. 15 | "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass 16 | "SIM117", # Merge with-statements that use the same scope 17 | "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() 18 | "SIM201", # Use {left} != {right} instead of not {left} == {right} 19 | "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} 20 | "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. 21 | "SIM401", # Use get from dict with default instead of an if block 22 | "T20", # flake8-print 23 | "TRY004", # Prefer TypeError exception for invalid type 24 | "RUF006", # Store a reference to the return value of asyncio.create_task 25 | "UP", # pyupgrade 26 | "W", # pycodestyle 27 | ] 28 | 29 | lint.ignore = [ 30 | "D202", # No blank lines allowed after function docstring 31 | "D203", # 1 blank line required before class docstring 32 | "D213", # Multi-line docstring summary should start at the second line 33 | "D404", # First word of the docstring should not be This 34 | "D406", # Section name should end with a newline 35 | "D407", # Section name underlining 36 | "D411", # Missing blank line before section 37 | "E501", # line too long 38 | "E731", # do not assign a lambda expression, use a def 39 | ] 40 | 41 | [lint.flake8-pytest-style] 42 | fixture-parentheses = false 43 | 44 | # [tool.ruff.pyupgrade] 45 | # keep-runtime-typing = true 46 | 47 | [lint.mccabe] 48 | max-complexity = 25 49 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 8123", 6 | "type": "shell", 7 | "command": "scripts/develop", 8 | "problemMatcher": [] 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Github is used for everything 11 | 12 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | Pull requests are the best way to propose changes to the codebase. 15 | 16 | 1. Fork the repo and create your branch from `main`. 17 | 2. If you've changed something, update the documentation. 18 | 3. Make sure your code lints (using `scripts/lint`). 19 | 4. Test you contribution. 20 | 5. Issue that pull request! 21 | 22 | ## Any contributions you make will be under the MIT Software License 23 | 24 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](../../issues) 27 | 28 | GitHub issues are used to track public bugs. 29 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 30 | 31 | ## Write bug reports with detail, background, and sample code 32 | 33 | **Great Bug Reports** tend to have: 34 | 35 | - A quick summary and/or background 36 | - Steps to reproduce 37 | - Be specific! 38 | - Give sample code if you can. 39 | - What you expected would happen 40 | - What actually happens 41 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 42 | 43 | People *love* thorough bug reports. I'm not even kidding. 44 | 45 | ## Use a Consistent Coding Style 46 | 47 | Use [black](https://github.com/ambv/black) to make sure the code follows the style. 48 | 49 | ## Test your code modification 50 | 51 | This custom component is based on [chime_tts template](https://github.com/ludeeus/chime_tts). 52 | 53 | It comes with development environment in a container, easy to launch 54 | if you use Visual Studio Code. With this container you will have a stand alone 55 | Home Assistant instance running and already configured with the included 56 | [`configuration.yaml`](./config/configuration.yaml) 57 | file. 58 | 59 | ## License 60 | 61 | By contributing, you agree that your contributions will be licensed under its MIT License. 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 - 2023 Joakim Sørensen @ludeeus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Chime TTS](https://raw.githubusercontent.com/nimroddolev/chime_tts/main/icon.png) 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg)](https://github.com/hacs/integration) 4 | ![version](https://img.shields.io/github/v/release/nimroddolev/chime_tts) 5 | [![Community Forum][forum-shield]][forum] 6 | 7 | 8 | Chime TTS is a custom Home Assistant integration that eliminates the audio lag between playing a chime/notification sound effect before a TTS audio notification. 9 | 10 | #### If you find Chime TTS useful, consider showing your support: Buy Me A Coffee 11 | 12 | 13 | - [What is Chime TTS?](https://nimroddolev.github.io/chime_tts/docs/getting-started#what-is-chime-tts) 14 | - [Features](https://nimroddolev.github.io/chime_tts/docs/getting-started#features) 15 | - [Quick Start](https://nimroddolev.github.io/chime_tts/docs/getting-started#quick-start) 16 | - [How Do I Use It?](https://nimroddolev.github.io/chime_tts/docs/getting-started#how-do-i-use-it) 17 | - [Support & Discussion](https://nimroddolev.github.io/chime_tts/docs/getting-started#support-and-discussion) 18 | 19 | --- 20 | 21 | ## What is Chime TTS? 22 | 23 | Chime TTS is a custom Home Assistant integration that locally combines TTS audio and sound effects into seamless audio for playback in a single action call, eliminating the lag. Chime TTS includes a [suite of options](https://nimroddolev.github.io/chime_tts/docs/getting-started#features) to further customize the audio. 24 | 25 | ### The Problem: 26 | 27 | 28 | 29 | Latency is introduced between the notification chime and the TTS audio 30 | 31 | Adding a notification chime before Text-To-Speech (TTS) audio messages requires two separate action calls, which introduces lag due to networking latency of cloud TTS platforms, audio processing, and delays before media_player playback begins. 32 | 33 | ### The Solution: 34 | 35 | 36 | 37 | Chime TTS removes the latency between the notification chime and the TTS audio 38 | 39 | **Chime TTS** addresses this issue by combining the audio files into _a single file_ locally on your Home Assistant device. This combined file is then played through your speakers in one seamless event, eliminating any lag. 40 | 41 | *** 42 | 43 | ## Features 44 | 45 | Chime TTS offers various features that enhance TTS audio playback experience: 46 | 47 | - **No lag or timing issues:** Precise timing between audio files without cloud TTS delays. 48 | - **Customizable audio cues:** Play preset or custom audio before and after TTS messages. 49 | - **Flexible TTS platform selection:** Supports various [TTS platform integrations](https://www.home-assistant.io/integrations/#text-to-speech). 50 | - **Easy action invocation:** Use the [`chime_tts.say`](https://nimroddolev.github.io/chime_tts/docs/documentation/actions/say-action/) and [`chime_tts.say_url`](https://nimroddolev.github.io/chime_tts/docs/documentation/actions/say_url-action/) actions in automations and scripts. 51 | - **Set media player volume:** Notifications can be played at a defined volume and restored after playback. 52 | - **Restore previous audio:** Chime TTS supports pausing and resuming currently playing audio beyond the media player platforms supported by Home Assistant *(eg: HomePods)*. 53 | - **Mix and match TTS platforms:** Combine TTS audio using multiple TTS platforms within the same audio announcement. 54 | - **Configurable TTS speed:** Set the TTS audio speed anywhere from 1-500%. 55 | - **Configurable TTS pitch:** Set the pitch for TTS audio, allowing for more customization. 56 | - **Support for FFmpeg arguments:** Apply FFmpeg jobs to the generated audio, or specific jobs to specific chimes and TTS audio segments. 57 | - **Configurable delay:** Set custom delays between chimes & TTS audio. 58 | - **Configurable overlay:** Set custom overlay durations between chimes & TTS audio. 59 | - **Caching:** Cache audio for faster playback. 60 | - **Speaker Groups:** Group speakers for simultaneous playback *(on supported platforms)*. 61 | 62 | *** 63 | 64 | ## Quick Start 65 | 66 | Follow these easy steps to get started with Chime TTS: 67 | 68 | 1. [Installation](https://nimroddolev.github.io/chime_tts/docs/quick-start/installing-chime-tts) - Quickly install Chime TTS via HACS or manually. 69 | 2. [Add the Integration](https://nimroddolev.github.io/chime_tts/docs/quick-start/adding-the-integration) - Add Chime TTS to your Home Assistant instance. 70 | 71 | *** 72 | 73 | ## How Do I Use It? 74 | 75 | ### Actions 76 | 77 | Chime TTS adds 4 new actions to your Home Assistant instance: `chime_tts.say`, `chime_tts.say_url`, `chime_tts.replay` and `chime_tts.clear_cache`. Discover how you can use these actions and the features they offer: 78 | 79 | - [`chime_tts.say`](https://nimroddolev.github.io/chime_tts/docs/documentation/actions/say-action/): Play audio and TTS messages with various settings. 80 | - [`chime_tts.say_url`](https://nimroddolev.github.io/chime_tts/docs/documentation/actions/say_url-action/): Generates a publicly accessible URL to the MP3 file generated by `chime_tts.say`. 81 | - [`chime_tts.replay`](https://nimroddolev.github.io/chime_tts/docs/documentation/actions/replay-action): Repeats the previous action call made to `chime_tts.say`. 82 | - [`chime_tts.clear_cache`](https://nimroddolev.github.io/chime_tts/docs/documentation/actions/clear_cache-action): Clear generated audio cache. 83 | 84 | ### Notify Entities 85 | Chime TTS adds a [notify platform](https://www.home-assistant.io/integrations/notify/): "[chime_tts](https://nimroddolev.github.io/chime_tts/docs/documentation/notify)", which allows you to create fully customised notify entries for use in your automations and scripts. 86 | 87 | *** 88 | 89 | ## Configuration & Documentation 90 | 91 | For configuration, examples and documentation check out [the official Chime TTS site](https://nimroddolev.github.io/chime_tts). 92 | 93 | ## Support and Discussion 94 | 95 | For questions, suggestions, and community discussion about Chime TTS, visit our [Community Forum](https://community.home-assistant.io/t/chime-tts-play-audio-before-after-tts-audio-lag-free/578430). 96 | 97 | *** 98 | 99 | [forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=popout 100 | [forum]: https://community.home-assistant.io/t/chime-tts-play-audio-before-after-tts-audio-lag-free/578430 101 | -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | # https://www.home-assistant.io/integrations/default_config/ 2 | default_config: 3 | 4 | # https://www.home-assistant.io/integrations/logger/ 5 | logger: 6 | default: info 7 | logs: 8 | custom_components.chime_tts: debug 9 | 10 | homeassistant: 11 | media_dirs: 12 | local: /workspaces/chime_tts/media 13 | allowlist_external_dirs: 14 | - "/workspaces/chime_tts/media" 15 | - "/workspaces/chime_tts/config/www" 16 | 17 | media_source: 18 | 19 | ffmpeg: 20 | ffmpeg_bin: /usr/bin/ffmpeg 21 | 22 | tts: 23 | - platform: google_translate 24 | service_name: google_say 25 | -------------------------------------------------------------------------------- /custom_components/chime_tts/config.py: -------------------------------------------------------------------------------- 1 | """Configuratble flags for Chime TTS.""" 2 | 3 | SONOS_SNAPSHOT_ENABLED = False # Change to True to enable Sonos snapshot & restore 4 | -------------------------------------------------------------------------------- /custom_components/chime_tts/config_flow.py: -------------------------------------------------------------------------------- 1 | """Adds config flow for Chime TTS.""" 2 | import logging 3 | import requests 4 | import os 5 | import voluptuous as vol 6 | from homeassistant import config_entries 7 | from homeassistant.helpers import selector 8 | 9 | from .helpers.helpers import ChimeTTSHelper 10 | from .const import ( 11 | DOMAIN, 12 | VERSION, 13 | QUEUE_TIMEOUT_KEY, 14 | QUEUE_TIMEOUT_DEFAULT, 15 | TTS_TIMEOUT_KEY, 16 | TTS_TIMEOUT_DEFAULT, 17 | TTS_PLATFORM_KEY, 18 | DEFAULT_LANGUAGE_KEY, 19 | DEFAULT_VOICE_KEY, 20 | DEFAULT_TLD_KEY, 21 | FALLBACK_TTS_PLATFORM_KEY, 22 | OFFSET_KEY, 23 | DEFAULT_OFFSET_MS, 24 | CROSSFADE_KEY, 25 | FADE_TRANSITION_KEY, 26 | DEFAULT_FADE_TRANSITION_MS, 27 | REMOVE_TEMP_FILE_DELAY_KEY, 28 | ADD_COVER_ART_KEY, 29 | CUSTOM_CHIMES_PATH_KEY, 30 | TEMP_CHIMES_PATH_KEY, 31 | TEMP_CHIMES_PATH_DEFAULT, 32 | TEMP_PATH_KEY, 33 | TEMP_PATH_DEFAULT, 34 | WWW_PATH_KEY, 35 | WWW_PATH_DEFAULT, 36 | ) 37 | 38 | LOGGER = logging.getLogger(__name__) 39 | helpers = ChimeTTSHelper() 40 | 41 | @config_entries.HANDLERS.register(DOMAIN) 42 | class ChimeTTSFlowHandler(config_entries.ConfigFlow): 43 | """Config flow for Chime TTS.""" 44 | 45 | VERSION = 1 46 | 47 | @staticmethod 48 | def async_get_options_flow(config_entry: config_entries.ConfigEntry) -> config_entries.OptionsFlow: 49 | """Create the options flow.""" 50 | return ChimeTTSOptionsFlowHandler(config_entry) 51 | 52 | async def async_step_user(self, user_input=None): 53 | """Chime TTS async_step_user.""" 54 | helpers.debug_title(f"Adding Chime TTS Version {VERSION}") 55 | 56 | if self._async_current_entries(): 57 | return self.async_abort(reason="single_instance_allowed") 58 | 59 | tts_platforms = helpers.get_installed_tts_platforms(self.hass) 60 | if len(tts_platforms) == 0: 61 | LOGGER.debug("No TTS Platforms detected") 62 | return self.async_show_form( 63 | step_id="no_tts_platforms", 64 | data_schema=None, 65 | description_placeholders=user_input, 66 | last_step=True 67 | ) 68 | 69 | return self.async_create_entry(title="Chime TTS", data={}) 70 | 71 | 72 | async def async_step_no_tts_platforms(self, user_input=None): 73 | """Warn the user that no TTS platforms are installed.""" 74 | return self.async_create_entry(title="Chime TTS", data={}) 75 | 76 | class ChimeTTSOptionsFlowHandler(config_entries.OptionsFlow): 77 | """Handle options flow Chime TTS integration.""" 78 | 79 | data: dict 80 | 81 | def __init__(self, config_entry: config_entries.ConfigEntry): 82 | """Initialize options flow.""" 83 | helpers.debug_title(f"Chime TTS Version {VERSION} Configuration") 84 | self._config_entry = config_entry 85 | 86 | async def async_step_init(self, user_input): 87 | """Initialize the options flow.""" 88 | # Default TTS Platform 89 | stripped_tts_platforms = self.get_installed_tts() 90 | if self.hass is not None: 91 | root_path = self.hass.config.path("").replace("/config/", "") 92 | else: 93 | LOGGER.warning("Unable to determine root path") 94 | root_path = "" 95 | 96 | # Installed TTS platforms 97 | tts_platforms = sorted(helpers.get_installed_tts_platforms(self.hass)) 98 | 99 | # TLD Options 100 | tld_options = ["", "com", "co.uk", "com.au", "ca", "co.in", "ie", "co.za", "fr", "com.br", "pt", "es"] 101 | 102 | self.data = { 103 | QUEUE_TIMEOUT_KEY: self.get_data_key_value(QUEUE_TIMEOUT_KEY, user_input, QUEUE_TIMEOUT_DEFAULT), 104 | TTS_TIMEOUT_KEY: self.get_data_key_value(TTS_TIMEOUT_KEY, user_input, TTS_TIMEOUT_DEFAULT), 105 | TTS_PLATFORM_KEY: self.get_data_key_value(TTS_PLATFORM_KEY, user_input, ""), 106 | DEFAULT_LANGUAGE_KEY: self.get_data_key_value(DEFAULT_LANGUAGE_KEY, user_input, ""), 107 | DEFAULT_VOICE_KEY: self.get_data_key_value(DEFAULT_VOICE_KEY, user_input, ""), 108 | DEFAULT_TLD_KEY: self.get_data_key_value(DEFAULT_TLD_KEY, user_input, ""), 109 | FALLBACK_TTS_PLATFORM_KEY: self.get_data_key_value(FALLBACK_TTS_PLATFORM_KEY, user_input, ""), 110 | OFFSET_KEY: self.get_data_key_value(OFFSET_KEY, user_input, DEFAULT_OFFSET_MS), 111 | CROSSFADE_KEY: self.get_data_key_value(CROSSFADE_KEY, user_input, 0), 112 | FADE_TRANSITION_KEY: self.get_data_key_value(FADE_TRANSITION_KEY, user_input, DEFAULT_FADE_TRANSITION_MS), 113 | REMOVE_TEMP_FILE_DELAY_KEY: self.get_data_key_value(REMOVE_TEMP_FILE_DELAY_KEY, user_input, ""), 114 | CUSTOM_CHIMES_PATH_KEY: self.get_data_key_value(CUSTOM_CHIMES_PATH_KEY, user_input, ""), 115 | TEMP_CHIMES_PATH_KEY: self.get_data_key_value(TEMP_CHIMES_PATH_KEY, user_input, f"{root_path}{TEMP_CHIMES_PATH_DEFAULT}"), 116 | TEMP_PATH_KEY: self.get_data_key_value(TEMP_PATH_KEY, user_input, f"{root_path}{TEMP_PATH_DEFAULT}"), 117 | WWW_PATH_KEY: self.get_data_key_value(WWW_PATH_KEY, user_input, f"{root_path}{WWW_PATH_DEFAULT}"), 118 | ADD_COVER_ART_KEY: self.get_data_key_value(ADD_COVER_ART_KEY, user_input, False) 119 | } 120 | 121 | options_schema = vol.Schema( 122 | { 123 | vol.Required(QUEUE_TIMEOUT_KEY, default=self.data[QUEUE_TIMEOUT_KEY]): int, 124 | vol.Optional(TTS_TIMEOUT_KEY, default=self.data[TTS_TIMEOUT_KEY]): int, 125 | vol.Optional(TTS_PLATFORM_KEY, default=self.data[TTS_PLATFORM_KEY]):selector.SelectSelector( 126 | selector.SelectSelectorConfig( 127 | options=tts_platforms, 128 | mode=selector.SelectSelectorMode.DROPDOWN, 129 | custom_value=True)), 130 | vol.Optional(DEFAULT_LANGUAGE_KEY, description={"suggested_value": self.data[DEFAULT_LANGUAGE_KEY]}): str, 131 | vol.Optional(DEFAULT_VOICE_KEY, description={"suggested_value": self.data[DEFAULT_VOICE_KEY]}): str, 132 | vol.Optional(DEFAULT_TLD_KEY, description={"suggested_value": self.data[DEFAULT_TLD_KEY]}):selector.SelectSelector( 133 | selector.SelectSelectorConfig( 134 | options=tld_options, 135 | mode=selector.SelectSelectorMode.DROPDOWN, 136 | custom_value=False)), 137 | vol.Optional(FALLBACK_TTS_PLATFORM_KEY, default=self.data[FALLBACK_TTS_PLATFORM_KEY]):selector.SelectSelector( 138 | selector.SelectSelectorConfig( 139 | options=tts_platforms, 140 | mode=selector.SelectSelectorMode.DROPDOWN, 141 | custom_value=True)), 142 | vol.Optional(OFFSET_KEY, description={"suggested_value": self.data.get(OFFSET_KEY, DEFAULT_OFFSET_MS)}): int, 143 | vol.Optional(CROSSFADE_KEY, description={"suggested_value": self.data.get(CROSSFADE_KEY, 0)}): int, 144 | vol.Optional(FADE_TRANSITION_KEY, description={"suggested_value": self.data[FADE_TRANSITION_KEY]}): int, 145 | vol.Optional(REMOVE_TEMP_FILE_DELAY_KEY, description={"suggested_value": self.data[REMOVE_TEMP_FILE_DELAY_KEY]}): int, 146 | vol.Optional(CUSTOM_CHIMES_PATH_KEY, description={"suggested_value": self.data[CUSTOM_CHIMES_PATH_KEY]}): str, 147 | vol.Required(TEMP_CHIMES_PATH_KEY,default=self.data[TEMP_CHIMES_PATH_KEY]): str, 148 | vol.Required(TEMP_PATH_KEY,default=self.data[TEMP_PATH_KEY]): str, 149 | vol.Required(WWW_PATH_KEY,default=self.data[WWW_PATH_KEY]): str, 150 | vol.Required(ADD_COVER_ART_KEY,default=self.data[ADD_COVER_ART_KEY]): bool 151 | } 152 | ) 153 | # Display the configuration form with the current values 154 | if not user_input: 155 | return self.async_show_form( 156 | step_id="init", 157 | data_schema=options_schema, 158 | description_placeholders=user_input, 159 | last_step=True, 160 | ) 161 | 162 | # Validation 163 | 164 | _errors = {} 165 | 166 | # Timeout 167 | if user_input[QUEUE_TIMEOUT_KEY] < 0: 168 | _errors["base"] = "timeout" 169 | _errors[QUEUE_TIMEOUT_KEY] = "timeout_sub" 170 | 171 | # TTS timeout 172 | if user_input[TTS_TIMEOUT_KEY] < -1: 173 | _errors["base"] = "timeout" 174 | _errors[TTS_TIMEOUT_KEY] = "timeout_sub" 175 | 176 | # List of TTS platforms 177 | stripped_tts_platforms = [platform.lower().replace("tts", "").replace(" ", "").replace(" ", "").replace(".", "").replace("-", "").replace("_", "") for platform in helpers.get_installed_tts_platforms(self.hass)] 178 | 179 | # Default TTS Platform 180 | if len(user_input.get(TTS_PLATFORM_KEY, "")) > 0: 181 | 182 | # Replace friendly name with entity/platform name 183 | default_tts_provider = helpers.get_stripped_tts_platform(user_input[TTS_PLATFORM_KEY]).lower().replace("tts", "").replace(" ", "").replace(" ", "").replace(" ", "").replace(".", "").replace("-", "").replace("_", "") 184 | 185 | if len(stripped_tts_platforms) == 0: 186 | _errors[TTS_PLATFORM_KEY] = "tts_platform_none" 187 | elif default_tts_provider not in stripped_tts_platforms: 188 | LOGGER.debug("Unable to find TTS platform %s", user_input[TTS_PLATFORM_KEY]) 189 | _errors[TTS_PLATFORM_KEY] = "tts_platform_select" 190 | else: 191 | index = stripped_tts_platforms.index(default_tts_provider) 192 | default_tts_provider = helpers.get_installed_tts_platforms(self.hass)[index] 193 | 194 | user_input[TTS_PLATFORM_KEY] = default_tts_provider 195 | 196 | # Fallback TTS Platform 197 | if len(user_input.get(FALLBACK_TTS_PLATFORM_KEY, "")) > 0: 198 | 199 | # Replace friendly name with entity/platform name 200 | fallback_tts_provider = helpers.get_stripped_tts_platform(user_input[FALLBACK_TTS_PLATFORM_KEY]).lower().replace("tts", "").replace(" ", "").replace(" ", "").replace(" ", "").replace(".", "").replace("-", "").replace("_", "") 201 | 202 | if len(stripped_tts_platforms) == 0: 203 | _errors[FALLBACK_TTS_PLATFORM_KEY] = "tts_platform_none" 204 | elif fallback_tts_provider not in stripped_tts_platforms: 205 | LOGGER.debug("Unable to find fallback TTS platform %s", user_input[FALLBACK_TTS_PLATFORM_KEY]) 206 | _errors[FALLBACK_TTS_PLATFORM_KEY] = "tts_platform_select" 207 | else: 208 | index = stripped_tts_platforms.index(fallback_tts_provider) 209 | fallback_tts_provider = helpers.get_installed_tts_platforms(self.hass)[index] 210 | 211 | user_input[FALLBACK_TTS_PLATFORM_KEY] = fallback_tts_provider 212 | 213 | # Temp folder must be a subfolder of a media directory 214 | temp_folder_in_media_dir = False 215 | # Get absolute paths of both directories 216 | sub_dir = os.path.abspath(self.data[TEMP_PATH_KEY]) 217 | # Verify the subdirectory starts with the parent directory path 218 | media_dirs_dict = self.hass.config.media_dirs or {} 219 | for _key, value in media_dirs_dict.items(): 220 | parent_dir = os.path.abspath(value) 221 | if os.path.commonpath([parent_dir]) == os.path.commonpath([parent_dir, sub_dir]): 222 | temp_folder_in_media_dir = True 223 | if not temp_folder_in_media_dir: 224 | _errors[TEMP_PATH_KEY] = TEMP_PATH_KEY 225 | ### 226 | 227 | # `chime_tts.say_url` folder must be subfolder of an external directory 228 | external_folder_in_external_dirs = False 229 | sub_dir = os.path.abspath(self.data[WWW_PATH_KEY]) 230 | # Verify the subdirectory starts with the parent directory path 231 | external_dirs_dict = self.hass.config.allowlist_external_dirs or {} 232 | for value in external_dirs_dict: 233 | parent_dir = os.path.abspath(value) 234 | if os.path.commonpath([parent_dir]) == os.path.commonpath([parent_dir, sub_dir]): 235 | external_folder_in_external_dirs = True 236 | if not external_folder_in_external_dirs: 237 | # /media or /config/www ? 238 | www_path: str = user_input.get(WWW_PATH_KEY, "") 239 | if not (www_path.startswith(f"{root_path}/media/") or 240 | www_path.startswith(f"{root_path}/config/www/")): 241 | _errors[WWW_PATH_KEY] = WWW_PATH_KEY 242 | 243 | 244 | if _errors: 245 | return self.async_show_form( 246 | step_id="init", data_schema=options_schema, errors=_errors 247 | ) 248 | 249 | if not user_input.get(CUSTOM_CHIMES_PATH_KEY) or len(user_input.get(CUSTOM_CHIMES_PATH_KEY)) == 0: 250 | self.data[CUSTOM_CHIMES_PATH_KEY] = "" 251 | 252 | # 1st time Custom Chimes Folder path modified 253 | if (user_input.get(CUSTOM_CHIMES_PATH_KEY) and not self._config_entry.options.get(CUSTOM_CHIMES_PATH_KEY)): 254 | # Show restart reminder step before saving config 255 | return self.async_show_form( 256 | step_id="restart_required", 257 | data_schema=None, 258 | description_placeholders=user_input, 259 | last_step=True 260 | ) 261 | 262 | # User input is valid, update the options 263 | LOGGER.debug("Updating configuration...") 264 | return self.async_create_entry( 265 | data=user_input 266 | ) 267 | 268 | async def async_step_restart_required(self, user_input): 269 | """Warn the user that Home Assistant needs to be restarted.""" 270 | return self.async_create_entry( 271 | data=self.data 272 | ) 273 | 274 | def get_data_key_value(self, key, user_input, default=None): 275 | """Get the value for a given key. Options flow 1st, Config flow 2nd.""" 276 | if user_input: 277 | return user_input.get(key, default) 278 | dicts = [dict(self._config_entry.options), dict(self._config_entry.data)] 279 | value = None 280 | for p_dict in dicts: 281 | if key in p_dict and not value: 282 | value = p_dict[key] 283 | if not value: 284 | value = default 285 | return value 286 | 287 | async def ping_url(self, url: str): 288 | """Ping a URL and receive a boolean result.""" 289 | if url is None: 290 | return False 291 | try: 292 | response = await self.hass.async_add_executor_job(requests.head, url) 293 | if 200 <= response.status_code < 300: 294 | return True 295 | LOGGER.warning("Error: Received status code %s from %s", str(response.status_code), url) 296 | except requests.ConnectionError: 297 | LOGGER.warning("Error: Failed to connect to %s", url) 298 | 299 | return False 300 | 301 | def get_installed_tts(self): 302 | """List of installed TTS platforms.""" 303 | return list((self.hass.data["tts_manager"].providers).keys()) 304 | -------------------------------------------------------------------------------- /custom_components/chime_tts/const.py: -------------------------------------------------------------------------------- 1 | """Constants for chime_tts.""" 2 | from logging import Logger, getLogger 3 | 4 | import os 5 | import json 6 | 7 | LOGGER: Logger = getLogger(__package__) 8 | 9 | DOMAIN = "chime_tts" 10 | NAME = "Chime TTS" 11 | DESCRIPTION = "A custom Home Assistant integration to play audio with text-to-speech (TTS) messages" 12 | 13 | # Current version number from manifest.json 14 | integration_dir = os.path.dirname(__file__) 15 | manifest_path = os.path.join(integration_dir, "manifest.json") 16 | if os.path.isfile(manifest_path): 17 | with open(manifest_path) as manifest_file: 18 | manifest_data = json.load(manifest_file) 19 | VERSION = manifest_data.get("version") 20 | else: 21 | VERSION = None 22 | 23 | SERVICE_CLEAR_CACHE = "clear_cache" 24 | SERVICE_REPLAY = "replay" 25 | SERVICE_SAY = "say" 26 | SERVICE_SAY_URL = "say_url" 27 | 28 | OFFSET_KEY = "offset" 29 | DEFAULT_OFFSET_MS = 450 30 | CROSSFADE_KEY = "crossfade" 31 | 32 | DATA_STORAGE_KEY = "chime_tts_integration_data" 33 | AUDIO_PATH_KEY = "audio_path" # <-- Deprecated 34 | LOCAL_PATH_KEY = "local_path" 35 | PUBLIC_PATH_KEY = "public_path" 36 | AUDIO_DURATION_KEY = "audio_duration" 37 | 38 | FADE_TRANSITION_KEY = "fade_transition_key" 39 | DEFAULT_FADE_TRANSITION_MS = 500 40 | REMOVE_TEMP_FILE_DELAY_KEY = "remove_temp_file_delay" 41 | TRANSITION_STEP_MS = 150 42 | ADD_COVER_ART_KEY = "add_cover_art" 43 | 44 | ALEXA_MEDIA_PLAYER_PLATFORM = "alexa_media" 45 | SONOS_PLATFORM = "sonos" 46 | SPOTIFY_PLATFORM = "spotify" 47 | 48 | ROOT_PATH_KEY = "root_path_key" 49 | MEDIA_FOLDER_PATH = "/local/" 50 | PUBLIC_FOLDER_PATH = "/config/www/" 51 | CUSTOM_CHIMES_PATH_KEY = "custom_chimes_path" 52 | DEFAULT_TEMP_CHIMES_PATH_KEY = "default_temp_chimes_path" 53 | TEMP_CHIMES_PATH_KEY = "temp_chimes_path" 54 | TEMP_CHIMES_PATH_DEFAULT = "/media/sounds/temp/chime_tts/chimes/" 55 | DEFAULT_TEMP_PATH_KEY = "default_temp_path" 56 | TEMP_PATH_KEY = "temp_path" 57 | TEMP_PATH_DEFAULT = "/media/sounds/temp/chime_tts/" 58 | DEFAULT_WWW_PATH_KEY = "default_www_path" 59 | WWW_PATH_KEY = "www_path" 60 | WWW_PATH_DEFAULT = "/config/www/chime_tts/" 61 | 62 | MP3_PRESET_PATH = "custom_components/chime_tts/mp3s/" 63 | MP3_PRESET_PATH_PLACEHOLDER = "mp3_path_placeholder-" # DEPRECATED 64 | DEFAULT_CHIME_OPTIONS = [ 65 | {"label": "Ba-Dum Tss!", "value": "ba_dum_tss"}, 66 | {"label": "Bells", "value": "bells"}, 67 | {"label": "Bells 2", "value": "bells_2"}, 68 | {"label": "Bright", "value": "bright"}, 69 | {"label": "Chirp", "value": "chirp"}, 70 | {"label": "Choir", "value": "choir"}, 71 | {"label": "Chord", "value": "chord"}, 72 | {"label": "Classical", "value": "classical"}, 73 | {"label": "Crickets", "value": "crickets"}, 74 | {"label": "Ding Dong", "value": "ding_dong"}, 75 | {"label": "Drum Roll", "value": "drumroll"}, 76 | {"label": "Dun dun DUUUN!", "value": "dun_dun_dun"}, 77 | {"label": "Error", "value": "error"}, 78 | {"label": "Fanfare", "value": "fanfare"}, 79 | {"label": "Glockenspiel", "value": "glockenspiel"}, 80 | {"label": "Hail", "value": "hail"}, 81 | {"label": "Knock", "value": "knock"}, 82 | {"label": "Marimba", "value": "marimba"}, 83 | {"label": "Mario Coin", "value": "mario_coin"}, 84 | {"label": "Microphone Tap", "value": "microphone_tap"}, 85 | {"label": "Ta-da!", "value": "tada"}, 86 | {"label": "Toast", "value": "toast"}, 87 | {"label": "Twenty Four", "value": "twenty_four"}, 88 | {"label": "Sad Trombone", "value": "sad_trombone"}, 89 | {"label": "Soft", "value": "soft"}, 90 | {"label": "Whistle", "value": "whistle"} 91 | ] 92 | MP3_PRESET_CUSTOM_PREFIX = "custom_chime_path_" 93 | MP3_PRESET_CUSTOM_KEY = "custom_paths" 94 | QUEUE = "QUEUE" 95 | QUEUE_STATUS_KEY = "QUEUE_STATUS" 96 | QUEUE_RUNNING = "QUEUE_RUNNING" 97 | QUEUE_IDLE = "QUEUE_IDLE" 98 | QUEUE_CURRENT_ID_KEY = "QUEUE_CURRENT_ID" 99 | QUEUE_LAST_ID = "QUEUE_LAST_ID" 100 | QUEUE_TIMEOUT_KEY = "queue_timeout" 101 | QUEUE_TIMEOUT_DEFAULT = 60 102 | TTS_TIMEOUT_KEY = "tts_timeout" 103 | TTS_TIMEOUT_DEFAULT = 30 104 | MAX_CONCURRENT_TASKS = 10 105 | MAX_TIMEOUT = 600 106 | QUEUE_PROCESSOR_SLEEP_TIME = 0.2 107 | 108 | TTS_PLATFORM_KEY = "tts_platform_key" 109 | FALLBACK_TTS_PLATFORM_KEY = "fallback_tts_platform_key" 110 | DEFAULT_LANGUAGE_KEY = "default_language_key" 111 | DEFAULT_VOICE_KEY = "default_voice_key" 112 | DEFAULT_TLD_KEY = "default_tld_key" 113 | 114 | # FFmpeg Arguments 115 | FFMPEG_ARGS_ALEXA = "-y -ac 2 -codec:a libmp3lame -b:a 48k -ar 24000 -write_xing 0" 116 | FFMPEG_ARGS_VOLUME = '-filter:a volume=X' 117 | 118 | # TTS Platforms 119 | AMAZON_POLLY = "amazon_polly" 120 | BAIDU = "baidu" 121 | ELEVENLABS = "tts.elevenlabs" 122 | GOOGLE_CLOUD = "tts.google_cloud" 123 | GOOGLE_TRANSLATE = "google_translate" 124 | IBM_WATSON_TTS = "watson_tts" 125 | MARYTTS = "MaryTTS" 126 | MICROSOFT_TTS = "microsoft" 127 | MICROSOFT_EDGE_TTS = "edge_tts" 128 | NABU_CASA_CLOUD_TTS = "cloud" 129 | NABU_CASA_CLOUD_TTS_OLD = "cloud_say" 130 | OPENAI_TTS = "openai_tts" 131 | PICOTTS = "picotts" 132 | PIPER = "tts.piper" 133 | VOICE_RSS = "voicerss" 134 | YANDEX_TTS = "yandextts" 135 | 136 | QUOTE_CHAR_SUBSTITUTE = "🁢" 137 | -------------------------------------------------------------------------------- /custom_components/chime_tts/cover_art.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/cover_art.jpg -------------------------------------------------------------------------------- /custom_components/chime_tts/helpers/filesystem.py: -------------------------------------------------------------------------------- 1 | """Filesystem helper functions for Chime TTS.""" 2 | 3 | import logging 4 | import secrets 5 | import os 6 | import hashlib 7 | import shutil 8 | from io import BytesIO 9 | import re 10 | import asyncio 11 | from asyncio import create_subprocess_exec 12 | from asyncio.subprocess import PIPE 13 | from concurrent.futures import ThreadPoolExecutor 14 | from pydub import AudioSegment 15 | import requests 16 | 17 | from homeassistant.helpers.network import get_url 18 | from homeassistant.core import HomeAssistant 19 | from ..const import ( 20 | MP3_PRESET_PATH, 21 | DEFAULT_CHIME_OPTIONS, 22 | MP3_PRESET_PATH_PLACEHOLDER, # DEPRECATED 23 | MP3_PRESET_CUSTOM_PREFIX, 24 | MP3_PRESET_CUSTOM_KEY, 25 | CUSTOM_CHIMES_PATH_KEY, 26 | TEMP_CHIMES_PATH_KEY, 27 | LOCAL_PATH_KEY, 28 | AUDIO_DURATION_KEY, 29 | ) 30 | from .media_player_helper import MediaPlayerHelper 31 | media_player_helper = MediaPlayerHelper() 32 | 33 | _LOGGER = logging.getLogger(__name__) 34 | _AUDIO_EXTENSIONS = ['.mp3', '.wav', '.flac', '.aac', '.ogg', '.wma', '.m4a', '.aiff', '.aif', '.ape'] 35 | 36 | class FilesystemHelper: 37 | """Filesystem helper functions for Chime TTS.""" 38 | 39 | def filepath_exists_locally(self, hass: HomeAssistant, filepath): 40 | """Test whether a local filepath or extenral URL exists locally.""" 41 | local_path = self.get_local_path(hass, filepath) 42 | return local_path and os.path.isfile(local_path) 43 | 44 | def path_exists(self, path): 45 | """Test whether filepath/folderpath exists.""" 46 | if not path or len(path) == 0: 47 | return False 48 | if os.path.exists(path): 49 | return True 50 | # Check if folder path not found due to incorrect letter case 51 | parent_directory = os.path.dirname(path) or "." 52 | target_name = os.path.basename(path).lower() 53 | if not target_name or len(target_name) == 0: 54 | return False 55 | # List of all items in parent directory 56 | try: 57 | dir_contents = os.listdir(parent_directory) 58 | except FileNotFoundError: 59 | return False 60 | except PermissionError: 61 | _LOGGER.warning(f"Error: No permission to access '{parent_directory}'.") 62 | return False 63 | 64 | # Case-insensitive search for folder/file 65 | for item in dir_contents: 66 | if item.lower() == target_name: 67 | full_path = os.path.join(parent_directory, item) 68 | if os.path.isdir(full_path): 69 | return True 70 | elif os.path.isfile(full_path): 71 | return True 72 | return False 73 | 74 | async def async_validate_path(self, hass: HomeAssistant, p_filepath: str = ""): 75 | """Return a valid file path string.""" 76 | ret_value = None 77 | if p_filepath is None: 78 | return ret_value 79 | 80 | filepaths = [p_filepath] 81 | 82 | # Test for docker/virtual instances filepath 83 | root_path = hass.config.path("") 84 | absolute_path = (root_path + p_filepath).replace("/config", "").replace("//", "/") 85 | if p_filepath is not absolute_path: 86 | filepaths.append(absolute_path) 87 | 88 | # Test each filepath 89 | for filepath in filepaths: 90 | if await hass.async_add_executor_job(self.path_exists, filepath) is True: 91 | ret_value = filepath 92 | 93 | return ret_value 94 | 95 | def path_to_parent_folder(self, folder): 96 | """Absolute path to a parent folder.""" 97 | current_dir = os.path.dirname(os.path.abspath(__file__)) 98 | count = 0 99 | while os.path.basename(current_dir) != folder and count < 100: 100 | parent_dir = os.path.dirname(current_dir) 101 | if parent_dir == current_dir: 102 | _LOGGER.warning("%s folder not found", folder) 103 | return None 104 | current_dir = parent_dir 105 | count = count + 1 106 | return current_dir 107 | 108 | async def async_get_chime_path(self, chime_path: str, cache, data: dict, hass: HomeAssistant): 109 | """Retrieve preset chime path if selected.""" 110 | 111 | # Remove prefix (prefix deprecated in v0.9.1) 112 | chime_path = chime_path.replace(MP3_PRESET_PATH_PLACEHOLDER, "") 113 | 114 | # Preset chime mp3 path? 115 | for option in DEFAULT_CHIME_OPTIONS: 116 | if option.get("value") == chime_path: 117 | # Validate MP3 preset path before use 118 | mp3_path = MP3_PRESET_PATH 119 | absolute_custom_comopnents_dir = self.path_to_parent_folder('custom_components') 120 | if absolute_custom_comopnents_dir: 121 | mp3_path = MP3_PRESET_PATH.replace("custom_components", absolute_custom_comopnents_dir) 122 | final_chime_path = mp3_path + chime_path + ".mp3" 123 | if await hass.async_add_executor_job(self.path_exists, final_chime_path): 124 | _LOGGER.debug("Local path to chime: %s", final_chime_path) 125 | return final_chime_path 126 | 127 | # Custom chime from chimes folder? 128 | custom_chimes_folder_options = await self.async_get_chime_options_from_path(data.get(CUSTOM_CHIMES_PATH_KEY, "")) 129 | for option_dict in custom_chimes_folder_options: 130 | p_chime_name = str(option_dict.get("label", "")).lower() 131 | p_chime_path = option_dict.get("value") 132 | chime_path_clean = chime_path.lower() 133 | for ext in _AUDIO_EXTENSIONS: 134 | chime_path_clean = chime_path_clean.replace(ext, "") 135 | if (p_chime_name and p_chime_path and chime_path_clean == p_chime_name): 136 | return option_dict.get("value") 137 | 138 | # Custom chime mp3 path? DEPRECATED since v1.1.0 139 | if chime_path.startswith(MP3_PRESET_CUSTOM_PREFIX): 140 | custom_chime_paths_dict = data.get(MP3_PRESET_CUSTOM_KEY, {}) 141 | index = chime_path.replace(MP3_PRESET_CUSTOM_PREFIX, "") 142 | chime_path = custom_chime_paths_dict.get(chime_path, "") 143 | if chime_path == "": 144 | _LOGGER.warning("MP3 file path missing for custom chime path `Custom #%s`", str(index)) 145 | return None 146 | elif await hass.async_add_executor_job(self.path_exists, chime_path): 147 | return chime_path 148 | else: 149 | _LOGGER.debug("Custom chime not found at path: %s", chime_path) 150 | return None 151 | 152 | # External URL? 153 | if chime_path.startswith("http://") or chime_path.startswith("https://"): 154 | temp_chimes_path = data.get(TEMP_CHIMES_PATH_KEY, "") 155 | # Use cached version? 156 | if cache is True: 157 | local_file = self.get_downloaded_chime_path(folder=temp_chimes_path, url=chime_path) 158 | if local_file is not None and await hass.async_add_executor_job(self.path_exists, local_file): 159 | _LOGGER.debug("Chime found in cache") 160 | return local_file 161 | _LOGGER.debug("External chime not found in cache") 162 | 163 | # Download from URL 164 | audio_dict = await self.async_download_file(hass, chime_path, temp_chimes_path) 165 | if audio_dict is not None: 166 | _LOGGER.debug("Chime downloaded successfully") 167 | file_hash = self.get_hash_for_string(chime_path) 168 | return { 169 | "audio_dict": audio_dict, 170 | "file_hash": file_hash 171 | } 172 | 173 | _LOGGER.warning("Unable to downloaded chime from URL: %s", chime_path) 174 | return None 175 | 176 | chime_path = await self.async_validate_path(hass, chime_path) 177 | return chime_path 178 | 179 | def get_downloaded_chime_path(self, folder: str, url: str): 180 | """Local file path string for chime URL in local folder.""" 181 | return folder + ("" if folder.endswith("/") else "/") + re.sub(r'[\/:*?"<>|]', '_', url.replace("https://", "").replace("http://", "")) 182 | 183 | async def async_save_audio_to_folder(self, hass: HomeAssistant, audio: AudioSegment, folder, file_name: str = None): 184 | """Save audio to local folder.""" 185 | 186 | folder_exists = await self.async_create_folder(hass, folder) 187 | if folder_exists is False: 188 | _LOGGER.warning("Unable to create folder: %s", folder) 189 | return None 190 | 191 | # Save to file 192 | if file_name is None: 193 | try: 194 | # Generate a secure & unique file name 195 | secure_name = f"{secrets.token_hex(16)}.mp3" 196 | audio_full_path = os.path.join(folder, secure_name) 197 | 198 | # Ensure the directory exists 199 | os.makedirs(folder, exist_ok=True) 200 | 201 | # Export the audio to the secure file path 202 | await self.async_export_audio(audio, audio_full_path) 203 | except Exception as error: 204 | _LOGGER.warning( 205 | "An error occurred when creating the temp mp3 file: %s", error 206 | ) 207 | return None 208 | else: 209 | try: 210 | # Make file name safe 211 | audio_full_path = self.get_downloaded_chime_path(url=file_name, folder=folder) 212 | if audio_full_path and isinstance(audio_full_path, str): 213 | if await hass.async_add_executor_job(self.path_exists, audio_full_path): 214 | os.remove(audio_full_path) 215 | await self.async_export_audio(audio, audio_full_path) 216 | except Exception as error: 217 | _LOGGER.warning( 218 | "An error occurred when creating the mp3 file: %s", error 219 | ) 220 | return None 221 | 222 | if await hass.async_add_executor_job(self.path_exists, audio_full_path): 223 | _LOGGER.debug("File saved to path: %s", audio_full_path) 224 | else: 225 | _LOGGER.error("Saved file inaccessible, something went wrong. Path = %s", audio_full_path) 226 | 227 | return audio_full_path 228 | 229 | async def async_download_file(self, hass: HomeAssistant, url, folder): 230 | """Download a file and save locally.""" 231 | try: 232 | _LOGGER.debug("Downloading chime at URL: %s", url) 233 | response = await hass.async_add_executor_job(requests.get, url) 234 | response.raise_for_status() # Raise an HTTPError for bad responses (4xx and 5xx status codes) 235 | except requests.exceptions.HTTPError as errh: 236 | _LOGGER.warning("HTTP Error: %s", str(errh)) 237 | return None 238 | except requests.exceptions.ConnectionError as errc: 239 | _LOGGER.warning("Error Connecting: %s", str(errc)) 240 | return None 241 | except requests.exceptions.Timeout as errt: 242 | _LOGGER.warning("Timeout Error: %s", str(errt)) 243 | return None 244 | except requests.exceptions.RequestException as err: 245 | _LOGGER.warning("Request Exception: %s", str(err)) 246 | return None 247 | except Exception as error: 248 | _LOGGER.warning("An unexpected error occurred: %s", str(error)) 249 | return None 250 | 251 | if response is None: 252 | _LOGGER.warning("Received an invalid response") 253 | return None 254 | 255 | content_type = response.headers.get('Content-Type', '') 256 | if 'audio' in content_type: 257 | _LOGGER.debug("Audio downloaded successfully") 258 | _, file_extension = os.path.splitext(url) 259 | try: 260 | audio_content = await self.async_load_audio( 261 | BytesIO(response.content))#, 262 | #format=file_extension.replace(".", "")) 263 | except Exception as error: 264 | _LOGGER.warning("Error when loading audio from downloaded file: %s", str(error)) 265 | return None 266 | if audio_content is not None: 267 | audio_file_path = await self.async_save_audio_to_folder(hass=hass, 268 | audio=audio_content, 269 | folder=folder, 270 | file_name=url) 271 | audio_duration = float(len(audio_content) / 1000) 272 | return { 273 | LOCAL_PATH_KEY: audio_file_path, 274 | AUDIO_DURATION_KEY: audio_duration 275 | } 276 | else: 277 | _LOGGER.warning("Downloaded file did not contain audio: %s", url) 278 | else: 279 | _LOGGER.warning("Unable to extract audio from URL with content-type '%s'", 280 | str(content_type)) 281 | return None 282 | 283 | async def async_create_folder(self, hass: HomeAssistant, folder): 284 | """Create folder if it doesn't already exist.""" 285 | if await hass.async_add_executor_job(self.path_exists, folder) is False: 286 | _LOGGER.debug("Creating audio folder: %s", folder) 287 | try: 288 | os.makedirs(folder) 289 | return True 290 | except OSError as error: 291 | _LOGGER.warning( 292 | "An OSError occurred while creating the folder '%s': %s", 293 | folder, error) 294 | except Exception as error: 295 | _LOGGER.warning( 296 | "An error occurred while creating the folder '%s': %s", 297 | folder, error) 298 | return False 299 | return True 300 | 301 | async def async_copy_file(self, hass: HomeAssistant, source_file: str, destination_folder: str): 302 | """Copy a file to a folder.""" 303 | if not destination_folder: 304 | _LOGGER.warning("Unable to copy file: No destination folder path provided") 305 | return None 306 | if await self.async_create_folder(hass, destination_folder): 307 | try: 308 | copied_file_path = shutil.copy(source_file, destination_folder) 309 | return copied_file_path 310 | except FileNotFoundError: 311 | _LOGGER.warning("Unable to copy file: Source file %s not found.", source_file) 312 | except PermissionError: 313 | _LOGGER.warning("Unable to copy file: Permission denied. Check if you have sufficient permissions.") 314 | except Exception as e: 315 | if str(e).find("are the same file") != -1: 316 | return source_file 317 | _LOGGER.warning("Unable to copy file: An error occurred: %s", str(e)) 318 | return None 319 | 320 | async def async_file_exists_in_directory(self, file_path, directory): 321 | """Determine whether a file path exists within a given directory asynchronously.""" 322 | loop = asyncio.get_running_loop() 323 | with ThreadPoolExecutor() as pool: 324 | return await loop.run_in_executor( 325 | pool, 326 | self._file_exists_in_directory, 327 | file_path, 328 | directory) 329 | 330 | def _file_exists_in_directory(self, file_path, directory): 331 | """Determine whether a file path exists within a given directory.""" 332 | for root, _, files in os.walk(directory): 333 | for filename in files: 334 | if os.path.join(root, filename).lower() == file_path.lower(): 335 | return True 336 | return False 337 | 338 | def get_external_address(self, hass: HomeAssistant): 339 | """External address of the Home Assistant instance.""" 340 | instance_url = hass.config.external_url 341 | if instance_url is None: 342 | instance_url = str(get_url(hass, prefer_external=True)) 343 | if instance_url and instance_url.endswith("/"): 344 | instance_url = instance_url[:-1] 345 | return instance_url 346 | 347 | async def async_get_external_url(self, hass: HomeAssistant, file_path: str): 348 | """Convert file system path of public file to external URL.""" 349 | if file_path is None: 350 | return None 351 | 352 | # File is already external URL 353 | if file_path.lower().startswith(self.get_external_address(hass).lower()): 354 | return file_path 355 | 356 | # Return local path if file not in www folder 357 | public_dir = hass.config.path('www') 358 | if public_dir is None: 359 | _LOGGER.warning("Unable to locate public 'www' folder. Please check that the folder: /config/www exists.") 360 | return None 361 | 362 | if await self.async_file_exists_in_directory(file_path, public_dir) is False: 363 | return None 364 | 365 | instance_url = self.get_external_address(hass) 366 | 367 | return ( 368 | (instance_url + "/" + file_path) 369 | .replace("/config", "") 370 | .replace("www/", "local/") 371 | .replace(instance_url + "//", instance_url + "/") 372 | ) 373 | 374 | 375 | async def async_is_audio_alexa_compatible(self, hass: HomeAssistant, file_path: str) -> bool: 376 | """Determine whether a given audio file is Alexa Media Player compatible. 377 | 378 | Args: 379 | hass: HomeAssistant object 380 | file_path: Path to the audio file to check 381 | 382 | Returns: 383 | bool: True if file meets Alexa compatibility requirements, False otherwise 384 | 385 | """ 386 | try: 387 | # Validate file path 388 | file_path = self.get_local_path(hass=hass, file_path=file_path) 389 | if not (os.path.isfile(file_path) and await hass.async_add_executor_job(self.path_exists, file_path)): 390 | _LOGGER.debug("Unable to convert audio. File not found: %s", file_path) 391 | return False 392 | 393 | try: 394 | # Create and run async subprocess 395 | process = await create_subprocess_exec( 396 | 'ffmpeg', '-i', file_path, 397 | stdout=PIPE, 398 | stderr=PIPE 399 | ) 400 | 401 | try: 402 | # Wait for the process to complete with timeout 403 | stdout, stderr = await asyncio.wait_for( 404 | process.communicate(), 405 | timeout=30.0 # 30 second timeout 406 | ) 407 | 408 | # Convert bytes to string and combine outputs for checking 409 | try: 410 | output = (stderr.decode('utf-8', errors='replace').lower() if stderr else '') + \ 411 | (stdout.decode('utf-8', errors='replace').lower() if stdout else '') 412 | 413 | # More robust pattern matching 414 | requirements = [ 415 | 'mp3' in output, 416 | any(rate in output for rate in ['24000 hz', '24khz', '24000hz']), 417 | any(ch in output for ch in ['stereo', '2 channels', '2ch']), 418 | any(rate in output for rate in ['48 kb/s', '48k', '48000']), 419 | 'xing' not in output # Check for absence of Xing header 420 | ] 421 | 422 | # File failed Alexa Media Player compatibility test 423 | if not all(requirements): 424 | _LOGGER.debug("File is not Alexa Media Player compatibile: %s", file_path) 425 | return False 426 | 427 | return True 428 | 429 | except UnicodeDecodeError as decode_error: 430 | _LOGGER.error("Failed to decode FFmpeg output: %s", decode_error) 431 | return False 432 | 433 | except asyncio.TimeoutError: 434 | _LOGGER.error("FFmpeg process timed out while analyzing: %s", file_path) 435 | # Ensure we clean up the process 436 | try: 437 | process.kill() 438 | await process.wait() 439 | except ProcessLookupError: 440 | pass 441 | return False 442 | 443 | except FileNotFoundError: 444 | _LOGGER.error("FFmpeg executable not found. Please ensure FFmpeg is installed") 445 | return False 446 | except PermissionError: 447 | _LOGGER.error("Permission denied when trying to execute FFmpeg") 448 | return False 449 | except asyncio.CancelledError: 450 | _LOGGER.debug("Audio analysis cancelled for: %s", file_path) 451 | raise # Re-raise CancelledError to properly handle task cancellation 452 | except Exception as subprocess_error: 453 | _LOGGER.error("Subprocess error during FFmpeg execution: %s", subprocess_error) 454 | return False 455 | 456 | except OSError as os_error: 457 | _LOGGER.error("OS error while processing file %s: %s", file_path, os_error) 458 | return False 459 | except Exception as general_error: 460 | _LOGGER.error("Unexpected error while checking audio compatibility: %s", general_error) 461 | return False 462 | 463 | 464 | def delete_file(self, hass: HomeAssistant, file_path) -> None: 465 | """Delete local / public-facing file in filesystem.""" 466 | if file_path is None: 467 | return 468 | 469 | # Convert external URL to local filepath 470 | instance_url = self.get_external_address(hass) 471 | if f"{file_path}".startswith(instance_url): 472 | file_path = ( 473 | file_path 474 | .replace(f"{instance_url}/", "") 475 | .replace(f"{instance_url}", "") 476 | .replace("local/", "/config/www/") 477 | .replace("//", "/") 478 | ) 479 | 480 | # If file exists, delete it 481 | if self.path_exists(file_path): 482 | _LOGGER.debug("Deleting file %s", file_path) 483 | os.remove(file_path) 484 | else: 485 | _LOGGER.warning("No file at path %s - unable to delete", file_path) 486 | 487 | def get_local_path(self, hass: HomeAssistant, file_path: str = ""): 488 | """Convert external URL to local public path.""" 489 | file_path = f"{file_path}" 490 | if not file_path: 491 | _LOGGER.debug("get_local_path(): No file_path provided") 492 | return None 493 | if file_path.startswith("/"): 494 | return file_path 495 | 496 | instance_url = self.get_external_address(hass) 497 | if not instance_url: 498 | _LOGGER.error("Failed to determine the instance URL.") 499 | return file_path 500 | 501 | instance_url = f"{instance_url}" 502 | if instance_url.endswith("/"): 503 | instance_url = instance_url[:-1] 504 | public_dir = hass.config.path('www') 505 | 506 | local_file_path = file_path.replace(instance_url, public_dir).replace('/www/local/', '/www/') 507 | if self.path_exists(local_file_path) and os.path.isfile(local_file_path): 508 | if local_file_path != file_path: 509 | _LOGGER.debug("Local file path for external URL is '%s'", local_file_path) 510 | return local_file_path 511 | return None 512 | 513 | def get_hash_for_string(self, string): 514 | """Generate a has for a given string.""" 515 | hash_object = hashlib.sha256() 516 | hash_object.update(string.encode("utf-8")) 517 | hash_value = str(hash_object.hexdigest()) 518 | return hash_value 519 | 520 | def make_folder_path_safe(self, path): 521 | """Validate folder path.""" 522 | if not path: 523 | return "" 524 | if not f"{path}".startswith("/"): 525 | path = f"/{path}" 526 | if not f"{path}".endswith("/"): 527 | path = f"{path}/" 528 | path = path.replace("//", "/").strip() 529 | return path 530 | 531 | ### Offloading to asyncio.to_thread #### 532 | 533 | async def async_export_audio(self, audio: AudioSegment, audio_full_path: str): 534 | """Save AudioSegment to a filepath.""" 535 | await asyncio.to_thread(audio.export, audio_full_path, format="mp3") 536 | 537 | async def async_load_audio(self, file_path: str): 538 | """Load AudioSegment from a filepath.""" 539 | return await asyncio.to_thread(AudioSegment.from_file, file_path) 540 | 541 | async def async_get_chime_options_from_path(self, directory): 542 | """Walk through a directory of chime audio files and return a formatted dictionary.""" 543 | loop = asyncio.get_running_loop() 544 | with ThreadPoolExecutor() as pool: 545 | return await loop.run_in_executor( 546 | pool, 547 | self._get_chime_options_from_path, 548 | directory) 549 | 550 | def _get_chime_options_from_path(self, directory): 551 | """Walk through a directory of chime audio files and return a formatted dictionary.""" 552 | chime_options = [] 553 | 554 | if directory and self.path_exists(directory): 555 | for dirpath, _, filenames in os.walk(directory): 556 | for filename in filenames: 557 | # Construct the absolute file path 558 | file_path = os.path.join(dirpath, filename) 559 | absolute_file_path = os.path.abspath(file_path) 560 | 561 | # Separte the file extension from the label 562 | label = os.path.splitext(filename)[0] 563 | ext = os.path.splitext(filename)[1] 564 | if ext in _AUDIO_EXTENSIONS: 565 | # Append the dictionary to the list 566 | chime_options.append({"label": label, "value": absolute_file_path}) 567 | return chime_options 568 | -------------------------------------------------------------------------------- /custom_components/chime_tts/helpers/helpers.py: -------------------------------------------------------------------------------- 1 | """Audio helper functions for Chime TTS.""" 2 | 3 | import logging 4 | import os 5 | import re 6 | import subprocess 7 | import shutil 8 | import yaml 9 | from .media_player_helper import MediaPlayerHelper 10 | from .media_player import ChimeTTSMediaPlayer 11 | from .filesystem import FilesystemHelper 12 | 13 | from ..const import ( 14 | OFFSET_KEY, 15 | CROSSFADE_KEY, 16 | TTS_PLATFORM_KEY, 17 | DEFAULT_LANGUAGE_KEY, 18 | DEFAULT_VOICE_KEY, 19 | DEFAULT_TLD_KEY, 20 | DEFAULT_OFFSET_MS, 21 | FFMPEG_ARGS_ALEXA, 22 | FFMPEG_ARGS_VOLUME, 23 | AMAZON_POLLY, 24 | BAIDU, 25 | ELEVENLABS, 26 | GOOGLE_CLOUD, 27 | GOOGLE_TRANSLATE, 28 | IBM_WATSON_TTS, 29 | MARYTTS, 30 | MICROSOFT_EDGE_TTS, 31 | MICROSOFT_TTS, 32 | NABU_CASA_CLOUD_TTS, 33 | NABU_CASA_CLOUD_TTS_OLD, 34 | OPENAI_TTS, 35 | PICOTTS, 36 | PIPER, 37 | VOICE_RSS, 38 | YANDEX_TTS, 39 | QUOTE_CHAR_SUBSTITUTE 40 | ) 41 | from homeassistant.core import HomeAssistant 42 | from homeassistant.components.media_player.const import ATTR_MEDIA_VOLUME_LEVEL 43 | 44 | from pydub import AudioSegment 45 | 46 | filesystem_helper = FilesystemHelper() 47 | 48 | _LOGGER = logging.getLogger(__name__) 49 | class ChimeTTSHelper: 50 | """Helper functions for Chime TTS.""" 51 | 52 | # Services.yaml 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | # Parameters / Options 62 | 63 | async def async_parse_params(self, hass: HomeAssistant, data, is_say_url, media_player_helper: MediaPlayerHelper): 64 | """Parse TTS service parameters.""" 65 | entity_ids = media_player_helper.parse_entity_ids(data, hass) if is_say_url is False else [] 66 | chime_path =str(data.get("chime_path", "")) 67 | end_chime_path = str(data.get("end_chime_path", "")) 68 | offset = float(data.get("delay", data.get(OFFSET_KEY, DEFAULT_OFFSET_MS)) or 0) 69 | crossfade = int(data.get(CROSSFADE_KEY, 0)) 70 | final_delay = float(data.get("final_delay", 0) or 0) 71 | message = str(data.get("message", "")) 72 | tts_platform = str(data.get("tts_platform", "")) 73 | tts_speed = float(data.get("tts_playback_speed", data.get("tts_speed", 100)) or 100) 74 | tts_pitch = data.get("tts_pitch", 0) or 0 75 | volume_level = data.get(ATTR_MEDIA_VOLUME_LEVEL, -1) or -1 76 | join_players = data.get("join_players", False) or False 77 | unjoin_players = data.get("unjoin_players", False) or False 78 | language = data.get("language", None) 79 | cache = data.get("cache", False) or False 80 | announce = data.get("announce", False) or False 81 | fade_audio = data.get("fade_audio", False) or False 82 | media_players_array = await media_player_helper.async_initialize_media_players( 83 | hass, entity_ids, volume_level, join_players, unjoin_players, announce, fade_audio 84 | ) if is_say_url is False else [] 85 | 86 | # No valid media players included 87 | if len(media_players_array) == 0 and is_say_url is False: 88 | return None 89 | 90 | # FFmpeg arguments 91 | ffmpeg_args: str = self.parse_ffmpeg_args(data.get("audio_conversion", None)) 92 | 93 | params = { 94 | "entity_ids": entity_ids, 95 | "hass": hass, 96 | "chime_path": chime_path, 97 | "end_chime_path": end_chime_path, 98 | "cache": cache, 99 | "offset": offset, 100 | "crossfade": crossfade, 101 | "final_delay": final_delay, 102 | "message": message, 103 | "language": language, 104 | "tts_platform": tts_platform, 105 | "tts_speed": tts_speed, 106 | "tts_pitch": tts_pitch, 107 | "announce": announce, 108 | "fade_audio": fade_audio, 109 | "volume_level": volume_level, 110 | "join_players": join_players, 111 | "unjoin_players": unjoin_players, 112 | "ffmpeg_args": ffmpeg_args, 113 | "media_players_array": media_players_array, 114 | } 115 | 116 | self.debug_subtitle("General Parameters") 117 | for key, value in params.items(): 118 | if value is not None and value != "" and key not in ["hass"]: 119 | p_key = "audio_conversion" if key == "ffmpeg_args" else key 120 | if isinstance(value, list) and ((p_key != "audio_conversion" 121 | and len(value) > 1) 122 | or (p_key == "media_players_array" and len(value) > 0)): 123 | _LOGGER.debug(" * %s:", p_key) 124 | for i in range(0, len(value)): 125 | if isinstance(value[i], ChimeTTSMediaPlayer): 126 | media_player_i: ChimeTTSMediaPlayer = value[i] 127 | _LOGGER.debug(" - %s: entity_id: %s", str(i), str(media_player_i.entity_id)) 128 | _LOGGER.debug(" platform: %s", str(media_player_i.platform)) 129 | _LOGGER.debug(" initial volume: %s", str(media_player_i.initial_volume_level)) 130 | _LOGGER.debug(" target volume: %s", str(media_player_i.target_volume_level)) 131 | _LOGGER.debug(" now playing: %s", str(media_player_i.initially_playing)) 132 | _LOGGER.debug(" join supported: %s", str(media_player_i.join_supported)) 133 | _LOGGER.debug(" announce supported: %s", str(media_player_i.announce_supported)) 134 | else: 135 | _LOGGER.debug(" - %s: %s", str(i), str(value[i])) 136 | else: 137 | _LOGGER.debug(" * %s = %s", p_key, str(value)) 138 | 139 | return params 140 | 141 | def parse_options_yaml(self, data: dict, default_data: dict): 142 | """Parse TTS service options YAML into dict object.""" 143 | data = data or {} 144 | options = {} 145 | try: 146 | options_string = data.get("options", "") 147 | options = self.convert_yaml_str(options_string) or {} 148 | except yaml.YAMLError as error: 149 | _LOGGER.error("Error parsing options YAML: %s", error) 150 | return {} 151 | except Exception as error: 152 | _LOGGER.error("An unexpected error occurred while parsing options YAML: %s", 153 | str(error)) 154 | 155 | for key in ["tld", "voice"]: 156 | if key not in options: 157 | value = data.get(key, None) 158 | if value is not None: 159 | options[key] = value 160 | 161 | is_default_values = [] 162 | 163 | 164 | # Apply default values if not already set, and TTS Platform is the default 165 | default_tts_platform = default_data.get(TTS_PLATFORM_KEY, None) 166 | selected_tts_platform = data.get("tts_platform", default_tts_platform) 167 | tts_platform_is_default = default_tts_platform == selected_tts_platform 168 | 169 | # Language 170 | language = data.get("language", None) or options.get("language", None) 171 | if (not language 172 | and default_data.get(DEFAULT_LANGUAGE_KEY, None) 173 | and tts_platform_is_default): 174 | options["language"] = default_data.get(DEFAULT_LANGUAGE_KEY, None) 175 | is_default_values.append("language") 176 | 177 | # Voice 178 | voice = data.get("voice", None) or options.get("voice", None) 179 | # Apply default voice if not already set, and TTS Platform is the default 180 | if (not voice 181 | and default_data.get(DEFAULT_VOICE_KEY, None) 182 | and tts_platform_is_default): 183 | options["voice"] = default_data.get(DEFAULT_VOICE_KEY, None) 184 | is_default_values.append("voice") 185 | 186 | # TLD 187 | tld = data.get("tld", None) or options.get("tld", None) 188 | # Apply default TLD if not already set, and TTS Platform is Google Translate 189 | if (not tld 190 | and default_data.get(DEFAULT_TLD_KEY, None) 191 | and selected_tts_platform == GOOGLE_TRANSLATE): 192 | options["tld"] = default_data.get(DEFAULT_TLD_KEY, None) 193 | is_default_values.append("tld") 194 | 195 | if options: 196 | self.debug_subtitle("TTS-Specific Params") 197 | for key, value in options.items(): 198 | if key in is_default_values: 199 | _LOGGER.debug(" * %s = %s (default value entered in configuration)", key, str(value)) 200 | else: 201 | _LOGGER.debug(" * %s = %s", key, str(value)) 202 | 203 | return options 204 | 205 | def remove_niqqud(self, message_text: str): 206 | """Replace Hebrew niqqud characters with non-voweled characters.""" 207 | # Unicode range for Hebrew niqqud is \u0591 to \u05C7 208 | niqqud_pattern = re.compile(r'[\u0591-\u05C7]') 209 | cleaned_text = niqqud_pattern.sub('', message_text) 210 | return cleaned_text 211 | 212 | def parse_message(self, message_string: str): 213 | """Parse the message string/YAML object into segments dictionary.""" 214 | message_string = self.remove_niqqud(message_string) 215 | segments = [] 216 | if len(message_string) == 0 or message_string == "None": 217 | return [] 218 | 219 | contains_keys = True 220 | for key in ["type", "tts", "chime", "delay"]: 221 | contains_keys = contains_keys or message_string.find(f"'{key}':") > -1 or message_string.find(f'"{key}":') > -1 222 | if contains_keys: 223 | # Convert message string to YAML object 224 | message_yaml = self.convert_yaml_str(message_string) 225 | 226 | # Verify objects in YAML are valid chime/tts/delay segements 227 | if message_yaml and isinstance(message_yaml, list): 228 | is_valid = True 229 | for elem in message_yaml: 230 | if isinstance(elem, dict): 231 | 232 | # Convert new short format to old format 233 | if "type" not in elem: 234 | # Chime 235 | if "chime" in elem: 236 | elem["type"] = "chime" 237 | elem["path"] = elem["chime"].replace(QUOTE_CHAR_SUBSTITUTE, "'") 238 | del elem["chime"] 239 | # TTS 240 | elif "tts" in elem: 241 | elem["type"] = "tts" 242 | elem["message"] = elem["tts"].replace(QUOTE_CHAR_SUBSTITUTE, "'") 243 | del elem["tts"] 244 | # Delay 245 | elif "delay" in elem: 246 | elem["type"] = "delay" 247 | elem["length"] = elem["delay"] 248 | del elem["delay"] 249 | else: 250 | is_valid = False 251 | else: 252 | is_valid = False 253 | if is_valid is True: 254 | segments = message_yaml 255 | 256 | # Add message string as TTS segment 257 | if len(segments) == 0: 258 | segments.append({ 259 | 'type': 'tts', 260 | 'message': message_string 261 | }) 262 | 263 | # Final adjustments 264 | final_segments = [] 265 | for _, segment_n in enumerate(segments): 266 | segment = {} 267 | for key, value in segment_n.items(): 268 | if isinstance(value, dict): 269 | for key_n, value_n in value.items(): 270 | value[key_n.lower()] = value_n 271 | # Make all segment keys lowercase 272 | segment[key.lower()] = value 273 | 274 | # Support alternate key names 275 | if segment.get("speed"): 276 | segment["tts_speed"] = segment.get("speed") 277 | del segment["speed"] 278 | if segment.get("pitch"): 279 | segment["tts_pitch"] = segment.get("pitch") 280 | del segment["pitch"] 281 | 282 | # Duplicate segments "repeat" times 283 | repeat = segment.get("repeat", 1) 284 | if isinstance(repeat, int): 285 | repeat = max(segment.get("repeat", 1), 1) 286 | else: 287 | repeat = 1 288 | for _ in range(repeat): 289 | final_segments.append(segment) 290 | 291 | return final_segments 292 | 293 | def convert_yaml_str(self, yaml_string): 294 | """Convert a yaml string into an object.""" 295 | if not yaml_string: 296 | return {} 297 | if isinstance(yaml_string, dict): 298 | return yaml_string 299 | 300 | try: 301 | yaml_string = yaml_string.replace("'", "\\'").replace("\\\\'", QUOTE_CHAR_SUBSTITUTE).replace("\\'", "'") 302 | yaml_object = yaml.safe_load(yaml_string) 303 | return yaml_object 304 | except yaml.YAMLError as exc: 305 | if hasattr(exc, 'problem_mark'): 306 | _LOGGER.debug("YAML string parsing error at line %s, column %s: %s", 307 | str(exc.problem_mark.line + 1), 308 | str(exc.problem_mark.column + 1), 309 | str(exc)) 310 | else: 311 | _LOGGER.debug("YAML string parsing error: %s", str(exc)) 312 | except Exception as error: 313 | _LOGGER.debug("An unexpected error occurred while parsing YAML string: %s", 314 | str(error)) 315 | return None 316 | 317 | 318 | def parse_ffmpeg_args(self, ffmpeg_args_str: str): 319 | """Parse the FFmpeg argument string.""" 320 | if ffmpeg_args_str is not None: 321 | if ffmpeg_args_str.lower() == "alexa": 322 | return FFMPEG_ARGS_ALEXA 323 | if (len(ffmpeg_args_str.split(" ")) == 2 and 324 | ffmpeg_args_str.split(" ")[0].lower() == "volume"): 325 | try: 326 | volume = float(ffmpeg_args_str.split(" ")[1].replace("%","")) / 100 327 | return FFMPEG_ARGS_VOLUME.replace("X", str(volume)) 328 | except ValueError: 329 | _LOGGER.warning("Error parsing audio_conversion string") 330 | if ffmpeg_args_str.lower() == "custom": 331 | return None 332 | return ffmpeg_args_str 333 | 334 | def get_tts_platform(self, 335 | hass, 336 | tts_platform: str = "", 337 | default_tts_platform: str = "", 338 | fallback_tts_platform: str = ""): 339 | """TTS platform/entity_id to use for TTS audio.""" 340 | 341 | installed_tts_platforms: list[str] = self.get_installed_tts_platforms(hass) 342 | 343 | # No TTS platform provided 344 | if not tts_platform: 345 | tts_platform = default_tts_platform if default_tts_platform else fallback_tts_platform 346 | 347 | # Match for deprecated Nabu Casa platform string 348 | if tts_platform.lower() == NABU_CASA_CLOUD_TTS_OLD: 349 | tts_platform = NABU_CASA_CLOUD_TTS 350 | 351 | # Match for installed tts platform 352 | if tts_platform.lower() in installed_tts_platforms: 353 | return tts_platform.lower() 354 | 355 | # Contains "google" - return alternate Google platform, if available 356 | if tts_platform.find("google") != -1: 357 | # Return alternate Google Translate entity, eg: "tts.google_en_com" 358 | if tts_platform.startswith("tts."): 359 | for installed_tts_platform in installed_tts_platforms: 360 | if (installed_tts_platform.lower().find("google") != -1 361 | and installed_tts_platform.startswith("tts.")): 362 | _LOGGER.warning("The TTS entity '%s' was not found. Using '%s' instead.", tts_platform, installed_tts_platform) 363 | return installed_tts_platform 364 | # Return Google Translate, if installed 365 | if GOOGLE_TRANSLATE in installed_tts_platforms: 366 | _LOGGER.warning("The TTS platform '%s' was not found. Using '%s' instead.", tts_platform, GOOGLE_TRANSLATE) 367 | return GOOGLE_TRANSLATE 368 | 369 | _LOGGER.warning("Unable to select a TTS platform") 370 | return None 371 | 372 | 373 | def get_stripped_tts_platform(self, tts_provider = ""): 374 | """Validate the TTS platform name.""" 375 | stripped_tts_provider = tts_provider.replace(" ", "").replace(" ", "").replace(" ", "").replace(".", "").replace("-", "").replace("_", "").lower() 376 | if stripped_tts_provider == "amazonpolly": 377 | tts_provider = AMAZON_POLLY 378 | elif stripped_tts_provider == "baidu": 379 | tts_provider = BAIDU 380 | elif stripped_tts_provider == "elevenlabs": 381 | tts_provider = ELEVENLABS 382 | elif stripped_tts_provider == "googlecloud": 383 | tts_provider = GOOGLE_CLOUD 384 | elif stripped_tts_provider == "googletranslate": 385 | tts_provider = GOOGLE_TRANSLATE 386 | elif stripped_tts_provider == "watsontts": 387 | tts_provider = IBM_WATSON_TTS 388 | elif stripped_tts_provider == "marytts": 389 | tts_provider = MARYTTS 390 | elif stripped_tts_provider == "microsofttts": 391 | tts_provider = MICROSOFT_TTS 392 | elif stripped_tts_provider == "microsoftedgetts": 393 | tts_provider = MICROSOFT_EDGE_TTS 394 | elif stripped_tts_provider in ["nabucasacloudtts", 395 | "nabucasacloud", 396 | "nabucasa", 397 | "cloudsay"]: 398 | tts_provider = NABU_CASA_CLOUD_TTS 399 | elif stripped_tts_provider == "openaitts": 400 | tts_provider = OPENAI_TTS 401 | elif stripped_tts_provider == "picotts": 402 | tts_provider = PICOTTS 403 | elif stripped_tts_provider == "piper": 404 | tts_provider = PIPER 405 | elif stripped_tts_provider == "voicerss": 406 | tts_provider = VOICE_RSS 407 | elif stripped_tts_provider == "yandextts": 408 | tts_provider = YANDEX_TTS 409 | 410 | return tts_provider 411 | 412 | def get_installed_tts_platforms(self, hass: HomeAssistant) -> list[str]: 413 | """List of installed tts platforms.""" 414 | # Installed TTS Providers 415 | tts_providers = list((hass.data["tts_manager"].providers).keys()) 416 | 417 | # Installed TTS Platform Entities 418 | tts_entities = [] 419 | all_entities = hass.states.async_all() 420 | for entity in all_entities: 421 | if str(entity.entity_id).startswith("tts."): 422 | tts_entities.append(str(entity.entity_id)) 423 | 424 | # Installed TTS Components 425 | tts_components = [] 426 | for key, _value in dict(hass.data["components"]).items(): 427 | if isinstance(key, str) and key.endswith(".tts"): 428 | tts_components.append(key[0:len(key)-4]) 429 | 430 | # Remove any duplicates and sort alphabetically 431 | all_tts_platforms_found: list[str] = tts_entities + tts_providers + tts_components 432 | final_tts_platforms: list[str] = [] 433 | for tts_platform in all_tts_platforms_found: 434 | if tts_platform not in final_tts_platforms and f"tts.{tts_platform}" not in final_tts_platforms: 435 | final_tts_platforms.append(tts_platform) 436 | final_tts_platforms.sort() 437 | return final_tts_platforms 438 | 439 | 440 | async def async_ffmpeg_convert_from_audio_segment(self, 441 | hass: HomeAssistant, 442 | audio_segment: AudioSegment = None, 443 | ffmpeg_args: str = "", 444 | folder: str = ""): 445 | """Convert pydub AudioSegment with FFmpeg and provided arguments.""" 446 | ret_val = audio_segment 447 | 448 | if not ffmpeg_args or len(ffmpeg_args) == 0: 449 | return ret_val 450 | 451 | # Validate parameters 452 | error_string = "" 453 | if not audio_segment: 454 | error_string = "No audio segment provided. " 455 | if not folder or folder == "": 456 | error_string += "No temporary folder path provided." 457 | if len(error_string) > 0: 458 | _LOGGER.warning("Skipping FFmpeg conversion: %s", error_string) 459 | return ret_val 460 | 461 | # Save to temp file 462 | temp_filename = "temp_segment.mp3" 463 | temp_audio_file = await filesystem_helper.async_save_audio_to_folder( 464 | hass=hass, 465 | audio=audio_segment, 466 | folder=folder, 467 | file_name=temp_filename) 468 | if not temp_audio_file: 469 | full_path = f"{folder}/{temp_filename}" 470 | _LOGGER.warning("ffmpeg_convert_from_audio_segment - Unable to store audio segment to: %s", full_path) 471 | return ret_val 472 | 473 | # Convert with FFmpeg 474 | converted_audio_file = await self.async_ffmpeg_convert_from_file(hass, temp_audio_file, ffmpeg_args) 475 | if converted_audio_file is None or converted_audio_file is False or len(converted_audio_file) < 5: 476 | _LOGGER.warning("ffmpeg_convert_from_audio_segment - Unable to convert audio segment from file %s", temp_audio_file) 477 | 478 | # Load new AudioSegment from converted file 479 | else: 480 | try: 481 | ret_val = await filesystem_helper.async_load_audio(str(converted_audio_file)) 482 | except Exception as error: 483 | _LOGGER.warning("ffmpeg_convert_from_audio_segment - Unable to load converted audio segment from file: %s. Error: %s", 484 | str(converted_audio_file), error) 485 | 486 | # Delete temp file & converted file 487 | for file_path in [temp_audio_file, converted_audio_file]: 488 | if (file_path 489 | and isinstance(file_path, str) 490 | and await hass.async_add_executor_job(filesystem_helper.path_exists, file_path)): 491 | try: 492 | os.remove(file_path) 493 | except Exception as error: 494 | _LOGGER.warning("ffmpeg_convert_from_audio_segment - Unable to delete file: %s. Error: %s", 495 | str(file_path), error) 496 | 497 | return ret_val 498 | 499 | async def async_ffmpeg_convert_from_file(self, hass: HomeAssistant, file_path: str, ffmpeg_args: str): 500 | """Convert audio file with FFmpeg and provided arguments.""" 501 | 502 | local_file_path = filesystem_helper.get_local_path(hass, file_path) 503 | if not await hass.async_add_executor_job(filesystem_helper.filepath_exists_locally, hass, local_file_path): 504 | _LOGGER.warning("Unable to perform FFmpeg conversion: source file not found on file system: %s", local_file_path) 505 | return False 506 | 507 | # Prevent Alexa FFmpeg comversion if file is aleady comaptible 508 | if ffmpeg_args == FFMPEG_ARGS_ALEXA and await filesystem_helper.async_is_audio_alexa_compatible(hass, local_file_path): 509 | _LOGGER.debug("Audio is already Alexa Media Player compatible") 510 | return file_path 511 | 512 | ffmpeg_cmd_string = "" 513 | try: 514 | # Add standard arguments 515 | ffmpeg_cmd = [ 516 | 'ffmpeg', 517 | '-i', 518 | local_file_path, 519 | *ffmpeg_args.split() 520 | ] 521 | 522 | # Save to a specific file type (eg: -f wav) 523 | try: 524 | # Use specified file type 525 | index = ffmpeg_cmd.index('-f') 526 | if index >= 0 and len(ffmpeg_args) >= index: 527 | file_extension = ffmpeg_cmd[index+1] 528 | if file_extension != "mp3": 529 | converted_file_path = local_file_path.replace(".mp3", f".{file_extension}") 530 | except Exception: 531 | # Use mp3 as default 532 | converted_file_path = local_file_path.replace(".mp3", "_converted.mp3") 533 | 534 | if converted_file_path == local_file_path: 535 | converted_file_path = local_file_path.replace(".mp3", "_converted.mp3") 536 | 537 | # Delete converted output file if it exists 538 | if await hass.async_add_executor_job(filesystem_helper.path_exists, converted_file_path): 539 | os.remove(converted_file_path) 540 | 541 | ffmpeg_cmd.append(converted_file_path) 542 | 543 | # Convert the audio file 544 | ffmpeg_cmd_string = " ".join(ffmpeg_cmd) 545 | _LOGGER.debug("Running FFmpeg operation: \"%s\"", ffmpeg_cmd_string) 546 | ffmpeg_process = subprocess.Popen(ffmpeg_cmd, 547 | stdin=subprocess.PIPE, 548 | stdout=subprocess.PIPE, 549 | stderr=subprocess.PIPE) 550 | _, error_output = ffmpeg_process.communicate() 551 | 552 | 553 | if ffmpeg_process.returncode != 0: 554 | error_message = error_output.decode('utf-8') 555 | _LOGGER.error(("FFmpeg operation failed.\n\nArguments string: \"%s\"\n\nError code: %s\n\nError output:\n%s"), 556 | str(ffmpeg_process.returncode), 557 | str(error_message), 558 | ffmpeg_cmd_string) 559 | return False 560 | 561 | # Replace original with converted file 562 | if converted_file_path == local_file_path.replace(".mp3", "_converted.mp3"): 563 | try: 564 | shutil.move(converted_file_path, local_file_path) 565 | except Exception as error: 566 | _LOGGER.error("Error renaming file %s to %s. Error: %s. FFmpeg options: %s", 567 | local_file_path, converted_file_path, error, ffmpeg_cmd_string) 568 | return False 569 | 570 | return file_path 571 | 572 | except subprocess.CalledProcessError as error: 573 | _LOGGER.error("FFmpeg subproces error: %s FFmpeg options: %s", 574 | error, ffmpeg_cmd_string) 575 | 576 | except Exception as error: 577 | _LOGGER.error("FFmpeg unexpected error: %s FFmpeg options: %s", 578 | error, ffmpeg_cmd_string) 579 | 580 | return file_path 581 | 582 | def add_atempo_values_to_ffmpeg_args_string(self, tempo: float, ffmpeg_args_string: str = None): 583 | """Add atempo values (supporting values less than 0.5) to an FFmpeg argument string.""" 584 | tempos = [] 585 | if tempo < 0.5: 586 | tempos = [0.5] 587 | remaining = tempo 588 | while remaining < 0.5: 589 | remaining /= 0.5 590 | if remaining >= 0.5: 591 | tempos.append(remaining) 592 | break 593 | tempos.append(0.5) 594 | else: 595 | tempos = [tempo] 596 | 597 | for tempo_n in tempos: 598 | if ffmpeg_args_string is None: 599 | ffmpeg_args_string = f"-af atempo={tempo_n}" 600 | else: 601 | ffmpeg_args_string += f",atempo={tempo_n}" 602 | 603 | return ffmpeg_args_string 604 | 605 | async def async_change_speed_of_audiosegment(self, hass: HomeAssistant, audio_segment: AudioSegment, speed: float = 100.0, temp_folder: str = None): 606 | """Change the playback speed of an audio segment.""" 607 | if not audio_segment or speed == 100 or speed < 1 or speed > 500: 608 | if not audio_segment: 609 | _LOGGER.warning("Cannot change TTS audio playback speed. No audio available") 610 | elif speed != 100: 611 | _LOGGER.warning("TTS audio playback speed values must be between 1% and 500%") 612 | return audio_segment 613 | 614 | _LOGGER.debug(f" - ...changing TTS playback speed to {str(speed)}% of original") 615 | 616 | tempo = float(speed / 100) 617 | 618 | ffmpeg_args_string = self.add_atempo_values_to_ffmpeg_args_string(tempo) 619 | 620 | return await self.async_ffmpeg_convert_from_audio_segment( 621 | hass=hass, 622 | audio_segment=audio_segment, 623 | ffmpeg_args=ffmpeg_args_string, 624 | folder=temp_folder) 625 | 626 | async def async_change_pitch_of_audiosegment(self, hass: HomeAssistant, audio_segment: AudioSegment, pitch: int = 0, temp_folder: str = None): 627 | """Change the pitch of an audio segment.""" 628 | if not audio_segment: 629 | _LOGGER.warning("Cannot change TTS audio pitch. No audio available") 630 | return audio_segment 631 | elif pitch == 0.0: 632 | return audio_segment 633 | 634 | _LOGGER.debug( 635 | " - ...changing pitch of TTS audio by %s semitone%s", 636 | str(pitch), 637 | ("" if pitch == 1 else "s") 638 | ) 639 | 640 | # Generate FFmpeg arguments string 641 | pitch_shift = 2 ** (pitch / 12) 642 | tempo_adjustment = 1 / pitch_shift 643 | frame_rate = audio_segment.frame_rate 644 | ffmpeg_args_string = f"-af asetrate={frame_rate}*{pitch_shift}" 645 | ffmpeg_args_string = self.add_atempo_values_to_ffmpeg_args_string(tempo_adjustment, ffmpeg_args_string) 646 | return await self.async_ffmpeg_convert_from_audio_segment( 647 | hass=hass, 648 | audio_segment=audio_segment, 649 | ffmpeg_args=ffmpeg_args_string, 650 | folder=temp_folder) 651 | 652 | def combine_audio(self, 653 | audio_1: AudioSegment, 654 | audio_2: AudioSegment, 655 | offset: int = 0, 656 | crossfade: int = 0): 657 | """Combine two AudioSegment object with a delay (if offset>0) overlay (if offset<0) or crossfade.""" 658 | if audio_1 is None: 659 | return audio_2 660 | if audio_2 is None: 661 | return audio_1 662 | ret_val = audio_1 + audio_2 663 | 664 | # Overlay / delay 665 | if offset < 0: 666 | _LOGGER.debug("Performing overlay of %sms", str(offset)) 667 | ret_val = self.overlay(audio_1, audio_2, offset) 668 | elif offset > 0: 669 | _LOGGER.debug("Adding gap of %sms", str(offset)) 670 | ret_val = audio_1 + (AudioSegment.silent(duration=offset) + audio_2) 671 | elif crossfade > 0: 672 | crossfade = min(len(audio_1), len(audio_2), crossfade) 673 | _LOGGER.debug("Performing crossfade of %sms", str(crossfade)) 674 | ret_val = audio_1.append(audio_2, crossfade=crossfade) 675 | else: 676 | _LOGGER.debug("Combining audio files with no delay, overlay or crossfade") 677 | 678 | return ret_val 679 | 680 | def overlay(self, audio_1: AudioSegment, audio_2: AudioSegment, overlay: int = 0): 681 | """Overlay two audio segments.""" 682 | overlay = abs(overlay) 683 | overlap_point = len(audio_1) - overlay 684 | overlap_point = max(0, overlap_point) 685 | 686 | crossover_audio = audio_1.overlay(audio_2, position=overlap_point) 687 | if len(audio_2) > overlay: 688 | crossover_audio += audio_2[overlay:] 689 | return crossover_audio 690 | 691 | def debug_title(self, title: str = ""): 692 | """Debug log a title string.""" 693 | if len(title) == 0: 694 | return 695 | _LOGGER.debug(f"╔{"═"*(int(len(title) + 2))}╗") 696 | _LOGGER.debug(f"║ {title} ║") 697 | _LOGGER.debug(f"╚{"═"*(int(len(title) + 2))}╝") 698 | 699 | def debug_subtitle(self, title: str = ""): 700 | """Debug log a subtitle string.""" 701 | if len(title) == 0: 702 | return 703 | _LOGGER.debug(f"╭{"─"*(int(len(title) + 2))}╮") 704 | _LOGGER.debug(f"│ {title} │") 705 | _LOGGER.debug(f"╰{"─"*(int(len(title) + 2))}╯") 706 | 707 | def debug_finish(self, title: str = ""): 708 | """Debug log a subtitle string.""" 709 | if len(title) == 0: 710 | return 711 | _LOGGER.debug(f"╭{"─"*(int(len(title) + 5))}─────╮") 712 | _LOGGER.debug(f"│──── {title} ────│") 713 | _LOGGER.debug(f"╰{"─"*(int(len(title) + 5))}─────╯") 714 | -------------------------------------------------------------------------------- /custom_components/chime_tts/helpers/media_player.py: -------------------------------------------------------------------------------- 1 | """Media player classes to handle different pre-playback, playback & post-playback.""" 2 | import logging 3 | from homeassistant.core import HomeAssistant 4 | from homeassistant.const import CONF_ENTITY_ID, SERVICE_TURN_ON 5 | from homeassistant.components.media_player.const import ( 6 | ATTR_MEDIA_VOLUME_LEVEL, 7 | ATTR_MEDIA_ANNOUNCE, 8 | ATTR_GROUP_MEMBERS, 9 | ) 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | class ChimeTTSMediaPlayer: 14 | """Base media player class.""" 15 | 16 | hass: HomeAssistant 17 | entity_id: str 18 | platform: str 19 | initial_volume_level: float 20 | target_volume_level: float 21 | initially_playing: bool 22 | announce_supported: bool 23 | join_supported: bool 24 | 25 | def __init__(self, hass: HomeAssistant, entity_id: str, target_volume_level): 26 | """Initialise the class.""" 27 | self.hass: HomeAssistant = hass 28 | self.entity_id: str = entity_id 29 | self.platform = self.get_platform() 30 | 31 | # Initialise state and values 32 | self.turn_on() 33 | self.initially_playing = (self.get_state() == "playing" 34 | # Check that media_player is actually playing (HomePods can incorrectly have the state "playing" when no media is playing) 35 | and self.get_entity().attributes.get("media_duration", -1) != 0) 36 | self.initial_volume_level: float = self.get_current_volume_level() 37 | self.announce_supported = self.get_supported_feature(ATTR_MEDIA_ANNOUNCE) 38 | self.join_supported = self.get_supported_feature(ATTR_GROUP_MEMBERS) 39 | 40 | # Extract target volume level 41 | # From dictionary 42 | if isinstance(target_volume_level, dict) and entity_id in target_volume_level: 43 | self.target_volume_level = float(target_volume_level[entity_id]) 44 | # From array of dicts 45 | elif isinstance(target_volume_level, list): 46 | for volume_level_dict in target_volume_level: 47 | if entity_id in volume_level_dict: 48 | self.target_volume_level = float(volume_level_dict[entity_id]) 49 | # From float 50 | elif isinstance(target_volume_level, float): 51 | self.target_volume_level = target_volume_level 52 | # Default 53 | else: 54 | self.target_volume_level = -1 55 | 56 | # Service Calls 57 | 58 | def turn_on(self): 59 | """Turn on the media player if it is currently off.""" 60 | if self.get_state() == "off": 61 | _LOGGER.info('Turning on "%s"...', self.entity_id) 62 | try: 63 | self.hass.async_create_task( 64 | self.hass.services.async_call( 65 | domain="media_player", 66 | service=SERVICE_TURN_ON, 67 | service_data={CONF_ENTITY_ID: self.entity_id}, 68 | blocking=True 69 | ) 70 | ) 71 | except Exception as error: 72 | _LOGGER.error("Error calling media_player.turn_on: %s", str(error)) 73 | 74 | 75 | # Getters & Setters 76 | 77 | def get_entity(self): 78 | """media_player entity object.""" 79 | return self.hass.states.get(self.entity_id) 80 | 81 | def get_state(self): 82 | """media_player entity state.""" 83 | return self.get_entity().state 84 | 85 | def get_platform(self): 86 | """media_player entity integration platform.""" 87 | entity_registry = self.hass.data["entity_registry"] 88 | for entity in entity_registry.entities.values(): 89 | if entity.entity_id == self.entity_id: 90 | return entity.platform 91 | return None 92 | 93 | def get_supported_feature(self, feature: str): 94 | """Whether a feature is supported by the media_player device.""" 95 | if self.get_entity() is None or self.get_entity().attributes is None: 96 | return False 97 | supported_features = self.get_entity().attributes.get("supported_features", 0) 98 | if feature is ATTR_MEDIA_VOLUME_LEVEL: 99 | return bool(supported_features & 2) 100 | if feature is ATTR_MEDIA_ANNOUNCE: 101 | return bool(supported_features & 1048576) 102 | if feature is ATTR_GROUP_MEMBERS: 103 | return bool(supported_features & 524288) 104 | return False 105 | 106 | def get_should_change_volume(self): 107 | """Boolean for whether the media player's volume level should be changed.""" 108 | return self.target_volume_level >= 0 and self.target_volume_level != self.initial_volume_level 109 | 110 | @property 111 | def target_volume_level(self) -> float: 112 | """Media player's target volume level.""" 113 | return self._target_volume_level 114 | 115 | @target_volume_level.setter 116 | def target_volume_level(self, value): 117 | """Store the media player's target volume level.""" 118 | if isinstance(value, dict): 119 | value = float(value.get(self.entity_id, -1.0)) 120 | self._target_volume_level = float(value) if value and float(value) > 0 else -1.0 121 | 122 | def get_current_volume_level(self) -> float: 123 | """Media player's current volume level.""" 124 | return float(self.get_entity().attributes.get(ATTR_MEDIA_VOLUME_LEVEL, -1.0)) 125 | 126 | @property 127 | def initial_volume_level(self) -> float: 128 | """Media player's initial volume level.""" 129 | return self._initial_volume_level 130 | 131 | @initial_volume_level.setter 132 | def initial_volume_level(self, value: float): 133 | """Store the media player's initial volume level.""" 134 | self._initial_volume_level = float(value if float(value) >= 0 else -1.0) 135 | -------------------------------------------------------------------------------- /custom_components/chime_tts/helpers/media_player_helper.py: -------------------------------------------------------------------------------- 1 | """Media player helper functions for Chime TTS.""" 2 | 3 | import logging 4 | import time 5 | import math 6 | from homeassistant.core import HomeAssistant, State 7 | from homeassistant.const import CONF_ENTITY_ID, SERVICE_VOLUME_SET 8 | from .media_player import ChimeTTSMediaPlayer 9 | 10 | from homeassistant.components.media_player.const import ( 11 | ATTR_MEDIA_ANNOUNCE, 12 | ATTR_GROUP_MEMBERS, 13 | ATTR_MEDIA_VOLUME_LEVEL, 14 | SERVICE_JOIN, 15 | SERVICE_UNJOIN, 16 | ) 17 | from ..const import ( 18 | ALEXA_MEDIA_PLAYER_PLATFORM, 19 | SONOS_PLATFORM, 20 | SPOTIFY_PLATFORM, 21 | TRANSITION_STEP_MS 22 | ) 23 | from ..config import ( 24 | SONOS_SNAPSHOT_ENABLED, 25 | ) 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | class MediaPlayerHelper: 29 | """Media player helper functions for Chime TTS.""" 30 | 31 | media_players: list[ChimeTTSMediaPlayer] = [] 32 | joined_media_player_entity_ids: list[str] = [] 33 | unjoined_media_player_entity_ids: list[str] = [] 34 | join_players: bool = False 35 | unjoin_players: bool = False 36 | joined_entity_id: str 37 | announce: bool = False 38 | fade_audio: bool = False 39 | sonos_restored: bool = False 40 | media_dirs_dict: object = {} 41 | 42 | async def async_initialize_media_players(self, 43 | hass: HomeAssistant, 44 | entity_ids, 45 | volume_level, 46 | join_players, 47 | unjoin_players, 48 | announce, 49 | fade_audio): 50 | """Initialize media player entities.""" 51 | # Service call was from chime_tts.say_url, so media_players are irrelevant 52 | if len(entity_ids) == 0: 53 | return [] 54 | 55 | self.media_players: list[ChimeTTSMediaPlayer] = [] 56 | self.joined_media_player_entity_ids = [] 57 | self.unjoined_media_player_entity_ids = [] 58 | self.join_players = join_players 59 | self.unjoin_players = unjoin_players 60 | self.joined_entity_id = None 61 | self.announce = announce 62 | self.fade_audio = fade_audio 63 | self.sonos_restored = False 64 | self.media_dirs_dict: object = hass.config.media_dirs or {} 65 | 66 | for entity_id in entity_ids: 67 | media_player_object = await self.async_get_media_player_object(hass, entity_id, volume_level) 68 | if media_player_object: 69 | self.media_players.append(media_player_object) 70 | 71 | if len(self.media_players) == 0: 72 | _LOGGER.error("No valid media players found") 73 | 74 | return self.media_players 75 | 76 | async def async_get_media_player_object(self, 77 | hass: HomeAssistant, 78 | entity_id: str, 79 | target_volume_level): 80 | """Create a Chime TTS media player object from a given entity_id.""" 81 | 82 | if (hass is None 83 | or entity_id is None 84 | or hass.states.get(entity_id) is None): 85 | return None 86 | 87 | return ChimeTTSMediaPlayer( 88 | hass=hass, 89 | entity_id=entity_id, 90 | target_volume_level=target_volume_level) 91 | 92 | def parse_entity_ids(self, data, hass) -> list[str]: 93 | """Parse media_player entity_ids into list object.""" 94 | entity_ids: list[str] = data.get(CONF_ENTITY_ID, []) 95 | if isinstance(entity_ids, str): 96 | entity_ids = entity_ids.split(",") 97 | 98 | # Find all media_player entities associated with device/s specified 99 | device_ids = data.get("device_id", []) 100 | if isinstance(device_ids, str): 101 | device_ids = device_ids.split(",") 102 | entity_registry = hass.data["entity_registry"] 103 | for device_id in device_ids: 104 | matching_entity_ids = [ 105 | entity.entity_id 106 | for entity in entity_registry.entities.values() 107 | if entity.device_id == device_id 108 | and entity.entity_id.startswith("media_player.") 109 | ] 110 | entity_ids.extend(matching_entity_ids) 111 | entity_ids: list[str] = list(set(entity_ids)) 112 | return entity_ids 113 | 114 | def get_fade_in_out_media_players(self) -> list[ChimeTTSMediaPlayer]: 115 | """List of media_player objects that should fade out before Chime TTS playback and fade back in when completed.""" 116 | announce_unsupported_media_players: list[ChimeTTSMediaPlayer] = [] 117 | for media_player in self.media_players: 118 | if (media_player.initially_playing and 119 | (self.fade_audio or (self.announce and not media_player.announce_supported))): 120 | announce_unsupported_media_players.append(media_player) 121 | return announce_unsupported_media_players 122 | 123 | def get_set_volume_media_players(self) -> list[ChimeTTSMediaPlayer]: 124 | """List of media_player objects whose volume levels need to be changed (without fading) to the target volume level.""" 125 | set_volume_media_players: list[ChimeTTSMediaPlayer] = [] 126 | for media_player in self.media_players: 127 | if (media_player not in self.get_fade_in_out_media_players() 128 | and media_player.target_volume_level not in [-1, media_player.initial_volume_level] 129 | and media_player.platform not in (SPOTIFY_PLATFORM, SONOS_PLATFORM) 130 | ): 131 | set_volume_media_players.append(media_player) 132 | return set_volume_media_players 133 | 134 | def get_media_player_target_volume(self, entity_id): 135 | """Get the target volume level for a given media_player entity.""" 136 | for media_player in self.media_players: 137 | if media_player.entity_id == entity_id: 138 | return media_player.target_volume_level 139 | return None 140 | 141 | def get_media_player_platform(self, hass: HomeAssistant, entity_id): 142 | """Get the platform for a given media_player entity.""" 143 | entity_registry = hass.data["entity_registry"] 144 | for entity in entity_registry.entities.values(): 145 | if entity and entity.entity_id == entity_id: 146 | _LOGGER.debug("%s", entity.platform) 147 | return entity.platform 148 | return None 149 | 150 | def get_media_players_from_entity_ids(self, entity_ids) -> list[ChimeTTSMediaPlayer]: 151 | """List of media_player objects from a list of entity_ids.""" 152 | media_players: list[ChimeTTSMediaPlayer] = [] 153 | for entity_id in entity_ids: 154 | media_player = self.get_media_players_from_entity_id(entity_id) 155 | if media_player: 156 | media_players.append(media_player) 157 | return media_players 158 | 159 | def get_media_players_from_entity_id(self, entity_id) -> ChimeTTSMediaPlayer: 160 | """media_player objects matching a given entity_id.""" 161 | for media_player in self.media_players: 162 | if media_player.entity_id == entity_id: 163 | return media_player 164 | return None 165 | 166 | def get_uniform_target_volume_level(self, entity_ids): 167 | """Target volume level (if identical between media_players).""" 168 | uniform_volume_level = -1 169 | for media_player in self.get_media_players_from_entity_ids(entity_ids): 170 | media_player_volume = media_player.target_volume_level 171 | if media_player_volume == -1: 172 | continue 173 | if uniform_volume_level == -1: 174 | uniform_volume_level = media_player_volume 175 | elif uniform_volume_level != media_player_volume: 176 | return -1 177 | return uniform_volume_level 178 | 179 | def get_is_standard_media_player(self, entity_id): 180 | """Determine whether a media_player can be used with the media_player.play_media service.""" 181 | platform = self.get_platform_from_entity_id(entity_id) 182 | return platform and platform not in (ALEXA_MEDIA_PLAYER_PLATFORM, SONOS_PLATFORM, SPOTIFY_PLATFORM) 183 | 184 | def get_platform_from_entity_id(self, entity_id): 185 | """Platform for the media_player with entity_id.""" 186 | media_player: ChimeTTSMediaPlayer = self.get_media_players_from_entity_id(entity_id) 187 | if media_player: 188 | return media_player.platform 189 | 190 | def get_is_media_player_alexa(self, entity_id): 191 | """Determine whether a media_player belongs to the Alexa Media Player platform.""" 192 | return self.get_platform_from_entity_id(entity_id) == ALEXA_MEDIA_PLAYER_PLATFORM 193 | 194 | def get_is_media_player_sonos(self, entity_id): 195 | """Determine whether a media_player belongs to the Sonos platform.""" 196 | return self.get_platform_from_entity_id(entity_id) == SONOS_PLATFORM 197 | 198 | def get_is_media_player_spotify(self, entity_id): 199 | """Determine whether a media_player belongs to the Spotify platform.""" 200 | return self.get_platform_from_entity_id(entity_id) == SPOTIFY_PLATFORM 201 | 202 | def get_alexa_media_players_count(self): 203 | """Count of alexa_media_players.""" 204 | alexa_media_players = [media_player for media_player in self.media_players if media_player.platform == ALEXA_MEDIA_PLAYER_PLATFORM] 205 | return len(alexa_media_players) 206 | 207 | def get_media_players_of_platform(self, entity_ids: list = [], platform: str = ""): 208 | """List of media_players belonging to a specific platform.""" 209 | if entity_ids and platform: 210 | return [entity_id for entity_id in entity_ids if self.get_media_players_from_entity_id(entity_id) and self.get_media_players_from_entity_id(entity_id).platform == platform] 211 | return [] 212 | 213 | def get_supported_feature(self, entity: State, feature: str): 214 | """Whether a feature is supported by the media_player device.""" 215 | if entity is None or entity.attributes is None: 216 | return False 217 | supported_features = entity.attributes.get("supported_features", 0) 218 | 219 | if feature is ATTR_MEDIA_VOLUME_LEVEL: 220 | return bool(supported_features & 2) 221 | 222 | if feature is ATTR_MEDIA_ANNOUNCE: 223 | return bool(supported_features & 1048576) 224 | 225 | if feature is ATTR_GROUP_MEMBERS: 226 | return bool(supported_features & 524288) 227 | 228 | return False 229 | 230 | def get_media_content_id(self, hass: HomeAssistant, file_path: str): 231 | """Create the media content id for a local media directory file.""" 232 | if not file_path: 233 | _LOGGER.error("Audio file path missing in call to get_media_content_id") 234 | return None 235 | 236 | media_source_path = file_path 237 | 238 | media_dir_key = "" 239 | for name_i, path_i in hass.config.media_dirs.items(): 240 | if file_path.startswith(path_i) and len(media_dir_key) < len(path_i): 241 | media_dir_key = name_i 242 | if self.media_dirs_dict.get(media_dir_key, None): 243 | path = self.media_dirs_dict.get(media_dir_key, None) 244 | media_source_path = media_source_path[len(f"/{path}") :] 245 | media_source_path = f"media-source://media_source/{media_dir_key}/{media_source_path}" 246 | return media_source_path 247 | 248 | # Media file exists outside of a media folder 249 | return None 250 | 251 | #### ACTIONS #### 252 | 253 | async def async_fade_out_and_pause(self, hass: HomeAssistant, fade_duration: float): 254 | """Fade out and pause relevant media players.""" 255 | fade_in_out_media_players: list[ChimeTTSMediaPlayer] = self.get_fade_in_out_media_players() 256 | if len(fade_in_out_media_players) > 0: 257 | 258 | # Fade out media players manually if platform does not support `announce` 259 | await self.async_set_volume_for_media_players(hass=hass, 260 | media_players=fade_in_out_media_players, 261 | volume_key=0, 262 | fade_duration=fade_duration) 263 | 264 | # Pause playing media_players 265 | pause_entity_ids = [] 266 | for media_player in fade_in_out_media_players: 267 | pause_entity_ids.append(media_player.entity_id) 268 | _LOGGER.debug(" - Pausing %s media_player", str(len(pause_entity_ids))) 269 | try: 270 | await hass.services.async_call( 271 | domain="media_player", 272 | service="media_pause", 273 | service_data={CONF_ENTITY_ID: pause_entity_ids}, 274 | blocking=True 275 | ) 276 | except Exception as error: 277 | _LOGGER.warning("Unable to pause media player%s: %s", ("" if len(pause_entity_ids) == 1 else "s"), str(error)) 278 | 279 | # Wait until media_players' state = paused 280 | await self.async_wait_until_media_players_state_is( 281 | hass=hass, 282 | media_players=fade_in_out_media_players, 283 | target_state="paused", 284 | timeout=1.5 285 | ) 286 | 287 | # Set media players to target volume level for Chime TTS Playback 288 | playback_media_players = [] 289 | for media_player in fade_in_out_media_players: 290 | if media_player.platform != SPOTIFY_PLATFORM: 291 | playback_media_players.append(media_player) 292 | await self.async_set_volume_for_media_players( 293 | hass=hass, 294 | media_players=playback_media_players, 295 | volume_key="target_volume_level", 296 | fade_duration=0 297 | ) 298 | 299 | async def async_resume_playback(self, hass, fade_duration: float): 300 | """Resume paused media players after Chime TTS playback is completed.""" 301 | fade_in_media_players = self.get_fade_in_out_media_players() 302 | if len(fade_in_media_players) > 0: 303 | # 1. Wait until all media_players paused 304 | if not await self.async_wait_until_media_players_state_is(hass=hass, 305 | media_players=fade_in_media_players, 306 | target_state="paused", 307 | timeout=5): 308 | _LOGGER.warning("Timed out waiting for %s media_player%s to pause", 309 | str(len(fade_in_media_players)), 310 | ("" if len(fade_in_media_players) == 1 else "s")) 311 | 312 | # 2. Set media_players volume to 0 313 | _LOGGER.debug(" - Setting volume to 0") 314 | resume_entity_ids = [] 315 | for media_player in fade_in_media_players: 316 | entity_id = media_player.entity_id 317 | resume_entity_ids.append(entity_id) 318 | try: 319 | await hass.services.async_call( 320 | domain="media_player", 321 | service=SERVICE_VOLUME_SET, 322 | service_data={ 323 | ATTR_MEDIA_VOLUME_LEVEL: 0, 324 | CONF_ENTITY_ID: resume_entity_ids 325 | }, 326 | blocking=True 327 | ) 328 | except Exception as error: 329 | _LOGGER.warning("Unable to set %s's volume to 0 for: %s. Error: %s", 330 | entity_id, (", ".join(map(str, resume_entity_ids))), error) 331 | 332 | # 3a. Restore from Sonos snapshot 333 | await self.async_sonos_restore(hass) 334 | 335 | # 3b. Call `media_play` until all media_players' states are "playing" 336 | _LOGGER.debug(" - Resuming %s media_player%s", 337 | str(len(resume_entity_ids)), 338 | ("" if len(resume_entity_ids) == 1 else "s")) 339 | retry_duration = 3 340 | delay_s = 0.25 341 | paused_media_players = resume_entity_ids.copy() 342 | while len(paused_media_players) > 0 and retry_duration > 0: 343 | try: 344 | await hass.services.async_call( 345 | domain="media_player", 346 | service="media_play", 347 | service_data={CONF_ENTITY_ID: paused_media_players}, 348 | blocking=True, 349 | ) 350 | except Exception as error: 351 | _LOGGER.warning("media_player.play_media failed: %s", error) 352 | 353 | still_paused_media_players = [] 354 | for entity_id in paused_media_players: 355 | if hass.states.get(entity_id).state != "playing": 356 | still_paused_media_players.append(entity_id) 357 | else: 358 | _LOGGER.debug(" - ✔️ %s resumed", entity_id) 359 | paused_media_players = still_paused_media_players 360 | 361 | if len(paused_media_players) > 0: 362 | await hass.async_add_executor_job(time.sleep, delay_s) 363 | retry_duration = retry_duration - delay_s 364 | 365 | for entity_id in paused_media_players: 366 | _LOGGER.warning("Failed to resume playback on %s", entity_id) 367 | _LOGGER.debug(" - 𝘅 %s - timed out", entity_id) 368 | 369 | # 4. Fade in all media players at the same time 370 | await self.async_set_volume_for_media_players(hass=hass, 371 | media_players=self.get_fade_in_out_media_players(), 372 | volume_key="initial_volume_level", 373 | fade_duration=fade_duration) 374 | 375 | async def async_wait_until_media_players_state_is( 376 | self, 377 | hass: HomeAssistant, 378 | media_players: list[ChimeTTSMediaPlayer], 379 | target_state: str, 380 | timeout: float = 3.5) -> bool: 381 | """Wait until the state of a list of media_players equals a target state.""" 382 | def condition(media_player: ChimeTTSMediaPlayer) -> bool: 383 | return media_player.get_state() == target_state 384 | 385 | _LOGGER.debug(" - Waiting until %s media_player%s %s %s...", 386 | len(media_players), 387 | ("" if len(media_players) == 1 else "s"), 388 | ("is" if len(media_players) == 1 else "are"), 389 | target_state) 390 | return await self._async_wait_until_media_players(hass, media_players, condition, timeout) 391 | 392 | async def async_wait_until_media_players_state_not( 393 | self, 394 | hass: HomeAssistant, 395 | media_players: list[ChimeTTSMediaPlayer], 396 | target_state: str, 397 | timeout: float = 3.5) -> bool: 398 | """Wait until the state of a list of media_players no longer equals a target state.""" 399 | def condition(media_player: ChimeTTSMediaPlayer): 400 | return media_player.get_state() != target_state 401 | 402 | _LOGGER.debug(" - Waiting until %s media_player%s %s %s...", 403 | len(media_players), 404 | ("" if len(media_players) == 1 else "s"), 405 | ("isn't" if len(media_players) == 1 else "aren't"), 406 | target_state) 407 | return await self._async_wait_until_media_players(hass, media_players, condition, timeout) 408 | 409 | async def async_wait_until_media_players_volume_level_is(self, 410 | hass: HomeAssistant, 411 | media_players: list[ChimeTTSMediaPlayer], 412 | target_volume: str, 413 | timeout: float = 5) -> bool: 414 | """Wait for a media_player to have a target volume_level.""" 415 | def condition(media_player: ChimeTTSMediaPlayer) -> bool: 416 | return media_player.get_current_volume_level() == target_volume 417 | 418 | _LOGGER.debug(" - Waiting until %s media_player%s volume_level %s %s...", 419 | len(media_players), 420 | ("" if len(media_players) == 1 else "s"), 421 | ("is" if len(media_players) == 1 else "are"), 422 | target_volume) 423 | return await self._async_wait_until_media_players(hass, media_players, condition, timeout) 424 | 425 | async def _async_wait_until_media_players(self, 426 | hass: HomeAssistant, 427 | media_players: list[ChimeTTSMediaPlayer], 428 | condition, 429 | timeout: float = 3.5): 430 | """Wait until the state of a list of media_players equals/no longer equals a target state.""" 431 | # Validation 432 | if (hass is None or media_players is None or len(media_players) == 0 or condition is None): 433 | return False 434 | 435 | delay = 0.2 436 | still_waiting: list[ChimeTTSMediaPlayer] = media_players.copy() 437 | while len(still_waiting) > 0 and timeout > 0: 438 | for media_player in media_players: 439 | if condition(media_player) and media_player in still_waiting: 440 | _LOGGER.debug(" ✔ %s", media_player.entity_id) 441 | index = still_waiting.index(media_player) 442 | try: 443 | del still_waiting[index] 444 | except Exception as error: 445 | _LOGGER.error("Error updating media player %s's state: %s", media_player.entity_id, error) 446 | timeout = timeout - delay 447 | 448 | if len(still_waiting) > 0: 449 | await hass.async_add_executor_job(time.sleep, delay) 450 | 451 | # Timeout 452 | if len(still_waiting) > 0: 453 | for media_player in still_waiting: 454 | _LOGGER.debug(" 𝘅 %s - Timed out. Current state: %s", media_player.entity_id, str(media_player.get_state())) 455 | 456 | return len(still_waiting) == 0 457 | 458 | async def async_sonos_snapshot(self, hass: HomeAssistant): 459 | """Take a Sonos snapshot of Sonos media players.""" 460 | if not SONOS_SNAPSHOT_ENABLED: 461 | return 462 | sonos_media_player_entity_ids: list[str] = [media_player.entity_id for media_player in self.media_players if media_player.platform == SONOS_PLATFORM] 463 | if len(sonos_media_player_entity_ids) > 0: 464 | _LOGGER.debug("Taking a Sonos snapshot of %s media player%s", str(len(sonos_media_player_entity_ids)), "" if len(sonos_media_player_entity_ids) == 1 else "s") 465 | try: 466 | await hass.services.async_call( 467 | domain="sonos", 468 | service="snapshot", 469 | service_data={ 470 | CONF_ENTITY_ID: sonos_media_player_entity_ids, 471 | "with_group": True, 472 | }, 473 | blocking=True 474 | ) 475 | except Exception as error: 476 | _LOGGER.warning("Unable to create Sonos snapshot: %s", str(error)) 477 | 478 | async def async_sonos_restore(self, hass: HomeAssistant): 479 | """Restore Sonos media_players from snapshot.""" 480 | if not SONOS_SNAPSHOT_ENABLED or self.sonos_restored: 481 | return 482 | 483 | sonos_media_player_entity_ids: list[str] = [media_player.entity_id for media_player in self.media_players if media_player.platform == SONOS_PLATFORM] 484 | if len(sonos_media_player_entity_ids) > 0: 485 | _LOGGER.debug("Restoring %s Sonos media player%s from snapshot", str(len(sonos_media_player_entity_ids)), "" if len(sonos_media_player_entity_ids) == 1 else "s") 486 | try: 487 | await hass.services.async_call( 488 | domain="sonos", 489 | service="restore", 490 | service_data={ 491 | CONF_ENTITY_ID: sonos_media_player_entity_ids, 492 | "with_group": True, 493 | }, 494 | blocking=True 495 | ) 496 | self.sonos_restored = True 497 | except Exception as error: 498 | _LOGGER.warning("Unable to restore Sonos snapshot: %s", str(error)) 499 | 500 | async def async_set_volume_for_media_players(self, 501 | hass: HomeAssistant, 502 | media_players: list[ChimeTTSMediaPlayer], 503 | volume_key, 504 | fade_duration: int): 505 | """Set the volume level for media players either in steps or instantaneously.""" 506 | if not media_players: 507 | return 508 | 509 | fade_duration /= 1000 # Convert from milliseconds to seconds 510 | fade_steps = max(math.ceil(fade_duration * 1000 / TRANSITION_STEP_MS), 1) 511 | delay_s = fade_duration / fade_steps if fade_steps > 1 else 0 512 | volume_changed_dicts = [] 513 | 514 | for media_player in media_players: 515 | entity_id: str = media_player.entity_id 516 | current_volume: float = media_player.get_current_volume_level() 517 | target_volume: float = getattr(media_player, volume_key, 0) if isinstance(volume_key, str) else volume_key 518 | 519 | if target_volume == -1: 520 | if volume_key == "initial_volume_level": 521 | _LOGGER.debug("Initial volume for %s is unknown. Unable to restore volume.", entity_id) 522 | continue 523 | 524 | if target_volume == current_volume: 525 | _LOGGER.debug("The volume level for %s is already set to %s", entity_id, str(target_volume)) 526 | continue 527 | 528 | volume_changed_dicts.append({"media_player": media_player, "target_volume": target_volume}) 529 | 530 | if fade_steps > 1: 531 | volume_step: float = (target_volume - current_volume) / fade_steps 532 | volume_steps: list[float] = [current_volume + volume_step * i for i in range(1, fade_steps + 1)] 533 | _LOGGER.debug(" - Fading %s %s's volume from %s to %s over %ss", 534 | "in" if volume_step > 0 else "out", 535 | entity_id, 536 | str(current_volume), 537 | str(target_volume), 538 | str(fade_duration)) 539 | for step in range(fade_steps): 540 | new_volume = round(max(volume_steps[step], 0), 4) 541 | await self.async_set_volume_action(hass, entity_id, new_volume) 542 | await hass.async_add_executor_job(time.sleep, delay_s) 543 | else: 544 | if target_volume > current_volume: 545 | _LOGGER.debug("Increasing %s's volume from %s to %s", entity_id, str(current_volume), str(target_volume)) 546 | else: 547 | _LOGGER.debug("Decreasing %s's volume from %s to %s", entity_id, str(current_volume), str(target_volume)) 548 | await self.async_set_volume_action(hass, entity_id, target_volume) 549 | 550 | await self.async_wait_until_target_volume_reached(hass, volume_changed_dicts) 551 | 552 | async def async_set_volume_action(self, hass: HomeAssistant, entity_id: str, target_volume: float, is_retry: bool = False): 553 | """Call the media_player.set_volume service with a specific media player & volume level.""" 554 | if is_retry: 555 | _LOGGER.debug(" - Retry setting %s's volume to %s", entity_id, str(target_volume)) 556 | else: 557 | _LOGGER.debug(" - Setting %s's volume to %s", entity_id, str(target_volume)) 558 | try: 559 | await hass.services.async_call( 560 | domain="media_player", 561 | service=SERVICE_VOLUME_SET, 562 | service_data={ 563 | ATTR_MEDIA_VOLUME_LEVEL: target_volume, 564 | CONF_ENTITY_ID: entity_id 565 | }, 566 | blocking=True, 567 | ) 568 | except Exception as error: 569 | _LOGGER.warning("Unable to set %s's volume to %s: %s", entity_id, str(target_volume), error) 570 | 571 | async def async_wait_until_target_volume_reached(self, hass, volume_changed_dicts): 572 | """Wait until all media players register their new volumes.""" 573 | if not volume_changed_dicts: 574 | return 575 | 576 | timeout = 5 577 | delay_s = 0.150 578 | end_time = time.time() + timeout 579 | has_debug_log_written = False 580 | 581 | while time.time() < end_time: 582 | all_volumes_set = True 583 | 584 | for media_player_dict in volume_changed_dicts[:]: # Iterate over a copy to allow safe removal 585 | media_player: ChimeTTSMediaPlayer = media_player_dict.get("media_player") 586 | target_volume = media_player_dict.get("target_volume") 587 | 588 | if media_player and round(media_player.get_current_volume_level(), 3) not in (round(target_volume,3), round(-1,3)): 589 | all_volumes_set = False 590 | if not has_debug_log_written: 591 | _LOGGER.debug("...waiting until new volume levels reached...") 592 | has_debug_log_written = True 593 | await self.async_set_volume_action(hass, media_player.entity_id, target_volume, True) 594 | else: 595 | _LOGGER.debug(" - ✔️ %s's volume now %s", media_player.entity_id, str(media_player.get_current_volume_level())) 596 | volume_changed_dicts.remove(media_player_dict) # Remove the entry once the volume is set 597 | 598 | if all_volumes_set: 599 | break 600 | 601 | await hass.async_add_executor_job(time.sleep, delay_s) 602 | _LOGGER.debug("...") 603 | 604 | # Log the media players which timed out waiting for their new volumes to register 605 | for media_player_dict in volume_changed_dicts: 606 | media_player: ChimeTTSMediaPlayer = media_player_dict.get("media_player") 607 | if media_player: 608 | target_volume = media_player_dict.get("target_volume") 609 | current_volume = media_player.get_current_volume_level() 610 | if current_volume != target_volume: 611 | _LOGGER.debug(" - 𝘅 %s (timed out before volume set to %s. Current volume = %s)", 612 | media_player.entity_id, str(target_volume), str(current_volume)) 613 | 614 | 615 | async def async_join_media_players(self, hass: HomeAssistant): 616 | """Join media players.""" 617 | self.joined_entity_id = None 618 | if self.join_players is False: 619 | return None 620 | 621 | # Separate media players into joined and unjoined lists 622 | joined_count = 0 623 | for media_player in self.media_players: 624 | if media_player.join_supported: 625 | # Assign first supported media_player as speaker leader 626 | if not self.joined_entity_id: 627 | self.joined_entity_id = media_player.entity_id 628 | else: 629 | # Add 2nd+ supported media_player to the joined_supported list 630 | self.joined_media_player_entity_ids.append(media_player.entity_id) 631 | joined_count += 1 632 | else: 633 | self.unjoined_media_player_entity_ids.append(media_player.entity_id) 634 | 635 | # Validation 636 | if joined_count == 0: 637 | _LOGGER.warning("No media_players were found that support joining speakers into a group. A minimum of 2 is required.") 638 | return 639 | if joined_count == 1: 640 | _LOGGER.warning("Only 1 media_player was found that supports joining speakers into a group. A minimum of 2 is required.") 641 | return 642 | 643 | # Log the speaker group 'leader' (joined_entity_id) 644 | _LOGGER.debug( 645 | "Joined speaker leader: %s, with %s group member%s:", 646 | str(self.joined_entity_id), 647 | str(len(self.joined_media_player_entity_ids)), 648 | ("s" if len(self.joined_media_player_entity_ids) > 1 else ""), 649 | ) 650 | # Log the speaker group members 651 | for media_player_entity_id in self.joined_media_player_entity_ids: 652 | _LOGGER.debug(" - %s", media_player_entity_id) 653 | 654 | # Perform join 655 | try: 656 | await hass.services.async_call( 657 | domain="media_player", 658 | service=SERVICE_JOIN, 659 | service_data={ 660 | CONF_ENTITY_ID: self.joined_entity_id, 661 | ATTR_GROUP_MEMBERS: self.joined_media_player_entity_ids, 662 | }, 663 | blocking=True, 664 | ) 665 | except Exception as error: 666 | _LOGGER.warning("Error joining media_player entities: %s", error) 667 | self.joined_entity_id = None 668 | 669 | return self.joined_entity_id 670 | 671 | async def async_unjoin_media_players(self, hass): 672 | """Unjoin media players.""" 673 | if self.unjoin_players is True and self.joined_entity_id: 674 | _LOGGER.debug(" - Calling media_player.unjoin service...") 675 | media_player_entity_ids: list[str] = (self.joined_media_player_entity_ids + [self.joined_entity_id]) 676 | count = 0 677 | for entity_id in media_player_entity_ids: 678 | count += 1 679 | _LOGGER.debug(" - media_player.unjoin %s/%s: %s", str(count), str(len(media_player_entity_ids)), entity_id) 680 | try: 681 | await hass.services.async_call( 682 | domain="media_player", 683 | service=SERVICE_UNJOIN, 684 | service_data={CONF_ENTITY_ID: entity_id}, 685 | blocking=True, 686 | ) 687 | except Exception as error: 688 | _LOGGER.warning( 689 | "Error calling unjoin service for %s: %s", entity_id, error 690 | ) 691 | -------------------------------------------------------------------------------- /custom_components/chime_tts/helpers/services_helper.py: -------------------------------------------------------------------------------- 1 | """TTS services.yaml helper functions for Chime TTS.""" 2 | import os 3 | import yaml 4 | import aiofiles 5 | import aiofiles.os 6 | import logging 7 | # import voluptuous as vol 8 | from homeassistant.core import HomeAssistant, SupportsResponse 9 | from .filesystem import FilesystemHelper 10 | from ..const import ( 11 | DOMAIN, 12 | SERVICE_SAY, 13 | SERVICE_SAY_URL, 14 | DEFAULT_CHIME_OPTIONS, 15 | CUSTOM_CHIMES_PATH_KEY, 16 | ) 17 | filesystem_helper = FilesystemHelper() 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | class ChimeTTSServicesHelper: 21 | """Helper services YAML file functions for Chime TTS.""" 22 | 23 | _data = {} 24 | 25 | async def async_update_services_yaml(self, 26 | hass, 27 | say_service_func, 28 | say_url_service_func): 29 | """Update the list of chimes for the say and say-url services.""" 30 | custom_chimes_options = await filesystem_helper.async_get_chime_options_from_path(self._data[CUSTOM_CHIMES_PATH_KEY]) 31 | await self._async_update_chime_lists(hass=hass, custom_chime_options=custom_chimes_options) 32 | hass.services.async_remove(DOMAIN, SERVICE_SAY) 33 | hass.services.async_register(DOMAIN, SERVICE_SAY, say_service_func) 34 | hass.services.async_remove(DOMAIN, SERVICE_SAY_URL) 35 | hass.services.async_register(DOMAIN, 36 | SERVICE_SAY_URL, 37 | say_url_service_func, 38 | supports_response=SupportsResponse.ONLY) 39 | 40 | async def _async_update_chime_lists(self, hass: HomeAssistant, custom_chime_options: str): 41 | """Modify the chime path drop down options.""" 42 | 43 | services_yaml = await self._async_parse_services_yaml() 44 | if not services_yaml: 45 | return 46 | 47 | try: 48 | # Chime Paths 49 | final_options: list = DEFAULT_CHIME_OPTIONS + custom_chime_options 50 | final_options = sorted(final_options, key=lambda x: x['label'].lower()) 51 | if not custom_chime_options: 52 | final_options.append({"label": "*** Add a local folder path in the configuration for your own custom chimes ***", "value": None}) 53 | 54 | # New chimes detected? 55 | if final_options != services_yaml['say']['fields']['chime_path']['selector']['select']['options']: 56 | # Update `say` and `say_url` chime path fields 57 | services_yaml['say']['fields']['chime_path']['selector']['select']['options'] = list(final_options) 58 | services_yaml['say']['fields']['end_chime_path']['selector']['select']['options'] = list(final_options) 59 | services_yaml['say_url']['fields']['chime_path']['selector']['select']['options'] = list(final_options) 60 | services_yaml['say_url']['fields']['end_chime_path']['selector']['select']['options'] = list(final_options) 61 | except Exception as e: 62 | _LOGGER.error("Unexpected error updating services.yaml: %s", str(e)) 63 | await self._async_save_services_yaml(services_yaml) 64 | 65 | async def _async_parse_services_yaml(self): 66 | """Load the services.yaml file into a dictionary.""" 67 | services_file_path = os.path.join(os.path.dirname(__file__), '../services.yaml') 68 | 69 | try: 70 | async with aiofiles.open(services_file_path) as file: 71 | services_yaml = yaml.safe_load(await file.read()) 72 | return services_yaml 73 | except FileNotFoundError: 74 | _LOGGER.error("services.yaml file not found at %s", services_file_path) 75 | return 76 | except yaml.YAMLError as e: 77 | _LOGGER.error("Error parsing services.yaml: %s", str(e)) 78 | return 79 | except Exception as e: 80 | _LOGGER.error("Unexpected error reading services.yaml: %s", str(e)) 81 | return 82 | 83 | async def _async_save_services_yaml(self, services_yaml): 84 | """Save a dictionary to the services.yaml file.""" 85 | 86 | services_file_path = os.path.join(os.path.dirname(__file__), '../services.yaml') 87 | 88 | try: 89 | async with aiofiles.open(services_file_path, mode='w') as file: 90 | await file.write(yaml.safe_dump(services_yaml, default_flow_style=False, sort_keys=False)) 91 | 92 | _LOGGER.info("Updated services.yaml chime options.") 93 | except Exception as e: 94 | _LOGGER.error("Unexpected error updating services.yaml: %s", str(e)) 95 | 96 | # async def async_get_schema_for_service(self, service_name: str): 97 | # """Modify the chime path drop down options.""" 98 | 99 | # service_yaml = await self._async_parse_services_yaml() 100 | # if not service_yaml or service_name not in service_yaml: 101 | # return 102 | # service_info = service_yaml[service_name] 103 | # fields = service_info.get('fields', {}) 104 | # schema = {} 105 | 106 | # # Process each field in the service 107 | # for field_name, field_info in fields.items(): 108 | # selector = field_info.get('selector', {}) 109 | 110 | # if selector and selector.get('text'): 111 | # multiline = selector['text'].get('multiline', False) 112 | # if multiline: 113 | # schema[vol.Required(field_name)] = vol.All(vol.Coerce(str), vol.Length(max=1024)) 114 | # else: 115 | # schema[vol.Required(field_name)] = vol.Coerce(str) 116 | # elif selector and selector.get('select'): 117 | # options = [option['value'] for option in selector['select']['options']] 118 | # schema[vol.Required(field_name)] = vol.In(options) 119 | # elif selector and selector.get('boolean'): 120 | # schema[vol.Required(field_name)] = vol.Coerce(bool) 121 | # elif selector and selector.get('number'): 122 | # number_selector = selector['number'] 123 | # schema[vol.Required(field_name)] = vol.All( 124 | # vol.Coerce(float), 125 | # vol.Range( 126 | # min=number_selector.get('min', None), 127 | # max=number_selector.get('max', None), 128 | # ), 129 | # ) 130 | # else: 131 | # schema[vol.Required(field_name)] = vol.Coerce(str) # Default to string if selector type is missing 132 | 133 | # return schema 134 | -------------------------------------------------------------------------------- /custom_components/chime_tts/helpers/tts_audio_helper.py: -------------------------------------------------------------------------------- 1 | """Helper class for generating TTS Audio in Chime TTS.""" 2 | import asyncio 3 | import io 4 | from datetime import datetime 5 | from homeassistant.core import HomeAssistant 6 | from homeassistant.components import tts 7 | from hass_nabucasa import voice as nabu_voices 8 | import logging 9 | from .filesystem import FilesystemHelper 10 | from .helpers import ChimeTTSHelper 11 | from ..const import ( 12 | TTS_TIMEOUT_KEY, 13 | TTS_TIMEOUT_DEFAULT, 14 | TTS_PLATFORM_KEY, 15 | FALLBACK_TTS_PLATFORM_KEY, 16 | AMAZON_POLLY, 17 | BAIDU, 18 | ELEVENLABS, 19 | GOOGLE_CLOUD, 20 | GOOGLE_TRANSLATE, 21 | IBM_WATSON_TTS, 22 | MARYTTS, 23 | MICROSOFT_EDGE_TTS, 24 | MICROSOFT_TTS, 25 | NABU_CASA_CLOUD_TTS, 26 | NABU_CASA_CLOUD_TTS_OLD, 27 | OPENAI_TTS, 28 | PICOTTS, 29 | PIPER, 30 | VOICE_RSS, 31 | YANDEX_TTS, 32 | ) 33 | 34 | helpers = ChimeTTSHelper() 35 | filesystem_helper = FilesystemHelper() 36 | 37 | _LOGGER = logging.getLogger(__name__) 38 | 39 | class TTSAudioHelper: 40 | """Helper class for generating TTS Audio in Chime TTS.""" 41 | 42 | _data = {} 43 | 44 | async def async_request_tts_audio(self, hass: HomeAssistant, tts_platform: str, message: str, language: str, cache: bool, options: dict): 45 | """Send an API request for TTS audio and return the audio file's local filepath.""" 46 | start_time = datetime.now() 47 | 48 | # Step 1: Input validation and preparation 49 | tts_platform, tts_options, language = self._prepare_tts_request(hass, tts_platform, message, language, options) 50 | if not tts_platform: 51 | return None 52 | 53 | # Step 2: Generate TTS audio 54 | media_source_id, audio_data = await self._generate_tts_audio( 55 | hass, tts_platform, message, language, cache, tts_options 56 | ) 57 | 58 | # Step 3: Process the audio data 59 | audio = await self._process_audio_data(hass, media_source_id, audio_data, start_time) 60 | if audio: 61 | return audio 62 | 63 | # Step 4: Retry with fallback platform if needed 64 | return await self._retry_with_fallback(hass, tts_platform, message, language, cache, options) 65 | 66 | def _prepare_tts_request(self, hass: HomeAssistant, tts_platform, message, language, options): 67 | if not options: 68 | options = {} 69 | tts_options = options.copy() 70 | 71 | if not message: 72 | _LOGGER.warning("No message text provided for TTS audio") 73 | return None, None, None 74 | 75 | tts_platform = helpers.get_tts_platform( 76 | hass=hass, 77 | tts_platform=tts_platform, 78 | default_tts_platform=self._data[TTS_PLATFORM_KEY], 79 | fallback_tts_platform=self._data[FALLBACK_TTS_PLATFORM_KEY], 80 | ) 81 | if not tts_platform: 82 | return None, None, None 83 | 84 | language = self._adjust_language_and_voice(tts_platform, language, tts_options) 85 | return tts_platform, tts_options, language 86 | 87 | def _adjust_language_and_voice(self, tts_platform, language, tts_options): 88 | voice = tts_options.get("voice", None) 89 | if ( 90 | (language or tts_options.get("language")) 91 | and tts_platform 92 | in [ 93 | AMAZON_POLLY, 94 | GOOGLE_TRANSLATE, 95 | NABU_CASA_CLOUD_TTS, 96 | IBM_WATSON_TTS, 97 | MICROSOFT_EDGE_TTS, 98 | MICROSOFT_TTS, 99 | ] 100 | ): 101 | if tts_platform == IBM_WATSON_TTS and voice is None: 102 | tts_options["voice"] = language 103 | language = None 104 | if tts_platform == MICROSOFT_TTS: 105 | if not language: 106 | language = tts_options.get("language") 107 | tts_options.pop("language", None) 108 | if voice: 109 | tts_options["type"] = voice 110 | tts_options.pop("voice", None) 111 | else: 112 | language = None 113 | 114 | if ( 115 | tts_platform == NABU_CASA_CLOUD_TTS 116 | and voice 117 | and not language 118 | ): 119 | for key, value in nabu_voices.TTS_VOICES.items(): 120 | if voice in value: 121 | language = key 122 | _LOGGER.debug( 123 | " - Setting language to '%s' for Nabu Casa TTS voice: '%s'.", 124 | language, 125 | voice, 126 | ) 127 | return language 128 | 129 | async def _generate_tts_audio(self, hass: HomeAssistant, tts_platform, message, language, cache, tts_options): 130 | media_source_id = None 131 | audio_data = None 132 | try: 133 | timeout = int(self._data.get(TTS_TIMEOUT_KEY, TTS_TIMEOUT_DEFAULT)) 134 | media_source_id = await asyncio.wait_for( 135 | asyncio.to_thread( 136 | tts.media_source.generate_media_source_id, 137 | hass=hass, 138 | message=message, 139 | engine=tts_platform, 140 | language=language, 141 | cache=cache, 142 | options=tts_options, 143 | ), 144 | timeout=timeout, 145 | ) 146 | except asyncio.TimeoutError: 147 | _LOGGER.error("TTS audio generation with %s timed out after %ss. Consider increasing the TTS audio generation timeout value in the configuration.", tts_platform, str(timeout)) 148 | except asyncio.CancelledError: 149 | _LOGGER.error("TTS audio generation with %s cancelled.", tts_platform) 150 | except Exception as error: 151 | _LOGGER.error("Error generating TTS audio with %s.", tts_platform) 152 | self._handle_generation_error(error, tts_platform, media_source_id) 153 | 154 | return media_source_id, audio_data 155 | 156 | async def _process_audio_data(self, hass: HomeAssistant, media_source_id, audio_data, start_time): 157 | if not media_source_id: 158 | _LOGGER.error("Error: Unable to generate media_source_id") 159 | return None 160 | 161 | try: 162 | audio_data = await tts.async_get_media_source_audio( 163 | hass=hass, media_source_id=media_source_id 164 | ) 165 | except Exception as error: 166 | _LOGGER.error( 167 | " - Error calling tts.async_get_media_source_audio with media_source_id = '%s': %s", 168 | str(media_source_id), 169 | str(error), 170 | ) 171 | 172 | if audio_data is not None and len(audio_data) == 2: 173 | return await self._extract_audio(audio_data, start_time) 174 | 175 | return None 176 | 177 | async def _extract_audio(self, audio_data, start_time): 178 | audio_bytes = audio_data[1] 179 | file = io.BytesIO(audio_bytes) 180 | if not file: 181 | _LOGGER.error("...could not convert TTS bytes to audio") 182 | return None 183 | 184 | audio = await filesystem_helper.async_load_audio(file) 185 | if audio and len(audio) > 0: 186 | end_time = datetime.now() 187 | completion_time = round((end_time - start_time).total_seconds(), 2) 188 | completion_time_string = ( 189 | f"{completion_time}s" if completion_time >= 1 else f"{completion_time * 1000}ms" 190 | ) 191 | _LOGGER.debug(" ...TTS audio generated in %s", completion_time_string) 192 | return audio 193 | 194 | _LOGGER.error("...could not extract TTS audio from file") 195 | return None 196 | 197 | async def _retry_with_fallback(self, hass: HomeAssistant, tts_platform, message, language, cache, options): 198 | fallback_platform = self._data.get(FALLBACK_TTS_PLATFORM_KEY) 199 | if tts_platform != fallback_platform and fallback_platform: 200 | _LOGGER.debug( 201 | "Retrying TTS audio generation with fallback platform '%s'", fallback_platform 202 | ) 203 | return await self.async_request_tts_audio( 204 | hass=hass, 205 | tts_platform=fallback_platform, 206 | message=message, 207 | language=language, 208 | cache=cache, 209 | options=options, 210 | ) 211 | _LOGGER.error("...audio_data generation failed") 212 | return None 213 | 214 | def _handle_generation_error(self, error, tts_platform, media_source_id): 215 | if str(error) == "Invalid TTS provider selected": 216 | missing_tts_platform_error(tts_platform) 217 | else: 218 | _LOGGER.error( 219 | " - Error calling tts.media_source.generate_media_source_id: %s", 220 | error, 221 | ) 222 | 223 | if media_source_id: 224 | try: 225 | asyncio.run(tts.async_get_media_source_audio( 226 | hass=self.hass, media_source_id=media_source_id 227 | )) 228 | except Exception as error: 229 | _LOGGER.error( 230 | " - Error calling tts.async_get_media_source_audio with media_source_id = '%s': %s", 231 | str(media_source_id), 232 | str(error), 233 | ) 234 | 235 | def missing_tts_platform_error(tts_platform): 236 | """Write a TTS platform specific debug warning when the TTS platform has not been configured.""" 237 | tts_platform_name = tts_platform 238 | tts_platform_documentation = "https://www.home-assistant.io/integrations/#text-to-speech" 239 | if tts_platform is AMAZON_POLLY: 240 | tts_platform_name = "Amazon Polly" 241 | tts_platform_documentation = "https://www.home-assistant.io/integrations/amazon_polly" 242 | if tts_platform is BAIDU: 243 | tts_platform_name = "Baidu" 244 | tts_platform_documentation = "https://www.home-assistant.io/integrations/baidu" 245 | if tts_platform is ELEVENLABS: 246 | tts_platform_name = "ElevenLabsTS" 247 | tts_platform_documentation = "https://www.home-assistant.io/integrations/elevenlabs" 248 | if tts_platform is GOOGLE_CLOUD: 249 | tts_platform_name = "Google Cloud" 250 | tts_platform_documentation = "https://www.home-assistant.io/integrations/google_cloud" 251 | if tts_platform is GOOGLE_TRANSLATE: 252 | tts_platform_name = "Google Translate" 253 | tts_platform_documentation = "https://www.home-assistant.io/integrations/google_translate" 254 | if tts_platform is IBM_WATSON_TTS: 255 | tts_platform_name = "Watson TTS" 256 | tts_platform_documentation = "https://www.home-assistant.io/integrations/watson_tts" 257 | if tts_platform is MARYTTS: 258 | tts_platform_name = "MaryTTS" 259 | tts_platform_documentation = "https://www.home-assistant.io/integrations/marytts" 260 | if tts_platform is MICROSOFT_TTS: 261 | tts_platform_name = "Microsoft TTS" 262 | tts_platform_documentation = "https://www.home-assistant.io/integrations/microsoft" 263 | if tts_platform is MICROSOFT_EDGE_TTS: 264 | tts_platform_name = "Microsoft Edge TTS" 265 | tts_platform_documentation = "https://github.com/hasscc/hass-edge-tts" 266 | if tts_platform is NABU_CASA_CLOUD_TTS or tts_platform is NABU_CASA_CLOUD_TTS_OLD: 267 | tts_platform_name = "Nabu Casa Cloud TTS" 268 | tts_platform_documentation = "https://www.home-assistant.io/integrations/cloud" 269 | if tts_platform is OPENAI_TTS: 270 | tts_platform_name = "OpenAI TTS" 271 | tts_platform_documentation = "https://github.com/sfortis/openai_tts" 272 | if tts_platform is PICOTTS: 273 | tts_platform_name = "PicoTTS" 274 | tts_platform_documentation = "https://www.home-assistant.io/integrations/picotts" 275 | if tts_platform is PIPER: 276 | tts_platform_name = "Piper" 277 | tts_platform_documentation = "https://www.home-assistant.io/integrations/piper" 278 | if tts_platform is VOICE_RSS: 279 | tts_platform_name = "VoiceRSS" 280 | tts_platform_documentation = "https://www.home-assistant.io/integrations/voicerss" 281 | if tts_platform is YANDEX_TTS: 282 | tts_platform_name = "Yandex TTS" 283 | tts_platform_documentation = "https://www.home-assistant.io/integrations/yandextts" 284 | _LOGGER.error( 285 | "The %s platform was not found. Please check that it has been configured correctly: %s", 286 | tts_platform_name, 287 | tts_platform_documentation 288 | ) 289 | -------------------------------------------------------------------------------- /custom_components/chime_tts/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "clear_cache": "mdi:delete-forever", 4 | "replay": "mdi:speaker-multiple", 5 | "say": "mdi:speaker-message", 6 | "say_url": "mdi:speaker-stop" 7 | } 8 | } -------------------------------------------------------------------------------- /custom_components/chime_tts/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "chime_tts", 3 | "name": "Chime TTS", 4 | "after_dependencies": [ 5 | "api", 6 | "http", 7 | "media_source" 8 | ], 9 | "codeowners": [ 10 | "@nimroddolev" 11 | ], 12 | "config_flow": true, 13 | "documentation": "https://nimroddolev.github.io/chime_tts", 14 | "integration_type": "service", 15 | "iot_class": "local_push", 16 | "issue_tracker": "https://github.com/nimroddolev/chime_tts/issues", 17 | "requirements": [ 18 | "pydub", 19 | "aiofiles" 20 | ], 21 | "version": "v1.2.2" 22 | } -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/README: -------------------------------------------------------------------------------- 1 | To add your own custom chimes please read the documentation: 2 | 3 | https://nimroddolev.github.io/chime_tts/docs/documentation/services/say-service/#custom-chimes-folder 4 | -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/ba_dum_tss.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/ba_dum_tss.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/bells.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/bells.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/bells_2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/bells_2.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/bright.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/bright.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/chirp.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/chirp.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/choir.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/choir.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/chord.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/chord.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/classical.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/classical.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/crickets.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/crickets.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/ding_dong.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/ding_dong.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/drumroll.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/drumroll.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/dun_dun_dun.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/dun_dun_dun.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/error.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/error.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/fanfare.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/fanfare.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/glockenspiel.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/glockenspiel.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/hail.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/hail.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/knock.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/knock.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/marimba.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/marimba.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/mario_coin.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/mario_coin.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/microphone_tap.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/microphone_tap.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/sad_trombone.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/sad_trombone.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/soft.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/soft.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/tada.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/tada.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/toast.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/toast.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/twenty_four.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/twenty_four.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/mp3s/whistle.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/custom_components/chime_tts/mp3s/whistle.mp3 -------------------------------------------------------------------------------- /custom_components/chime_tts/notify.py: -------------------------------------------------------------------------------- 1 | """Chime TTS Notify.""" 2 | 3 | import logging 4 | from .const import ( 5 | DOMAIN, 6 | SERVICE_SAY 7 | ) 8 | from .helpers.helpers import ChimeTTSHelper 9 | from homeassistant.components.notify import BaseNotificationService 10 | from homeassistant.core import HomeAssistant 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | helpers = ChimeTTSHelper() 14 | 15 | async def async_get_service(hass: HomeAssistant, config, _discovery_info): 16 | """Retrieve instance of ChimeTTSNotificationService class.""" 17 | _config = config or {} 18 | return ChimeTTSNotificationService(hass, config) 19 | 20 | class ChimeTTSNotificationService(BaseNotificationService): 21 | """Chime TTS Notify Service class.""" 22 | 23 | def __init__(self, hass: HomeAssistant, config: any): 24 | """Initialize the Chime TTS Notify Service.""" 25 | self.hass = hass 26 | self._config = config 27 | 28 | async def async_send_message(self, message="", **kwargs): 29 | """Send a notification with the Chime TTS Notify Service.""" 30 | kwargs["message"] = message 31 | data = kwargs.get("data", {}) or {} 32 | 33 | for key in [ 34 | "entity_id", 35 | "chime_path", 36 | "end_chime_path", 37 | "offset", 38 | "crossafade", 39 | "final_delay", 40 | "tts_platform", 41 | "tts_speed", 42 | "tts_pitch", 43 | "volume_level", 44 | "join_players", 45 | "unjoin_players", 46 | "cache", 47 | "announce", 48 | "fade_audio", 49 | "language", 50 | "tld", 51 | "voice", 52 | "options", 53 | "audio_conversion" 54 | ]: 55 | kwargs[key] = data.get(key, self._config.get(key)) 56 | 57 | helpers.debug_title("Chime TTS Notify") 58 | for key, value in kwargs.items(): 59 | _LOGGER.debug(f" - {key} = '{value}'" if isinstance(value, str) else f" - {key} = {value}") 60 | 61 | try: 62 | await self.hass.services.async_call( 63 | domain=DOMAIN, 64 | service=SERVICE_SAY, 65 | service_data=kwargs, 66 | blocking=True) 67 | except Exception as error: 68 | _LOGGER.error("Service `chime_tts.say` error: %s", error) 69 | -------------------------------------------------------------------------------- /custom_components/chime_tts/queue_manager.py: -------------------------------------------------------------------------------- 1 | """Chime TTS service call queue manager.""" 2 | 3 | import logging 4 | import asyncio 5 | from collections.abc import Callable, Coroutine 6 | from datetime import datetime 7 | from typing import TypedDict 8 | from .const import ( 9 | QUEUE_TIMEOUT_DEFAULT, 10 | MAX_CONCURRENT_TASKS, 11 | MAX_TIMEOUT, 12 | QUEUE_PROCESSOR_SLEEP_TIME 13 | ) 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | class ServiceCall(TypedDict): 18 | """Represents a service call in the Chime TTS queue.""" 19 | 20 | function: Callable[..., Coroutine[any, any, any]] 21 | args: tuple 22 | kwargs: dict 23 | future: asyncio.Future 24 | 25 | class ChimeTTSQueueManager: 26 | """Manage the Chime TTS service call queue.""" 27 | 28 | def __init__(self, p_timeout_s: int = QUEUE_TIMEOUT_DEFAULT): 29 | """Initialize the queue manager.""" 30 | self.running_tasks: list[asyncio.Task] = [] 31 | self.timeout_s: int = min(p_timeout_s, MAX_TIMEOUT) 32 | self._shutdown_event: asyncio.Event = asyncio.Event() 33 | self.semaphore: asyncio.Semaphore = asyncio.Semaphore(MAX_CONCURRENT_TASKS) 34 | self.queue: asyncio.Queue[ServiceCall | None] = asyncio.Queue() 35 | 36 | async def async_process_queue(self) -> None: 37 | """Process the Chime TTS service call queue.""" 38 | while not self._shutdown_event.is_set(): 39 | try: 40 | service_call = await asyncio.wait_for(self.queue.get(), timeout=1.0) 41 | except asyncio.TimeoutError: 42 | continue 43 | 44 | if service_call is None: 45 | _LOGGER.debug("Queue empty") 46 | self.queue.task_done() 47 | continue # Keep processing instead of breaking 48 | 49 | # Process the service call 50 | await self._process_service_call(service_call) 51 | 52 | async def _process_service_call(self, service_call: ServiceCall) -> None: 53 | """Process a single service call.""" 54 | start_time = datetime.now() 55 | try: 56 | async with self.semaphore: 57 | result = await asyncio.wait_for( 58 | service_call['function'](*service_call['args'], **service_call['kwargs']), 59 | timeout=self.timeout_s 60 | ) 61 | try: 62 | service_call['future'].set_result(result) 63 | except Exception as e: 64 | _LOGGER.error("Error setting result for service call %s: %s", service_call, str(e)) 65 | 66 | except asyncio.TimeoutError: 67 | self._handle_timeout_error(service_call, start_time) 68 | except asyncio.CancelledError: 69 | _LOGGER.info("Service call %s was cancelled", service_call) 70 | service_call['future'].set_exception(asyncio.CancelledError()) 71 | except Exception as e: 72 | _LOGGER.error("Error processing service call %s: %s", service_call, str(e)) 73 | service_call['future'].set_exception(e) 74 | finally: 75 | self.queue.task_done() 76 | 77 | def _handle_timeout_error(self, service_call: ServiceCall, start_time: datetime) -> None: 78 | """Handle timeout error for a service call.""" 79 | end_time = datetime.now() 80 | completion_time = round((end_time - start_time).total_seconds(), 2) or 0 81 | elapsed_time = f"{completion_time}s" if completion_time >= 1 else f"{completion_time * 1000}ms" 82 | _LOGGER.warning("Service call %s timed out after %s", service_call, elapsed_time) 83 | service_call['future'].set_exception( 84 | TimeoutError(f"Service call timed out after {elapsed_time} (configured timeout = {self.timeout_s}s)") 85 | ) 86 | 87 | async def async_queue_processor(self) -> None: 88 | """Continuously process the Chime TTS service call queue.""" 89 | while not self._shutdown_event.is_set(): 90 | await asyncio.sleep(QUEUE_PROCESSOR_SLEEP_TIME) 91 | if not self.queue.empty(): 92 | await self.async_process_queue() 93 | 94 | def add_to_queue(self, 95 | function: Callable[..., Coroutine[any, any, any]], 96 | p_timeout: int, 97 | *args: any, 98 | **kwargs: any) -> asyncio.Future: 99 | """Add a new service call to the Chime TTS service call queue.""" 100 | self.set_timeout(p_timeout) 101 | future: asyncio.Future = asyncio.Future() 102 | queue_size = self.queue.qsize() 103 | if queue_size == 0: 104 | _LOGGER.debug("Adding service call to queue") 105 | else: 106 | _LOGGER.debug("Adding service call to queue (%s ahead)", str(queue_size)) 107 | try: 108 | self.queue.put_nowait(ServiceCall( 109 | function=function, 110 | args=args, 111 | kwargs=kwargs, 112 | future=future 113 | )) 114 | except asyncio.QueueFull: 115 | _LOGGER.error("Unable to add Chime TTS task to queue: Queue is full") 116 | future.set_exception(RuntimeError("Queue is full")) 117 | return future 118 | 119 | def reset_queue(self) -> None: 120 | """Remove any existing items in the queue and reset.""" 121 | _LOGGER.info("Resetting queue") 122 | self._shutdown_event.set() 123 | self._clear_queue() 124 | self._shutdown_event.clear() 125 | _LOGGER.debug("Queue reset") 126 | if not self.running_tasks: 127 | self.start_queue_processor() 128 | 129 | def _clear_queue(self) -> None: 130 | """Clear all items from the queue.""" 131 | while not self.queue.empty(): 132 | try: 133 | task = self.queue.get_nowait() 134 | if isinstance(task, asyncio.Future): 135 | task.cancel() 136 | self.queue.task_done() 137 | except asyncio.QueueEmpty: 138 | break 139 | 140 | def set_timeout(self, p_timeout: int) -> None: 141 | """Set the timeout duration for queued service calls.""" 142 | self.timeout_s = min(p_timeout, MAX_TIMEOUT) 143 | 144 | def start_queue_processor(self) -> None: 145 | """Start the queue processor task.""" 146 | task = asyncio.create_task(self.async_queue_processor()) 147 | task.add_done_callback(lambda t: self.running_tasks.remove(t) if t in self.running_tasks else None) 148 | self.running_tasks.append(task) 149 | 150 | async def stop_queue_processor(self) -> None: 151 | """Stop the queue processor task.""" 152 | _LOGGER.info("Stopping queue processor") 153 | self._shutdown_event.set() 154 | for task in self.running_tasks: 155 | task.cancel() # Gracefully cancel tasks 156 | try: 157 | await task # Await task to handle asyncio.CancelledError cleanly 158 | except asyncio.CancelledError: 159 | _LOGGER.debug("Task was cancelled cleanly") 160 | self.running_tasks.clear() 161 | -------------------------------------------------------------------------------- /custom_components/chime_tts/services.yaml: -------------------------------------------------------------------------------- 1 | clear_cache: 2 | name: Clear Cache 3 | description: Remove text-to-speech cache files from Chime TTS and/or Home Assistant. 4 | fields: 5 | clear_chimes_cache: 6 | default: true 7 | description: Remove the cached local chime files downloaded by Chime TTS 8 | example: 'True' 9 | name: Temporary Chimes Cache 10 | required: false 11 | selector: 12 | boolean: null 13 | clear_ha_tts_cache: 14 | name: Home Assistant TTS Cache 15 | description: Remove the TTS audio files stored in the Home Assistant TTS cache 16 | default: true 17 | example: 'True' 18 | required: false 19 | selector: 20 | boolean: null 21 | clear_temp_tts_cache: 22 | default: true 23 | description: Remove the local temporary audio files stored in the Chime TTS 24 | cache 25 | example: 'True' 26 | name: Temporary Chime TTS Cache 27 | required: false 28 | selector: 29 | boolean: null 30 | clear_www_tts_cache: 31 | default: true 32 | description: Remove the publicly accessible audio files stored in the Chime 33 | TTS cache 34 | example: 'True' 35 | name: Publicly Accessible Chime TTS Cache 36 | required: false 37 | selector: 38 | boolean: null 39 | replay: 40 | name: Replay 41 | description: Replay the last service call to chime_tts.say with the same parameters 42 | say: 43 | name: Say 44 | description: Play an audio file before TTS audio 45 | target: 46 | entity: 47 | domain: media_player 48 | supported_features: 49 | - media_player.MediaPlayerEntityFeature.PLAY_MEDIA 50 | fields: 51 | chime_path: 52 | description: A preset or custom audio file to be played before TTS audio 53 | example: custom_components/chime_tts/mp3s/bells.mp3 54 | name: Chime Path 55 | required: false 56 | selector: 57 | select: 58 | custom_value: true 59 | mode: dropdown 60 | multiple: false 61 | options: [] 62 | translation_key: chime_paths 63 | end_chime_path: 64 | description: A preset or custom audio file to be played after TTS audio 65 | example: custom_components/chime_tts/mp3s/tada.mp3 66 | name: End Chime Path 67 | required: false 68 | selector: 69 | select: 70 | custom_value: true 71 | mode: dropdown 72 | multiple: false 73 | options: [] 74 | translation_key: chime_paths 75 | offset: 76 | description: Adds a delay between audio segments when value > 0, or overlays 77 | audio segments when value < 0. 78 | example: 450 79 | name: Offset 80 | required: false 81 | selector: 82 | number: 83 | max: 10000 84 | min: -10000 85 | mode: box 86 | step: 10 87 | unit_of_measurement: ms 88 | crossfade: 89 | description: Crossfade (in milliseconds) between audio segments 90 | example: 1000 91 | name: Crossfade 92 | required: false 93 | selector: 94 | number: 95 | min: 0 96 | mode: box 97 | step: 1 98 | unit_of_measurement: ms 99 | final_delay: 100 | description: Final delay (in milliseconds) added to the end of the audio 101 | example: 100 102 | name: Final Delay 103 | required: false 104 | selector: 105 | number: 106 | max: 10000 107 | min: 0 108 | mode: box 109 | step: 1 110 | unit_of_measurement: ms 111 | message: 112 | description: Text converted into TTS audio 113 | example: Hello world! 114 | name: Message 115 | required: false 116 | selector: 117 | template: null 118 | tts_platform: 119 | description: TTS platform used to generate TTS audio 120 | example: google_translate 121 | name: TTS Platform 122 | required: false 123 | selector: 124 | select: 125 | custom_value: true 126 | mode: dropdown 127 | multiple: false 128 | options: 129 | - label: Amazon Polly 130 | value: amazon_polly 131 | - label: Baidu 132 | value: baidu 133 | - label: ElevenLabs 134 | value: tts.elevenlabs 135 | - label: Google Cloud 136 | value: google_cloud 137 | - label: Google Translate 138 | value: google_translate 139 | - label: IBM Watson TTS 140 | value: watson_tts 141 | - label: MaryTTS 142 | value: marytts 143 | - label: Microsoft Edge TTS 144 | value: edge_tts 145 | - label: Microsoft Text-to-Speech (TTS) 146 | value: microsoft 147 | - label: Nabu Casa Cloud TTS 148 | value: cloud 149 | - label: OpenAI TTS 150 | value: openai_tts 151 | - label: Pico TTS 152 | value: picotts 153 | - label: Piper 154 | value: tts.piper 155 | - label: VoiceRSS 156 | value: voicerss 157 | - label: Yandex TTS 158 | value: yandextts 159 | tts_speed: 160 | description: Set the speed of the TTS audio to between 1% and 500% of the original 161 | example: 125 162 | name: TTS Speed 163 | required: false 164 | selector: 165 | number: 166 | max: 500 167 | min: 1 168 | mode: slider 169 | step: 5 170 | unit_of_measurement: '%' 171 | tts_pitch: 172 | description: Change the the TTS pitch in semitones. Negative values for lower, 173 | positive for higher 174 | example: 3 175 | name: TTS Pitch 176 | required: false 177 | selector: 178 | number: 179 | max: 100 180 | min: -100 181 | mode: slider 182 | step: 1 183 | unit_of_measurement: semitones 184 | volume_level: 185 | description: The volume to use when playing audio 186 | example: 0.75 187 | name: Volume Level 188 | required: false 189 | selector: 190 | number: 191 | max: 1.0 192 | min: 0.0 193 | mode: slider 194 | step: 0.01 195 | join_players: 196 | description: Join media_players for simultaneous playback (for supported speakers) 197 | example: 'True' 198 | name: Join Players 199 | required: false 200 | selector: 201 | boolean: null 202 | unjoin_players: 203 | description: Release the joined media_players after playback 204 | example: 'True' 205 | name: Unjoin Players 206 | required: false 207 | selector: 208 | boolean: null 209 | cache: 210 | description: Whether or not to save/reuse the generated audio file in a local 211 | cache 212 | example: 'True' 213 | name: Cache 214 | required: false 215 | selector: 216 | boolean: null 217 | announce: 218 | description: Reduce volume of currently playing audio during during announcement 219 | (on supported devices) 220 | example: 'True' 221 | name: Announce 222 | required: false 223 | selector: 224 | boolean: null 225 | fade_audio: 226 | description: Fade out playing audio during announcement, fade back in when completed 227 | (on supported devices) 228 | example: 'True' 229 | name: Fade Audio 230 | required: false 231 | selector: 232 | boolean: null 233 | audio_conversion: 234 | description: Convert the audio to match Alexa speaker requirements, or use your 235 | own FFmpeg arguments 236 | example: Alexa 237 | name: Audio Conversion 238 | required: false 239 | selector: 240 | select: 241 | custom_value: true 242 | options: 243 | - label: Alexa 244 | value: Alexa 245 | - label: 'Volume x% (replace this text, eg: "Volume 125%")' 246 | value: Volume 100% 247 | - label: Custom (replace this text with your FFmpeg arguments) 248 | value: Custom 249 | translation_key: audio_conversion 250 | language: 251 | description: The TTS language (supported by Google Translate, Microsoft Edge 252 | TTS, Amazon Polly and Nabu Casa Cloud TTS) 253 | example: en 254 | name: Language 255 | required: false 256 | selector: 257 | text: null 258 | tld: 259 | description: The dialect (supported by Google Translate) 260 | example: com.au 261 | name: TLD 262 | required: false 263 | selector: 264 | select: 265 | options: 266 | - label: com 267 | value: com 268 | - label: co.uk 269 | value: co.uk 270 | - label: com.au 271 | value: com.au 272 | - label: ca 273 | value: ca 274 | - label: co.in 275 | value: co.in 276 | - label: ie 277 | value: ie 278 | - label: co.za 279 | value: co.za 280 | - label: fr 281 | value: fr 282 | - label: com.br 283 | value: com.br 284 | - label: pt 285 | value: pt 286 | - label: es 287 | value: es 288 | voice: 289 | description: Define the voice for the TTS audio (on supported TTS platforms) 290 | example: en-AU 291 | name: Voice 292 | required: false 293 | selector: 294 | text: null 295 | options: 296 | description: YAML Options to pass to TTS services (will override `tld` and `voice` 297 | fields) 298 | example: "tld: com.au\voice: en-AU" 299 | name: Options 300 | required: false 301 | selector: 302 | text: 303 | multiline: true 304 | say_url: 305 | name: Say URL 306 | description: Generates an audio file with the `chime_tts.say` service and returns 307 | either an external URL or a local file path, depending on the folder set in the 308 | configuration 309 | fields: 310 | chime_path: 311 | description: A preset or custom audio file to be played before TTS audio 312 | example: custom_components/chime_tts/mp3s/bells.mp3 313 | name: Chime Path 314 | required: false 315 | selector: 316 | select: 317 | custom_value: true 318 | mode: dropdown 319 | multiple: false 320 | options: [] 321 | translation_key: chime_paths 322 | end_chime_path: 323 | description: A preset or custom audio file to be played after TTS audio 324 | example: custom_components/chime_tts/mp3s/tada.mp3 325 | name: End Chime Path 326 | required: false 327 | selector: 328 | select: 329 | custom_value: true 330 | mode: dropdown 331 | multiple: false 332 | options: [] 333 | translation_key: chime_paths 334 | offset: 335 | description: Adds a delay between audio segments when value > 0, or overlays 336 | audio segments when value < 0. 337 | example: 450 338 | name: Offset 339 | required: false 340 | selector: 341 | number: 342 | max: 10000 343 | min: -10000 344 | mode: box 345 | step: 10 346 | unit_of_measurement: ms 347 | crossfade: 348 | description: Crossfade (in milliseconds) between audio segments 349 | example: 1000 350 | name: Crossfade 351 | required: false 352 | selector: 353 | number: 354 | min: 0 355 | mode: box 356 | step: 1 357 | unit_of_measurement: ms 358 | final_delay: 359 | description: Final delay (in milliseconds) added to the end of the audio 360 | example: 100 361 | name: Final Delay 362 | required: false 363 | selector: 364 | number: 365 | max: 10000 366 | min: 0 367 | mode: box 368 | step: 1 369 | unit_of_measurement: ms 370 | message: 371 | description: Text converted into TTS audio 372 | example: Hello world! 373 | name: Message 374 | required: false 375 | selector: 376 | template: null 377 | tts_platform: 378 | description: TTS platform used to generate TTS audio 379 | example: google_translate 380 | name: TTS Platform 381 | required: false 382 | selector: 383 | select: 384 | custom_value: true 385 | mode: dropdown 386 | multiple: false 387 | options: 388 | - label: Amazon Polly 389 | value: amazon_polly 390 | - label: Baidu 391 | value: baidu 392 | - label: ElevenLabs 393 | value: tts.elevenlabs 394 | - label: Google Cloud 395 | value: google_cloud 396 | - label: Google Translate 397 | value: google_translate 398 | - label: IBM Watson TTS 399 | value: watson_tts 400 | - label: MaryTTS 401 | value: marytts 402 | - label: Microsoft Edge TTS 403 | value: edge_tts 404 | - label: Microsoft Text-to-Speech (TTS) 405 | value: microsoft 406 | - label: Nabu Casa Cloud TTS 407 | value: cloud 408 | - label: OpenAI TTS 409 | value: openai_tts 410 | - label: Pico TTS 411 | value: picotts 412 | - label: Piper 413 | value: tts.piper 414 | - label: VoiceRSS 415 | value: voicerss 416 | - label: Yandex TTS 417 | value: yandextts 418 | tts_speed: 419 | description: Set the speed of the TTS audio to between 1% and 500% of the original 420 | example: 125 421 | name: TTS Speed 422 | required: false 423 | selector: 424 | number: 425 | max: 500 426 | min: 1 427 | mode: slider 428 | step: 5 429 | unit_of_measurement: '%' 430 | tts_pitch: 431 | description: Change the the TTS pitch in semitones. Negative values for lower, 432 | positive for higher 433 | example: 3 434 | name: TTS Pitch 435 | required: false 436 | selector: 437 | number: 438 | max: 100 439 | min: -100 440 | mode: slider 441 | step: 1 442 | unit_of_measurement: semitones 443 | cache: 444 | description: Whether or not to save/reuse the generated audio file in a local 445 | cache 446 | example: 'True' 447 | name: Cache 448 | required: false 449 | selector: 450 | boolean: null 451 | audio_conversion: 452 | description: Convert the audio to match Alexa speaker requirements, or use your 453 | own FFmpeg arguments 454 | example: Alexa 455 | name: Audio Conversion 456 | required: false 457 | selector: 458 | select: 459 | custom_value: true 460 | options: 461 | - label: Alexa 462 | value: Alexa 463 | - label: 'Volume x% (replace this text, eg: "Volume 125%")' 464 | value: Volume 100% 465 | - label: Custom (replace this text with your FFmpeg arguments) 466 | value: Custom 467 | translation_key: audio_conversion 468 | language: 469 | description: The TTS language (supported by Google Translate, Microsoft Edge 470 | TTS and Nabu Casa Cloud TTS) 471 | example: en 472 | name: Language 473 | required: false 474 | selector: 475 | text: null 476 | tld: 477 | description: The dialect (supported by Google Translate) 478 | example: com.au 479 | name: TLD 480 | required: false 481 | selector: 482 | select: 483 | options: 484 | - label: com 485 | value: com 486 | - label: co.uk 487 | value: co.uk 488 | - label: com.au 489 | value: com.au 490 | - label: ca 491 | value: ca 492 | - label: co.in 493 | value: co.in 494 | - label: ie 495 | value: ie 496 | - label: co.za 497 | value: co.za 498 | - label: fr 499 | value: fr 500 | - label: com.br 501 | value: com.br 502 | - label: pt 503 | value: pt 504 | - label: es 505 | value: es 506 | voice: 507 | description: Define the voice for the TTS audio (on supported TTS platforms) 508 | example: en-AU 509 | name: Voice 510 | required: false 511 | selector: 512 | text: null 513 | options: 514 | description: YAML Options to pass to TTS services (will override `tld` and `voice` 515 | fields) 516 | example: "tld: com.au\voice: en-AU" 517 | name: Options 518 | required: false 519 | selector: 520 | text: 521 | multiline: true 522 | -------------------------------------------------------------------------------- /custom_components/chime_tts/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "no_tts_platforms": { 5 | "title": "Chime TTS", 6 | "description": "ℹ️ No TTS Platforms Found.\n\nThe Chime TTS integration uses TTS platform/s already installed in your Home Assistant Instance.\n\nPlease add at least 1 TTS platform to begin using it.\n\nFor more details please review the documentation." 7 | } 8 | } 9 | }, 10 | "services": { 11 | "clear_cache": { 12 | "name": "Clear Cache", 13 | "description": "Removes all locally cached TTS audio files created from Chime TTS", 14 | "fields": { 15 | "clear_chimes_cache": { 16 | "name": "Temporary Chimes Cache", 17 | "description": "Remove the cached local chime files downloaded by Chime TTS" 18 | }, 19 | "clear_temp_tts_cache": { 20 | "name": "Temporary Chime TTS Cache", 21 | "description": "Remove the local temporary audio files stored in the Chime TTS cache" 22 | }, 23 | "clear_www_tts_cache": { 24 | "name": "Publicly Accessible Chime TTS Cache", 25 | "description": "Remove the publicly accessible audio files stored in the Chime TTS cache" 26 | }, 27 | "clear_ha_tts_cache": { 28 | "name": "Home Assistant TTS Cache", 29 | "description": "Remove the TTS audio files stored in the Home Assistant TTS cache" 30 | } 31 | } 32 | }, 33 | "replay": { 34 | "name": "Replay", 35 | "description": "Repeat the last service call to chime_tts.say with the same parameters" 36 | }, 37 | "say": { 38 | "name": "Say", 39 | "description": "Play an audio file before TTS audio", 40 | "fields": { 41 | "chime_path": { 42 | "name": "Chime Path", 43 | "description": "A preset or custom audio file to be played before TTS audio" 44 | }, 45 | "end_chime_path": { 46 | "name": "End Chime Path", 47 | "description": "A preset or custom audio file to be played after TTS audio" 48 | }, 49 | "offset": { 50 | "name": "Offset", 51 | "description": "Adds a delay between audio segments when value > 0, or overlays audio segments when value < 0." 52 | }, 53 | "final_delay": { 54 | "name": "Final Delay", 55 | "description": "Final delay (in milliseconds) added to the end of the audio" 56 | }, 57 | "message": { 58 | "name": "Message", 59 | "description": "Text converted into TTS audio", 60 | "example": "Hello" 61 | }, 62 | "tts_platform": { 63 | "name": "TTS Platform", 64 | "description": "TTS platform used to generate TTS audio" 65 | }, 66 | "tts_speed": { 67 | "name": "TTS Speed", 68 | "description": "Set the speed of the TTS audio to between 1% and 500% of the original" 69 | }, 70 | "tts_pitch": { 71 | "name": "TTS Pitch", 72 | "description": "Change the the TTS pitch in semitones. Negative values for lower, positive for higher" 73 | }, 74 | "volume_level": { 75 | "name": "Volume Level", 76 | "description": "The volume to use when playing audio" 77 | }, 78 | "join_players": { 79 | "name": "Join Players", 80 | "description": "Join media_players for simultaneous playback (for supported speakers)" 81 | }, 82 | "unjoin_players": { 83 | "name": "Unjoin Players", 84 | "description": "Release the joined media_players after playback" 85 | }, 86 | "cache": { 87 | "name": "Cache", 88 | "description": "Whether or not to save/reuse the generated audio file in a local cache" 89 | }, 90 | "announce": { 91 | "name": "Announce", 92 | "description": "Reduce volume of currently playing audio during during announcement (on supported devices)" 93 | }, 94 | "fade_audio": { 95 | "name": "Fade Audio", 96 | "description": "Fade out playing audio during announcement, fade back in when completed (on supported devices)" 97 | }, 98 | "language": { 99 | "name": "Language", 100 | "description": "The TTS language (supported by Google Translate, Microsoft Edge TTS and Nabu Casa Cloud TTS)" 101 | }, 102 | "tld": { 103 | "name": "TLD", 104 | "description": "The dialect (supported by Google Translate)" 105 | }, 106 | "voice": { 107 | "name": "Voice", 108 | "description": "Define the voice for the TTS audio (on supported TTS platforms)" 109 | }, 110 | "options": { 111 | "name": "Options", 112 | "description": "YAML Options to pass to TTS services (will override `tld` and `voice` fields)" 113 | }, 114 | "audio_conversion": { 115 | "name": "Audio Conversion", 116 | "description": "Convert the audio to match Alexa speaker requirements, or use your own FFmpeg arguments" 117 | } 118 | } 119 | }, 120 | "say_url": { 121 | "name": "Say URL", 122 | "description": "Generates an audio file with the `chime_tts.say` service and returns either an external URL or a local file path, depending on the folder set in the configuration", 123 | "fields": { 124 | "chime_path": { 125 | "name": "Chime Path", 126 | "description": "A preset or custom audio file to be played before TTS audio" 127 | }, 128 | "end_chime_path": { 129 | "name": "End Chime Path", 130 | "description": "A preset or custom audio file to be played after TTS audio" 131 | }, 132 | "offset": { 133 | "name": "Offset", 134 | "description": "Adds a delay between audio segments when value > 0, or overlays audio segments when value < 0." 135 | }, 136 | "final_delay": { 137 | "name": "Final Delay", 138 | "description": "Final delay (in milliseconds) added to the end of the audio" 139 | }, 140 | "message": { 141 | "name": "Message", 142 | "description": "Text converted into TTS audio", 143 | "example": "Hello" 144 | }, 145 | "tts_platform": { 146 | "name": "TTS Platform", 147 | "description": "TTS platform used to generate TTS audio" 148 | }, 149 | "tts_speed": { 150 | "name": "TTS Speed", 151 | "description": "Set the speed of the TTS audio to between 1% and 500% of the original" 152 | }, 153 | "tts_pitch": { 154 | "name": "TTS Pitch", 155 | "description": "Change the the TTS pitch in semitones. Negative values for lower, positive for higher" 156 | }, 157 | "cache": { 158 | "name": "Cache", 159 | "description": "Whether or not to save/reuse the generated audio file in a local cache" 160 | }, 161 | "language": { 162 | "name": "Language", 163 | "description": "The TTS language (supported by Google Translate, Microsoft Edge TTS and Nabu Casa Cloud TTS)" 164 | }, 165 | "tld": { 166 | "name": "TLD", 167 | "description": "The dialect (supported by Google Translate)" 168 | }, 169 | "voice": { 170 | "name": "Voice", 171 | "description": "Define the voice for the TTS audio (on supported TTS platforms)" 172 | }, 173 | "options": { 174 | "name": "Options", 175 | "description": "YAML Options to pass to TTS services (will override `tld` and `voice` fields)" 176 | }, 177 | "audio_conversion": { 178 | "name": "Audio Conversion", 179 | "description": "Convert the audio to match Alexa speaker requirements, or use your own FFmpeg arguments" 180 | } 181 | } 182 | } 183 | }, 184 | "selector": { 185 | "audio_conversion": { 186 | "options": { 187 | "alexa": "Alexa", 188 | "custom": "Custom (replace this text with your FFmpeg arguments)" 189 | } 190 | }, 191 | "chime_paths": { 192 | "options": { 193 | "ba_dum_tss": "Ba-Dum Tss!", 194 | "bells": "Bells", 195 | "bells_2": "Bells 2", 196 | "bright": "Bright", 197 | "chirp": "Chirp", 198 | "choir": "Choir", 199 | "chord": "Chord", 200 | "classical": "Classical", 201 | "crickets": "Crickets", 202 | "ding_dong": "Ding Dong", 203 | "drumroll": "Drum Roll", 204 | "dun_dun_dun": "Dun dun DUUUN!", 205 | "error": "Error", 206 | "fanfare": "Fanfare", 207 | "glockenspiel": "Glockenspiel", 208 | "hail": "Hail", 209 | "knock": "Knock", 210 | "marimba": "Marimba", 211 | "mario_coin": "Mario Coin", 212 | "microphone_tap": "Microphone Tap", 213 | "tada": "Ta-da!", 214 | "toast": "Toast", 215 | "twenty_four": "Twenty Four", 216 | "sad_trombone": "Sad Trombone", 217 | "soft": "Soft", 218 | "whistle": "Whistle" 219 | } 220 | } 221 | }, 222 | "options": { 223 | "step": { 224 | "init": { 225 | "title": "Chime TTS Configuration", 226 | "description": "Configurable options for the `chime_tts.say` and `chime_tts.say_url` actions.\r\n\r\nPlease review the [documentation](https://nimroddolev.github.io/chime_tts/docs/documentation/configuration/) for more details", 227 | "data": { 228 | "queue_timeout": "Service call timeout (in seconds)", 229 | "tts_timeout": "TTS audio generation timeout (in seconds)", 230 | "tts_platform_key": "Default TTS platform", 231 | "default_language_key": "Default language (when using default TTS platform)", 232 | "default_voice_key": "Default voice (when using default TTS platform)", 233 | "default_tld_key": "Default dialect (when using the Google Translate TTS platform)", 234 | "fallback_tts_platform_key": "Fallback TTS platform", 235 | "offset": "Default offset value (in milliseconds) between chimes & TTS audio", 236 | "fade_transition_key": "Fade transition (in milliseconds) when fading currently playing audio for `announce` and `fade_audio`", 237 | "remove_temp_file_delay": "Delay (in milliseconds) before removing temporary files", 238 | "custom_chimes_path": "Folder path for custom chime audio files", 239 | "temp_chimes_path": "Folder path for downloaded chime audio files", 240 | "temp_path": "Folder path for temporary TTS audio mp3 files", 241 | "www_path": "Folder path for Chime TTS mp3s generated by `chime_tts.say_url`", 242 | "add_cover_art": "Add Chime TTS cover art to generated MP3 files" 243 | } 244 | }, 245 | "restart_required": { 246 | "title": "Custom Chimes Folder", 247 | "description": "🔄 Restart Required\n\nChanging the custom chimes folder path (or adding/removing files inside the folder) will require a restart of Home Assistant." 248 | } 249 | }, 250 | "error": { 251 | "timeout": "The timeout value is invalid", 252 | "timeout_sub": "Enter a valid timeout duration", 253 | "tts_platform_none": "No TTS platforms were detected. Please add at least 1 TTS integration.", 254 | "tts_platform_select": "The TTS platform was not found. Please make sure it has been installed before selecting it.", 255 | "multiple": "Multiple issues detected", 256 | "invalid_chime_paths": "Invalid custom chime path detected", 257 | "temp_path": "The temp folder must be a subfolder of a media directory", 258 | "www_path": "The chime_tts.say_url folder must be a subfolder of an external directory, eg: /config/www/chime_tts" 259 | } 260 | } 261 | } -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Chime TTS", 3 | "filename": "chime_tts.zip", 4 | "hide_default_branch": true, 5 | "homeassistant": "2023.3.0", 6 | "render_readme": true, 7 | "zip_release": true 8 | } -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/icon.png -------------------------------------------------------------------------------- /images/call_service_clear_cache_from_ui-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/call_service_clear_cache_from_ui-dark.png -------------------------------------------------------------------------------- /images/call_service_clear_cache_from_ui-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/call_service_clear_cache_from_ui-light.png -------------------------------------------------------------------------------- /images/call_service_from_ui-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/call_service_from_ui-dark.png -------------------------------------------------------------------------------- /images/call_service_from_ui-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/call_service_from_ui-light.png -------------------------------------------------------------------------------- /images/icon_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/icon_small.png -------------------------------------------------------------------------------- /images/wiki/chimes/chime_options.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/wiki/chimes/chime_options.gif -------------------------------------------------------------------------------- /images/wiki/chimes/chime_path.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/wiki/chimes/chime_path.png -------------------------------------------------------------------------------- /images/wiki/chimes/config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/wiki/chimes/config.png -------------------------------------------------------------------------------- /images/wiki/chimes/config_custom_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/wiki/chimes/config_custom_1.png -------------------------------------------------------------------------------- /images/wiki/chimes/custom_1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/wiki/chimes/custom_1.gif -------------------------------------------------------------------------------- /images/wiki/home/no_chime_tts-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/wiki/home/no_chime_tts-dark.png -------------------------------------------------------------------------------- /images/wiki/home/no_chime_tts-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/wiki/home/no_chime_tts-light.png -------------------------------------------------------------------------------- /images/wiki/home/with_chime_tts-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/wiki/home/with_chime_tts-dark.png -------------------------------------------------------------------------------- /images/wiki/home/with_chime_tts-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/wiki/home/with_chime_tts-light.png -------------------------------------------------------------------------------- /images/wiki/installation/add_custom_repository-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/wiki/installation/add_custom_repository-dark.png -------------------------------------------------------------------------------- /images/wiki/installation/add_custom_repository-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/wiki/installation/add_custom_repository-light.png -------------------------------------------------------------------------------- /images/wiki/installation/download_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/wiki/installation/download_button.png -------------------------------------------------------------------------------- /images/wiki/installation/setup-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/wiki/installation/setup-dark.png -------------------------------------------------------------------------------- /images/wiki/installation/setup-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/wiki/installation/setup-light.png -------------------------------------------------------------------------------- /images/wiki/installation/success-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/wiki/installation/success-dark.png -------------------------------------------------------------------------------- /images/wiki/installation/success-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimroddolev/chime_tts/a5534fd20185dc2a04ed2dc1bda629d0e3336b83/images/wiki/installation/success-light.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorlog==6.9.0 2 | homeassistant==2023.7.3 3 | pip>=21.0,<24.4 4 | ruff==0.8.4 5 | -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Create config dir if not present 8 | if [[ ! -d "${PWD}/config" ]]; then 9 | mkdir -p "${PWD}/config" 10 | hass --config "${PWD}/config" --script ensure_config 11 | fi 12 | 13 | # Set the path to custom_components 14 | ## This let's us have the structure we want /custom_components/chime_tts 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 18 | 19 | # Start Home Assistant 20 | hass --config "${PWD}/config" --debug 21 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | ruff check . --fix 8 | -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | python3 -m pip install --requirement requirements.txt 8 | --------------------------------------------------------------------------------