From 1da044c83fec7c2c8ded858ad13e99d4e8a47a58 Mon Sep 17 00:00:00 2001 From: andreasntr Date: Wed, 3 Dec 2025 21:47:19 +0000 Subject: [PATCH 1/4] add static sentences mode --- README.md | 55 ++++++++++++++++++++++++++ wyoming_piper/__main__.py | 11 ++++++ wyoming_piper/handler.py | 81 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) diff --git a/README.md b/README.md index eeb68c8..d8c741f 100644 --- a/README.md +++ b/README.md @@ -37,3 +37,58 @@ docker run -it -p 10200:10200 -v /path/to/local/data:/data rhasspy/wyoming-piper ``` [Source](https://github.com/rhasspy/wyoming-addons/tree/master/piper) + +## Static Sentences +For non-english speakers, some TTS models do not offer a good pronunciation or in general enough tone consistency. + +To avoid this, one can pre-generate sentences with bigger models or using paid services, download them and let Piper forward those instead of the auto-generated voice. + +Static sentences _do not_ prevent Piper text generation: sentences which are not mapped (see the following sections) will be generated by Piper as usual. + +For example, suppose you have a device called "lamp". You could generate a "turn on lamp" sentence and have Piper forward that instead of generating the sentence on the fly. + +To accomplish this: +- enable static sentences (using `--use-static-sentences`) +- provide the static sentences folder to the script (using `--static-sentences-dir`) +- create a _static-sentences.json_ file in the static sentences folder by mapping each sentence with the path of the audio (_.wav_) to play. __The file name must not be changed.__ + +Keep in mind that the `static-sentences.json` file will be parsed as follows: +- keys: + - leading and trailing spaces will be removed + - leading and trailing punctuation will be removed + - text will be made lowercase +- values: + - relative paths will be inferred as originating from the static sentences folder (e.g. _lamp.wav_ -> _/static-sentences/lamp.wav_) + +__Important__: Keys are preprocessed the same way as the text to be synthetised, so there is no risk of spaces, punctuation or casing getting in the way of correctly selecting a static sentence. + +### Docker +Use the same parameters and mount the static sentences dir as a volume. + +### Example static-sentences.json +Suppose you have the following folder structure: + +```md +. +├── ... +└── static-sentences/ + ├── static-sentences.json + ├── on/ + | ├── lamp.wav + ├── ... + | └── heating.wav + └── off/ + ├── lamp.wav + ├── ... + └── heating.wav +``` + +Then a valid _static-sentences.json_ could be as follows: +```json +{ + "turn on lamp": "on/lamp.wav", + "turn off heating": "off/heating.wav" +} +``` + +In this case, `--static-sentences-dir` shall be set to _static-sentences_. \ No newline at end of file diff --git a/wyoming_piper/__main__.py b/wyoming_piper/__main__.py index 4942b5f..48d08a6 100755 --- a/wyoming_piper/__main__.py +++ b/wyoming_piper/__main__.py @@ -76,6 +76,17 @@ async def main() -> None: help="Use CUDA if available (requires onnxruntime-gpu)", ) # + parser.add_argument( + "--static-sentences-dir", + help="Use static sentences from pre-computed WAV files (does not prevent Piper from being used for esogenous sentences)", + ) + # + parser.add_argument( + "--use-static-sentences", + action="store_true", + help="Use static sentences from pre-computed WAV files (does not prevent Piper from being used for esogenous sentences)", + ) + # parser.add_argument("--debug", action="store_true", help="Log DEBUG messages") parser.add_argument( "--log-format", default=logging.BASIC_FORMAT, help="Format for log messages" diff --git a/wyoming_piper/handler.py b/wyoming_piper/handler.py index 4245b46..f4c8004 100644 --- a/wyoming_piper/handler.py +++ b/wyoming_piper/handler.py @@ -7,6 +7,8 @@ import tempfile import wave from typing import Any, Dict, Optional +from os.path import join +from json import load from piper import PiperVoice, SynthesisConfig from sentence_stream import SentenceBoundaryDetector @@ -22,6 +24,7 @@ SynthesizeStop, SynthesizeStopped, ) +import string from .download import ensure_voice_exists, find_voice @@ -33,6 +36,20 @@ _VOICE_LOCK = asyncio.Lock() +def preprocess_text(text: str) -> str: + """ + Preprocesses the input text by removing leading and trailing spaces and punctuation and making it lowercase. + + Args: + text (str): the text to be preprocessed. + + Returns: + str: the lowecase preprocessed text without leading and trailing spaces and punctuation. + """ + punctuation_remover = str.maketrans('', '', string.punctuation) + return text.strip().lower().translate(punctuation_remover) + + class PiperEventHandler(AsyncEventHandler): def __init__( self, @@ -50,8 +67,21 @@ def __init__( self.is_streaming: Optional[bool] = None self.sbd = SentenceBoundaryDetector() self._synthesize: Optional[Synthesize] = None + self.static_sentences_mapping = {} + # load static sentences + if self.cli_args.use_static_sentences: + with open(join(self.cli_args.static_sentences_dir, 'static-sentences.json')) as static_sentences_file: + static_sentences_mapping_raw = load(static_sentences_file) + for k_raw, v_raw in static_sentences_mapping_raw.items(): + k = preprocess_text(k_raw) + v = v_raw + if not v.startswith(self.cli_args.static_sentences_dir): + v = join(self.cli_args.static_sentences_dir, v) + self.static_sentences_mapping[k] = v + _LOGGER.debug(f"Parsed static sentence: {dict(sentence=k, path=v)}") async def handle_event(self, event: Event) -> bool: + if Describe.is_type(event.type): await self.write_event(self.wyoming_info_event) _LOGGER.debug("Sent info") @@ -136,6 +166,51 @@ async def handle_event(self, event: Event) -> bool: ) raise err + async def _handle_static_sentence( + self, static_sentence_text: str + ) -> None: + global _VOICE, _VOICE_NAME + + _LOGGER.debug(f"Got static sentence: `{static_sentence_text}`") + static_sentence_audio_file_path = self.static_sentences_mapping[static_sentence_text] + + with open(static_sentence_audio_file_path, mode="rb") as static_sentence_audio_file: + wav_file: wave.Wave_read = wave.open(static_sentence_audio_file, "rb") + + with wav_file: + rate = wav_file.getframerate() + width = wav_file.getsampwidth() + channels = wav_file.getnchannels() + + await self.write_event( + AudioStart( + rate=rate, + width=width, + channels=channels, + ).event(), + ) + + # Audio + audio_bytes = wav_file.readframes(wav_file.getnframes()) + bytes_per_sample = width * channels + bytes_per_chunk = bytes_per_sample * self.cli_args.samples_per_chunk + num_chunks = int(math.ceil(len(audio_bytes) / bytes_per_chunk)) + + # Split into chunks + for i in range(num_chunks): + offset = i * bytes_per_chunk + chunk = audio_bytes[offset : offset + bytes_per_chunk] + await self.write_event( + AudioChunk( + audio=chunk, + rate=rate, + width=width, + channels=channels, + ).event(), + ) + + await self.write_event(AudioStop().event()) + async def _handle_synthesize( self, synthesize: Synthesize, send_start: bool = True, send_stop: bool = True ) -> bool: @@ -159,6 +234,12 @@ async def _handle_synthesize( if not has_punctuation: text = text + self.cli_args.auto_punctuation[0] + # Handle static sentence detection + preprocessed_text = preprocess_text(raw_text) + if preprocessed_text in self.static_sentences_mapping: + await self._handle_static_sentence(preprocessed_text) + return True + # Resolve voice _LOGGER.debug("synthesize: raw_text=%s, text='%s'", raw_text, text) voice_name: Optional[str] = None From 8c4a108adb5ed413bc5cf0628e8898b220897cfd Mon Sep 17 00:00:00 2001 From: Andrea Santoro Date: Thu, 4 Dec 2025 08:09:30 +0100 Subject: [PATCH 2/4] fix example sentences --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d8c741f..1e4ece8 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ To avoid this, one can pre-generate sentences with bigger models or using paid s Static sentences _do not_ prevent Piper text generation: sentences which are not mapped (see the following sections) will be generated by Piper as usual. -For example, suppose you have a device called "lamp". You could generate a "turn on lamp" sentence and have Piper forward that instead of generating the sentence on the fly. +For example, suppose you have a device called "lamp". You could generate a "turned on lamp" sentence and have Piper forward that instead of generating the sentence on the fly. To accomplish this: - enable static sentences (using `--use-static-sentences`) @@ -75,7 +75,7 @@ Suppose you have the following folder structure: ├── static-sentences.json ├── on/ | ├── lamp.wav - ├── ... + | ├── ... | └── heating.wav └── off/ ├── lamp.wav @@ -86,8 +86,8 @@ Suppose you have the following folder structure: Then a valid _static-sentences.json_ could be as follows: ```json { - "turn on lamp": "on/lamp.wav", - "turn off heating": "off/heating.wav" + "turned on lamp": "on/lamp.wav", + "turned off heating": "off/heating.wav" } ``` From ebbbb540335c53b9b0ff25fdd429367bb5457908 Mon Sep 17 00:00:00 2001 From: andreasntr Date: Fri, 5 Dec 2025 16:36:23 +0000 Subject: [PATCH 3/4] mention HA cache strategy --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 1e4ece8..98bf082 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,9 @@ __Important__: Keys are preprocessed the same way as the text to be synthetised, ### Docker Use the same parameters and mount the static sentences dir as a volume. +### HomeAssistant +HomeAssistant caches TTS output audios. If you've used Assist previously, make sure you delete files in the audio cache folder: _path/to/homeassistant/config/tts_ . + ### Example static-sentences.json Suppose you have the following folder structure: From dbf01d6b4912e529bd26a1acfcfc5c0ea7ab4026 Mon Sep 17 00:00:00 2001 From: andreasntr Date: Fri, 5 Dec 2025 17:23:40 +0000 Subject: [PATCH 4/4] improve tts clear cache reference --- README.md | 4 +++- assets/ha_clear_tts_cache.png | Bin 0 -> 20692 bytes 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 assets/ha_clear_tts_cache.png diff --git a/README.md b/README.md index 98bf082..bdaef03 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,9 @@ __Important__: Keys are preprocessed the same way as the text to be synthetised, Use the same parameters and mount the static sentences dir as a volume. ### HomeAssistant -HomeAssistant caches TTS output audios. If you've used Assist previously, make sure you delete files in the audio cache folder: _path/to/homeassistant/config/tts_ . +HomeAssistant caches TTS output audios. If you've used Assist previously, make sure you clear TTS cache via _Developer Tools_: + +![Clear TTS cache](assets/ha_clear_tts_cache.png) ### Example static-sentences.json Suppose you have the following folder structure: diff --git a/assets/ha_clear_tts_cache.png b/assets/ha_clear_tts_cache.png new file mode 100644 index 0000000000000000000000000000000000000000..c52ad9e6b46d5b65f056fdfd8c1e7634351cc608 GIT binary patch literal 20692 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