[pipertts] Add quality to UID/label & Code improvements (#20201)

* [pipertts] Add quality to UID and label
This allows to distinct between different qualities of a model.

Signed-off-by: Florian Hotze <dev@florianhotze.com>
This commit is contained in:
Florian Hotze
2026-02-10 22:21:32 +01:00
committed by GitHub
parent 95725562fe
commit 23a0b76d23
2 changed files with 85 additions and 31 deletions
@@ -21,6 +21,7 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.Serial;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
@@ -69,7 +70,6 @@ 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;
@@ -117,7 +117,7 @@ public class PiperTTSService extends AbstractCachedTTSService {
activateTask = executor.submit(() -> {
try {
setupNativeDependencies();
piper = new PiperJNI();
PiperJNI piper = this.piper = new PiperJNI();
piper.initialize(true, false);
logger.debug("Using Piper version {}", piper.getPiperVersion());
ready = true;
@@ -130,6 +130,7 @@ public class PiperTTSService extends AbstractCachedTTSService {
@Deactivate
private void deactivate() {
Future<?> activateTask = this.activateTask;
if (activateTask != null && !activateTask.isDone()) {
activateTask.cancel(true);
}
@@ -284,41 +285,37 @@ public class PiperTTSService extends AbstractCachedTTSService {
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();
PiperVoiceConfig voiceConfig = new ObjectMapper().readValue(voiceData, PiperVoiceConfig.class);
String voiceName = voiceConfig.dataset;
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) {
String quality = voiceConfig.audio.quality;
String languageFamily = voiceConfig.language.family;
String languageRegion = voiceConfig.language.region;
int numSpeakers = voiceConfig.numSpeakers;
Map<String, Integer> speakerIdMap = voiceConfig.speakerIdMap;
if (voiceName.isBlank() || quality.isBlank() || languageFamily.isBlank() || languageRegion.isBlank()) {
throw new IOException("Unknown voice config structure");
}
if (numSpeakers != 1 && speakerIdMap != null) {
List<Voice> voices = new ArrayList<>();
speakersIdsJsonNode.fieldNames().forEachRemaining(field -> {
JsonNode fieldNode = speakersIdsJsonNode.get(field);
speakerIdMap.forEach((field, id) -> {
voices.add(new PiperTTSVoice( //
voiceUID + "_" + field, //
capitalize(voiceName + " " + field), //
quality, //
languageFamily, //
languageRegion, //
modelPath, //
configFile, //
Optional.of(fieldNode.longValue())));
Optional.of(id.longValue())));
});
return voices;
}
return List.of(new PiperTTSVoice(voiceUID, capitalize(voiceName), languageFamily, languageRegion, modelPath,
configFile, Optional.empty()));
return List.of(new PiperTTSVoice(voiceUID, capitalize(voiceName), quality, languageFamily, languageRegion,
modelPath, configFile, Optional.empty()));
} catch (IOException e) {
logger.warn("IOException reading voice info: {}", e.getMessage());
return List.of();
@@ -342,6 +339,7 @@ public class PiperTTSService extends AbstractCachedTTSService {
VoiceModel voiceModel = null;
boolean usingPreloadedModel = false;
short[] buffer;
int sampleRate;
final VoiceModel preloadedModel = this.preloadedModel;
try {
try {
@@ -364,6 +362,7 @@ public class PiperTTSService extends AbstractCachedTTSService {
try {
logger.debug("Generating audio for: '{}'", text);
buffer = getPiper().textToAudio(voiceModel.piperVoice, text);
sampleRate = voiceModel.sampleRate;
logger.debug("Generated {} samples of audio", buffer.length);
} catch (IOException e) {
throw new TTSException("Voice generation failed: " + e.getMessage());
@@ -386,7 +385,7 @@ public class PiperTTSService extends AbstractCachedTTSService {
}
try {
logger.debug("Return re-encoded audio stream");
return getAudioStream(buffer, voiceModel.sampleRate, audioFormat);
return getAudioStream(buffer, sampleRate, audioFormat);
} catch (IOException e) {
throw new TTSException("Error while creating audio stream: " + e.getMessage());
}
@@ -477,26 +476,30 @@ public class PiperTTSService extends AbstractCachedTTSService {
return new ByteArrayAudioStream(outputStream.toByteArray(), audioFormat);
}
private record PiperTTSVoice(String voiceId, String voiceName, String languageFamily, String languageRegion,
Path voiceModelPath, Path voiceModelConfigPath, Optional<Long> speakerId) implements Voice {
private record PiperTTSVoice(String voiceId, String voiceName, String quality, String languageFamily,
String languageRegion, Path voiceModelPath, Path voiceModelConfigPath,
Optional<Long> 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;
return SERVICE_ID + ":" + voiceId + "-" + quality + "-" + languageFamily + "_" + languageRegion;
}
@Override
public String getLabel() {
return voiceName;
return voiceName + " (" + quality + ")";
}
@Override
public Locale getLocale() {
return new Locale(languageFamily, languageRegion);
return Locale.of(languageFamily, languageRegion);
}
}
private static class LibraryNotLoaded extends Exception {
@Serial
private static final long serialVersionUID = 538222631776595180L;
private LibraryNotLoaded() {
super("Library not loaded");
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2010-2026 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 java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* The {@link PiperVoiceConfig} class is a Data Transfer Object for Piper voice configuration from .onnx.json.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
@JsonIgnoreProperties(ignoreUnknown = true)
class PiperVoiceConfig {
public String dataset = "";
public Audio audio = new Audio();
public Language language = new Language();
@JsonProperty("num_speakers")
public int numSpeakers = 1;
@JsonProperty("speaker_id_map")
public @Nullable Map<String, Integer> speakerIdMap;
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Audio {
public String quality = "";
}
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Language {
public String family = "";
public String region = "";
}
}