diff --git a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/AudioManager.java b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/AudioManager.java index b887448680..8b6f1bdbe6 100644 --- a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/AudioManager.java +++ b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/AudioManager.java @@ -19,6 +19,8 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.library.types.PercentType; +import io.reactivex.annotations.NonNull; + /** * This service provides functionality around audio services and is the central service to be used directly by others. * @@ -27,6 +29,7 @@ import org.openhab.core.library.types.PercentType; * @author Christoph Weitkamp - Added parameter to adjust the volume * @author Wouter Born - Added methods for getting all sinks and sources * @author Miguel Álvarez - Add record method + * @author Karel Goderis - Add multisink support */ @NonNullByDefault public interface AudioManager { @@ -51,6 +54,14 @@ public interface AudioManager { */ void play(@Nullable AudioStream audioStream, @Nullable String sinkId); + /** + * Plays the passed audio stream on the given set of sinks. + * + * @param audioStream The audio stream to play or null if streaming should be stopped + * @param sinkIds The set of the audio sink ids to use + */ + void play(@Nullable AudioStream audioStream, @NonNull Set sinkIds); + /** * Plays the passed audio stream on the given sink. * @@ -60,6 +71,15 @@ public interface AudioManager { */ void play(@Nullable AudioStream audioStream, @Nullable String sinkId, @Nullable PercentType volume); + /** + * Plays the passed audio stream on the given set of sinks. + * + * @param audioStream The audio stream to play or null if streaming should be stopped + * @param sinkIds The set of the audio sink ids to use + * @param volume The volume to be used or null if the default notification volume should be used + */ + void play(@Nullable AudioStream audioStream, Set sinkIds, @Nullable PercentType volume); + /** * Plays an audio file from the "sounds" folder using the default audio sink. * @@ -86,6 +106,15 @@ public interface AudioManager { */ void playFile(String fileName, @Nullable String sinkId) throws AudioException; + /** + * Plays an audio file from the "sounds" folder using the given audio sink. + * + * @param fileName The file from the "sounds" folder + * @param sinkIds The set of the audio sink ids to use + * @throws AudioException in case the file does not exist or cannot be opened + */ + void playFile(String fileName, @NonNull Set sinkIds) throws AudioException; + /** * Plays an audio file with the given volume from the "sounds" folder using the given audio sink. * @@ -96,6 +125,16 @@ public interface AudioManager { */ void playFile(String fileName, @Nullable String sinkId, @Nullable PercentType volume) throws AudioException; + /** + * Plays an audio file with the given volume from the "sounds" folder using the given audio sink. + * + * @param fileName The file from the "sounds" folder + * @param sinkIds The set of the audio sink ids to use + * @param volume The volume to be used or null if the default notification volume should be used + * @throws AudioException in case the file does not exist or cannot be opened + */ + void playFile(String fileName, @NonNull Set sinkIds, @Nullable PercentType volume) throws AudioException; + /** * Stream audio from the passed url using the default audio sink. * @@ -113,6 +152,15 @@ public interface AudioManager { */ void stream(@Nullable String url, @Nullable String sinkId) throws AudioException; + /** + * Stream audio from the passed url to the given set of sinks + * + * @param url The url to stream from or null if streaming should be stopped + * @param sinkIds The set of the audio sink ids to use + * @throws AudioException in case the url stream cannot be opened + */ + void stream(@Nullable String url, @NonNull Set sinkIds) throws AudioException; + /** * Parse and synthesize a melody and play it into the default sink. * @@ -138,6 +186,19 @@ public interface AudioManager { */ void playMelody(String melody, @Nullable String sinkId); + /** + * Parse and synthesize a melody and play it into the given sink. + * + * The melody should be a spaced separated list of note names or silences (character 0 or O). + * You can optionally add the character "'" to increase the note one octave. + * You can optionally add ":ms" where ms is an int value to customize the note/silence milliseconds duration + * (defaults to 200ms). + * + * @param melody The url to stream from or null if streaming should be stopped. + * @param sinkIds The set of the audio sink ids to use + */ + void playMelody(String melody, @NonNull Set sinkIds); + /** * Parse and synthesize a melody and play it into the given sink at the desired volume. * @@ -152,6 +213,20 @@ public interface AudioManager { */ void playMelody(String melody, @Nullable String sinkId, @Nullable PercentType volume); + /** + * Parse and synthesize a melody and play it into the given sink at the desired volume. + * + * The melody should be a spaced separated list of note names or silences (character 0 or O). + * You can optionally add the character "'" to increase the note one octave. + * You can optionally add ":ms" where ms is an int value to customize the note/silence milliseconds duration + * (defaults to 200ms). + * + * @param melody The url to stream from or null if streaming should be stopped. + * @param sinkIds The set of the audio sink ids to use + * @param volume The volume to be used or null if the default notification volume should be used + */ + void playMelody(String melody, @NonNull Set sinkIds, @Nullable PercentType volume); + /** * Record audio as a WAV file of the specified length to the sounds folder. * @@ -256,9 +331,10 @@ public interface AudioManager { AudioSink getSink(@Nullable String sinkId); /** - * Get a list of sink ids that match a given pattern + * Get a list of sink ids that match a given set of patterns * - * @param pattern pattern to search, can include {@code *} and {@code ?} placeholders + * @param pattern patterns to search; patterns is a comma-separated list, whereby each can include {@code *} and + * {@code ?} placeholders, or can be literal string to designate a single sink * @return ids of matching sinks */ Set getSinkIds(String pattern); diff --git a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/internal/AudioConsoleCommandExtension.java b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/internal/AudioConsoleCommandExtension.java index 2e93a724a2..a3524e4a42 100644 --- a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/internal/AudioConsoleCommandExtension.java +++ b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/internal/AudioConsoleCommandExtension.java @@ -35,6 +35,8 @@ import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; +import io.reactivex.annotations.NonNull; + /** * Console command extension for all audio features. * @@ -237,9 +239,7 @@ public class AudioConsoleCommandExtension extends AbstractConsoleCommandExtensio } private void playOnSinks(String pattern, String fileName, @Nullable PercentType volume, Console console) { - for (String sinkId : audioManager.getSinkIds(pattern)) { - playOnSink(sinkId, fileName, volume, console); - } + playOnSinks(audioManager.getSinkIds(pattern), fileName, volume, console); } private void playOnSink(@Nullable String sinkId, String fileName, @Nullable PercentType volume, Console console) { @@ -251,6 +251,16 @@ public class AudioConsoleCommandExtension extends AbstractConsoleCommandExtensio } } + private void playOnSinks(@NonNull Set sinkIds, String fileName, @Nullable PercentType volume, + Console console) { + try { + audioManager.playFile(fileName, sinkIds, volume); + } catch (AudioException e) { + console.println(Objects.requireNonNullElse(e.getMessage(), + String.format("An error occurred while playing '%s' on sinks '%s'", fileName, sinkIds))); + } + } + private void stream(String[] args, Console console) { switch (args.length) { case 1: diff --git a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/internal/AudioManagerImpl.java b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/internal/AudioManagerImpl.java index 2fe2260ebf..1fd7eebb73 100644 --- a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/internal/AudioManagerImpl.java +++ b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/internal/AudioManagerImpl.java @@ -24,11 +24,15 @@ import java.nio.file.Path; import java.text.ParseException; import java.util.Collection; import java.util.HashSet; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.stream.Collectors; import javax.sound.sampled.AudioFileFormat; import javax.sound.sampled.AudioInputStream; @@ -47,6 +51,7 @@ import org.openhab.core.audio.FileAudioStream; import org.openhab.core.audio.URLAudioStream; import org.openhab.core.audio.utils.AudioWaveUtils; import org.openhab.core.audio.utils.ToneSynthesizer; +import org.openhab.core.common.ThreadPoolManager; import org.openhab.core.config.core.ConfigOptionProvider; import org.openhab.core.config.core.ConfigurableService; import org.openhab.core.config.core.ParameterOption; @@ -62,6 +67,8 @@ import org.osgi.service.component.annotations.ReferencePolicy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import io.reactivex.annotations.NonNull; + /** * This service provides functionality around audio services and is the central service to be used directly by others. * @@ -71,6 +78,7 @@ import org.slf4j.LoggerFactory; * @author Christoph Weitkamp - Added parameter to adjust the volume * @author Wouter Born - Sort audio sink and source options * @author Miguel Álvarez - Add record from source + * @author Karel Goderis - Add multisink support */ @NonNullByDefault @Component(immediate = true, configurationPid = "org.openhab.audio", // @@ -82,6 +90,7 @@ public class AudioManagerImpl implements AudioManager, ConfigOptionProvider { static final String CONFIG_URI = "system:audio"; static final String CONFIG_DEFAULT_SINK = "defaultSink"; static final String CONFIG_DEFAULT_SOURCE = "defaultSource"; + static final String AUDIO_THREADPOOL_NAME = "audio"; private final Logger logger = LoggerFactory.getLogger(AudioManagerImpl.class); @@ -89,6 +98,8 @@ public class AudioManagerImpl implements AudioManager, ConfigOptionProvider { private final Map audioSources = new ConcurrentHashMap<>(); private final Map audioSinks = new ConcurrentHashMap<>(); + private final ExecutorService pool = ThreadPoolManager.getPool(AUDIO_THREADPOOL_NAME); + /** * default settings filled through the service configuration */ @@ -114,74 +125,192 @@ public class AudioManagerImpl implements AudioManager, ConfigOptionProvider { @Override public void play(@Nullable AudioStream audioStream) { - play(audioStream, null); + playSingleSink(audioStream, null, null); } @Override public void play(@Nullable AudioStream audioStream, @Nullable String sinkId) { - play(audioStream, sinkId, null); + playSingleSink(audioStream, sinkId, null); + } + + @Override + public void play(@Nullable AudioStream audioStream, @NonNull Set sinkIds) { + playMultiSink(audioStream, sinkIds, null); } @Override public void play(@Nullable AudioStream audioStream, @Nullable String sinkId, @Nullable PercentType volume) { + playSingleSink(audioStream, sinkId, volume); + } + + @Override + public void play(@Nullable AudioStream audioStream, Set sinkIds, @Nullable PercentType volume) { + playMultiSink(audioStream, sinkIds, volume); + } + + protected void playSingleSink(@Nullable AudioStream audioStream, @Nullable String sinkId, + @Nullable PercentType volume) { AudioSink sink = getSink(sinkId); - if (sink != null) { - Runnable restoreVolume = handleVolumeCommand(volume, sink); - sink.processAndComplete(audioStream).exceptionally(exception -> { - logger.warn("Error playing '{}': {}", audioStream, exception.getMessage(), exception); - return null; - }).thenRun(restoreVolume); - } else { - logger.warn("Failed playing audio stream '{}' as no audio sink was found.", audioStream); + if (sink == null) { + logger.warn("No audio sink provided for playback."); + return; } + + // Handle volume adjustment for the current sink + Runnable restoreVolume = handleVolumeCommand(volume, sink); + + try { + // Process and complete playback asynchronously + sink.processAndComplete(audioStream).exceptionally(exception -> { + logger.error("Error playing audio stream '{}' on sink '{}': {}", audioStream, sinkId, + exception.getMessage(), exception); + return null; // Handle the exception gracefully + }).thenRun(() -> { + restoreVolume.run(); // Ensure volume is restored after playback completes + logger.info("Audio stream '{}' has been successfully played on sink '{}'.", audioStream, sinkId); + }); + } catch (Exception e) { + logger.error("Unexpected error while processing audio stream '{}' on sink '{}': {}", audioStream, sinkId, + e.getMessage(), e); + } + } + + protected void playMultiSink(@Nullable AudioStream audioStream, Set sinkIds, @Nullable PercentType volume) { + if (sinkIds.isEmpty()) { + logger.warn("No audio sinks provided for playback."); + return; + } + + // Create a list of CompletableFutures for parallel execution + List> futures = sinkIds.stream().map(sinkId -> CompletableFuture.supplyAsync(() -> { + AudioSink sink = getSink(sinkId); + if (sink == null) { + logger.warn("Sink '{}' not found. Skipping.", sinkId); + return null; // Return null for missing sinks + } + + // Handle volume adjustment for the current sink + Runnable restoreVolume = handleVolumeCommand(volume, sink); + + try { + // Play the audio stream synchronously on this sink + sink.processAndComplete(audioStream); + logger.debug("Audio stream '{}' has been played on sink '{}'.", audioStream, sinkId); + } catch (Exception e) { + logger.error("Error playing '{}' on sink '{}': {}", audioStream, sinkId, e.getMessage(), e); + } finally { + restoreVolume.run(); // Ensure volume is restored after playback completes + } + return null; + }, pool)).collect(Collectors.toList()); + + // Wait for all sinks to complete playback + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenRun(() -> logger.info("Audio stream '{}' has been played on all sinks.", audioStream)) + .exceptionally(exception -> { + logger.error("Error completing playback on all sinks: {}", exception.getMessage(), exception); + return null; + }); } @Override public void playFile(String fileName) throws AudioException { - playFile(fileName, null, null); + playFileSingleSink(fileName, null, null); } @Override public void playFile(String fileName, @Nullable PercentType volume) throws AudioException { - playFile(fileName, null, volume); + playFileSingleSink(fileName, null, volume); } @Override public void playFile(String fileName, @Nullable String sinkId) throws AudioException { - playFile(fileName, sinkId, null); + playFileSingleSink(fileName, sinkId, null); + } + + @Override + public void playFile(String fileName, @NonNull Set sinkIds) throws AudioException { + playFileMultipleSink(fileName, sinkIds, null); } @Override public void playFile(String fileName, @Nullable String sinkId, @Nullable PercentType volume) throws AudioException { + playFileSingleSink(fileName, sinkId, volume); + } + + @Override + public void playFile(String fileName, @NonNull Set sinkIds, @Nullable PercentType volume) + throws AudioException { + playFileMultipleSink(fileName, sinkIds, volume); + } + + protected void playFileSingleSink(String fileName, @Nullable String sinkId, @Nullable PercentType volume) + throws AudioException { Objects.requireNonNull(fileName, "File cannot be played as fileName is null."); File file = Path.of(OpenHAB.getConfigFolder(), SOUND_DIR, fileName).toFile(); FileAudioStream is = new FileAudioStream(file); play(is, sinkId, volume); } + protected void playFileMultipleSink(String fileName, @NonNull Set sinkIds, @Nullable PercentType volume) + throws AudioException { + Objects.requireNonNull(fileName, "File cannot be played as fileName is null."); + File file = Path.of(OpenHAB.getConfigFolder(), SOUND_DIR, fileName).toFile(); + FileAudioStream is = new FileAudioStream(file); + play(is, sinkIds, volume); + } + @Override public void stream(@Nullable String url) throws AudioException { - stream(url, null); + streamSingleSink(url, null); } @Override public void stream(@Nullable String url, @Nullable String sinkId) throws AudioException { + streamSingleSink(url, sinkId); + } + + @Override + public void stream(@Nullable String url, @NonNull Set sinkIds) throws AudioException { + streamMultipleSink(url, sinkIds); + } + + protected void streamSingleSink(@Nullable String url, @Nullable String sinkId) throws AudioException { AudioStream audioStream = url != null ? new URLAudioStream(url) : null; play(audioStream, sinkId, null); } + protected void streamMultipleSink(@Nullable String url, @NonNull Set sinkIds) throws AudioException { + AudioStream audioStream = url != null ? new URLAudioStream(url) : null; + play(audioStream, sinkIds, null); + } + @Override public void playMelody(String melody) { - playMelody(melody, null); + playMelodySingleSink(melody, null, null); } @Override public void playMelody(String melody, @Nullable String sinkId) { - playMelody(melody, sinkId, null); + playMelodySingleSink(melody, sinkId, null); + } + + @Override + public void playMelody(String melody, @NonNull Set sinkIds) { + playMelodyMultiSink(melody, sinkIds, null); } @Override public void playMelody(String melody, @Nullable String sinkId, @Nullable PercentType volume) { + playMelodySingleSink(melody, sinkId, volume); + } + + @Override + public void playMelody(String melody, @NonNull Set sinkIds, @Nullable PercentType volume) { + playMelodyMultiSink(melody, sinkIds, volume); + } + + protected void playMelodySingleSink(String melody, @Nullable String sinkId, @Nullable PercentType volume) { AudioSink sink = getSink(sinkId); if (sink == null) { logger.warn("Failed playing melody as no audio sink {} was found.", sinkId); @@ -201,6 +330,51 @@ public class AudioManagerImpl implements AudioManager, ConfigOptionProvider { } } + protected void playMelodyMultiSink(String melody, @NonNull Set sinkIds, @Nullable PercentType volume) { + + if (sinkIds.isEmpty()) { + logger.warn("Failed playing melody as no audio sinks were provided."); + return; + } + + // Create a list of CompletableFutures for parallel execution + List> futures = sinkIds.stream().map(sinkId -> CompletableFuture.supplyAsync(() -> { + AudioSink sink = getSink(sinkId); + if (sink == null) { + logger.warn("Sink '{}' not found. Skipping.", sinkId); + return null; + } + + var synthesizerFormat = AudioFormat.getBestMatch(ToneSynthesizer.getSupportedFormats(), + sink.getSupportedFormats()); + if (synthesizerFormat == null) { + logger.warn("Sink '{}' does not support the required audio format. Skipping.", sinkId); + return null; + } + + try { + + // Generate the audio stream for the melody + var audioStream = new ToneSynthesizer(synthesizerFormat).getStream(ToneSynthesizer.parseMelody(melody)); + + // Play the melody on this sink asynchronously + play(audioStream, sinkId, volume); + logger.debug("Melody '{}' has been played on sink '{}'.", melody, sinkId); + } catch (Exception e) { + logger.warn("Error playing melody on sink '{}': {}", sinkId, e.getMessage(), e); + } + return null; + }, pool)).collect(Collectors.toList()); + + // Wait for all sinks to complete playback + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenRun(() -> logger.info("Melody '{}' has been played on all sinks.", melody)) + .exceptionally(exception -> { + logger.error("Error completing playback on all sinks: {}", exception.getMessage(), exception); + return null; + }); + } + @Override public void record(int seconds, String filename, @Nullable String sourceId) throws AudioException { var audioSource = sourceId != null ? getSource(sourceId) : getSource(); @@ -359,12 +533,26 @@ public class AudioManagerImpl implements AudioManager, ConfigOptionProvider { @Override public Set getSinkIds(String pattern) { - String regex = pattern.replace("?", ".?").replace("*", ".*?"); + + if (pattern.isEmpty()) { + throw new IllegalArgumentException("Input cannot be empty."); + } + Set matchedSinkIds = new HashSet<>(); - for (String sinkId : audioSinks.keySet()) { - if (sinkId.matches(regex)) { - matchedSinkIds.add(sinkId); + for (String segment : pattern.split(",")) { + segment = segment.trim(); + + if (segment.contains("*") || segment.contains("?")) { + String regex = segment.replace("?", ".?").replace("*", ".*?"); + + for (String sinkId : audioSinks.keySet()) { + if (sinkId.matches(regex)) { + matchedSinkIds.add(sinkId); + } + } + } else { + matchedSinkIds.add(segment); } } diff --git a/bundles/org.openhab.core.audio/src/test/java/org/openhab/core/audio/internal/fake/AudioSinkFake.java b/bundles/org.openhab.core.audio/src/test/java/org/openhab/core/audio/internal/fake/AudioSinkFake.java index bf3219d211..cb41789f98 100644 --- a/bundles/org.openhab.core.audio/src/test/java/org/openhab/core/audio/internal/fake/AudioSinkFake.java +++ b/bundles/org.openhab.core.audio/src/test/java/org/openhab/core/audio/internal/fake/AudioSinkFake.java @@ -15,6 +15,7 @@ package org.openhab.core.audio.internal.fake; import java.io.IOException; import java.util.Locale; import java.util.Set; +import java.util.concurrent.CompletableFuture; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -80,6 +81,18 @@ public class AudioSinkFake implements AudioSink { isStreamProcessed = true; } + @Override + public CompletableFuture<@Nullable Void> processAndComplete(@Nullable AudioStream audioStream) { + this.audioStream = audioStream; + if (audioStream != null) { + audioFormat = audioStream.getFormat(); + } else { + isStreamStopped = true; + } + isStreamProcessed = true; + return CompletableFuture.completedFuture(null); + } + public @Nullable AudioFormat getAudioFormat() { return audioFormat; } diff --git a/bundles/org.openhab.core.model.script/src/org/openhab/core/model/script/actions/Audio.java b/bundles/org.openhab.core.model.script/src/org/openhab/core/model/script/actions/Audio.java index b81d94f9fe..34d5954e6b 100644 --- a/bundles/org.openhab.core.model.script/src/org/openhab/core/model/script/actions/Audio.java +++ b/bundles/org.openhab.core.model.script/src/org/openhab/core/model/script/actions/Audio.java @@ -65,7 +65,7 @@ public class Audio { public static void playSound(@ParamDoc(name = "sink", text = "the id of the sink") String sink, @ParamDoc(name = "filename", text = "the filename with extension") String filename) { try { - AudioActionService.audioManager.playFile(filename, sink); + AudioActionService.audioManager.playFile(filename, AudioActionService.audioManager.getSinkIds(sink)); } catch (AudioException e) { logger.warn("Failed playing audio file: {}", e.getMessage()); } @@ -76,7 +76,8 @@ public class Audio { @ParamDoc(name = "filename", text = "the filename with extension") String filename, @ParamDoc(name = "volume", text = "the volume to be used") PercentType volume) { try { - AudioActionService.audioManager.playFile(filename, sink, volume); + AudioActionService.audioManager.playFile(filename, AudioActionService.audioManager.getSinkIds(sink), + volume); } catch (AudioException e) { logger.warn("Failed playing audio file: {}", e.getMessage()); } @@ -103,7 +104,7 @@ public class Audio { public static synchronized void playStream(@ParamDoc(name = "sink", text = "the id of the sink") String sink, @ParamDoc(name = "url", text = "the url of the audio stream") String url) { try { - AudioActionService.audioManager.stream(url, sink); + AudioActionService.audioManager.stream(url, AudioActionService.audioManager.getSinkIds(sink)); } catch (AudioException e) { logger.warn("Failed streaming audio url: {}", e.getMessage()); } @@ -167,7 +168,7 @@ public class Audio { /** * Converts a float volume to a {@link PercentType} volume and checks if float volume is in the [0;1] range. - * + * * @param volume * @return */