pull/4653/merge
Karel Goderis 2025-06-11 13:46:13 +02:00 committed by GitHub
commit 3935e2e8b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 317 additions and 29 deletions

View File

@ -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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> getSinkIds(String pattern);

View File

@ -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<String> 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:

View File

@ -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<String, AudioSource> audioSources = new ConcurrentHashMap<>();
private final Map<String, AudioSink> 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<String> 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<String> 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<String> 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<CompletableFuture<Object>> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<CompletableFuture<Object>> 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<String> getSinkIds(String pattern) {
String regex = pattern.replace("?", ".?").replace("*", ".*?");
if (pattern.isEmpty()) {
throw new IllegalArgumentException("Input cannot be empty.");
}
Set<String> 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);
}
}

View File

@ -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;
}

View File

@ -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
*/