connections = new ArrayList<>(webSocketConnections);
+ for (var connection : connections) {
+ onClientDisconnected(connection);
+ connection.disconnect();
+ }
+ }
+}
diff --git a/bundles/org.openhab.core.io.websocket.audio/src/main/java/org/openhab/core/io/websocket/audio/internal/PCMWebSocketAudioSink.java b/bundles/org.openhab.core.io.websocket.audio/src/main/java/org/openhab/core/io/websocket/audio/internal/PCMWebSocketAudioSink.java
new file mode 100644
index 000000000..d38f075a2
--- /dev/null
+++ b/bundles/org.openhab.core.io.websocket.audio/src/main/java/org/openhab/core/io/websocket/audio/internal/PCMWebSocketAudioSink.java
@@ -0,0 +1,261 @@
+/*
+ * Copyright (c) 2010-2025 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.core.io.websocket.audio.internal;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.audio.*;
+import org.openhab.core.audio.utils.AudioWaveUtils;
+import org.openhab.core.library.types.PercentType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This is an {@link AudioSink} implementation connected to the {@link PCMWebSocketConnection} that allows to
+ * transmit concurrent PCM audio lines through WebSocket.
+ *
+ * To identify the different audio lines the data chucks are prefixed by a header added by the
+ * {@link PCMWebSocketOutputStream} class.
+ *
+ * @author Miguel Álvarez Díez - Initial contribution
+ */
+@NonNullByDefault
+public class PCMWebSocketAudioSink implements AudioSink {
+ /**
+ * Byte sent after the last chunk for a stream to indicate the stream has ended, so the client can dispose resources
+ * associated with the stream.
+ * It's sent on a finally clause that covers the audio transmission execution so it gets sent even if some
+ * exception interrupts the audio transmission.
+ */
+ private static final byte STREAM_TERMINATION_BYTE = (byte) 254;
+ private static final Set SUPPORTED_FORMATS = Set.of(AudioFormat.WAV, AudioFormat.PCM_SIGNED);
+ private static final Set> SUPPORTED_STREAMS = Set.of(FixedLengthAudioStream.class,
+ PipedAudioStream.class);
+
+ private final Logger logger = LoggerFactory.getLogger(PCMWebSocketAudioSink.class);
+
+ private final String sinkId;
+ private final String sinkLabel;
+ private final PCMWebSocketConnection websocket;
+ private PercentType sinkVolume = new PercentType(100);
+ @Nullable
+ private Integer forceSampleRate;
+ @Nullable
+ private Integer forceBitDepth;
+ @Nullable
+ private Integer forceChannels;
+
+ public PCMWebSocketAudioSink(String id, String label, PCMWebSocketConnection websocket,
+ @Nullable Integer forceSampleRate, @Nullable Integer forceBitDepth, @Nullable Integer forceChannels) {
+ this.sinkId = id;
+ this.sinkLabel = label;
+ this.websocket = websocket;
+ this.forceSampleRate = forceSampleRate;
+ this.forceBitDepth = forceBitDepth;
+ this.forceChannels = forceChannels;
+ }
+
+ @Override
+ public String getId() {
+ return this.sinkId;
+ }
+
+ @Override
+ public @Nullable String getLabel(@Nullable Locale locale) {
+ return this.sinkLabel;
+ }
+
+ @Override
+ public void process(@Nullable AudioStream audioStream)
+ throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
+ if (audioStream == null) {
+ return;
+ }
+ OutputStream outputStream = null;
+ try {
+ long duration = -1;
+ if (AudioFormat.CONTAINER_WAVE.equals(audioStream.getFormat().getContainer())) {
+ logger.debug("Removing wav container from data");
+ try {
+ AudioWaveUtils.removeFMT(audioStream);
+ } catch (IOException e) {
+ logger.warn("IOException trying to remove wav header: {}", e.getMessage());
+ }
+ }
+ var audioFormat = audioStream.getFormat();
+ if (audioStream instanceof SizeableAudioStream sizeableAudioStream) {
+ long byteLength = sizeableAudioStream.length();
+ long bytesPerSecond = (Objects.requireNonNull(audioFormat.getBitDepth()) / 8)
+ * Objects.requireNonNull(audioFormat.getFrequency())
+ * Objects.requireNonNull(audioFormat.getChannels());
+ float durationInSeconds = (float) byteLength / bytesPerSecond;
+ duration = Math.round(durationInSeconds * 1000);
+ logger.debug("Duration of input stream : {}", duration);
+ }
+ AtomicBoolean transferenceAborted = new AtomicBoolean(false);
+ if (audioStream instanceof PipedAudioStream pipedAudioStream) {
+ pipedAudioStream.onClose(() -> transferenceAborted.set(true));
+ }
+ int sampleRate = Objects.requireNonNull(audioFormat.getFrequency()).intValue();
+ int bitDepth = Objects.requireNonNull(audioFormat.getBitDepth());
+ int channels = Objects.requireNonNull(audioFormat.getChannels());
+ int targetSampleRate = Objects.requireNonNullElse(forceSampleRate, sampleRate);
+ Integer targetBitDepth = Objects.requireNonNullElse(forceBitDepth, bitDepth);
+ Integer targetChannels = Objects.requireNonNullElse(forceChannels, channels);
+ outputStream = new PCMWebSocketOutputStream(websocket, targetSampleRate, targetBitDepth.byteValue(),
+ targetChannels.byteValue());
+ InputStream finalAudioStream;
+ if ( //
+ (forceSampleRate != null && !forceSampleRate.equals(sampleRate)) || //
+ (forceBitDepth != null && !forceBitDepth.equals(bitDepth)) || //
+ (forceChannels != null && !forceChannels.equals(channels)) //
+ ) {
+ logger.debug("Sound is not in the target format. Trying to re-encode it");
+ finalAudioStream = PCMWebSocketAudioUtil.getPCMStreamNormalized(audioStream, sampleRate, bitDepth,
+ channels, targetSampleRate, targetBitDepth, targetChannels);
+ } else {
+ finalAudioStream = audioStream;
+ }
+ int bytesPer500ms = (targetSampleRate * (targetBitDepth / 8) * targetChannels) / 2;
+ transferAudio(finalAudioStream, outputStream, bytesPer500ms, duration, transferenceAborted);
+ } catch (InterruptedIOException ignored) {
+ } catch (IOException e) {
+ logger.warn("IOException: {}", e.getMessage());
+ } catch (InterruptedException e) {
+ logger.warn("InterruptedException: {}", e.getMessage());
+ } finally {
+ try {
+ audioStream.close();
+ } catch (IOException e) {
+ logger.warn("IOException: {}", e.getMessage(), e);
+ }
+ try {
+ if (outputStream != null) {
+ outputStream.close();
+ }
+ } catch (IOException e) {
+ logger.warn("IOException: {}", e.getMessage(), e);
+ }
+ }
+ }
+
+ private void transferAudio(InputStream inputStream, OutputStream outputStream, int chunkSize, long duration,
+ AtomicBoolean aborted) throws IOException, InterruptedException {
+ Instant start = Instant.now();
+ long transferred = 0;
+ try {
+ byte[] buffer = new byte[chunkSize];
+ int read;
+ while (!aborted.get() && (read = inputStream.read(buffer, 0, chunkSize)) >= 0) {
+ outputStream.write(buffer, 0, read);
+ transferred += read;
+ }
+ } finally {
+ try {
+ // send a byte indicating this stream has ended, so it can be tear down on the client
+ outputStream.write(new byte[] { STREAM_TERMINATION_BYTE }, 0, 1);
+ } catch (IOException e) {
+ logger.warn("Unable to send termination byte to sink {}", sinkId);
+ }
+ }
+ logger.debug("Sent {} bytes of audio", transferred);
+ if (duration != -1) {
+ Instant end = Instant.now();
+ long millisSecondTimedToSendAudioData = Duration.between(start, end).toMillis();
+ if (millisSecondTimedToSendAudioData < duration) {
+ long timeToSleep = duration - millisSecondTimedToSendAudioData;
+ logger.debug("Sleep time to let the system play sound : {}ms", timeToSleep);
+ Thread.sleep(timeToSleep);
+ }
+ }
+ }
+
+ @Override
+ public Set getSupportedFormats() {
+ return SUPPORTED_FORMATS;
+ }
+
+ @Override
+ public Set> getSupportedStreams() {
+ return SUPPORTED_STREAMS;
+ }
+
+ @Override
+ public PercentType getVolume() throws IOException {
+ return this.sinkVolume;
+ }
+
+ @Override
+ public void setVolume(PercentType percentType) throws IOException {
+ this.sinkVolume = percentType;
+ websocket.setSinkVolume(percentType.intValue());
+ }
+
+ /**
+ * This is an {@link OutputStream} implementation for writing binary data to the websocket that
+ * will prefix each chunk with a header composed of 8 bytes.
+ * Header: 2 bytes (stream id) + 4 byte (stream sample rate) + 1 byte (stream bit depth) + 1 byte (channels).
+ */
+ protected static class PCMWebSocketOutputStream extends OutputStream {
+ private final byte[] header;
+ private final PCMWebSocketConnection websocket;
+ private boolean closed = false;
+
+ public PCMWebSocketOutputStream(PCMWebSocketConnection websocket, int sampleRate, byte bitDepth,
+ byte channels) {
+ this.websocket = websocket;
+ this.header = PCMWebSocketStreamIdUtil.generateAudioPacketHeader(sampleRate, bitDepth, channels).array();
+ }
+
+ @Override
+ public void write(int b) throws IOException {
+ write(ByteBuffer.allocate(4).putInt(b).array());
+ }
+
+ @Override
+ public void write(byte @Nullable [] b) throws IOException {
+ if (closed) {
+ throw new IOException("Stream closed");
+ }
+ if (b != null) {
+ websocket.sendAudio(header, b);
+ }
+ }
+
+ @Override
+ public void write(byte @Nullable [] b, int off, int len) throws IOException {
+ if (b != null) {
+ write(Arrays.copyOfRange(b, off, off + len));
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ closed = true;
+ super.close();
+ }
+ }
+}
diff --git a/bundles/org.openhab.core.io.websocket.audio/src/main/java/org/openhab/core/io/websocket/audio/internal/PCMWebSocketAudioSource.java b/bundles/org.openhab.core.io.websocket.audio/src/main/java/org/openhab/core/io/websocket/audio/internal/PCMWebSocketAudioSource.java
new file mode 100644
index 000000000..d32aedbd4
--- /dev/null
+++ b/bundles/org.openhab.core.io.websocket.audio/src/main/java/org/openhab/core/io/websocket/audio/internal/PCMWebSocketAudioSource.java
@@ -0,0 +1,195 @@
+/*
+ * Copyright (c) 2010-2025 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.core.io.websocket.audio.internal;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.io.PipedInputStream;
+import java.io.PipedOutputStream;
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.audio.*;
+import org.openhab.core.common.ThreadPoolManager;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This is an {@link AudioSource} implementation connected to a {@link PCMWebSocketConnection} supporting
+ * a single PCM audio line through a WebSocket connection and shared across all active {@link AudioStream} instances.
+ *
+ * @author Miguel Álvarez Díez - Initial contribution
+ */
+@NonNullByDefault
+public class PCMWebSocketAudioSource implements AudioSource {
+ private static final int SUPPORTED_BIT_DEPTH = 16;
+ private static final int SUPPORTED_SAMPLE_RATE = 16000;
+ private static final int SUPPORTED_CHANNELS = 1;
+ public static AudioFormat supportedFormat = new AudioFormat(AudioFormat.CONTAINER_WAVE,
+ AudioFormat.CODEC_PCM_SIGNED, false, SUPPORTED_BIT_DEPTH, null, (long) SUPPORTED_SAMPLE_RATE,
+ SUPPORTED_CHANNELS);
+ private final Logger logger = LoggerFactory.getLogger(PCMWebSocketAudioSource.class);
+ private final String sourceId;
+ private final String sourceLabel;
+ private final PCMWebSocketConnection websocket;
+ private @Nullable PipedOutputStream sourceAudioPipedOutput;
+ private @Nullable PipedInputStream sourceAudioPipedInput;
+ private @Nullable InputStream sourceAudioStream;
+ private final PipedAudioStream.Group streamGroup = PipedAudioStream.newGroup(supportedFormat);
+ private @Nullable Future> sourceWriteTask;
+ private final ScheduledExecutorService scheduler = ThreadPoolManager.getScheduledPool("pcm-audio-source");
+ private byte @Nullable [] streamId;
+
+ public PCMWebSocketAudioSource(String id, String label, PCMWebSocketConnection websocket) {
+ this.sourceId = id;
+ this.sourceLabel = label;
+ this.websocket = websocket;
+ }
+
+ @Override
+ public String getId() {
+ return this.sourceId;
+ }
+
+ @Override
+ public String getLabel(@Nullable Locale locale) {
+ return this.sourceLabel;
+ }
+
+ @Override
+ public Set getSupportedFormats() {
+ return Set.of(supportedFormat);
+ }
+
+ @Override
+ public AudioStream getInputStream(AudioFormat audioFormat) throws AudioException {
+ try {
+ final PipedAudioStream stream = streamGroup.getAudioStreamInGroup();
+ synchronized (streamGroup) {
+ if (this.streamGroup.size() == 1) {
+ logger.debug("Send start listening {}", getId());
+ this.streamId = null;
+ websocket.setListening(true);
+ }
+ }
+ stream.onClose(this::onStreamClose);
+ return stream;
+ } catch (IOException e) {
+ throw new AudioException(e);
+ }
+ }
+
+ public void close() throws Exception {
+ streamGroup.close();
+ }
+
+ public void writeToStreams(byte[] id, int sampleRate, int bitDepth, int channels, byte[] payload) {
+ if (streamGroup.isEmpty()) {
+ logger.debug("Source already disposed, ignoring data");
+ return;
+ }
+ if (this.streamId == null) {
+ this.streamId = id;
+ } else if (!Arrays.equals(this.streamId, id)) {
+ logger.warn("Only one concurrent data line is supported, ignoring data from source stream {}", id);
+ return;
+ }
+ boolean needsConvert = sampleRate != SUPPORTED_SAMPLE_RATE || bitDepth != SUPPORTED_BIT_DEPTH
+ || channels != SUPPORTED_CHANNELS;
+ if (!needsConvert) {
+ streamGroup.write(payload);
+ return;
+ }
+ if (this.sourceAudioPipedOutput == null || this.sourceAudioStream == null) {
+ try {
+ this.sourceAudioPipedOutput = new PipedOutputStream();
+ var sourceAudioPipedInput = this.sourceAudioPipedInput = new PipedInputStream(
+ this.sourceAudioPipedOutput, (sampleRate * (bitDepth / 8) * channels) * 2);
+ logger.debug(
+ "Enabling converting pcm audio for the audio source stream: sample rate {}, bit depth {}, channels {} => sample rate {}, bit depth {}, channels {}",
+ sampleRate, bitDepth, channels, SUPPORTED_SAMPLE_RATE, SUPPORTED_BIT_DEPTH, SUPPORTED_CHANNELS);
+ this.sourceAudioStream = PCMWebSocketAudioUtil.getPCMStreamNormalized(sourceAudioPipedInput, sampleRate,
+ bitDepth, channels, SUPPORTED_SAMPLE_RATE, SUPPORTED_BIT_DEPTH, SUPPORTED_CHANNELS);
+ sourceWriteTask = scheduler.submit(() -> {
+ int bytesPer250ms = (SUPPORTED_SAMPLE_RATE * (SUPPORTED_BIT_DEPTH / 8) * SUPPORTED_CHANNELS) / 4;
+ while (true) {
+ byte[] convertedPayload;
+ try {
+ convertedPayload = this.sourceAudioStream.readNBytes(bytesPer250ms);
+ Thread.sleep(0);
+ } catch (InterruptedIOException | InterruptedException e) {
+ continue;
+ } catch (IOException e) {
+ if (e.getMessage().contains("Pipe closed")) {
+ return;
+ }
+ logger.error("Error reading converted audio data", e);
+ continue;
+ }
+ streamGroup.write(convertedPayload);
+ }
+ });
+ } catch (IOException e) {
+ logger.error("Unable to setup audio source stream", e);
+ return;
+ }
+ }
+ try {
+ this.sourceAudioPipedOutput.write(payload);
+ } catch (IOException e) {
+ logger.error("Error converting source audio format", e);
+ }
+ }
+
+ private void onStreamClose() {
+ logger.debug("Unregister source audio stream for '{}'", getId());
+ synchronized (streamGroup) {
+ if (streamGroup.isEmpty()) {
+ logger.debug("Send stop listening {}", getId());
+ websocket.setListening(false);
+ if (this.sourceWriteTask != null) {
+ this.sourceWriteTask.cancel(true);
+ this.sourceWriteTask = null;
+ }
+ if (this.sourceAudioStream != null) {
+ try {
+ this.sourceAudioStream.close();
+ } catch (IOException ignored) {
+ }
+ this.sourceAudioStream = null;
+ }
+ if (this.sourceAudioPipedOutput != null) {
+ try {
+ this.sourceAudioPipedOutput.close();
+ } catch (IOException ignored) {
+ }
+ this.sourceAudioPipedOutput = null;
+ }
+ if (this.sourceAudioPipedInput != null) {
+ try {
+ this.sourceAudioPipedInput.close();
+ } catch (IOException ignored) {
+ }
+ this.sourceAudioPipedInput = null;
+ }
+ this.streamId = null;
+ }
+ }
+ }
+}
diff --git a/bundles/org.openhab.core.io.websocket.audio/src/main/java/org/openhab/core/io/websocket/audio/internal/PCMWebSocketAudioUtil.java b/bundles/org.openhab.core.io.websocket.audio/src/main/java/org/openhab/core/io/websocket/audio/internal/PCMWebSocketAudioUtil.java
new file mode 100644
index 000000000..31f455f02
--- /dev/null
+++ b/bundles/org.openhab.core.io.websocket.audio/src/main/java/org/openhab/core/io/websocket/audio/internal/PCMWebSocketAudioUtil.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2010-2025 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.core.io.websocket.audio.internal;
+
+import java.io.InputStream;
+
+import javax.sound.sampled.AudioInputStream;
+import javax.sound.sampled.AudioSystem;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Audio utils.
+ *
+ * @author Miguel Álvarez Díez - Initial contribution
+ */
+@NonNullByDefault
+public final class PCMWebSocketAudioUtil {
+ private PCMWebSocketAudioUtil() {
+ }
+
+ /**
+ * Ensure right PCM format by converting if needed (sample rate, channel)
+ *
+ * @param sampleRate Stream sample rate
+ * @param stream PCM input stream
+ * @return A PCM normalized stream at the desired format
+ */
+ public static AudioInputStream getPCMStreamNormalized(InputStream stream, int sampleRate, int bitDepth,
+ int channels, int targetSampleRate, int targetBitDepth, int targetChannels) {
+ javax.sound.sampled.AudioFormat jFormat = new javax.sound.sampled.AudioFormat( //
+ (float) sampleRate, //
+ bitDepth, //
+ channels, //
+ true, //
+ false //
+ );
+ javax.sound.sampled.AudioFormat fixedJFormat = new javax.sound.sampled.AudioFormat( //
+ (float) targetSampleRate, //
+ targetBitDepth, //
+ targetChannels, //
+ true, //
+ false //
+ );
+ return AudioSystem.getAudioInputStream(fixedJFormat, new AudioInputStream(stream, jFormat, -1));
+ }
+}
diff --git a/bundles/org.openhab.core.io.websocket.audio/src/main/java/org/openhab/core/io/websocket/audio/internal/PCMWebSocketConnection.java b/bundles/org.openhab.core.io.websocket.audio/src/main/java/org/openhab/core/io/websocket/audio/internal/PCMWebSocketConnection.java
new file mode 100644
index 000000000..ee3436388
--- /dev/null
+++ b/bundles/org.openhab.core.io.websocket.audio/src/main/java/org/openhab/core/io/websocket/audio/internal/PCMWebSocketConnection.java
@@ -0,0 +1,375 @@
+/*
+ * Copyright (c) 2010-2025 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.core.io.websocket.audio.internal;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.HashMap;
+import java.util.Hashtable;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.websocket.api.RemoteEndpoint;
+import org.eclipse.jetty.websocket.api.Session;
+import org.eclipse.jetty.websocket.api.WebSocketListener;
+import org.eclipse.jetty.websocket.api.annotations.WebSocket;
+import org.openhab.core.audio.AudioSink;
+import org.openhab.core.audio.AudioSource;
+import org.osgi.framework.ServiceRegistration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * The {@link PCMWebSocketConnection} represents a WebSocket connection used to transmit PCM audio.
+ *
+ * The websocket uses the text protocol for send commands represented by {@link WebSocketCommand} and the binary
+ * protocol to transmit the audio data.
+ *
+ * The websocket supports only one line for the {@link PCMWebSocketAudioSource} (the audio is shared on the server),
+ * the data transmission is instructed by the server (START_LISTENING and STOP_LISTENING commands).
+ *
+ * The websocket supports multiple lines for the {@link PCMWebSocketAudioSink} (to accomplish that, the outgoing data
+ * chucks are prefixed with a 6 byte header to transmit the identity and format specification, check
+ * {@link PCMWebSocketAudioSink.PCMWebSocketOutputStream})
+ *
+ * @author Miguel Álvarez Díez - Initial contribution
+ */
+@WebSocket
+@NonNullByDefault
+@SuppressWarnings("unused")
+public class PCMWebSocketConnection implements WebSocketListener {
+ private final Logger logger = LoggerFactory.getLogger(PCMWebSocketConnection.class);
+ protected final Map> audioComponentRegistrations = new ConcurrentHashMap<>();
+ private volatile @Nullable Session session;
+ private @Nullable RemoteEndpoint remote;
+ private final PCMWebSocketAdapter wsAdapter;
+ private final ScheduledExecutorService executor;
+ private @Nullable ScheduledFuture> scheduledDisconnection;
+
+ private boolean initialized = false;
+ private @Nullable Runnable dialogTrigger = null;
+
+ private final ObjectMapper jsonMapper = new ObjectMapper();
+ private String id = "";
+ private @Nullable PCMWebSocketAudioSource audioSource = null;
+
+ public PCMWebSocketConnection(PCMWebSocketAdapter wsAdapter, ScheduledExecutorService executor) {
+ this.wsAdapter = wsAdapter;
+ this.executor = executor;
+ }
+
+ public void sendAudio(byte[] id, byte[] b) {
+ try {
+ var remote = getRemote();
+ if (remote != null) {
+ // concat stream identifier and send
+ ByteBuffer buff = ByteBuffer.wrap(new byte[id.length + b.length]);
+ buff.put(id);
+ buff.put(b);
+ remote.sendBytesByFuture(ByteBuffer.wrap(buff.array()));
+ }
+ } catch (IllegalStateException ignored) {
+ logger.warn("Unable to send audio buffer");
+ }
+ }
+
+ public void setListening(boolean listening) {
+ sendClientCommand(new WebSocketCommand(listening ? WebSocketCommand.OutputCommands.START_LISTENING
+ : WebSocketCommand.OutputCommands.STOP_LISTENING));
+ }
+
+ public void disconnect() {
+ var session = getSession();
+ if (session != null) {
+ try {
+ session.disconnect();
+ } catch (IOException ignored) {
+ }
+ }
+ }
+
+ @Override
+ public void onWebSocketConnect(@Nullable Session sess) {
+ if (sess == null) {
+ // never
+ return;
+ }
+ this.session = sess;
+ this.remote = sess.getRemote();
+ logger.debug("New client connected.");
+ scheduledDisconnection = executor.schedule(() -> {
+ try {
+ sess.disconnect();
+ } catch (IOException ignored) {
+ }
+ }, 5, TimeUnit.SECONDS);
+ }
+
+ private void sendClientCommand(T msg) {
+ var remote = getRemote();
+ if (remote != null) {
+ try {
+ remote.sendStringByFuture(new ObjectMapper().writeValueAsString(msg));
+ } catch (JsonProcessingException e) {
+ logger.warn("JsonProcessingException writing JSON message", e);
+ }
+ }
+ }
+
+ @Override
+ public void onWebSocketBinary(byte @Nullable [] payload, int offset, int len) {
+ logger.trace("Received binary data of length {}", len);
+ PCMWebSocketAudioSource audioSource = this.audioSource;
+ if (payload != null && audioSource != null) {
+ PCMWebSocketStreamIdUtil.AudioPacketData streamData;
+ try {
+ streamData = PCMWebSocketStreamIdUtil.parseAudioPacket(payload);
+ } catch (IOException e) {
+ logger.warn("Exception processing binary message: {}", e.getMessage());
+ return;
+ }
+ audioSource.writeToStreams(streamData.id(), streamData.sampleRate(), streamData.bitDepth(),
+ streamData.channels(), streamData.audioData());
+ }
+ }
+
+ @Override
+ public void onWebSocketText(@Nullable String message) {
+ try {
+ JsonNode rootMessageNode = jsonMapper.readTree(message);
+ if (rootMessageNode.has("cmd")) {
+ String cmd = rootMessageNode.get("cmd").asText().trim().toUpperCase();
+ try {
+ logger.debug("Handling msg '{}'", cmd);
+ var messageType = WebSocketCommand.InputCommands.valueOf(cmd);
+ switch (messageType) {
+ case INITIALIZE -> {
+ wsAdapter.onSpeakerConnected(this);
+ JsonNode argsNode = rootMessageNode.get("args");
+ var clientOptions = jsonMapper.treeToValue(argsNode, ConnectionOptions.class);
+ var scheduledDisconnection = this.scheduledDisconnection;
+ if (scheduledDisconnection != null) {
+ scheduledDisconnection.cancel(true);
+ }
+ // update connection settings
+ id = clientOptions.id;
+ registerSpeakerComponents(id, clientOptions);
+ sendClientCommand(new WebSocketCommand(WebSocketCommand.OutputCommands.INITIALIZED));
+ }
+ case ON_SPOT -> onRemoteSpot();
+ }
+ } catch (IOException | IllegalStateException e) {
+ logger.warn("Error handing command '{}' with message: {}. Disconnecting client", cmd,
+ e.getMessage());
+ disconnect();
+ }
+ }
+ } catch (JsonProcessingException e) {
+ logger.warn("Exception parsing JSON message.", e);
+ logger.warn("Disconnecting client.");
+ disconnect();
+ }
+ }
+
+ @Override
+ public void onWebSocketError(@Nullable Throwable cause) {
+ logger.warn("WebSocket Error", cause);
+ }
+
+ @Override
+ public void onWebSocketClose(int statusCode, @Nullable String reason) {
+ this.session = null;
+ this.remote = null;
+ logger.debug("Session closed with code {}: {}", statusCode, reason);
+ wsAdapter.onClientDisconnected(this);
+ unregisterSpeakerComponents(id);
+ }
+
+ public void setSinkVolume(int value) {
+ if (initialized) {
+ sendClientCommand(
+ new WebSocketCommand(WebSocketCommand.OutputCommands.SINK_VOLUME, Map.of("value", value)));
+ }
+ }
+
+ public void setSourceVolume(int value) {
+ if (initialized) {
+ sendClientCommand(
+ new WebSocketCommand(WebSocketCommand.OutputCommands.SOURCE_VOLUME, Map.of("value", value)));
+ }
+ }
+
+ public @Nullable RemoteEndpoint getRemote() {
+ return this.remote;
+ }
+
+ public @Nullable Session getSession() {
+ return this.session;
+ }
+
+ public boolean isConnected() {
+ Session sess = this.session;
+ return sess != null && sess.isOpen();
+ }
+
+ private synchronized void registerSpeakerComponents(String id, ConnectionOptions clientOptions) throws IOException {
+ if (id.isBlank()) {
+ throw new IOException("Unable to register audio components");
+ }
+ String label = "PCM Audio WebSocket (" + id + ")";
+ logger.debug("Registering dialog components for '{}'", id);
+ this.initialized = true;
+ // register source
+ var audioSource = this.audioSource = new PCMWebSocketAudioSource(getSourceId(id), label, this);
+ logger.debug("Registering audio source {}", this.audioSource.getId());
+ audioComponentRegistrations.put(this.audioSource.getId(), wsAdapter.bundleContext
+ .registerService(AudioSource.class.getName(), this.audioSource, new Hashtable<>()));
+ // register sink
+ var audioSink = new PCMWebSocketAudioSink(getSinkId(id), label, this, clientOptions.forceSampleRate,
+ clientOptions.forceBitDepth, clientOptions.forceChannels);
+ logger.debug("Registering audio sink {}", audioSink.getId());
+ audioComponentRegistrations.put(audioSink.getId(),
+ wsAdapter.bundleContext.registerService(AudioSink.class.getName(), audioSink, new Hashtable<>()));
+ // init dialog
+ if (clientOptions.startDialog) {
+ var dialogProvider = this.wsAdapter.audioDialogProvider;
+ if (dialogProvider == null) {
+ throw new IOException("Voice functionality is not ready");
+ }
+ dialogTrigger = dialogProvider.startDialog(audioSink, audioSource,
+ !clientOptions.locationItem.isBlank() ? clientOptions.locationItem : null,
+ !clientOptions.listeningItem.isBlank() ? clientOptions.listeningItem : null, () -> disconnect());
+ } else {
+ dialogTrigger = null;
+ }
+ }
+
+ private synchronized void unregisterSpeakerComponents(String id) {
+ initialized = false;
+ dialogTrigger = null;
+ var source = wsAdapter.audioManager.getSource(getSourceId(id));
+ if (source instanceof PCMWebSocketAudioSource hsAudioSource) {
+ try {
+ hsAudioSource.close();
+ } catch (Exception ignored) {
+ }
+ }
+ if (source != null) {
+ ServiceRegistration> sourceReg = audioComponentRegistrations.remove(source.getId());
+ if (sourceReg != null) {
+ logger.debug("Unregistering audio source {}", source.getId());
+ sourceReg.unregister();
+ }
+ }
+ var sink = wsAdapter.audioManager.getSink(getSinkId(id));
+ if (sink != null) {
+ ServiceRegistration> sinkReg = audioComponentRegistrations.remove(sink.getId());
+ if (sinkReg != null) {
+ logger.debug("Unregistering audio sink {}", sink.getId());
+ sinkReg.unregister();
+ }
+ }
+ }
+
+ private void onRemoteSpot() {
+ var dialogTrigger = this.dialogTrigger;
+ if (dialogTrigger != null) {
+ dialogTrigger.run();
+ }
+ }
+
+ private String getSinkId(String id) {
+ return "pcm::" + id + "::sink";
+ }
+
+ private String getSourceId(String id) {
+ return "pcm::" + id + "::source";
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ private static class WebSocketCommand {
+ public String cmd = "";
+ public Map args;
+
+ public WebSocketCommand(OutputCommands cmd) {
+ this(cmd, new HashMap<>());
+ }
+
+ public WebSocketCommand(OutputCommands cmd, Map args) {
+ this.cmd = cmd.name();
+ this.args = args;
+ }
+
+ public enum OutputCommands {
+ INITIALIZED,
+ START_LISTENING,
+ STOP_LISTENING,
+ SINK_VOLUME,
+ SOURCE_VOLUME,
+ }
+
+ public enum InputCommands {
+ INITIALIZE,
+ ON_SPOT,
+ }
+ }
+
+ /**
+ * The {@link ConnectionOptions} represents the options provided by the ws client.
+ */
+ public static class ConnectionOptions {
+ /**
+ * Identifier to concatenate to related services (dialog, source and sick)
+ */
+ public String id = "";
+ /**
+ * Force sink audio sample rate (resample in server)
+ */
+ public @Nullable Integer forceSampleRate;
+ /**
+ * Force sink audio bit depth (resample in server)
+ */
+ public @Nullable Integer forceBitDepth;
+ /**
+ * Force sink audio channels (resample in server)
+ */
+ public @Nullable Integer forceChannels;
+ /**
+ * Start a dialog processor using the registered audio components
+ */
+ public boolean startDialog = false;
+ /**
+ * Listening item for the dialog
+ */
+ public String listeningItem = "";
+ /**
+ * Location item for the dialog
+ */
+ public String locationItem = "";
+
+ public ConnectionOptions() {
+ }
+ }
+}
diff --git a/bundles/org.openhab.core.io.websocket.audio/src/main/java/org/openhab/core/io/websocket/audio/internal/PCMWebSocketStreamIdUtil.java b/bundles/org.openhab.core.io.websocket.audio/src/main/java/org/openhab/core/io/websocket/audio/internal/PCMWebSocketStreamIdUtil.java
new file mode 100644
index 000000000..bc0bb36f4
--- /dev/null
+++ b/bundles/org.openhab.core.io.websocket.audio/src/main/java/org/openhab/core/io/websocket/audio/internal/PCMWebSocketStreamIdUtil.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2010-2025 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.core.io.websocket.audio.internal;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.security.SecureRandom;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Utils to read/write the audio packets send though the websocket binary protocol.
+ *
+ * @author Miguel Álvarez Díez - Initial contribution
+ */
+@NonNullByDefault
+public final class PCMWebSocketStreamIdUtil {
+ private PCMWebSocketStreamIdUtil() {
+ }
+
+ /**
+ * Packet header length in bytes:
+ *
+ * - 2 for id
+ * - 4 for sample rate as int little-endian
+ * - 1 for bitDepth
+ * - 1 for channels
+ *
+ */
+ public static final int PACKET_HEADER_BYTE_LENGTH = 2 + 4 + 1 + 1;
+
+ public static AudioPacketData parseAudioPacket(byte[] bytes) throws IOException {
+ if (bytes.length < PACKET_HEADER_BYTE_LENGTH) {
+ throw new IOException("Audio packet byte length is too small, invalid data.");
+ }
+ var byteBuffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
+ byte[] idBytes = new byte[] { byteBuffer.get(), byteBuffer.get() };
+ int sampleRate = byteBuffer.getInt();
+ byte bitDepth = byteBuffer.get();
+ byte channels = byteBuffer.get();
+ byte[] audioBytes = new byte[byteBuffer.remaining()];
+ byteBuffer.get(audioBytes);
+ return new AudioPacketData(idBytes, sampleRate, bitDepth, channels, audioBytes);
+ }
+
+ public static ByteBuffer generateAudioPacketHeader(int sampleRate, byte bitDepth, byte channels) {
+ var byteBuffer = ByteBuffer.allocate(PACKET_HEADER_BYTE_LENGTH).order(ByteOrder.LITTLE_ENDIAN);
+ SecureRandom sr = new SecureRandom();
+ byte[] rndBytes = new byte[2];
+ sr.nextBytes(rndBytes);
+ byteBuffer.put(rndBytes[0]);
+ byteBuffer.put(rndBytes[1]);
+ byteBuffer.putInt(sampleRate);
+ byteBuffer.put(bitDepth);
+ byteBuffer.put(channels);
+ return byteBuffer;
+ }
+
+ public record AudioPacketData(byte[] id, int sampleRate, byte bitDepth, byte channels, byte[] audioData) {
+ }
+}
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/BasicDTService.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/BasicDTService.java
new file mode 100644
index 000000000..0952ba76e
--- /dev/null
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/BasicDTService.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2010-2025 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.core.voice;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * This is the interface for a dialog trigger service that only allows to register a {@link DTEvent} listener.
+ *
+ * @author Miguel Álvarez Díez - Initial contribution
+ */
+@NonNullByDefault
+public interface BasicDTService extends DTService {
+ /**
+ * Used to register the dialog trigger events listener
+ *
+ * @param dtListener Non-null {@link DTListener} that {@link DTEvent} events target
+ * @throws DTException if the listener cannot be registered due to an internal error or invalid state
+ */
+ DTServiceHandle registerListener(DTListener dtListener) throws DTException;
+}
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTErrorEvent.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTErrorEvent.java
new file mode 100644
index 000000000..55fe87564
--- /dev/null
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTErrorEvent.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2010-2025 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.core.voice;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * A {@link DTEvent} fired when the {@link DTService} encounters an error.
+ *
+ * @author Miguel Álvarez Díez - Initial contribution
+ */
+@NonNullByDefault
+public class DTErrorEvent implements DTEvent {
+ /**
+ * The message describing the error
+ */
+ private final String message;
+
+ /**
+ * Constructs an instance with the passed {@code message}.
+ *
+ * @param message The message describing the error
+ */
+ public DTErrorEvent(String message) {
+ this.message = message;
+ }
+
+ /**
+ * Gets the message describing this error
+ *
+ * @return The message describing this error
+ */
+ public String getMessage() {
+ return this.message;
+ }
+}
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTEvent.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTEvent.java
new file mode 100644
index 000000000..840d6d6f5
--- /dev/null
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTEvent.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2010-2025 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.core.voice;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * A tagging interface for dialog trigger events.
+ *
+ * This interface is intended to represent events that can trigger a dialog,
+ * without being specific to any particular type of event such as keyword spotting.
+ *
+ * @author Miguel Álvarez Díez - Initial contribution
+ */
+@NonNullByDefault
+public interface DTEvent {
+}
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTException.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTException.java
new file mode 100644
index 000000000..607655ce3
--- /dev/null
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTException.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2010-2025 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.core.voice;
+
+import java.io.Serial;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * General purpose dialog trigger exception
+ *
+ * @author Miguel Álvarez Díez - Initial contribution
+ */
+@NonNullByDefault
+public class DTException extends Exception {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Constructs a new exception with null as its detail message.
+ */
+ public DTException() {
+ super();
+ }
+
+ /**
+ * Constructs a new exception with the specified detail message and cause.
+ *
+ * @param message Detail message
+ * @param cause The cause
+ */
+ public DTException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ /**
+ * Constructs a new exception with the specified detail message.
+ *
+ * @param message Detail message
+ */
+ public DTException(String message) {
+ super(message);
+ }
+
+ /**
+ * Constructs a new exception with the specified cause.
+ *
+ * @param cause The cause
+ */
+ public DTException(Throwable cause) {
+ super(cause);
+ }
+}
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTListener.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTListener.java
new file mode 100644
index 000000000..c49fa5eba
--- /dev/null
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTListener.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2010-2025 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.core.voice;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The listener interface for receiving {@link DTEvent} events.
+ *
+ * A class interested in processing {@link DTEvent} events implements this interface.
+ *
+ * @author Miguel Álvarez Díez - Initial contribution
+ */
+@NonNullByDefault
+public interface DTListener {
+ /**
+ * Invoked when a {@link DTEvent} event occurs.
+ *
+ * @param dtEvent The {@link DTEvent} fired by the {@link DTService}
+ */
+ void dtEventReceived(DTEvent dtEvent);
+}
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTService.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTService.java
new file mode 100644
index 000000000..ccb4911e6
--- /dev/null
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTService.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2010-2025 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.core.voice;
+
+import java.util.Locale;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * This is the interface that a dialog trigger service has to implement.
+ *
+ * @author Miguel Álvarez Díez - Initial contribution
+ */
+@NonNullByDefault
+public interface DTService {
+ /**
+ * Returns a simple string that uniquely identifies this service
+ *
+ * @return an id that identifies this service
+ */
+ String getId();
+
+ /**
+ * Returns a localized human-readable label that can be used within UIs.
+ *
+ * @param locale the locale to provide the label for
+ * @return a localized string to be used in UIs
+ */
+ String getLabel(@Nullable Locale locale);
+}
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTServiceHandle.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTServiceHandle.java
new file mode 100644
index 000000000..9872fda29
--- /dev/null
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTServiceHandle.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2010-2025 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.core.voice;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * A handle to a {@link DTService}.
+ *
+ * @author Miguel Álvarez Díez - Initial contribution
+ */
+@NonNullByDefault
+public interface DTServiceHandle {
+ /**
+ * Aborts execution in the associated {@link DTServiceHandle}
+ */
+ void abort();
+}
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTTriggeredEvent.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTTriggeredEvent.java
new file mode 100644
index 000000000..8c3923e6d
--- /dev/null
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DTTriggeredEvent.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2010-2025 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.core.voice;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * A {@link DTEvent} fired when the {@link DTService} wants to trigger the dialog.
+ *
+ * @author Miguel Álvarez Díez - Initial contribution
+ */
+@NonNullByDefault
+public class DTTriggeredEvent implements DTEvent {
+}
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DialogContext.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DialogContext.java
index a49d54beb..b67ee3bfa 100644
--- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DialogContext.java
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DialogContext.java
@@ -29,7 +29,7 @@ import org.openhab.core.voice.text.HumanLanguageInterpreter;
* @author Miguel Álvarez - Initial contribution
*/
@NonNullByDefault
-public record DialogContext(@Nullable KSService ks, @Nullable String keyword, STTService stt, TTSService tts,
+public record DialogContext(@Nullable DTService dt, @Nullable String keyword, STTService stt, TTSService tts,
@Nullable Voice voice, List hlis, AudioSource source, AudioSink sink, Locale locale,
String dialogGroup, @Nullable String locationItem, @Nullable String listeningItem,
@Nullable String listeningMelody) {
@@ -42,7 +42,7 @@ public record DialogContext(@Nullable KSService ks, @Nullable String keyword, ST
// services
private @Nullable AudioSource source;
private @Nullable AudioSink sink;
- private @Nullable KSService ks;
+ private @Nullable DTService dt;
private @Nullable STTService stt;
private @Nullable TTSService tts;
private @Nullable Voice voice;
@@ -72,7 +72,14 @@ public record DialogContext(@Nullable KSService ks, @Nullable String keyword, ST
public Builder withKS(@Nullable KSService service) {
if (service != null) {
- this.ks = service;
+ this.dt = service;
+ }
+ return this;
+ }
+
+ public Builder withDT(@Nullable DTService service) {
+ if (service != null) {
+ this.dt = service;
}
return this;
}
@@ -165,7 +172,7 @@ public record DialogContext(@Nullable KSService ks, @Nullable String keyword, ST
* @throws IllegalStateException if a required dialog component is missing
*/
public DialogContext build() throws IllegalStateException {
- KSService ksService = ks;
+ DTService dtService = dt;
STTService sttService = stt;
TTSService ttsService = tts;
List hliServices = hlis;
@@ -191,7 +198,7 @@ public record DialogContext(@Nullable KSService ks, @Nullable String keyword, ST
}
throw new IllegalStateException("Cannot build dialog context: " + String.join(", ", errors) + ".");
} else {
- return new DialogContext(ksService, keyword, sttService, ttsService, voice, hliServices, audioSource,
+ return new DialogContext(dtService, keyword, sttService, ttsService, voice, hliServices, audioSource,
audioSink, locale, dialogGroup, locationItem, listeningItem, listeningMelody);
}
}
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSEdgeService.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSEdgeService.java
deleted file mode 100644
index 8c7f43380..000000000
--- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSEdgeService.java
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- * Copyright (c) 2010-2025 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.core.voice;
-
-import java.util.Locale;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.openhab.core.audio.AudioStream;
-
-/**
- * This is the interface that an edge keyword spotting service has to implement.
- * Used to register a keyword spotting service that is running on a remote device.
- *
- * @author Miguel Álvarez - Initial contribution
- */
-@NonNullByDefault
-public interface KSEdgeService extends KSService {
-
- /**
- * This method links the remote keyword spotting process to a consumer.
- *
- * The method is supposed to return fast.
- *
- * @param ksListener Non-null {@link KSListener} that {@link KSEvent} events target
- * @throws KSException if any parameter is invalid or a problem occurs
- */
- KSServiceHandle spot(KSListener ksListener) throws KSException;
-
- @Override
- default KSServiceHandle spot(KSListener ksListener, AudioStream audioStream, Locale locale, String keyword)
- throws KSException {
- throw new KSException("An edge keyword spotter is not meant to process audio in the server");
- }
-}
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSErrorEvent.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSErrorEvent.java
index c74d04f6f..713c5d875 100644
--- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSErrorEvent.java
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSErrorEvent.java
@@ -20,27 +20,13 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
* @author Kelly Davis - Initial contribution
*/
@NonNullByDefault
-public class KSErrorEvent implements KSEvent {
- /**
- * The message describing the error
- */
- private final String message;
-
+public class KSErrorEvent extends DTErrorEvent implements KSEvent {
/**
* Constructs an instance with the passed {@code message}.
*
* @param message The message describing the error
*/
public KSErrorEvent(String message) {
- this.message = message;
- }
-
- /**
- * Gets the message describing this error
- *
- * @return The message describing this error
- */
- public String getMessage() {
- return this.message;
+ super(message);
}
}
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSEvent.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSEvent.java
index 3f0a50141..6dd8f28f6 100644
--- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSEvent.java
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSEvent.java
@@ -20,5 +20,5 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
* @author Kelly Davis - Initial contribution
*/
@NonNullByDefault
-public interface KSEvent {
+public interface KSEvent extends DTEvent {
}
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSException.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSException.java
index 1f878a330..59b3624fd 100644
--- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSException.java
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSException.java
@@ -22,7 +22,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
* @author Kelly Davis - Initial contribution
*/
@NonNullByDefault
-public class KSException extends Exception {
+public class KSException extends DTException {
@Serial
private static final long serialVersionUID = 1L;
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSListener.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSListener.java
index 4ed83f144..608d40766 100644
--- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSListener.java
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSListener.java
@@ -25,7 +25,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
* @author Kelly Davis - Initial contribution
*/
@NonNullByDefault
-public interface KSListener {
+public interface KSListener extends DTListener {
/**
* Invoked when a {@link KSEvent} event occurs during keyword spotting.
*
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSService.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSService.java
index 7626b6004..3488b23da 100644
--- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSService.java
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSService.java
@@ -16,7 +16,6 @@ import java.util.Locale;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.audio.AudioFormat;
import org.openhab.core.audio.AudioStream;
@@ -27,23 +26,7 @@ import org.openhab.core.audio.AudioStream;
* @author Kai Kreuzer - Refactored to use AudioStream
*/
@NonNullByDefault
-public interface KSService {
-
- /**
- * Returns a simple string that uniquely identifies this service
- *
- * @return an id that identifies this service
- */
- String getId();
-
- /**
- * Returns a localized human readable label that can be used within UIs.
- *
- * @param locale the locale to provide the label for
- * @return a localized string to be used in UIs
- */
- String getLabel(@Nullable Locale locale);
-
+public interface KSService extends DTService {
/**
* Obtain the Locales available from this KSService
*
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSServiceHandle.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSServiceHandle.java
index 7554c51a2..8b42c6b08 100644
--- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSServiceHandle.java
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSServiceHandle.java
@@ -20,7 +20,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
* @author Kelly Davis - Initial contribution
*/
@NonNullByDefault
-public interface KSServiceHandle {
+public interface KSServiceHandle extends DTServiceHandle {
/**
* Aborts keyword spotting in the associated {@link KSService}
*/
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSpottedEvent.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSpottedEvent.java
index 565d6ec8d..b8a1d7f92 100644
--- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSpottedEvent.java
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/KSpottedEvent.java
@@ -21,5 +21,5 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
* @author Yannick Schaus - Removed AudioSource information
*/
@NonNullByDefault
-public class KSpottedEvent implements KSEvent {
+public class KSpottedEvent extends DTTriggeredEvent implements KSEvent {
}
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/VoiceManager.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/VoiceManager.java
index 133d0c4ba..203ed0c38 100644
--- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/VoiceManager.java
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/VoiceManager.java
@@ -173,8 +173,10 @@ public interface VoiceManager {
* @param context with the configured services and options for the dialog
* @throws IllegalStateException if required services are not compatible or the provided locale is not supported
* by all these services or a dialog is already started for this audio source
+ * @return a {@link DTServiceHandle} implementation if the dialog was started correctly.
*/
- void startDialog(DialogContext context) throws IllegalStateException;
+ @Nullable
+ DTServiceHandle startDialog(DialogContext context) throws IllegalStateException;
/**
* Stop the dialog associated to an audio source
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/AudioDialogProviderImpl.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/AudioDialogProviderImpl.java
new file mode 100644
index 000000000..e3a3024fc
--- /dev/null
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/AudioDialogProviderImpl.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright (c) 2010-2025 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.core.voice.internal;
+
+import java.util.Locale;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.audio.AudioDialogProvider;
+import org.openhab.core.audio.AudioSink;
+import org.openhab.core.audio.AudioSource;
+import org.openhab.core.voice.BasicDTService;
+import org.openhab.core.voice.DTListener;
+import org.openhab.core.voice.DTService;
+import org.openhab.core.voice.DTServiceHandle;
+import org.openhab.core.voice.DTTriggeredEvent;
+import org.openhab.core.voice.VoiceManager;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Allows the audio bundle to register a dialog that can be triggered programmatically.
+ *
+ * @author Miguel Álvarez - Initial contribution
+ */
+@Component(service = AudioDialogProvider.class)
+@NonNullByDefault
+public class AudioDialogProviderImpl implements AudioDialogProvider {
+ private final Logger logger = LoggerFactory.getLogger(AudioDialogProviderImpl.class);
+ private final VoiceManager voiceManager;
+
+ @Activate
+ public AudioDialogProviderImpl(@Reference VoiceManager voiceManager) {
+ this.voiceManager = voiceManager;
+ }
+
+ @Override
+ public @Nullable Runnable startDialog(AudioSink audioSink, AudioSource audioSource, @Nullable String locationItem,
+ @Nullable String listeningItem, @Nullable Runnable onAbort) {
+ DTService dt = new TriggerService(onAbort);
+ TriggerServiceHandle triggerHandle = (TriggerServiceHandle) voiceManager.startDialog( //
+ voiceManager.getDialogContextBuilder() //
+ .withSource(audioSource) //
+ .withSink(audioSink) //
+ .withLocationItem(locationItem) //
+ .withListeningItem(listeningItem) //
+ .withDT(dt) //
+ .build() //
+ );
+ if (triggerHandle == null) {
+ return null;
+ }
+ return triggerHandle::trigger;
+ }
+
+ private static class TriggerService implements BasicDTService {
+ @Nullable
+ Runnable onAbort;
+
+ public TriggerService(@Nullable Runnable onAbort) {
+ this.onAbort = onAbort;
+ }
+
+ @Override
+ public DTServiceHandle registerListener(DTListener dtListener) {
+ return new TriggerServiceHandle(dtListener, onAbort);
+ }
+
+ @Override
+ public String getId() {
+ return "audiodialog::anonymous::trigger";
+ }
+
+ @Override
+ public String getLabel(@Nullable Locale locale) {
+ // never shown
+ return "Anonymous";
+ }
+ }
+
+ private static class TriggerServiceHandle implements DTServiceHandle {
+ public final DTListener dtListener;
+ public @Nullable Runnable abortCallback;
+
+ public TriggerServiceHandle(DTListener dtListener, @Nullable Runnable abortCallback) {
+ this.dtListener = dtListener;
+ this.abortCallback = abortCallback;
+ }
+
+ public void trigger() {
+ dtListener.dtEventReceived(new DTTriggeredEvent());
+ }
+
+ @Override
+ public void abort() {
+ if (this.abortCallback != null) {
+ this.abortCallback.run();
+ this.abortCallback = null;
+ }
+ }
+ }
+}
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/DialogProcessor.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/DialogProcessor.java
index d6288da82..0240c7f36 100644
--- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/DialogProcessor.java
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/DialogProcessor.java
@@ -33,14 +33,19 @@ import org.openhab.core.i18n.TranslationProvider;
import org.openhab.core.items.ItemUtil;
import org.openhab.core.items.events.ItemEventFactory;
import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.voice.BasicDTService;
+import org.openhab.core.voice.DTErrorEvent;
+import org.openhab.core.voice.DTEvent;
+import org.openhab.core.voice.DTException;
+import org.openhab.core.voice.DTService;
+import org.openhab.core.voice.DTServiceHandle;
+import org.openhab.core.voice.DTTriggeredEvent;
import org.openhab.core.voice.DialogContext;
-import org.openhab.core.voice.KSEdgeService;
import org.openhab.core.voice.KSErrorEvent;
import org.openhab.core.voice.KSEvent;
import org.openhab.core.voice.KSException;
import org.openhab.core.voice.KSListener;
import org.openhab.core.voice.KSService;
-import org.openhab.core.voice.KSServiceHandle;
import org.openhab.core.voice.KSpottedEvent;
import org.openhab.core.voice.RecognitionStartEvent;
import org.openhab.core.voice.RecognitionStopEvent;
@@ -98,7 +103,7 @@ public class DialogProcessor implements KSListener, STTListener {
*/
private boolean isSTTServerAborting = false;
- private @Nullable KSServiceHandle ksServiceHandle;
+ private @Nullable DTServiceHandle dtServiceHandle;
private @Nullable STTServiceHandle sttServiceHandle;
private @Nullable AudioStream streamKS;
@@ -113,8 +118,8 @@ public class DialogProcessor implements KSListener, STTListener {
this.i18nProvider = i18nProvider;
this.activeDialogGroups = activeDialogGroups;
this.bundle = bundle;
- var ks = context.ks();
- this.ksFormat = ks != null
+ var dt = context.dt();
+ this.ksFormat = dt instanceof KSService ks
? VoiceManagerImpl.getBestMatch(context.source().getSupportedFormats(), ks.getSupportedFormats())
: null;
this.sttFormat = VoiceManagerImpl.getBestMatch(context.source().getSupportedFormats(),
@@ -151,34 +156,42 @@ public class DialogProcessor implements KSListener, STTListener {
* Starts a persistent dialog
*
* @throws IllegalStateException if keyword spot service is misconfigured
+ * @return {@link DTServiceHandle} or null if dialog fails to start
*/
- public void start() throws IllegalStateException {
- KSService ksService = dialogContext.ks();
+ public @Nullable DTServiceHandle start() throws IllegalStateException {
+ DTService dtService = dialogContext.dt();
String keyword = dialogContext.keyword();
- if (ksService != null && keyword != null) {
+ if (dtService != null && keyword != null) {
abortKS();
closeStreamKS();
- AudioFormat fmt = ksFormat;
- if (fmt == null) {
- logger.warn("No compatible audio format found for ks '{}' and source '{}'", ksService.getId(),
- dialogContext.source().getId());
- return;
- }
try {
- if (ksService instanceof KSEdgeService service) {
- service.spot(this);
- } else {
+ if (dtService instanceof KSService ksService) {
+ AudioFormat fmt = ksFormat;
+ if (fmt == null) {
+ logger.warn("No compatible audio format found for ks '{}' and source '{}'", ksService.getId(),
+ dialogContext.source().getId());
+ return null;
+ }
AudioStream stream = dialogContext.source().getInputStream(fmt);
streamKS = stream;
- ksServiceHandle = ksService.spot(this, stream, dialogContext.locale(), keyword);
+ dtServiceHandle = ksService.spot(this, stream, dialogContext.locale(), keyword);
+ } else if (dtService instanceof BasicDTService basicDTService) {
+ dtServiceHandle = basicDTService.registerListener(this);
+ } else {
+ logger.warn("Voice manager is not able to handle this DTService implementation '{}'",
+ dtService.getClass().getName());
}
playStartSound();
+ return dtServiceHandle;
} catch (AudioException e) {
logger.warn("Encountered audio error: {}", e.getMessage());
} catch (KSException e) {
logger.warn("Encountered error calling spot: {}", e.getMessage());
closeStreamKS();
+ } catch (DTException e) {
+ logger.warn("Encountered error starting the dialog trigger: {}", e.getMessage());
}
+ return null;
} else {
throw new IllegalStateException("Unable to run persistent dialog ks service is not configured");
}
@@ -258,10 +271,10 @@ public class DialogProcessor implements KSListener, STTListener {
}
private void abortKS() {
- KSServiceHandle handle = ksServiceHandle;
+ DTServiceHandle handle = dtServiceHandle;
if (handle != null) {
handle.abort();
- ksServiceHandle = null;
+ dtServiceHandle = null;
}
}
@@ -314,22 +327,28 @@ public class DialogProcessor implements KSListener, STTListener {
}
@Override
- public void ksEventReceived(KSEvent ksEvent) {
+ public void dtEventReceived(DTEvent dtEvent) {
isSTTServerAborting = false;
- if (ksEvent instanceof KSpottedEvent) {
- logger.debug("KSpottedEvent event received");
+ if (dtEvent instanceof DTTriggeredEvent) {
+ logger.debug("{} event received",
+ (dtEvent instanceof KSpottedEvent) ? "KSpottedEvent" : "DTTriggeredEvent");
try {
startSimpleDialog();
} catch (IllegalStateException e) {
logger.warn("{}", e.getMessage());
}
- } else if (ksEvent instanceof KSErrorEvent kse) {
- logger.debug("KSErrorEvent event received");
+ } else if (dtEvent instanceof DTErrorEvent dte) {
+ logger.debug("{} event received", dte instanceof KSErrorEvent ? "KSErrorEvent" : "DTErrorEvent");
String text = i18nProvider.getText(bundle, "error.ks-error", null, dialogContext.locale());
- say(text == null ? kse.getMessage() : text.replace("{0}", kse.getMessage()));
+ say(text == null ? dte.getMessage() : text.replace("{0}", dte.getMessage()));
}
}
+ @Override
+ public void ksEventReceived(KSEvent ksEvent) {
+ dtEventReceived(ksEvent);
+ }
+
@Override
public synchronized void sttEventReceived(STTEvent sttEvent) {
if (sttEvent instanceof SpeechRecognitionEvent sre) {
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/VoiceConsoleCommandExtension.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/VoiceConsoleCommandExtension.java
index 658d29110..c84eb766f 100644
--- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/VoiceConsoleCommandExtension.java
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/VoiceConsoleCommandExtension.java
@@ -406,7 +406,7 @@ public class VoiceConsoleCommandExtension extends AbstractConsoleCommandExtensio
Collection dialogContexts = voiceManager.getDialogsContexts();
if (!dialogContexts.isEmpty()) {
dialogContexts.stream().sorted(comparing(s -> s.source().getId())).forEach(c -> {
- var ks = c.ks();
+ var ks = c.dt();
String ksText = ks != null ? String.format(", KS: %s, Keyword: %s", ks.getId(), c.keyword()) : "";
String locationText = c.locationItem() != null ? String.format(" Location: %s", c.locationItem()) : "";
console.println(String.format(
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/VoiceManagerImpl.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/VoiceManagerImpl.java
index 7db8de5c7..e980008d6 100644
--- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/VoiceManagerImpl.java
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/VoiceManagerImpl.java
@@ -57,6 +57,7 @@ import org.openhab.core.i18n.TranslationProvider;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.storage.Storage;
import org.openhab.core.storage.StorageService;
+import org.openhab.core.voice.DTServiceHandle;
import org.openhab.core.voice.DialogContext;
import org.openhab.core.voice.DialogRegistration;
import org.openhab.core.voice.KSService;
@@ -608,10 +609,14 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
}
@Override
- public void startDialog(DialogContext context) throws IllegalStateException {
- var ksService = context.ks();
+ public @Nullable DTServiceHandle startDialog(DialogContext context) throws IllegalStateException {
+ var dtService = context.dt();
var ksKeyword = context.keyword();
- if (ksService == null || ksKeyword == null) {
+ if (dtService == null) {
+ throw new IllegalStateException(
+ "Invalid dialog context for persistent dialog: missing dialog trigger implementation");
+ }
+ if (dtService instanceof KSService && (ksKeyword == null || ksKeyword.isEmpty())) {
throw new IllegalStateException(
"Invalid dialog context for persistent dialog: missing keyword spot configuration");
}
@@ -619,7 +624,8 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
if (b == null) {
throw new IllegalStateException("Bundle is not (yet?) set.");
}
- if (!checkLocales(ksService.getSupportedLocales(), context.locale())
+ if ((dtService instanceof KSService ksService
+ && !checkLocales(ksService.getSupportedLocales(), context.locale()))
|| !checkLocales(context.stt().getSupportedLocales(), context.locale()) || !context.hlis().stream()
.allMatch(interpreter -> checkLocales(interpreter.getSupportedLocales(), context.locale()))) {
throw new IllegalStateException("Cannot start dialog as provided locale is not supported by all services.");
@@ -631,7 +637,7 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
processor = new DialogProcessor(context, this, this.eventPublisher, this.activeDialogGroups,
this.i18nProvider, b);
dialogProcessors.put(context.source().getId(), processor);
- processor.start();
+ return processor.start();
} else {
throw new IllegalStateException(
String.format("Cannot start dialog as a dialog is already started for audio source '%s'.",
@@ -785,8 +791,8 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
protected void removeKSService(KSService ksService) {
this.ksServices.remove(ksService.getId());
stopDialogs(dialog -> {
- var ks = dialog.dialogContext.ks();
- return ks != null && ks.getId().equals(ksService.getId());
+ var dt = dialog.dialogContext.dt();
+ return dt != null && dt.getId().equals(ksService.getId());
});
}
diff --git a/bundles/pom.xml b/bundles/pom.xml
index d10d7dd64..2d05fa0e6 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -87,6 +87,7 @@
org.openhab.core.io.transport.serial.rxtx.rfc2217
org.openhab.core.io.transport.upnp
org.openhab.core.io.websocket
+ org.openhab.core.io.websocket.audio
org.openhab.core.io.jetty.certificate
org.openhab.core.model.core
org.openhab.core.model.item
diff --git a/features/karaf/openhab-core/src/main/feature/feature.xml b/features/karaf/openhab-core/src/main/feature/feature.xml
index 491d3129c..0847ffc79 100644
--- a/features/karaf/openhab-core/src/main/feature/feature.xml
+++ b/features/karaf/openhab-core/src/main/feature/feature.xml
@@ -239,6 +239,12 @@
mvn:org.openhab.core.bundles/org.openhab.core.io.websocket/${project.version}
+
+ openhab-core-base
+ openhab-core-io-websocket
+ mvn:org.openhab.core.bundles/org.openhab.core.io.websocket.audio/${project.version}
+
+
openhab-core-base
@@ -466,6 +472,7 @@
openhab-core-io-rest-transform
openhab-core-io-rest-voice
openhab-core-io-websocket
+ openhab-core-io-websocket-audio
openhab-core-model-lsp
openhab-core-model-item
openhab-core-model-persistence