-
-
Notifications
You must be signed in to change notification settings - Fork 457
Add support for concurrent multisink audio #4653
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
080f8d9
to
466fa25
Compare
@wborn Can you help me explain why I get a test error whereas locally all runs fine:
|
6162850
to
2f48b3b
Compare
@wborn @kaikreuzer Something fishy is going on with the build process on Github. As you can see, the build here above fails on a NPE that does not occur when running tests locally with mvn or in the IDE Copying over one of the /src/test/java files into a new .java file (without change, and keeping the original "faulty" .java as well) suddenly resolves the error, and the build completes. Then removing that copy again, and the build fails. |
Signed-off-by: Karel Goderis <[email protected]>
I've seen it before but didn't figure it out quickly: #3405. Usually it's a timing issue. |
@wborn Clear. so how to proceed with this PR ? |
import org.eclipse.jdt.annotation.Nullable; | ||
import org.openhab.core.library.types.PercentType; | ||
|
||
import io.reactivex.annotations.NonNull; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi, I just briefly looked into this and I am wondering why you import an additional annotation framework.
As the main class is annotated with @ NonNullByDefault, you may not need this anyway.
Did I overlook something?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@holgerfriedrich You are right, but it is a bad habit of making things explicit in method signatures that comes into play.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@kgoderis The import uses a different framework, I would expect org.eclipse.jdt.annotation.NonNull
instead of io.reactivex.annotations.NonNull
....
@kgoderis could you please rebase? |
Yes, will do that, and fix also the NonNull import |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR adds concurrent multisink audio support to allow playing audio simultaneously on multiple sinks instead of sequentially. The key enhancement is adding support for comma-separated sink patterns and leveraging a configurable ThreadPool for parallel audio playback.
- Adds new methods to AudioManager and AudioManagerImpl that accept Set sinkIds for multisink operations
- Implements parallel audio playback using ThreadPoolManager with configurable thread pool
- Updates getSinkIds method to parse comma-separated patterns for flexible sink selection
Reviewed Changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
File | Description |
---|---|
AudioManager.java | Adds interface methods for multisink audio operations |
AudioManagerImpl.java | Implements concurrent playback logic and enhanced pattern matching |
Audio.java | Updates script actions to use new getSinkIds method |
AudioConsoleCommandExtension.java | Refactors console commands to use multisink functionality |
AudioSinkFake.java | Adds test implementation of processAndComplete method |
import io.reactivex.annotations.NonNull; | ||
|
Copilot
AI
Jul 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The import io.reactivex.annotations.NonNull
is unnecessary since the code already uses org.eclipse.jdt.annotation.NonNullByDefault
and standard JDT annotations. This creates confusion about which annotation framework is being used.
import io.reactivex.annotations.NonNull; |
Copilot uses AI. Check for mistakes.
import org.eclipse.jdt.annotation.Nullable; | ||
import org.openhab.core.library.types.PercentType; | ||
|
||
import io.reactivex.annotations.NonNull; |
Copilot
AI
Jul 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The import io.reactivex.annotations.NonNull
is inconsistent with the existing annotation framework. The interface already uses @NonNullByDefault
and should use JDT annotations (@NonNull
) for consistency.
import io.reactivex.annotations.NonNull; | |
// Removed the unused import for io.reactivex.annotations.NonNull |
Copilot uses AI. Check for mistakes.
import org.osgi.service.component.annotations.Component; | ||
import org.osgi.service.component.annotations.Reference; | ||
|
||
import io.reactivex.annotations.NonNull; |
Copilot
AI
Jul 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The import io.reactivex.annotations.NonNull
is inconsistent with the existing annotation framework used in this codebase. Should use JDT annotations for consistency.
import io.reactivex.annotations.NonNull; | |
import org.eclipse.jdt.annotation.NonNull; |
Copilot uses AI. Check for mistakes.
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()); |
Copilot
AI
Jul 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The processAndComplete
method returns a CompletableFuture
but the return value is ignored. This means the code doesn't wait for completion and volume restoration may happen before playback finishes.
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()); | |
List<CompletableFuture<Void>> futures = sinkIds.stream().map(sinkId -> CompletableFuture.supplyAsync(() -> { | |
AudioSink sink = getSink(sinkId); | |
if (sink == null) { | |
logger.warn("Sink '{}' not found. Skipping.", sinkId); | |
return CompletableFuture.completedFuture(null); // Return a completed future for missing sinks | |
} | |
// Handle volume adjustment for the current sink | |
Runnable restoreVolume = handleVolumeCommand(volume, sink); | |
// Play the audio stream asynchronously on this sink | |
return sink.processAndComplete(audioStream) | |
.exceptionally(exception -> { | |
logger.error("Error playing '{}' on sink '{}': {}", audioStream, sinkId, exception.getMessage(), exception); | |
return null; // Handle the exception gracefully | |
}) | |
.thenRun(() -> { | |
restoreVolume.run(); // Ensure volume is restored after playback completes | |
logger.debug("Audio stream '{}' has been played on sink '{}'.", audioStream, sinkId); | |
}); | |
}, pool).thenCompose(future -> future)).collect(Collectors.toList()); |
Copilot uses AI. Check for mistakes.
} | ||
|
||
@Override | ||
public void play(@Nullable AudioStream audioStream, @NonNull Set<String> sinkIds) { |
Copilot
AI
Jul 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The @NonNull
annotation here uses RxJava imports but should use JDT annotations (@org.eclipse.jdt.annotation.NonNull
) for consistency with the rest of the codebase.
public void play(@Nullable AudioStream audioStream, @NonNull Set<String> sinkIds) { | |
public void play(@Nullable AudioStream audioStream, @org.eclipse.jdt.annotation.NonNull Set<String> sinkIds) { |
Copilot uses AI. Check for mistakes.
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 |
Copilot
AI
Jul 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using CompletableFuture<Object>
with supplyAsync
that always returns null is misleading. Consider using CompletableFuture<Void>
with runAsync
for better semantic clarity since no value is being computed.
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 | |
List<CompletableFuture<Void>> futures = sinkIds.stream().map(sinkId -> CompletableFuture.runAsync(() -> { | |
AudioSink sink = getSink(sinkId); | |
if (sink == null) { | |
logger.warn("Sink '{}' not found. Skipping.", sinkId); | |
return; // Skip processing for missing sinks |
Copilot uses AI. Check for mistakes.
Add support for concurrent multisink audio
Allow playing audio concurrently on multiple sinks. In the context of the Sonos binding, this eliminates the need to group or ungroup Sonos players in order to play audio synchronously on all players (e.g. in Rules). Audio is also played concurrently on all sinks using a ThreadPool that is configurable in services.cfg ("threadpool:audio=10"), thereby eliminating the "sequential" output when playing audio on a large group of sinks (e.g. doorbell on Sonos)
Furthermore, it add support for comma-separated lists of sinks or patterns to the various commands, so that arbitrary groupings can be made by the end-user, e.g. "Sonos:CONNECT:*,enhancedjavasound" for usage with playSound() in Rules or the "openhab:audio play" command