Skip to content

Commit

Permalink
[audio] More capabilities for AudioSink using the AudioServlet
Browse files Browse the repository at this point in the history
Applying code review advices (CompletableFuture and others)

Signed-off-by: Gwendal Roulleau <[email protected]>
  • Loading branch information
dalgwen committed May 29, 2023
1 parent 041f466 commit b8a0a5b
Show file tree
Hide file tree
Showing 16 changed files with 393 additions and 261 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
package org.openhab.core.audio;

import java.io.IOException;
import java.util.concurrent.CompletableFuture;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.audio.internal.AudioServlet;
Expand All @@ -36,43 +37,48 @@ public interface AudioHTTPServer {
*
* @param stream the stream to serve on HTTP
* @return the relative URL to access the stream starting with a '/'
* @deprecated Use {@link AudioHTTPServer#serve(AudioStream, int, boolean, CompletableFuture)}
*/
@Deprecated
String serve(AudioStream stream);

/**
* Creates a relative url for a given {@link AudioStream} where it can be requested multiple times within the given
* time frame.
* This method accepts all {@link AudioStream}s, but it is better to use {@link ClonableAudioStream}s since it
* needs to be able to create multiple concurrent streams from it.
* If generic {@link AudioStream} is used, the method tries to add the Clonable capability by storing it in a small
* memory buffer,
* e.g {@link ByteArrayAudioStream}, or in a cached file if the stream reached the buffer capacity, or fails if the
* stream is too long.
* This method accepts all {@link AudioStream}s, but it is better to use {@link ClonableAudioStream}s. If generic
* {@link AudioStream} is used, the method tries to add the Clonable capability by storing it in a small memory
* buffer, e.g {@link ByteArrayAudioStream}, or in a cached file if the stream reached the buffer capacity,
* or fails if the stream is too long.
* Streams are closed, once they expire.
*
* @param stream the stream to serve on HTTP
* @param seconds number of seconds for which the stream is available through HTTP
* @return the relative URL to access the stream starting with a '/'
* @deprecated Use {@link AudioHTTPServer#serve(AudioStream, int, boolean, CompletableFuture)}
*/
@Deprecated
String serve(AudioStream stream, int seconds);

/**
* Creates a relative url for a given {@link AudioStream} where it can be requested multiple times within the given
* time frame.
* This method accepts all {@link AudioStream}s, but it is better to use {@link ClonableAudioStream}s since it
* needs to be able to create multiple concurrent streams from it.
* If generic {@link AudioStream} is used, method tries to add the Clonable capability by storing it in a small
* memory buffer,
* e.g {@link ByteArrayAudioStream}, or in a cached file if the stream reached the buffer capacity, or fails if the
* stream is too long.
* Creates a relative url for a given {@link AudioStream} where it can be requested one or multiple times within the
* given time frame.
* This method accepts all {@link AudioStream}s, but if multiTimeStream is set to true it is better to use
* {@link ClonableAudioStream}s. Otherwise, if a generic {@link AudioStream} is used, the method will then try
* to add the Clonable capability by storing it in a small memory buffer, e.g {@link ByteArrayAudioStream}, or in a
* cached file if the stream reached the buffer capacity, or fails to render the sound completely if the stream is
* too long.
* A {@link CompletableFuture} is used to inform the caller that the playback ends in order to clean
* resources and run delayed task, such as restoring volume.
* Streams are closed, once they expire.
*
* @param stream the stream to serve on HTTP
* @param seconds number of seconds for which the stream is available through HTTP
* @param a Runnable callback for cleaning resources. The AudioHTTPServer will run the callback when the stream is
* not used anymore and timed-out.
* @return the relative URL to access the stream starting with a '/'
* @param seconds number of seconds for which the stream is available through HTTP. The stream will be deleted only
* if not started, so you can set a duration shorter than the track's duration.
* @param multiTimeStream set to true if this stream should be played multiple time, and thus needs to be made
* Cloneable if it is not already.
* @return information about the {@link StreamServed}, including the relative URL to access the stream starting with
* a '/', and a CompletableFuture to know when the playback ends.
* @throws IOException when the stream is not a {@link ClonableAudioStream} and we cannot get or store it on disk.
*/
String serve(AudioStream stream, int seconds, Runnable callBack) throws IOException;
StreamServed serve(AudioStream stream, int seconds, boolean multiTimeStream) throws IOException;
}
Original file line number Diff line number Diff line change
Expand Up @@ -252,4 +252,15 @@ public interface AudioManager {
* @return ids of matching sinks
*/
Set<String> getSinkIds(String pattern);

/**
* Handles a volume command change and returns a Runnable to restore it.
* Returning a Runnable allows us to have a no-op Runnable if changing volume back is not needed, and conveniently
* keeping it as one liner usable in a chain for the caller.
*
* @param volume The volume to set
* @param sink The sink to set the volume to
* @return A runnable to restore the volume to its previous value, or no-operation if no change is required.
*/
Runnable handleVolumeCommand(@Nullable PercentType volume, AudioSink sink);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
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;
Expand Down Expand Up @@ -58,19 +59,21 @@ public interface AudioSink {
*
* In case the audioStream is null, this should be interpreted as a request to end any currently playing stream.
*
* When the process method ends, if the stream implements the {@link org.openhab.core.common.Disposable} interface,
* the sink should hereafter get rid of it by calling the dispose method.
* When the stream is not needed anymore, if the stream implements the {@link org.openhab.core.common.Disposable}
* interface, the sink should hereafter get rid of it by calling the dispose method.
*
* @param audioStream the audio stream to play or null to keep quiet
* @throws UnsupportedAudioFormatException If audioStream format is not supported
* @throws UnsupportedAudioStreamException If audioStream is not supported
* @deprecated Use {@link AudioSink#processAndComplete(AudioStream)}
*/
@Deprecated
void process(@Nullable AudioStream audioStream)
throws UnsupportedAudioFormatException, UnsupportedAudioStreamException;

/**
* Processes the passed {@link AudioStream}, and executes the whenFinished Runnable (or stores for later execution
* if the sink is asynchronous).
* Processes the passed {@link AudioStream}, and returns a CompletableFuture that should complete when the sound is
* fully played. It is the sink responsibility to complete this future.
*
* If the passed {@link AudioStream} is not supported by this instance, an {@link UnsupportedAudioStreamException}
* is thrown.
Expand All @@ -80,23 +83,17 @@ void process(@Nullable AudioStream audioStream)
*
* In case the audioStream is null, this should be interpreted as a request to end any currently playing stream.
*
* When the process method ends, if the stream implements the {@link org.openhab.core.common.Disposable} interface,
* the sink should hereafter get rid of it by calling the dispose method.
* When the stream is not needed anymore, if the stream implements the {@link org.openhab.core.common.Disposable}
* interface, the sink should hereafter get rid of it by calling the dispose method.
*
* @param audioStream the audio stream to play or null to keep quiet
* @param whenFinished A Runnable to run when the sound is finished playing.
* @throws UnsupportedAudioFormatException If audioStream format is not supported
* @throws UnsupportedAudioStreamException If audioStream is not supported
*/
default void process(@Nullable AudioStream audioStream, @Nullable Runnable whenFinished)
default CompletableFuture<@Nullable Void> processAndComplete(@Nullable AudioStream audioStream)
throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
try {
process(audioStream);
} finally {
if (whenFinished != null) {
whenFinished.run();
}
}
process(audioStream);
return CompletableFuture.completedFuture(null);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
Expand All @@ -25,10 +26,10 @@
/**
* Definition of an audio output like headphones, a speaker or for writing to
* a file / clip.
* This version is asynchronous: when the process() method returns, the {@link AudioStream}
* may or may not be played, and we don't know when the delayed task will be executed.
* CAUTION : It is the responsibility of the implementing AudioSink class to call the runDelayedTask
* method when playing is done.
* Helper class for asynchronous sink : when the process() method returns, the {@link AudioStream}
* may or may not be played. It is the responsibility of the implementing AudioSink class to
* complete the CompletableFuture when playing is done. Any delayed tasks will then be performed, such as volume
* restoration.
*
* @author Gwendal Roulleau - Initial contribution
*/
Expand All @@ -37,38 +38,66 @@ public abstract class AudioSinkAsync implements AudioSink {

private final Logger logger = LoggerFactory.getLogger(AudioSinkAsync.class);

private final Map<AudioStream, Runnable> runnableByAudioStream = new HashMap<>();
private final Map<AudioStream, CompletableFuture<@Nullable Void>> runnableByAudioStream = new HashMap<>();

@Override
public void process(@Nullable AudioStream audioStream, @Nullable Runnable whenFinished)
public CompletableFuture<@Nullable Void> processAndComplete(@Nullable AudioStream audioStream)
throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
CompletableFuture<@Nullable Void> completableFuture = new CompletableFuture<@Nullable Void>();
try {
if (audioStream != null && whenFinished != null) {
runnableByAudioStream.put(audioStream, whenFinished);
if (audioStream != null) {
runnableByAudioStream.put(audioStream, completableFuture);
}
process(audioStream);
processAsynchronously(audioStream);
return completableFuture;
} finally {
if (audioStream == null && whenFinished != null) {
if (audioStream == null) {
// No need to delay the post process task
whenFinished.run();
runnableByAudioStream.remove(audioStream);
completableFuture.complete(null);
}
}
}

@Override
public void process(@Nullable AudioStream audioStream)
throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
processAsynchronously(audioStream);
}

/**
* Will run the delayed task stored previously.
* Processes the passed {@link AudioStream} asynchronously. This method is expected to return before the stream is
* fully played. This is the sink responsibility to call the {@link AudioSinkAsync#playbackFinished(AudioStream)}
* when it is.
*
* If the passed {@link AudioStream} is not supported by this instance, an {@link UnsupportedAudioStreamException}
* is thrown.
*
* @param audioStream The AudioStream is the key to find the delayed Runnable task in the storage.
* If the passed {@link AudioStream} has an {@link AudioFormat} not supported by this instance,
* an {@link UnsupportedAudioFormatException} is thrown.
*
* In case the audioStream is null, this should be interpreted as a request to end any currently playing stream.
*
* @param audioStream the audio stream to play or null to keep quiet
* @throws UnsupportedAudioFormatException If audioStream format is not supported
* @throws UnsupportedAudioStreamException If audioStream is not supported
*/
protected void runDelayed(AudioStream audioStream) {
Runnable delayedTask = runnableByAudioStream.remove(audioStream);
protected abstract void processAsynchronously(@Nullable AudioStream audioStream)
throws UnsupportedAudioFormatException, UnsupportedAudioStreamException;

if (delayedTask != null) {
delayedTask.run();
/**
* Will complete the future previously returned, allowing the core to run delayed task.
*
* @param audioStream The AudioStream is the key to find the delayed CompletableFuture in the storage.
*/
protected void playbackFinished(AudioStream audioStream) {
CompletableFuture<@Nullable Void> completableFuture = runnableByAudioStream.remove(audioStream);
if (completableFuture != null) {
completableFuture.complete(null);
}

// if the stream is not needed anymore, then we should call back the AudioStream to let it a chance
// to auto dispose:
// to auto dispose.
if (audioStream instanceof Disposable disposableAudioStream) {
try {
disposableAudioStream.dispose();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
package org.openhab.core.audio;

import java.io.IOException;
import java.util.concurrent.CompletableFuture;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
Expand All @@ -23,9 +24,9 @@
/**
* Definition of an audio output like headphones, a speaker or for writing to
* a file / clip.
* This version is synchronous: when the process() method returns,
* Helper class for synchronous sink : when the process() method returns,
* the source is considered played, and could be disposed.
* Any delayed tasks can then be performed, such as volume restoration
* Any delayed tasks can then be performed, such as volume restoration.
*
* @author Gwendal Roulleau - Initial contribution
*/
Expand All @@ -35,17 +36,12 @@ public abstract class AudioSinkSync implements AudioSink {
private final Logger logger = LoggerFactory.getLogger(AudioSinkSync.class);

@Override
public void process(@Nullable AudioStream audioStream, @Nullable Runnable whenFinished)
public CompletableFuture<@Nullable Void> processAndComplete(@Nullable AudioStream audioStream)
throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
try {
process(audioStream);
processSynchronously(audioStream);
} finally {
if (whenFinished != null) {
whenFinished.run();
}

// if the stream is not needed anymore, then we should call back the AudioStream to let it a chance
// to auto dispose:
// as the stream is not needed anymore, we should dispose of it
if (audioStream instanceof Disposable disposableAudioStream) {
try {
disposableAudioStream.dispose();
Expand All @@ -59,5 +55,30 @@ public void process(@Nullable AudioStream audioStream, @Nullable Runnable whenFi
}
}
}
return CompletableFuture.completedFuture(null);
}

@Override
public void process(@Nullable AudioStream audioStream)
throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
processSynchronously(audioStream);
}

/**
* Processes the passed {@link AudioStream} and returns only when the playback is ended.
*
* If the passed {@link AudioStream} is not supported by this instance, an {@link UnsupportedAudioStreamException}
* is thrown.
*
* If the passed {@link AudioStream} has an {@link AudioFormat} not supported by this instance,
* an {@link UnsupportedAudioFormatException} is thrown.
*
* In case the audioStream is null, this should be interpreted as a request to end any currently playing stream.
*
* @param audioStream the audio stream to play or null to keep quiet
* @throws UnsupportedAudioFormatException If audioStream format is not supported
* @throws UnsupportedAudioStreamException If audioStream is not supported
*/
protected abstract void processSynchronously(@Nullable AudioStream audioStream)
throws UnsupportedAudioFormatException, UnsupportedAudioStreamException;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2010-2023 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.audio;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;

/**
* Streams served by the AudioHTTPServer.
*
* @author Gwendal Roulleau - Initial contribution
*/
@NonNullByDefault
public record StreamServed(String url, AudioStream audioStream, AtomicInteger currentlyServedStream, AtomicLong timeout,
boolean multiTimeStream, CompletableFuture<@Nullable Void> playEnd) {
}
Loading

0 comments on commit b8a0a5b

Please sign in to comment.