diff --git a/CODEOWNERS b/CODEOWNERS
index 3fc7762cb3c..ee8e72a383e 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -432,6 +432,7 @@
/bundles/org.openhab.voice.marytts/ @kaikreuzer
/bundles/org.openhab.voice.mimictts/ @dalgwen
/bundles/org.openhab.voice.picotts/ @FlorianSW
+/bundles/org.openhab.voice.pipertts/ @GiviMAD
/bundles/org.openhab.voice.pollytts/ @openhab/add-ons-maintainers
/bundles/org.openhab.voice.rustpotterks/ @GiviMAD
/bundles/org.openhab.voice.voicerss/ @lolodomo
diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index 49192aef099..6800b947e6a 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -2146,6 +2146,11 @@
org.openhab.voice.picotts
${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.voice.pipertts
+ ${project.version}
+
org.openhab.addons.bundles
org.openhab.voice.pollytts
diff --git a/bundles/org.openhab.voice.pipertts/NOTICE b/bundles/org.openhab.voice.pipertts/NOTICE
new file mode 100644
index 00000000000..e60895ed576
--- /dev/null
+++ b/bundles/org.openhab.voice.pipertts/NOTICE
@@ -0,0 +1,40 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
+
+== Third-party Content
+
+io.github.givimad: piper-jni
+* License: Apache 2.0 License
+* Project: https://github.com/GiviMAD/piper-jni
+* Source: https://github.com/GiviMAD/piper-jni
+
+io.github.rhasspy: piper
+* License: MIT License
+* Project: https://github.com/rhasspy/piper
+* Source: https://github.com/rhasspy/piper
+
+io.github.rhasspy: espeak-ng
+* License: GPL version 3, 2-clause BSD
+* Project: https://github.com/rhasspy/espeak-ng
+* Source: https://github.com/rhasspy/espeak-ng
+
+io.github.rhasspy: piper-phonemize
+* License: MIT License
+* Project: https://github.com/rhasspy/piper-phonemize
+* Source: https://github.com/rhasspy/piper-phonemize
+
+io.github.microsoft: onnxruntime
+* License: MIT License
+* Project: https://github.com/microsoft/onnxruntime
+* Source: https://github.com/microsoft/onnxruntime
\ No newline at end of file
diff --git a/bundles/org.openhab.voice.pipertts/README.md b/bundles/org.openhab.voice.pipertts/README.md
new file mode 100644
index 00000000000..f120900bb1c
--- /dev/null
+++ b/bundles/org.openhab.voice.pipertts/README.md
@@ -0,0 +1,59 @@
+# Piper Text-to-Speech
+
+This voice service allows you to use the open source library [Piper](https://github.com/rhasspy/piper) as your TTS service in openHAB.
+[Piper](https://github.com/rhasspy/piper) is a fast, local neural text to speech system that sounds great and is optimized for the Raspberry Pi 4.
+
+## Supported platforms
+
+The add-on is compatible with the following platforms:
+
+* linux (armv7l, aarch64, x86_64, min GLIBC version 2.31)
+* macOS (x86_64 min version 11.0, aarch64 min version 13.0)
+* win64 (x86_64 min version Windows 10).
+
+## Configuration
+
+### Downloading Voice Model Files
+
+You can find the link to the available voices at the [Piper README](https://github.com/rhasspy/piper).
+
+Each voice model is composed of two files an onnx runtime model file with extension '.onnx' and a model config file with extension '.onnx.json'.
+For the add-on to load your voices you need both to be named equal (obviously excluding their extensions).
+
+You should place both voice files at '/piper/'.
+After that the UI should display your available voices at 'Settings / System Settings / Voice'.
+
+### Multi Speaker Voices
+
+Models that support multiples speakers are shown as multiple voices in openHAB.
+
+### Text to Speech Configuration
+
+Use your favorite configuration UI to edit **Settings / Other Services - Piper Text-to-Speech**:
+
+* **Preload model** - Keep last voice model used loaded in memory, these way it can be reused on next execution if the voice option matches.
+
+### Configuration via a text file
+
+In case you would like to setup the service via a text file, create a new file in `$OPENHAB_ROOT/conf/services` named `pipertts.cfg`
+
+Its contents should look similar to:
+
+```text
+org.openhab.voice.pipertts:preloadModel=true
+```
+
+### Default Text-to-Speech Configuration
+
+You can setup your preferred default Speech-to-Text in the UI:
+
+* Go to **Settings**.
+* Edit **System Services - Voice**.
+* Set **Piper** as **Text-to-Speech**.
+* Set your **Default Voice**.
+
+In case you would like to set up these settings via a text file, you can edit the file `runtime.cfg` in `$OPENHAB_ROOT/conf/services` and set the following entries:
+
+```text
+org.openhab.voice:defaultTTS=pipertts
+```
diff --git a/bundles/org.openhab.voice.pipertts/pom.xml b/bundles/org.openhab.voice.pipertts/pom.xml
new file mode 100644
index 00000000000..c253ad5af9f
--- /dev/null
+++ b/bundles/org.openhab.voice.pipertts/pom.xml
@@ -0,0 +1,24 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 4.2.0-SNAPSHOT
+
+
+ org.openhab.voice.pipertts
+
+ openHAB Add-ons :: Bundles :: PiperTTS Binding
+
+
+
+ io.github.givimad
+ piper-jni
+ 1.2.0-e5cb84c
+
+
+
diff --git a/bundles/org.openhab.voice.pipertts/src/main/feature/feature.xml b/bundles/org.openhab.voice.pipertts/src/main/feature/feature.xml
new file mode 100644
index 00000000000..244b4c9a432
--- /dev/null
+++ b/bundles/org.openhab.voice.pipertts/src/main/feature/feature.xml
@@ -0,0 +1,9 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ mvn:org.openhab.addons.bundles/org.openhab.voice.pipertts/${project.version}
+
+
diff --git a/bundles/org.openhab.voice.pipertts/src/main/java/org/openhab/voice/pipertts/internal/PiperTTSConfiguration.java b/bundles/org.openhab.voice.pipertts/src/main/java/org/openhab/voice/pipertts/internal/PiperTTSConfiguration.java
new file mode 100644
index 00000000000..3b8adeeea8e
--- /dev/null
+++ b/bundles/org.openhab.voice.pipertts/src/main/java/org/openhab/voice/pipertts/internal/PiperTTSConfiguration.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.voice.pipertts.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link PiperTTSConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Miguel Álvarez Díez - Initial contribution
+ */
+@NonNullByDefault
+public class PiperTTSConfiguration {
+ /**
+ * Keep last voice model used loaded in memory.
+ */
+ boolean preloadModel;
+}
diff --git a/bundles/org.openhab.voice.pipertts/src/main/java/org/openhab/voice/pipertts/internal/PiperTTSConstants.java b/bundles/org.openhab.voice.pipertts/src/main/java/org/openhab/voice/pipertts/internal/PiperTTSConstants.java
new file mode 100644
index 00000000000..12c6099796c
--- /dev/null
+++ b/bundles/org.openhab.voice.pipertts/src/main/java/org/openhab/voice/pipertts/internal/PiperTTSConstants.java
@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.voice.pipertts.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link PiperTTSConstants} class defines common constants, which are
+ * used across the whole service.
+ *
+ * @author Miguel Álvarez Díez - Initial contribution
+ */
+@NonNullByDefault
+public class PiperTTSConstants {
+ /**
+ * Service name
+ */
+ public static final String SERVICE_NAME = "Piper";
+ /**
+ * Service id
+ */
+ public static final String SERVICE_ID = "pipertts";
+ /**
+ * Service category
+ */
+ public static final String SERVICE_CATEGORY = "voice";
+ /**
+ * Service pid
+ */
+ public static final String SERVICE_PID = "org.openhab." + SERVICE_CATEGORY + "." + SERVICE_ID;
+}
diff --git a/bundles/org.openhab.voice.pipertts/src/main/java/org/openhab/voice/pipertts/internal/PiperTTSService.java b/bundles/org.openhab.voice.pipertts/src/main/java/org/openhab/voice/pipertts/internal/PiperTTSService.java
new file mode 100644
index 00000000000..04e73cd3565
--- /dev/null
+++ b/bundles/org.openhab.voice.pipertts/src/main/java/org/openhab/voice/pipertts/internal/PiperTTSService.java
@@ -0,0 +1,418 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.voice.pipertts.internal;
+
+import static org.openhab.voice.pipertts.internal.PiperTTSConstants.SERVICE_CATEGORY;
+import static org.openhab.voice.pipertts.internal.PiperTTSConstants.SERVICE_ID;
+import static org.openhab.voice.pipertts.internal.PiperTTSConstants.SERVICE_NAME;
+import static org.openhab.voice.pipertts.internal.PiperTTSConstants.SERVICE_PID;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+import javax.sound.sampled.AudioFileFormat;
+import javax.sound.sampled.AudioInputStream;
+import javax.sound.sampled.AudioSystem;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.OpenHAB;
+import org.openhab.core.audio.AudioFormat;
+import org.openhab.core.audio.AudioStream;
+import org.openhab.core.audio.ByteArrayAudioStream;
+import org.openhab.core.config.core.ConfigurableService;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.voice.AbstractCachedTTSService;
+import org.openhab.core.voice.TTSCache;
+import org.openhab.core.voice.TTSException;
+import org.openhab.core.voice.TTSService;
+import org.openhab.core.voice.Voice;
+import org.osgi.framework.Constants;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.component.annotations.Modified;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import io.github.givimad.piperjni.PiperJNI;
+import io.github.givimad.piperjni.PiperVoice;
+
+/**
+ * The {@link PiperTTSService} class is a service implementation to use Piper for Text-to-Speech.
+ *
+ * @author Miguel Álvarez - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = TTSService.class, configurationPid = SERVICE_PID, property = Constants.SERVICE_PID + "="
+ + SERVICE_PID)
+@ConfigurableService(category = SERVICE_CATEGORY, label = SERVICE_NAME
+ + " Text-to-Speech", description_uri = SERVICE_CATEGORY + ":" + SERVICE_ID)
+public class PiperTTSService extends AbstractCachedTTSService {
+ private static final Path PIPER_FOLDER = Path.of(OpenHAB.getUserDataFolder(), "piper");
+ private final Logger logger = LoggerFactory.getLogger(PiperTTSService.class);
+ private final Object modelLock = new Object();
+ private PiperTTSConfiguration config = new PiperTTSConfiguration();
+ private @Nullable VoiceModel preloadedModel;
+ private @Nullable PiperJNI piper;
+ private Map> cachedVoicesByModel = new HashMap<>();
+
+ @Activate
+ public PiperTTSService(final @Reference TTSCache ttsCache) {
+ super(ttsCache);
+ }
+
+ @Activate
+ protected void activate(Map config) {
+ try {
+ piper = new PiperJNI();
+ piper.initialize(true, false);
+ logger.debug("Using Piper version {}", piper.getPiperVersion());
+ } catch (IOException e) {
+ logger.warn("Piper registration failed, the add-on will not work: {}", e.getMessage());
+ }
+ tryCreatePiperDirectory();
+ configChange(config);
+ }
+
+ @Modified
+ protected void modified(Map config) {
+ configChange(config);
+ }
+
+ @Deactivate
+ protected void deactivate(Map config) {
+ try {
+ unloadModel();
+ getPiper().close();
+ piper = null;
+ } catch (IOException e) {
+ logger.warn("Exception unloading model: {}", e.getMessage());
+ } catch (LibraryNotLoaded ignored) {
+ }
+ }
+
+ private void configChange(Map config) {
+ this.config = new Configuration(config).as(PiperTTSConfiguration.class);
+ try {
+ unloadModel();
+ } catch (IOException e) {
+ logger.warn("IOException unloading model: {}", e.getMessage());
+ }
+ }
+
+ private PiperJNI getPiper() throws LibraryNotLoaded {
+ PiperJNI piper = this.piper;
+ if (piper == null) {
+ throw new LibraryNotLoaded();
+ }
+ return piper;
+ }
+
+ private void tryCreatePiperDirectory() {
+ if (!Files.exists(PIPER_FOLDER)) {
+ try {
+ Files.createDirectory(PIPER_FOLDER);
+ logger.info("Piper directory created at: {}", PIPER_FOLDER);
+ } catch (IOException e) {
+ logger.warn("Unable to create piper directory at {}", PIPER_FOLDER);
+ }
+ }
+ }
+
+ @Override
+ public String getId() {
+ return SERVICE_ID;
+ }
+
+ @Override
+ public String getLabel(@Nullable Locale locale) {
+ return SERVICE_NAME;
+ }
+
+ @Override
+ public Set getAvailableVoices() {
+ try (var filesStream = Files.list(PIPER_FOLDER)) {
+ HashMap> newCachedVoices = new HashMap<>();
+ Set voices = filesStream //
+ .filter(filePath -> filePath.getFileName().toString().endsWith(".onnx")) //
+ .map(filePath -> {
+ List modelVoices = getVoice(filePath);
+ newCachedVoices.put(filePath.toString(), modelVoices);
+ return modelVoices;
+ }) //
+ .flatMap(List::stream) //
+ .collect(Collectors.toSet());
+ cachedVoicesByModel = newCachedVoices;
+ logger.debug("Available number of piper voices: {}", voices.size());
+ return voices;
+ } catch (IOException e) {
+ logger.warn("IOException getting piper voices: {}", e.getMessage());
+ }
+ return Set.of();
+ }
+
+ private List getVoice(Path modelPath) {
+ try {
+ Path configFile = modelPath.getParent().resolve(modelPath.getFileName() + ".json");
+ if (!Files.exists(configFile) || Files.isDirectory(configFile)) {
+ throw new IOException("Missed config file: " + configFile.toAbsolutePath());
+ }
+ List cachedVoices = cachedVoicesByModel.get(modelPath.toString());
+ if (cachedVoices != null) {
+ return cachedVoices;
+ }
+ String voiceData = Files.readString(configFile);
+ JsonNode voiceJsonRoot = new ObjectMapper().readTree(voiceData);
+ JsonNode datasetJsonNode = voiceJsonRoot.get("dataset");
+ JsonNode languageJsonNode = voiceJsonRoot.get("language");
+ JsonNode numSpeakersJsonNode = voiceJsonRoot.get("num_speakers");
+ if (datasetJsonNode == null || languageJsonNode == null) {
+ throw new IOException("Unknown voice config structure");
+ }
+ JsonNode languageFamilyJsonNode = languageJsonNode.get("family");
+ JsonNode languageRegionJsonNode = languageJsonNode.get("region");
+ if (languageFamilyJsonNode == null || languageRegionJsonNode == null) {
+ throw new IOException("Unknown voice config structure");
+ }
+ String voiceName = datasetJsonNode.textValue();
+ String voiceUID = voiceName.replace(" ", "_");
+ String languageFamily = languageFamilyJsonNode.textValue();
+ String languageRegion = languageRegionJsonNode.textValue();
+ int numSpeakers = numSpeakersJsonNode != null ? numSpeakersJsonNode.intValue() : 1;
+ JsonNode speakersIdsJsonNode = voiceJsonRoot.get("speaker_id_map");
+ if (numSpeakers != 1 && speakersIdsJsonNode != null) {
+ List voices = new ArrayList<>();
+ speakersIdsJsonNode.fieldNames().forEachRemaining(field -> {
+ JsonNode fieldNode = speakersIdsJsonNode.get(field);
+ voices.add(new PiperTTSVoice( //
+ voiceUID + "_" + field, //
+ capitalize(voiceName + " " + field), //
+ languageFamily, //
+ languageRegion, //
+ modelPath, //
+ configFile, //
+ Optional.of(fieldNode.longValue())));
+ });
+ return voices;
+ }
+ return List.of(new PiperTTSVoice(voiceUID, capitalize(voiceName), languageFamily, languageRegion, modelPath,
+ configFile, Optional.empty()));
+ } catch (IOException e) {
+ logger.warn("IOException reading voice info: {}", e.getMessage());
+ return List.of();
+ }
+ }
+
+ @Override
+ public Set getSupportedFormats() {
+ return Set.of(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, false, null, null, null,
+ null));
+ }
+
+ @Override
+ public AudioStream synthesizeForCache(String text, Voice voice, AudioFormat audioFormat) throws TTSException {
+ if (!(voice instanceof PiperTTSVoice ttsVoice)) {
+ throw new TTSException("No piper voice provided");
+ }
+ VoiceModel voiceModel = null;
+ boolean usingPreloadedModel = false;
+ short[] buffer;
+ final VoiceModel preloadedModel = this.preloadedModel;
+ try {
+ try {
+ if (preloadedModel != null && preloadedModel.ttsVoice.getUID().equals(ttsVoice.getUID())) {
+ logger.debug("Using preloaded voice model");
+ preloadedModel.consumers.incrementAndGet();
+ voiceModel = preloadedModel;
+ usingPreloadedModel = true;
+ } else {
+ unloadModel();
+ logger.debug("Loading voice model...");
+ voiceModel = loadModel(ttsVoice);
+ synchronized (modelLock) {
+ usingPreloadedModel = voiceModel.equals(this.preloadedModel);
+ }
+ }
+ } catch (IOException e) {
+ throw new TTSException("Unable to load voice model: " + e.getMessage());
+ }
+ try {
+ logger.debug("Generating audio for: '{}'", text);
+ buffer = getPiper().textToAudio(voiceModel.piperVoice, text);
+ logger.debug("Generated {} samples of audio", buffer.length);
+ } catch (IOException e) {
+ throw new TTSException("Voice generation failed: " + e.getMessage());
+ }
+ } catch (PiperJNI.NotInitialized | LibraryNotLoaded e) {
+ throw new TTSException("Piper not initialized, try restarting the add-on.");
+ } catch (RuntimeException e) {
+ logger.warn("RuntimeException running text to audio: {}", e.getMessage());
+ throw new TTSException("There was an error running Piper");
+ } finally {
+ if (voiceModel != null) {
+ if (!usingPreloadedModel
+ || voiceModel.consumers.decrementAndGet() == 0 && !voiceModel.equals(this.preloadedModel)) {
+ logger.debug("Unloading voice model");
+ voiceModel.close();
+ } else {
+ logger.debug("Skipping voice model unload");
+ }
+ }
+ }
+ try {
+ logger.debug("Return re-encoded audio stream");
+ return getAudioStream(buffer, voiceModel.sampleRate, audioFormat);
+ } catch (IOException e) {
+ throw new TTSException("Error while creating audio stream: " + e.getMessage());
+ }
+ }
+
+ private VoiceModel loadModel(PiperTTSVoice voice) throws IOException, PiperJNI.NotInitialized, LibraryNotLoaded {
+ if (!Files.exists(voice.voiceModelPath()) || !Files.exists(voice.voiceModelConfigPath())) {
+ throw new IOException("Missing voice files");
+ }
+ PiperJNI piper = getPiper();
+ PiperVoice piperVoice;
+ VoiceModel voiceModel;
+ piperVoice = piper.loadVoice(voice.voiceModelPath(), voice.voiceModelConfigPath(), voice.speakerId.orElse(-1L));
+ voiceModel = new VoiceModel(voice, piperVoice, piperVoice.getSampleRate(), new AtomicInteger(1), logger);
+ if (config.preloadModel) {
+ synchronized (modelLock) {
+ if (preloadedModel == null) {
+ logger.debug("Voice model will be kept preloaded");
+ preloadedModel = voiceModel;
+ } else {
+ logger.debug("Another voice model already preloaded");
+ }
+ }
+ }
+ return voiceModel;
+ }
+
+ private void unloadModel() throws IOException {
+ var model = preloadedModel;
+ if (model != null) {
+ synchronized (modelLock) {
+ preloadedModel = null;
+ if (model.consumers.get() == 0) {
+ // Do not release the model memory if it's been used, it should be released by the consumer
+ // when there is no other consumers and is not a ref of the preloaded model object.
+ logger.debug("Unloading preloaded model");
+ model.close();
+ } else {
+ logger.debug("Preloaded model in use, skip memory release");
+ }
+ }
+ }
+ }
+
+ private ByteArrayAudioStream getAudioStream(short[] samples, long sampleRate, AudioFormat targetFormat)
+ throws IOException {
+ // Convert the i16 samples returned by piper to a byte buffer
+ ByteBuffer byteBuffer;
+ int numSamples = samples.length;
+ byteBuffer = ByteBuffer.allocate(numSamples * 2).order(ByteOrder.LITTLE_ENDIAN);
+ for (var sample : samples) {
+ byteBuffer.putShort(sample);
+ }
+ // Initialize a Java audio stream using the Piper output format with the byte buffer created.
+ byte[] bytes = byteBuffer.array();
+ javax.sound.sampled.AudioFormat jAudioFormat = new javax.sound.sampled.AudioFormat(sampleRate, 16, 1, true,
+ false);
+ long audioLength = (long) Math.ceil(((double) bytes.length) / jAudioFormat.getFrameSize());
+ AudioInputStream audioInputStreamTemp = new AudioInputStream(new ByteArrayInputStream(bytes), jAudioFormat,
+ audioLength);
+ // Move the audio data to another Java audio stream in the target format so the Java AudioSystem encoded it as
+ // needed.
+ javax.sound.sampled.AudioFormat jTargetFormat = new javax.sound.sampled.AudioFormat(
+ Objects.requireNonNull(targetFormat.getFrequency()), Objects.requireNonNull(targetFormat.getBitDepth()),
+ Objects.requireNonNull(targetFormat.getChannels()), true, false);
+ AudioInputStream convertedInputStream = AudioSystem.getAudioInputStream(jTargetFormat, audioInputStreamTemp);
+ // It's required to add the wav header to the byte array stream returned for it to work with all the sink
+ // implementations.
+ // It can not be done with the AudioInputStream returned by AudioSystem::getAudioInputStream because it missed
+ // the length property.
+ // Therefore, the following method creates another AudioInputStream instance and uses the Java AudioSystem to
+ // prepend
+ // the wav header bytes,
+ // and finally initializes an OpenHAB audio stream.
+ return getAudioStreamWithRIFFHeader(convertedInputStream.readAllBytes(), jTargetFormat, targetFormat);
+ }
+
+ private String capitalize(String text) {
+ return text.substring(0, 1).toUpperCase() + text.substring(1);
+ }
+
+ private ByteArrayAudioStream getAudioStreamWithRIFFHeader(byte[] audioBytes,
+ javax.sound.sampled.AudioFormat jAudioFormat, AudioFormat audioFormat) throws IOException {
+ AudioInputStream audioInputStreamTemp = new AudioInputStream(new ByteArrayInputStream(audioBytes), jAudioFormat,
+ (long) Math.ceil(((double) audioBytes.length) / jAudioFormat.getFrameSize()));
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ AudioSystem.write(audioInputStreamTemp, AudioFileFormat.Type.WAVE, outputStream);
+ return new ByteArrayAudioStream(outputStream.toByteArray(), audioFormat);
+ }
+
+ private record PiperTTSVoice(String voiceId, String voiceName, String languageFamily, String languageRegion,
+ Path voiceModelPath, Path voiceModelConfigPath, Optional speakerId) implements Voice {
+ @Override
+ public String getUID() {
+ // Voice uid should be prefixed by service id to be listed properly on the UI.
+ return SERVICE_ID + ":" + voiceId + "-" + languageFamily + "_" + languageRegion;
+ }
+
+ @Override
+ public String getLabel() {
+ return voiceName;
+ }
+
+ @Override
+ public Locale getLocale() {
+ return new Locale(languageFamily, languageRegion);
+ }
+ }
+
+ private static class LibraryNotLoaded extends Exception {
+ private LibraryNotLoaded() {
+ super("Library not loaded");
+ }
+ }
+
+ private record VoiceModel(PiperTTSVoice ttsVoice, PiperVoice piperVoice, int sampleRate, AtomicInteger consumers,
+ Logger logger) implements AutoCloseable {
+
+ @Override
+ public void close() {
+ piperVoice.close();
+ }
+ }
+}
diff --git a/bundles/org.openhab.voice.pipertts/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.voice.pipertts/src/main/resources/OH-INF/addon/addon.xml
new file mode 100644
index 00000000000..b3637689848
--- /dev/null
+++ b/bundles/org.openhab.voice.pipertts/src/main/resources/OH-INF/addon/addon.xml
@@ -0,0 +1,15 @@
+
+
+
+ voice
+ Piper Text-to-Speech
+ This voice service allows using the open source project Piper as your TTS service in openHAB.
+ none
+
+ org.openhab.voice.pipertts
+
+
+
+
diff --git a/bundles/org.openhab.voice.pipertts/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.voice.pipertts/src/main/resources/OH-INF/config/config.xml
new file mode 100644
index 00000000000..17ac539913b
--- /dev/null
+++ b/bundles/org.openhab.voice.pipertts/src/main/resources/OH-INF/config/config.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+ Keep the last voice model loaded. If the parameter is set to true, the model will be reloaded only when
+ using a different voice.
+
+ false
+
+
+
+
diff --git a/bundles/org.openhab.voice.pipertts/src/main/resources/OH-INF/i18n/pipertts.properties b/bundles/org.openhab.voice.pipertts/src/main/resources/OH-INF/i18n/pipertts.properties
new file mode 100644
index 00000000000..436b1fa042a
--- /dev/null
+++ b/bundles/org.openhab.voice.pipertts/src/main/resources/OH-INF/i18n/pipertts.properties
@@ -0,0 +1,7 @@
+# add-on
+
+addon.pipertts.name = Piper Text-to-Speech
+addon.pipertts.description = This voice service allows using the open source project Piper as your TTS service in openHAB.
+
+voice.config.pipertts.preloadModel.label = Preload Model
+voice.config.pipertts.preloadModel.description = Keep the last voice model loaded. If the parameter is set to true, the model will be reloaded only when using a different voice.
diff --git a/bundles/pom.xml b/bundles/pom.xml
index 5299fc25078..ff2c07d9a2a 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -450,6 +450,7 @@
org.openhab.voice.marytts
org.openhab.voice.mimictts
org.openhab.voice.picotts
+ org.openhab.voice.pipertts
org.openhab.voice.pollytts
org.openhab.voice.rustpotterks
org.openhab.voice.voicerss