2 * Copyright (c) 2010-2022 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.voice.porcupineks.internal;
15 import static org.openhab.voice.porcupineks.internal.PorcupineKSConstants.SERVICE_CATEGORY;
16 import static org.openhab.voice.porcupineks.internal.PorcupineKSConstants.SERVICE_ID;
17 import static org.openhab.voice.porcupineks.internal.PorcupineKSConstants.SERVICE_NAME;
18 import static org.openhab.voice.porcupineks.internal.PorcupineKSConstants.SERVICE_PID;
20 import java.io.BufferedInputStream;
21 import java.io.BufferedOutputStream;
23 import java.io.FileOutputStream;
24 import java.io.IOException;
25 import java.io.InputStream;
26 import java.io.OutputStream;
28 import java.nio.ByteBuffer;
29 import java.nio.ByteOrder;
30 import java.nio.file.Files;
31 import java.nio.file.Path;
32 import java.util.Locale;
35 import java.util.concurrent.Future;
36 import java.util.concurrent.ScheduledExecutorService;
37 import java.util.logging.Level;
39 import org.eclipse.jdt.annotation.NonNullByDefault;
40 import org.eclipse.jdt.annotation.Nullable;
41 import org.openhab.core.OpenHAB;
42 import org.openhab.core.audio.AudioFormat;
43 import org.openhab.core.audio.AudioStream;
44 import org.openhab.core.common.ThreadPoolManager;
45 import org.openhab.core.config.core.ConfigurableService;
46 import org.openhab.core.config.core.Configuration;
47 import org.openhab.core.voice.KSErrorEvent;
48 import org.openhab.core.voice.KSException;
49 import org.openhab.core.voice.KSListener;
50 import org.openhab.core.voice.KSService;
51 import org.openhab.core.voice.KSServiceHandle;
52 import org.openhab.core.voice.KSpottedEvent;
53 import org.osgi.framework.BundleContext;
54 import org.osgi.framework.Constants;
55 import org.osgi.service.component.ComponentContext;
56 import org.osgi.service.component.annotations.Activate;
57 import org.osgi.service.component.annotations.Component;
58 import org.osgi.service.component.annotations.Modified;
59 import org.slf4j.Logger;
60 import org.slf4j.LoggerFactory;
62 import ai.picovoice.porcupine.Porcupine;
63 import ai.picovoice.porcupine.PorcupineException;
66 * The {@link PorcupineKSService} is a keyword spotting implementation based on porcupine.
68 * @author Miguel Álvarez - Initial contribution
71 @Component(configurationPid = SERVICE_PID, property = Constants.SERVICE_PID + "=" + SERVICE_PID)
72 @ConfigurableService(category = SERVICE_CATEGORY, label = SERVICE_NAME
73 + " Keyword Spotter", description_uri = SERVICE_CATEGORY + ":" + SERVICE_ID)
74 public class PorcupineKSService implements KSService {
75 private static final String PORCUPINE_FOLDER = Path.of(OpenHAB.getUserDataFolder(), "porcupine").toString();
76 private static final String EXTRACTION_FOLDER = Path.of(OpenHAB.getUserDataFolder(), "porcupine", "extracted")
78 private final Logger logger = LoggerFactory.getLogger(PorcupineKSService.class);
79 private final ScheduledExecutorService executor = ThreadPoolManager.getScheduledPool("OH-voice-porcupineks");
80 private PorcupineKSConfiguration config = new PorcupineKSConfiguration();
81 private boolean loop = false;
82 private @Nullable BundleContext bundleContext;
85 Logger logger = LoggerFactory.getLogger(PorcupineKSService.class);
86 File directory = new File(PORCUPINE_FOLDER);
87 if (!directory.exists()) {
88 if (directory.mkdir()) {
89 logger.info("porcupine dir created {}", PORCUPINE_FOLDER);
92 File childDirectory = new File(EXTRACTION_FOLDER);
93 if (!childDirectory.exists()) {
94 if (childDirectory.mkdir()) {
95 logger.info("porcupine extraction file dir created {}", EXTRACTION_FOLDER);
101 protected void activate(ComponentContext componentContext, Map<String, Object> config) {
102 this.bundleContext = componentContext.getBundleContext();
107 protected void modified(Map<String, Object> config) {
108 this.config = new Configuration(config).as(PorcupineKSConfiguration.class);
109 if (this.config.apiKey.isBlank()) {
110 logger.warn("Missing pico voice api key to use Porcupine Keyword Spotter");
114 private String prepareLib(BundleContext bundleContext, String path) throws IOException {
115 if (!path.contains("porcupine" + File.separator)) {
116 // this should never happen
117 throw new IOException("Path is not pointing to porcupine bundle files " + path);
119 // get a path relative to the porcupine bundle folder
121 if (path.startsWith("porcupine" + File.separator)) {
124 relativePath = path.substring(path.lastIndexOf(File.separator + "porcupine" + File.separator) + 1);
126 File localFile = new File(EXTRACTION_FOLDER,
127 relativePath.substring(relativePath.lastIndexOf(File.separator) + 1));
128 if (!localFile.exists()) {
129 if (File.separator.equals("\\")) {
130 // bundle requires unix path separator
131 logger.debug("use unix path separator");
132 relativePath = relativePath.replace("\\", "/");
134 URL porcupineResource = bundleContext.getBundle().getEntry(relativePath);
135 logger.debug("extracting binary {} from bundle to extraction folder", relativePath);
136 if (porcupineResource == null) {
137 throw new IOException("Missing bundle file: " + relativePath);
139 extractFromBundle(porcupineResource, localFile);
141 logger.debug("binary {} already extracted", relativePath);
143 return localFile.toString();
146 private void extractFromBundle(URL resourceUrl, File targetFile) throws IOException {
147 InputStream in = new BufferedInputStream(resourceUrl.openStream());
148 OutputStream out = new BufferedOutputStream(new FileOutputStream(targetFile));
149 byte[] buffer = new byte[1024];
151 while ((lengthRead = in.read(buffer)) > 0) {
152 out.write(buffer, 0, lengthRead);
160 public String getId() {
165 public String getLabel(@Nullable Locale locale) {
170 public Set<Locale> getSupportedLocales() {
171 return Set.of(Locale.ENGLISH, new Locale("es"), Locale.FRENCH, Locale.GERMAN);
175 public Set<AudioFormat> getSupportedFormats() {
177 .of(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, false, 16, null, 16000L));
181 public KSServiceHandle spot(KSListener ksListener, AudioStream audioStream, Locale locale, String keyword)
184 if (config.apiKey.isBlank()) {
185 throw new KSException("Missing pico voice api key");
187 BundleContext bundleContext = this.bundleContext;
188 if (bundleContext == null) {
189 throw new KSException("Missing bundle context");
192 porcupine = initPorcupine(bundleContext, locale, keyword);
193 } catch (PorcupineException | IOException e) {
194 throw new KSException(e);
196 Future<?> scheduledTask = executor.submit(() -> processInBackground(porcupine, ksListener, audioStream));
197 return new KSServiceHandle() {
199 public void abort() {
200 logger.debug("stopping service");
204 } catch (InterruptedException e) {
206 scheduledTask.cancel(true);
211 private Porcupine initPorcupine(BundleContext bundleContext, Locale locale, String keyword)
212 throws IOException, PorcupineException {
213 // Suppress library logs
214 java.util.logging.Logger globalJavaLogger = java.util.logging.Logger
215 .getLogger(java.util.logging.Logger.GLOBAL_LOGGER_NAME);
216 Level currentGlobalLogLevel = globalJavaLogger.getLevel();
217 globalJavaLogger.setLevel(java.util.logging.Level.OFF);
218 String bundleLibraryPath = Porcupine.LIBRARY_PATH;
219 if (bundleLibraryPath == null) {
220 throw new PorcupineException("Unsupported environment, ensure Porcupine is supported by your system");
222 String libraryPath = prepareLib(bundleContext, bundleLibraryPath);
223 String alternativeModelPath = getAlternativeModelPath(bundleContext, locale);
224 String modelPath = alternativeModelPath != null ? alternativeModelPath
225 : prepareLib(bundleContext, Porcupine.MODEL_PATH);
226 String keywordPath = getKeywordResourcePath(bundleContext, keyword, alternativeModelPath == null);
227 logger.debug("Porcupine library path: {}", libraryPath);
228 logger.debug("Porcupine model path: {}", modelPath);
229 logger.debug("Porcupine keyword path: {}", keywordPath);
230 logger.debug("Porcupine sensitivity: {}", config.sensitivity);
232 return new Porcupine(config.apiKey, libraryPath, modelPath, new String[] { keywordPath },
233 new float[] { config.sensitivity });
236 globalJavaLogger.setLevel(currentGlobalLogLevel);
240 private String getPorcupineEnv() {
241 // get porcupine env from resolved library path
242 String searchTerm = "lib" + File.separator + "java" + File.separator;
243 String env = Porcupine.LIBRARY_PATH.substring(Porcupine.LIBRARY_PATH.indexOf(searchTerm) + searchTerm.length());
244 env = env.substring(0, env.indexOf(File.separator));
248 private @Nullable String getAlternativeModelPath(BundleContext bundleContext, Locale locale) throws IOException {
249 String modelPath = null;
250 if (locale.getLanguage().equals(Locale.GERMAN.getLanguage())) {
251 Path dePath = Path.of(PORCUPINE_FOLDER, "porcupine_params_de.pv");
252 if (Files.exists(dePath)) {
253 modelPath = dePath.toString();
256 "You can provide a specific model for de language in {}, english language model will be used",
259 } else if (locale.getLanguage().equals(Locale.FRENCH.getLanguage())) {
260 Path frPath = Path.of(PORCUPINE_FOLDER, "porcupine_params_fr.pv");
261 if (Files.exists(frPath)) {
262 modelPath = frPath.toString();
265 "You can provide a specific model for fr language in {}, english language model will be used",
268 } else if (locale.getLanguage().equals("es")) {
269 Path esPath = Path.of(PORCUPINE_FOLDER, "porcupine_params_es.pv");
270 if (Files.exists(esPath)) {
271 modelPath = esPath.toString();
274 "You can provide a specific model for es language in {}, english language model will be used",
281 private String getKeywordResourcePath(BundleContext bundleContext, String keyWord, boolean allowBuildIn)
283 String localKeywordFile = keyWord.toLowerCase().replace(" ", "_") + ".ppn";
284 Path localKeywordPath = Path.of(PORCUPINE_FOLDER, localKeywordFile);
285 if (Files.exists(localKeywordPath)) {
286 return localKeywordPath.toString();
290 Porcupine.BuiltInKeyword.valueOf(keyWord.toUpperCase().replace(" ", "_"));
291 } catch (IllegalArgumentException e) {
292 throw new IllegalArgumentException(
293 "Unable to find model file for configured wake word neither is build-in. Should be at "
296 String env = getPorcupineEnv();
297 String keywordPath = Path
298 .of("porcupine", "resources", "keyword_files", env, keyWord.replace(" ", "_") + "_" + env + ".ppn")
300 return prepareLib(bundleContext, keywordPath);
302 throw new IllegalArgumentException(
303 "Unable to find model file for configured wake word; there are no build-in wake words for your language. Should be at "
308 private void processInBackground(Porcupine porcupine, KSListener ksListener, AudioStream audioStream) {
310 // buffers for processing audio
311 int frameLength = porcupine.getFrameLength();
312 ByteBuffer captureBuffer = ByteBuffer.allocate(frameLength * 2);
313 captureBuffer.order(ByteOrder.LITTLE_ENDIAN);
314 short[] porcupineBuffer = new short[frameLength];
318 // read a buffer of audio
319 numBytesRead = audioStream.read(captureBuffer.array(), 0, captureBuffer.capacity());
323 // don't pass to porcupine if we don't have a full buffer
324 if (numBytesRead != frameLength * 2) {
328 // copy into 16-bit buffer
329 captureBuffer.asShortBuffer().get(porcupineBuffer);
330 // process with porcupine
331 int result = porcupine.process(porcupineBuffer);
333 logger.debug("keyword detected!");
334 ksListener.ksEventReceived(new KSpottedEvent());
336 } catch (IOException | PorcupineException | InterruptedException e) {
337 String errorMessage = e.getMessage();
338 ksListener.ksEventReceived(new KSErrorEvent(errorMessage != null ? errorMessage : "Unexpected error"));
342 logger.debug("Porcupine stopped");