[pulseaudio] Declare pulseaudio module per audio stream (#16254)

* [pulseaudio] Declare pulseaudio module per audio stream

Signed-off-by: Miguel Álvarez <miguelwork92@gmail.com>
Signed-off-by: Ciprian Pascu <contact@ciprianpascu.ro>
This commit is contained in:
GiviMAD 2024-04-30 23:31:19 +02:00 committed by Ciprian Pascu
parent 659a748f85
commit bb1cad9ec1
14 changed files with 715 additions and 513 deletions

View File

@ -62,6 +62,18 @@ Sink things can register themselves as audio sink in openHAB. MP3 and WAV files
Use the appropriate parameter in the sink thing to activate this possibility (activateSimpleProtocolSink).
This requires the module **module-simple-protocol-tcp** to be present on the server which runs your openHAB instance. The binding will try to command (if not discovered first) the load of this module on the pulseaudio server.
### Thing Configuration
| Config Name | Item Type | Description |
|-----------------------------|-----------------------------------------------------------------------------------------------------------------|
| name | text | The name of one specific device. You can also use the description |
| activateSimpleProtocolSink | boolean | Activation of a corresponding sink in OpenHAB |
| additionalFilters | text | Additional filters to select the proper device on the pulseaudio server, in case of ambiguity |
| simpleProtocolIdleModules | integer | Number of Simple Protocol TCP Socket modules to keep loaded in the server |
| simpleProtocolMinPort | integer | Min port used by simple protocol module instances created by the binding on the pulseaudio host |
| simpleProtocolMaxPort | integer | Max port used by simple protocol module instances created by the binding on the pulseaudio host |
| simpleProtocolSOTimeout | integer | Socket SO timeout when connecting to pulseaudio server though module-simple-protocol-tcp |
## Audio source
Source things can register themselves as audio source in openHAB.
@ -69,6 +81,21 @@ WAV input format, rate and channels can be configured on the thing configuration
Use the appropriate parameter in the source thing to activate this possibility (activateSimpleProtocolSource).
This requires the module **module-simple-protocol-tcp** to be present on the target pulseaudio server. The binding will load this module on the pulseaudio server.
### Thing Configuration
| Config ID | Item Type | Description |
|------------------------------|-----------------------------------------------------------------------------------------------------------------|
| name | text | The name of one specific device. You can also use the description |
| activateSimpleProtocolSource | boolean | Activation of a corresponding sink in OpenHAB |
| additionalFilters | text | Additional filters to select the proper device on the pulseaudio server, in case of ambiguity |
| simpleProtocolIdleModules | integer | Number of Simple Protocol TCP Socket modules to keep loaded in the server |
| simpleProtocolMinPort | integer | Min port used by simple protocol module instances created by the binding on the pulseaudio host |
| simpleProtocolMaxPort | integer | Max port used by simple protocol module instances created by the binding on the pulseaudio host |
| simpleProtocolSOTimeout | integer | Socket SO timeout when connecting to pulseaudio server though module-simple-protocol-tcp |
| simpleProtocolSourceFormat | text | The audio format to be used by module-simple-protocol-tcp on the pulseaudio server |
| simpleProtocolSourceRate | integer | The audio sample rate to be used by module-simple-protocol-tcp on the pulseaudio server |
| simpleProtocolSourceChannels | integer | The audio channel number to be used by module-simple-protocol-tcp on the pulseaudio server |
## Full Example
### pulseaudio.things
@ -76,8 +103,8 @@ This requires the module **module-simple-protocol-tcp** to be present on the tar
```java
Bridge pulseaudio:bridge:<bridgname> "<Bridge Label>" @ "<Room>" [ host="<ipAddress>", port=4712 ] {
Things:
Thing sink multiroom "Snapcast" @ "Room" [name="alsa_card.pci-0000_00_1f.3", activateSimpleProtocolSink=true, simpleProtocolSinkPort=4711, additionalFilters="analog-stereo###internal"]
Thing source microphone "microphone" @ "Room" [name="alsa_input.pci-0000_00_14.2.analog-stereo"]
Thing sink multiroom "Snapcast" @ "Room" [name="alsa_card.pci-0000_00_1f.3", activateSimpleProtocolSink=true, additionalFilters="analog-stereo###internal"]
Thing source microphone "microphone" @ "Room" [name="alsa_input.pci-0000_00_14.2.analog-stereo", activateSimpleProtocolSource=true]
Thing sink-input openhabTTS "OH-Voice" @ "Room" [name="alsa_output.pci-0000_00_1f.3.hdmi-stereo-extra1"]
Thing source-output remotePulseSink "Other Room Speaker" @ "Other Room" [name="alsa_input.pci-0000_00_14.2.analog-stereo"]
Thing combined-sink hdmiAndAnalog "Zone 1+2" @ "Room" [name="combined"]

View File

@ -15,11 +15,11 @@ package org.openhab.binding.pulseaudio.internal;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import javazoom.spi.mpeg.sampled.convert.MpegFormatConversionProvider;
import javazoom.spi.mpeg.sampled.file.MpegAudioFileReader;
import javax.sound.sampled.AudioFileFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.UnsupportedAudioFileException;
@ -28,160 +28,143 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.audio.AudioFormat;
import org.openhab.core.audio.AudioStream;
import org.openhab.core.audio.SizeableAudioStream;
import org.openhab.core.audio.UnsupportedAudioFormatException;
import org.openhab.core.audio.utils.AudioWaveUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.tritonus.share.sampled.file.TAudioFileFormat;
/**
* This class convert a stream to the normalized pcm
* format wanted by the pulseaudio sink
* This class convert a stream to the pcm signed
* format supported by the pulseaudio sink
*
* @author Gwendal Roulleau - Initial contribution
* @author Miguel Álvarez Díez - Extend from AudioStream
*/
@NonNullByDefault
public class ConvertedInputStream extends InputStream {
public class ConvertedInputStream extends AudioStream {
private final Logger logger = LoggerFactory.getLogger(ConvertedInputStream.class);
private static final javax.sound.sampled.AudioFormat TARGET_FORMAT = new javax.sound.sampled.AudioFormat(
javax.sound.sampled.AudioFormat.Encoding.PCM_SIGNED, 44100, 16, 2, 4, 44100, false);
private AudioFormat originalAudioFormat;
private final AudioFormat outputAudioFormat;
private final InputStream pcmInnerInputStream;
private final AudioFormat audioFormat;
private AudioInputStream pcmNormalizedInputStream;
private long duration = -1;
private long length = -1;
private static final Set<String> COMPATIBLE_CODEC = Set.of(AudioFormat.CODEC_PCM_ALAW, AudioFormat.CODEC_PCM_ULAW,
AudioFormat.CODEC_PCM_UNSIGNED);
public ConvertedInputStream(AudioStream innerInputStream)
throws UnsupportedAudioFormatException, UnsupportedAudioFileException, IOException {
this.audioFormat = innerInputStream.getFormat();
this.originalAudioFormat = innerInputStream.getFormat();
if (innerInputStream instanceof SizeableAudioStream sizeableAudioStream) {
length = sizeableAudioStream.length();
String container = originalAudioFormat.getContainer();
if (container == null) {
throw new UnsupportedAudioFormatException("Unknown format, cannot process", innerInputStream.getFormat());
}
pcmNormalizedInputStream = getPCMStreamNormalized(getPCMStream(new BufferedInputStream(innerInputStream)));
if (container.equals(AudioFormat.CONTAINER_WAVE)) {
if (originalAudioFormat.getFrequency() == null || originalAudioFormat.getChannels() == null
|| originalAudioFormat.getBitRate() == null || originalAudioFormat.getCodec() == null
|| originalAudioFormat.getBitDepth() == null || originalAudioFormat.isBigEndian() == null) {
// parse it by ourself to maybe get missing information :
this.originalAudioFormat = AudioWaveUtils.parseWavFormat(innerInputStream);
}
}
if (AudioFormat.CODEC_PCM_SIGNED.equals(originalAudioFormat.getCodec())) {
outputAudioFormat = originalAudioFormat;
pcmInnerInputStream = innerInputStream;
if (container.equals(AudioFormat.CONTAINER_WAVE)) {
AudioWaveUtils.removeFMT(innerInputStream);
}
} else {
pcmInnerInputStream = getPCMStream(new BufferedInputStream(innerInputStream));
var javaAudioFormat = ((AudioInputStream) pcmInnerInputStream).getFormat();
int bitRate = (int) javaAudioFormat.getSampleRate() * javaAudioFormat.getSampleSizeInBits()
* javaAudioFormat.getChannels();
outputAudioFormat = new AudioFormat(AudioFormat.CONTAINER_NONE, AudioFormat.CODEC_PCM_SIGNED,
javaAudioFormat.isBigEndian(), javaAudioFormat.getSampleSizeInBits(), bitRate,
(long) javaAudioFormat.getSampleRate(), javaAudioFormat.getChannels());
}
}
@Override
public int read(byte @Nullable [] b) throws IOException {
return pcmNormalizedInputStream.read(b);
return pcmInnerInputStream.read(b);
}
@Override
public int read(byte @Nullable [] b, int off, int len) throws IOException {
return pcmNormalizedInputStream.read(b, off, len);
return pcmInnerInputStream.read(b, off, len);
}
@Override
public byte[] readAllBytes() throws IOException {
return pcmNormalizedInputStream.readAllBytes();
return pcmInnerInputStream.readAllBytes();
}
@Override
public byte[] readNBytes(int len) throws IOException {
return pcmNormalizedInputStream.readNBytes(len);
return pcmInnerInputStream.readNBytes(len);
}
@Override
public int readNBytes(byte @Nullable [] b, int off, int len) throws IOException {
return pcmNormalizedInputStream.readNBytes(b, off, len);
return pcmInnerInputStream.readNBytes(b, off, len);
}
@Override
public int read() throws IOException {
return pcmNormalizedInputStream.read();
return pcmInnerInputStream.read();
}
@Override
public void close() throws IOException {
pcmNormalizedInputStream.close();
pcmInnerInputStream.close();
}
/**
* Ensure right PCM format by converting if needed (sample rate, channel)
*
* @param pcmInputStream
*
* @return A PCM normalized stream (2 channel, 44100hz, 16 bit signed)
*/
private AudioInputStream getPCMStreamNormalized(AudioInputStream pcmInputStream) {
javax.sound.sampled.AudioFormat format = pcmInputStream.getFormat();
if (format.getChannels() != 2
|| !format.getEncoding().equals(javax.sound.sampled.AudioFormat.Encoding.PCM_SIGNED)
|| Math.abs(format.getFrameRate() - 44100) > 1000) {
logger.debug("Sound is not in the target format. Trying to reencode it");
return AudioSystem.getAudioInputStream(TARGET_FORMAT, pcmInputStream);
} else {
return pcmInputStream;
}
}
public long getDuration() {
return duration;
}
/**
* If necessary, this method convert MP3 to PCM, and try to
* extract duration information.
* If necessary, this method convert to target PCM
*
* @param resetableInnerInputStream A stream supporting reset operation
* (reset is mandatory to parse formation without loosing data)
*
* @return PCM stream
* @throws UnsupportedAudioFileException
* @throws IOException
* @throws UnsupportedAudioFormatException
* @throws IOException
*/
private AudioInputStream getPCMStream(InputStream resetableInnerInputStream)
throws UnsupportedAudioFileException, IOException, UnsupportedAudioFormatException {
if (AudioFormat.MP3.isCompatible(audioFormat)) {
MpegAudioFileReader mpegAudioFileReader = new MpegAudioFileReader();
if (length > 0) { // compute duration if possible
AudioFileFormat audioFileFormat = mpegAudioFileReader.getAudioFileFormat(resetableInnerInputStream);
if (audioFileFormat instanceof TAudioFileFormat) {
Map<String, Object> taudioFileFormatProperties = ((TAudioFileFormat) audioFileFormat).properties();
if (taudioFileFormatProperties.containsKey("mp3.framesize.bytes")
&& taudioFileFormatProperties.containsKey("mp3.framerate.fps")) {
Integer frameSize = (Integer) taudioFileFormatProperties.get("mp3.framesize.bytes");
Float frameRate = (Float) taudioFileFormatProperties.get("mp3.framerate.fps");
if (frameSize != null && frameRate != null) {
duration = Math.round((length / (frameSize * frameRate)) * 1000);
logger.debug("Duration of input stream : {}", duration);
}
}
}
resetableInnerInputStream.reset();
}
if (AudioFormat.CODEC_MP3.equals(originalAudioFormat.getCodec())) {
logger.debug("Sound is a MP3. Trying to reencode it");
// convert MP3 to PCM :
AudioInputStream sourceAIS = mpegAudioFileReader.getAudioInputStream(resetableInnerInputStream);
AudioInputStream sourceAIS = new MpegAudioFileReader().getAudioInputStream(resetableInnerInputStream);
javax.sound.sampled.AudioFormat sourceFormat = sourceAIS.getFormat();
MpegFormatConversionProvider mpegconverter = new MpegFormatConversionProvider();
int bitDepth = sourceFormat.getSampleSizeInBits() != -1 ? sourceFormat.getSampleSizeInBits() : 16;
javax.sound.sampled.AudioFormat convertFormat = new javax.sound.sampled.AudioFormat(
javax.sound.sampled.AudioFormat.Encoding.PCM_SIGNED, sourceFormat.getSampleRate(), 16,
sourceFormat.getChannels(), sourceFormat.getChannels() * 2, sourceFormat.getSampleRate(), false);
javax.sound.sampled.AudioFormat.Encoding.PCM_SIGNED, sourceFormat.getSampleRate(), bitDepth,
sourceFormat.getChannels(), 2 * sourceFormat.getChannels(), sourceFormat.getSampleRate(), false);
return mpegconverter.getAudioInputStream(convertFormat, sourceAIS);
} else if (AudioFormat.WAV.isCompatible(audioFormat)) {
// return the same input stream, but try to compute the duration first
AudioInputStream audioInputStream = AudioSystem.getAudioInputStream(resetableInnerInputStream);
if (length > 0) {
int frameSize = audioInputStream.getFormat().getFrameSize();
float frameRate = audioInputStream.getFormat().getFrameRate();
float durationInSeconds = (length / (frameSize * frameRate));
duration = Math.round(durationInSeconds * 1000);
logger.debug("Duration of input stream : {}", duration);
}
} else if (COMPATIBLE_CODEC.contains(originalAudioFormat.getCodec())) {
long frequency = Optional.ofNullable(originalAudioFormat.getFrequency()).orElse(44100L);
int channel = Optional.ofNullable(originalAudioFormat.getChannels()).orElse(1);
javax.sound.sampled.AudioFormat targetFormat = new javax.sound.sampled.AudioFormat(frequency, 16, channel,
true, false);
AudioInputStream audioInputStream = AudioSystem.getAudioInputStream(targetFormat,
AudioSystem.getAudioInputStream(resetableInnerInputStream));
return audioInputStream;
} else {
throw new UnsupportedAudioFormatException("Pulseaudio audio sink can only play pcm or mp3 stream",
audioFormat);
originalAudioFormat);
}
}
@Override
public AudioFormat getFormat() {
return outputAudioFormat;
}
}

View File

@ -13,9 +13,9 @@
package org.openhab.binding.pulseaudio.internal;
import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledExecutorService;
@ -26,12 +26,12 @@ import javax.sound.sampled.UnsupportedAudioFileException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.pulseaudio.internal.handler.PulseaudioHandler;
import org.openhab.binding.pulseaudio.internal.items.SimpleProtocolTCPModule;
import org.openhab.core.audio.AudioFormat;
import org.openhab.core.audio.AudioSink;
import org.openhab.core.audio.AudioStream;
import org.openhab.core.audio.FileAudioStream;
import org.openhab.core.audio.UnsupportedAudioFormatException;
import org.openhab.core.audio.UnsupportedAudioStreamException;
import org.openhab.core.audio.utils.AudioSinkUtils;
import org.openhab.core.common.Disposable;
import org.slf4j.Logger;
@ -41,8 +41,9 @@ import org.slf4j.LoggerFactory;
* The audio sink for openhab, implemented by a connection to a pulseaudio sink
*
* @author Gwendal Roulleau - Initial contribution
* @author Miguel Álvarez - move some code to the PulseaudioSimpleProtocolStream class so sink and source can extend
* @author Miguel Álvarez - Move some code to the PulseaudioSimpleProtocolStream class so sink and source can extend
* from it.
* @author Miguel Álvarez - Use a socket per stream.
*
*/
@NonNullByDefault
@ -50,12 +51,11 @@ public class PulseAudioAudioSink extends PulseaudioSimpleProtocolStream implemen
private final Logger logger = LoggerFactory.getLogger(PulseAudioAudioSink.class);
private AudioSinkUtils audioSinkUtils;
private final AudioSinkUtils audioSinkUtils;
private static final Set<AudioFormat> SUPPORTED_FORMATS = Set.of(AudioFormat.WAV, AudioFormat.MP3);
private static final Set<AudioFormat> SUPPORTED_FORMATS = Set.of(AudioFormat.PCM_SIGNED, AudioFormat.WAV,
AudioFormat.MP3);
private static final Set<Class<? extends AudioStream>> SUPPORTED_STREAMS = Set.of(AudioStream.class);
private static final AudioFormat TARGET_FORMAT = new AudioFormat(AudioFormat.CONTAINER_WAVE,
AudioFormat.CODEC_PCM_SIGNED, false, 16, 4 * 44100, 44100L, 2);
public PulseAudioAudioSink(PulseaudioHandler pulseaudioHandler, ScheduledExecutorService scheduler,
AudioSinkUtils audioSinkUtils) {
@ -64,8 +64,7 @@ public class PulseAudioAudioSink extends PulseaudioSimpleProtocolStream implemen
}
@Override
public void process(@Nullable AudioStream audioStream)
throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
public void process(@Nullable AudioStream audioStream) {
processAndComplete(audioStream);
}
@ -74,85 +73,86 @@ public class PulseAudioAudioSink extends PulseaudioSimpleProtocolStream implemen
if (audioStream == null) {
return CompletableFuture.completedFuture(null);
}
addClientCount();
try (ConvertedInputStream normalizedPCMStream = new ConvertedInputStream(audioStream)) {
for (int countAttempt = 1; countAttempt <= 2; countAttempt++) { // two attempts allowed
ConvertedInputStream preparedInputStream = null;
AcquireModuleResult acquireModuleResult = null;
CompletableFuture<@Nullable Void> soundPlayed = new CompletableFuture<>();
try {
preparedInputStream = new ConvertedInputStream(audioStream);
acquireModuleResult = acquireSimpleProtocolModule(preparedInputStream.getFormat());
// final var needed to use inside lambda :
final var finalPreparedInputStream = preparedInputStream;
final var finalAcquireModuleResult = acquireModuleResult;
scheduler.execute(() -> {
Socket spSocket = null;
try {
connectIfNeeded();
final Socket clientSocketLocal = clientSocket;
if (clientSocketLocal != null) {
// send raw audio to the socket and to pulse audio
Instant start = Instant.now();
if (normalizedPCMStream.getDuration() != -1) {
// ensure, if the sound has a duration
// that we let at least this time for the system to play
normalizedPCMStream.transferTo(clientSocketLocal.getOutputStream());
Instant end = Instant.now();
long millisSecondTimedToSendAudioData = Duration.between(start, end).toMillis();
if (millisSecondTimedToSendAudioData < normalizedPCMStream.getDuration()) {
CompletableFuture<@Nullable Void> soundPlayed = new CompletableFuture<>();
long timeToWait = normalizedPCMStream.getDuration() - millisSecondTimedToSendAudioData;
logger.debug("Some time to let the system play sound : {}", timeToWait);
scheduler.schedule(() -> soundPlayed.complete(null), timeToWait, TimeUnit.MILLISECONDS);
return soundPlayed;
} else {
return CompletableFuture.completedFuture(null);
}
} else {
// We have a second method available to guess the duration, and it is during transfer
Long timeStampEnd = audioSinkUtils.transferAndAnalyzeLength(normalizedPCMStream,
clientSocketLocal.getOutputStream(), TARGET_FORMAT);
CompletableFuture<@Nullable Void> soundPlayed = new CompletableFuture<>();
if (timeStampEnd != null) {
long now = System.nanoTime();
long timeToWait = timeStampEnd - now;
if (timeToWait > 0) {
scheduler.schedule(() -> soundPlayed.complete(null), timeToWait,
TimeUnit.NANOSECONDS);
}
return soundPlayed;
} else {
return CompletableFuture.completedFuture(null);
}
}
}
} catch (IOException e) {
disconnect(); // disconnect force to clear connection in case of socket not cleanly shutdown
if (countAttempt == 2) { // we won't retry : log and quit
final Socket clientSocketLocal = clientSocket;
String port = clientSocketLocal != null ? Integer.toString(clientSocketLocal.getPort())
: "unknown";
logger.warn(
"Error while trying to send audio to pulseaudio audio sink. Cannot connect to {}:{}, error: {}",
pulseaudioHandler.getHost(), port, e.getMessage());
return CompletableFuture.completedFuture(null);
}
} catch (InterruptedException ie) {
logger.info("Interrupted during sink audio connection: {}", ie.getMessage());
return CompletableFuture.completedFuture(null);
}
}
} catch (UnsupportedAudioFileException | UnsupportedAudioFormatException | IOException e) {
return CompletableFuture.failedFuture(new UnsupportedAudioFormatException(
"Cannot send sound to the pulseaudio sink", audioStream.getFormat(), e));
} finally {
minusClientCount();
// if the stream is not needed anymore, then we should call back the AudioStream to let it a chance
// to auto dispose.
if (audioStream instanceof Disposable disposableAudioStream) {
try {
disposableAudioStream.dispose();
} catch (IOException e) {
String fileName = audioStream instanceof FileAudioStream file ? file.toString() : "unknown";
if (logger.isDebugEnabled()) {
logger.debug("Cannot dispose of stream {}", fileName, e);
SimpleProtocolTCPModule spModule = finalAcquireModuleResult.module()
.orElseThrow(() -> new IOException("Unable to load new Simple Protocol module instance."));
spSocket = connectIfNeeded(spModule);
var moduleOutputStream = spSocket.getOutputStream();
Long timeStampEnded = audioSinkUtils.transferAndAnalyzeLength(finalPreparedInputStream,
moduleOutputStream, finalPreparedInputStream.getFormat());
long timeToWait = Optional.ofNullable(timeStampEnded)
.map(tse -> (tse - System.nanoTime()) / 1000000).orElse(0L);
if (timeToWait > 0) {
logger.debug("Some time to let the system play sound : {}", timeToWait);
scheduler
.schedule(
() -> endStream(finalPreparedInputStream,
finalAcquireModuleResult.releaseModule(), soundPlayed, null),
timeToWait, TimeUnit.MILLISECONDS);
} else {
logger.warn("Cannot dispose of stream {}, reason {}", fileName, e.getMessage());
endStream(finalPreparedInputStream, finalAcquireModuleResult.releaseModule(), soundPlayed,
null);
}
} catch (IOException e) {
if (spSocket != null) {
disconnect(spSocket);
}
endStream(finalPreparedInputStream, finalAcquireModuleResult.releaseModule(), soundPlayed, e);
}
});
} catch (UnsupportedAudioFileException | UnsupportedAudioFormatException | IOException
| InterruptedException e) {
endStream(preparedInputStream, null, soundPlayed, new UnsupportedAudioFormatException(
"Cannot send sound to the pulseaudio sink", audioStream.getFormat(), e));
}
return soundPlayed;
}
private void endStream(@Nullable InputStream inputStream, @Nullable Runnable releaseModule,
CompletableFuture<@Nullable Void> soundPlayed, @Nullable Exception sourceException) {
if (releaseModule != null) {
releaseModule.run();
}
try {
if (inputStream != null) {
inputStream.close();
}
} catch (IOException ignored) {
}
if (sourceException != null) {
soundPlayed.completeExceptionally(sourceException);
} else {
soundPlayed.complete(null);
}
// if the stream is not needed anymore, then we should call back the AudioStream to let it a chance
// to auto dispose.
if (inputStream instanceof Disposable disposableAudioStream) {
try {
disposableAudioStream.dispose();
} catch (IOException e) {
String fileName = inputStream instanceof FileAudioStream file ? file.toString() : "unknown";
if (logger.isDebugEnabled()) {
logger.debug("Cannot dispose of stream {}", fileName, e);
} else {
logger.warn("Cannot dispose of stream {}, reason {}", fileName, e.getMessage());
}
}
}
return CompletableFuture.completedFuture(null);
}
@Override

View File

@ -13,7 +13,6 @@
package org.openhab.binding.pulseaudio.internal;
import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;
import java.util.Set;
import java.util.concurrent.Future;
@ -22,6 +21,7 @@ import java.util.concurrent.ScheduledExecutorService;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.pulseaudio.internal.handler.PulseaudioHandler;
import org.openhab.binding.pulseaudio.internal.items.SimpleProtocolTCPModule;
import org.openhab.core.audio.AudioException;
import org.openhab.core.audio.AudioFormat;
import org.openhab.core.audio.AudioSource;
@ -52,7 +52,7 @@ public class PulseAudioAudioSource extends PulseaudioSimpleProtocolStream implem
streamFormat = pulseaudioHandler.getSourceAudioFormat();
executor = ThreadPoolManager
.getScheduledPool("OH-binding-" + pulseaudioHandler.getThing().getUID() + "-source");
streamGroup = PipedAudioStream.newGroup(streamFormat);
streamGroup = PipedAudioStream.newGroup(streamFormat, 1024 * 10);
}
@Override
@ -64,33 +64,22 @@ public class PulseAudioAudioSource extends PulseaudioSimpleProtocolStream implem
public AudioStream getInputStream(AudioFormat audioFormat) throws AudioException {
try {
for (int countAttempt = 1; countAttempt <= 2; countAttempt++) { // two attempts allowed
@Nullable
PipedAudioStream audioStream = null;
try {
connectIfNeeded();
final Socket clientSocketLocal = clientSocket;
if (clientSocketLocal == null) {
break;
}
if (!audioFormat.isCompatible(streamFormat)) {
throw new AudioException("Incompatible audio format requested");
}
var audioStream = streamGroup.getAudioStreamInGroup();
audioStream = streamGroup.getAudioStreamInGroup();
audioStream.onClose(() -> {
minusClientCount();
stopPipeWriteTask();
});
addClientCount();
startPipeWrite();
// get raw audio from the pulse audio socket
return audioStream;
} catch (IOException e) {
disconnect(); // disconnect to force clear connection in case of socket not cleanly shutdown
if (countAttempt == 2) { // we won't retry : log and quit
final Socket clientSocketLocal = clientSocket;
String port = clientSocketLocal != null ? Integer.toString(clientSocketLocal.getPort())
: "unknown";
logger.warn(
"Error while trying to get audio from pulseaudio audio source. Cannot connect to {}:{}, error: {}",
pulseaudioHandler.getHost(), port, e.getMessage());
logger.warn("Error while trying to get audio from pulseaudio audio source: {}", e.getMessage());
throw e;
}
} catch (InterruptedException ie) {
@ -110,41 +99,57 @@ public class PulseAudioAudioSource extends PulseaudioSimpleProtocolStream implem
* this wrapper method make the test before effectively
* locking the object (which is a costly operation)
*/
private void startPipeWrite() {
if (this.pipeWriteTask == null) {
private void startPipeWrite() throws IOException, InterruptedException {
if (this.pipeWriteTask == null && !streamGroup.isEmpty()) {
startPipeWriteSynchronized();
}
}
private synchronized void startPipeWriteSynchronized() {
private synchronized void startPipeWriteSynchronized() throws IOException, InterruptedException {
if (this.pipeWriteTask == null) {
AcquireModuleResult acquireModuleResult = acquireSimpleProtocolModule(streamFormat);
if (acquireModuleResult.module().isEmpty()) {
throw new IOException("Unable to create simple protocol module instance on pulseaudio server.");
}
SimpleProtocolTCPModule spModule = acquireModuleResult.module().get();
Runnable releaseModuleOp = acquireModuleResult.releaseModule();
this.pipeWriteTask = executor.submit(() -> {
int lengthRead;
byte[] buffer = new byte[1200];
int readRetries = 3;
byte[] buffer = new byte[1024];
int readRetries = 4;
while (!streamGroup.isEmpty()) {
var stream = getSourceInputStream();
if (stream != null) {
try {
lengthRead = stream.read(buffer);
readRetries = 3;
streamGroup.write(buffer, 0, lengthRead);
streamGroup.flush();
} catch (IOException e) {
logger.warn("IOException while reading from pulse source: {}", getExceptionMessage(e));
if (readRetries == 0) {
// force reconnection on persistent IOException
super.disconnect();
} else {
readRetries--;
}
} catch (RuntimeException e) {
logger.warn("RuntimeException while reading from pulse source: {}", getExceptionMessage(e));
Socket spSocket = null;
try {
spSocket = connectIfNeeded(spModule);
var stream = spSocket.getInputStream();
lengthRead = stream.read(buffer);
if (lengthRead == -1) {
logger.warn("Unable to read audio data.");
throw new IOException("Stream closed");
}
} else {
logger.warn("Unable to get source input stream");
readRetries = 4;
streamGroup.write(buffer, 0, lengthRead);
streamGroup.flush();
} catch (IOException e) {
logger.warn("IOException while reading from pulse source: {}", getExceptionMessage(e));
readRetries--;
if (readRetries == 1) {
// disconnect the socket in case it recovers
if (spSocket != null) {
disconnect(spSocket);
}
} else if (readRetries == 0) {
// unload the source so dialogs connected to it get stopped,
// the source will be reloaded on next state update
pulseaudioHandler.audioSourceUnsetup();
this.pipeWriteTask = null;
return;
}
} catch (RuntimeException e) {
logger.warn("RuntimeException while reading from pulse source: {}", getExceptionMessage(e));
}
}
releaseModuleOp.run();
this.pipeWriteTask = null;
});
}
@ -167,22 +172,10 @@ public class PulseAudioAudioSource extends PulseaudioSimpleProtocolStream implem
return message;
}
private @Nullable InputStream getSourceInputStream() {
try {
connectIfNeeded();
} catch (IOException | InterruptedException ignored) {
}
try {
var clientSocketFinal = clientSocket;
return (clientSocketFinal != null) ? clientSocketFinal.getInputStream() : null;
} catch (IOException ignored) {
return null;
}
}
@Override
public void disconnect() {
public void close() {
streamGroup.close();
stopPipeWriteTask();
super.disconnect();
super.close();
}
}

View File

@ -51,16 +51,14 @@ public class PulseaudioBindingConstants {
public static final String DEVICE_PARAMETER_NAME_OR_DESCRIPTION = "name";
public static final String DEVICE_PARAMETER_ADDITIONAL_FILTERS = "additionalFilters";
public static final String DEVICE_PARAMETER_AUDIO_SINK_ACTIVATION = "activateSimpleProtocolSink";
public static final String DEVICE_PARAMETER_AUDIO_SINK_PORT = "simpleProtocolSinkPort";
public static final String DEVICE_PARAMETER_AUDIO_SINK_IDLE_TIMEOUT = "simpleProtocolSinkIdleTimeout";
public static final String DEVICE_PARAMETER_AUDIO_SOURCE_ACTIVATION = "activateSimpleProtocolSource";
public static final String DEVICE_PARAMETER_AUDIO_SOURCE_PORT = "simpleProtocolSourcePort";
public static final String DEVICE_PARAMETER_AUDIO_SOURCE_IDLE_TIMEOUT = "simpleProtocolSourceIdleTimeout";
public static final String DEVICE_PARAMETER_AUDIO_SOURCE_RATE = "simpleProtocolSourceRate";
public static final String DEVICE_PARAMETER_AUDIO_SOURCE_FORMAT = "simpleProtocolSourceFormat";
public static final String DEVICE_PARAMETER_AUDIO_SOURCE_CHANNELS = "simpleProtocolSourceChannels";
public static final String DEVICE_PARAMETER_AUDIO_SOCKET_SO_TIMEOUT = "simpleProtocolSOTimeout";
public static final String DEVICE_PARAMETER_IDLE_MODULES = "simpleProtocolIdleModules";
public static final String DEVICE_PARAMETER_MIN_PORT = "simpleProtocolModuleMinPort";
public static final String DEVICE_PARAMETER_MAX_PORT = "simpleProtocolModuleMaxPort";
public static final String MODULE_SIMPLE_PROTOCOL_TCP_NAME = "module-simple-protocol-tcp";
public static final int MODULE_SIMPLE_PROTOCOL_TCP_DEFAULT_PORT = 4711;
}

View File

@ -12,12 +12,12 @@
*/
package org.openhab.binding.pulseaudio.internal;
import static org.openhab.binding.pulseaudio.internal.PulseaudioBindingConstants.*;
import static org.openhab.binding.pulseaudio.internal.PulseaudioBindingConstants.MODULE_SIMPLE_PROTOCOL_TCP_NAME;
import static org.openhab.binding.pulseaudio.internal.cli.Parser.extractArgumentFromLine;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintStream;
import java.math.BigDecimal;
import java.net.Socket;
import java.net.SocketException;
import java.net.SocketTimeoutException;
@ -25,9 +25,12 @@ import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Random;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
@ -36,10 +39,12 @@ import org.openhab.binding.pulseaudio.internal.handler.DeviceIdentifier;
import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig;
import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig.State;
import org.openhab.binding.pulseaudio.internal.items.Module;
import org.openhab.binding.pulseaudio.internal.items.SimpleProtocolTCPModule;
import org.openhab.binding.pulseaudio.internal.items.Sink;
import org.openhab.binding.pulseaudio.internal.items.SinkInput;
import org.openhab.binding.pulseaudio.internal.items.Source;
import org.openhab.binding.pulseaudio.internal.items.SourceOutput;
import org.openhab.core.audio.AudioFormat;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -89,7 +94,6 @@ public class PulseaudioClient {
* corresponding name to execute actions on source-output items
*/
private static final String ITEM_SOURCE_OUTPUT = "source-output";
/**
* command to list the loaded modules
*/
@ -148,7 +152,7 @@ public class PulseaudioClient {
* updates the item states and their relationships
*/
public synchronized void update() {
// one step copy
// one-step copy
modules = new ArrayList<>(Parser.parseModules(listModules()));
List<AbstractAudioDeviceConfig> newItems = new ArrayList<>(); // prepare new list before assigning it
@ -198,7 +202,7 @@ public class PulseaudioClient {
* @param id
* @return the corresponding {@link Module} to the given <code>id</code>
*/
public @Nullable Module getModule(int id) {
public synchronized @Nullable Module getModule(int id) {
for (Module module : modules) {
if (module.getId() == id) {
return module;
@ -207,6 +211,42 @@ public class PulseaudioClient {
return null;
}
/**
* Retrieves a list of {@link SimpleProtocolTCPModule} for the provided item in the provided port range.
*
* @param item Sink/Source item.
* @param minPort min port to include.
* @param maxPort max port to include.
* @return a list of {@link SimpleProtocolTCPModule} instances
*/
public List<SimpleProtocolTCPModule> getSimpleProtocolTCPModulesByDevice(AbstractAudioDeviceConfig item,
int minPort, int maxPort) {
String itemType = getItemCommandName(item);
if (itemType == null) {
return List.of();
}
return filterSimpleProtocolTCPModules((spModule) -> spModule.getPort() >= minPort && //
spModule.getPort() <= maxPort && //
extractArgumentFromLine(itemType, spModule.getArgument()) // extract sick|source
.map(name -> name.equals(item.getPaName())).orElse(false))
.collect(Collectors.toList());
}
/**
* Retrieves a filtered stream of {@link SimpleProtocolTCPModule}
*
* @param predicate a filter to apply
* @return a stream of {@link SimpleProtocolTCPModule} filtered by provided predicate
*/
private Stream<SimpleProtocolTCPModule> filterSimpleProtocolTCPModules(
Predicate<SimpleProtocolTCPModule> predicate) {
List<Module> modulesCopy = new ArrayList<>(modules);
return modulesCopy.stream() //
.filter(SimpleProtocolTCPModule.class::isInstance) //
.map(SimpleProtocolTCPModule.class::cast) //
.filter(predicate);
}
/**
* send the command directly to the pulseaudio server
* for a list of available commands please take a look at
@ -328,37 +368,60 @@ public class PulseaudioClient {
}
/**
* Locate or load (if needed) the simple protocol tcp module for the given sink
* and returns the port.
* Creates a new Simple Protocol TCP module instance on the server or return the provided one if still available.
* The module loading (if needed) will be tried several times, on a new random port each time.
*
* @param item the sink we are searching for
* @param simpleTcpPortPref the port to use if we have to load the module
* @return the port on which the module is listening
* @return the module representation
* @throws InterruptedException
*/
public Optional<Integer> loadModuleSimpleProtocolTcpIfNeeded(AbstractAudioDeviceConfig item,
Integer simpleTcpPortPref, @Nullable String format, @Nullable BigDecimal rate,
@Nullable BigDecimal channels) throws InterruptedException {
public Optional<SimpleProtocolTCPModule> loadModuleSimpleProtocolTcpIfNeeded(AbstractAudioDeviceConfig item,
AudioFormat format, int minPort, int maxPort, @Nullable SimpleProtocolTCPModule spModule)
throws InterruptedException {
int currentTry = 0;
int simpleTcpPortToTry = simpleTcpPortPref;
String paFormat = getPAFormatString(format);
long rate = Objects.requireNonNull(format.getFrequency());
int channels = Objects.requireNonNull(format.getChannels());
if (spModule != null) {
// check if cached module is still available, it should be
var module = findSimpleProtocolTcpModule(item, spModule.getId(), spModule.getPort(), paFormat, rate,
channels);
if (module.isPresent()) {
logger.debug("reusing simple protocol tcp module {}", module.get().getId());
return module;
}
logger.warn("previous module instance not found or incompatible, creating a new one");
}
String itemType = getItemCommandName(item);
do {
Optional<Integer> simplePort = findSimpleProtocolTcpModule(item, format, rate, channels);
if (simplePort.isPresent()) {
return simplePort;
} else {
String moduleOptions = itemType + "=" + item.getPaName() + " port=" + simpleTcpPortToTry;
if (item instanceof Source && format != null && rate != null && channels != null) {
moduleOptions = moduleOptions + String.format(" record=true format=%s rate=%d channels=%d", format,
rate.longValue(), channels.intValue());
}
sendRawCommand("load-module module-simple-protocol-tcp " + moduleOptions);
simpleTcpPortToTry = new Random().nextInt(64512) + 1024; // a random port above 1024
int simpleTcpPortToTry = new Random().nextInt(minPort, maxPort + 1);
if (filterSimpleProtocolTCPModules(m -> m.getPort() == simpleTcpPortToTry).findAny().isPresent()) {
currentTry++;
logger.warn("port {} already in use in the server, retrying random port generation",
simpleTcpPortToTry);
continue;
}
logger.debug("trying to load simple protocol tcp module at port {}", simpleTcpPortToTry);
String moduleOptions = String.format(" %s=%s port=%d format=%s rate=%d channels=%d", itemType,
item.getPaName(), simpleTcpPortToTry, paFormat, rate, channels);
if (item instanceof Source) {
moduleOptions = moduleOptions + " record=true";
}
sendRawCommand("load-module " + MODULE_SIMPLE_PROTOCOL_TCP_NAME + moduleOptions);
try {
do {
Thread.sleep(100);
update();
Optional<SimpleProtocolTCPModule> simpleProtocolModule = findSimpleProtocolTcpModule(item, null,
simpleTcpPortToTry, paFormat, rate, channels);
if (simpleProtocolModule.isPresent()) {
return simpleProtocolModule;
}
currentTry++;
} while (currentTry < 3);
} catch (NumberFormatException e) {
logger.warn("simple protocol module load failed");
}
Thread.sleep(100);
update();
currentTry++;
} while (currentTry < 3);
@ -369,6 +432,20 @@ public class PulseaudioClient {
return Optional.empty();
}
public String getPAFormatString(AudioFormat format) {
assert AudioFormat.CODEC_PCM_SIGNED.equals(format.getCodec());
switch (Objects.requireNonNull(format.getBitDepth())) {
case 16:
return "s16" + (Objects.requireNonNull(format.isBigEndian()) ? "be" : "le");
case 24:
return "s24" + (Objects.requireNonNull(format.isBigEndian()) ? "be" : "le");
case 32:
return "s32" + (Objects.requireNonNull(format.isBigEndian()) ? "be" : "le");
default:
throw new IllegalArgumentException("Unsupported format : " + format.getBitDepth());
}
}
/**
* Find a simple protocol module corresponding to the given sink in argument
* and returns the port it listens to
@ -376,69 +453,59 @@ public class PulseaudioClient {
* @param item
* @return
*/
private Optional<Integer> findSimpleProtocolTcpModule(AbstractAudioDeviceConfig item, @Nullable String format,
@Nullable BigDecimal rate, @Nullable BigDecimal channels) {
private Optional<SimpleProtocolTCPModule> findSimpleProtocolTcpModule(AbstractAudioDeviceConfig item,
@Nullable Integer id, @Nullable Integer port, @Nullable String format, @Nullable Long rate,
@Nullable Integer channels) {
String itemType = getItemCommandName(item);
if (itemType == null) {
return Optional.empty();
}
List<Module> modulesCopy = new ArrayList<>(modules);
var isSource = item instanceof Source;
return modulesCopy.stream() // iteration on modules
.filter(module -> MODULE_SIMPLE_PROTOCOL_TCP_NAME.equals(module.getPaName())) // filter on module name
.filter(module -> {
boolean nameMatch = extractArgumentFromLine(itemType, module.getArgument()) // extract sick|source
.map(name -> name.equals(item.getPaName())).orElse(false);
if (isSource && nameMatch) {
boolean recordStream = extractArgumentFromLine("record", module.getArgument())
.map("true"::equals).orElse(false);
if (!recordStream) {
return false;
}
if (format != null) {
boolean rateMatch = extractArgumentFromLine("format", module.getArgument())
.map(format::equals).orElse(false);
if (!rateMatch) {
return false;
}
}
if (rate != null) {
boolean rateMatch = extractArgumentFromLine("rate", module.getArgument())
.map(value -> Long.parseLong(value) == rate.longValue()).orElse(false);
if (!rateMatch) {
return false;
}
}
if (channels != null) {
boolean channelsMatch = extractArgumentFromLine("channels", module.getArgument())
.map(value -> Integer.parseInt(value) == channels.intValue()).orElse(false);
if (!channelsMatch) {
return false;
}
}
boolean isSource = item instanceof Source;
return filterSimpleProtocolTCPModules(spModule -> {
if (id != null && spModule.getId() != id) {
return false;
}
if (port != null && spModule.getPort() != port) {
return false;
}
boolean nameMatch = extractArgumentFromLine(itemType, spModule.getArgument()) // extract sick|source
.map(name -> name.equals(item.getPaName())).orElse(false);
if (nameMatch) {
if (isSource) {
boolean recordStream = extractArgumentFromLine("record", spModule.getArgument()).map("true"::equals)
.orElse(false);
if (!recordStream) {
return false;
}
return nameMatch;
}) // filter on sink name
.findAny() // get a corresponding module
.map(module -> extractArgumentFromLine("port", module.getArgument())
.orElse(Integer.toString(MODULE_SIMPLE_PROTOCOL_TCP_DEFAULT_PORT))) // get port
.map(portS -> Integer.parseInt(portS));
}
if (format != null) {
boolean rateMatch = extractArgumentFromLine("format", spModule.getArgument()).map(format::equals)
.orElse(false);
if (!rateMatch) {
return false;
}
}
if (rate != null) {
boolean rateMatch = extractArgumentFromLine("rate", spModule.getArgument())
.map(value -> Long.parseLong(value) == rate).orElse(false);
if (!rateMatch) {
return false;
}
}
if (channels != null) {
boolean channelsMatch = extractArgumentFromLine("channels", spModule.getArgument())
.map(value -> Integer.parseInt(value) == channels.intValue()).orElse(false);
if (!channelsMatch) {
return false;
}
}
}
return nameMatch;
}).findAny();
}
private Optional<String> extractArgumentFromLine(String argumentWanted, @Nullable String argumentLine) {
String argument = null;
if (argumentLine != null) {
int startPortIndex = argumentLine.indexOf(argumentWanted + "=");
if (startPortIndex != -1) {
startPortIndex = startPortIndex + argumentWanted.length() + 1;
int endPortIndex = argumentLine.indexOf(" ", startPortIndex);
if (endPortIndex == -1) {
endPortIndex = argumentLine.length();
}
argument = argumentLine.substring(startPortIndex, endPortIndex);
}
}
return Optional.ofNullable(argument);
public void unloadModule(Module module) {
sendCommand("unload-module " + module.getId());
}
/**

View File

@ -14,15 +14,20 @@ package org.openhab.binding.pulseaudio.internal;
import java.io.IOException;
import java.net.Socket;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.pulseaudio.internal.handler.PulseaudioHandler;
import org.openhab.binding.pulseaudio.internal.items.SimpleProtocolTCPModule;
import org.openhab.core.audio.AudioFormat;
import org.openhab.core.library.types.PercentType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -32,6 +37,7 @@ import org.slf4j.LoggerFactory;
*
* @author Gwendal Roulleau - Initial contribution
* @author Miguel Álvarez - Refactor some code from PulseAudioAudioSink here
* @author Miguel Álvarez - Use socket per stream
*
*/
@NonNullByDefault
@ -39,15 +45,22 @@ public abstract class PulseaudioSimpleProtocolStream {
private final Logger logger = LoggerFactory.getLogger(PulseaudioSimpleProtocolStream.class);
protected PulseaudioHandler pulseaudioHandler;
protected ScheduledExecutorService scheduler;
protected @Nullable Socket clientSocket;
private ReentrantLock countClientLock = new ReentrantLock();
private Integer countClient = 0;
private @Nullable ScheduledFuture<?> scheduledDisconnection;
protected final PulseaudioHandler pulseaudioHandler;
protected final ScheduledExecutorService scheduler;
protected boolean initialized = false;
protected boolean closed = false;
/**
* Collect sockets by module id.
*/
protected final Map<Integer, Socket> moduleSockets = new HashMap<>();
/**
* Collect created unused modules
*/
protected final Set<ModuleCache> idleModules = new HashSet<>();
/**
* Collect modules in use by some stream
*/
protected final Set<SimpleProtocolTCPModule> activeModules = new HashSet<>();
public PulseaudioSimpleProtocolStream(PulseaudioHandler pulseaudioHandler, ScheduledExecutorService scheduler) {
this.pulseaudioHandler = pulseaudioHandler;
@ -55,63 +68,146 @@ public abstract class PulseaudioSimpleProtocolStream {
}
/**
* Connect to pulseaudio with the simple protocol
* Will schedule an attempt for disconnection after timeout
* Load simple protocol instance
*
* @throws IOException
* @throws InterruptedException when interrupted during the loading module wait
*/
public void connectIfNeeded() throws IOException, InterruptedException {
Socket clientSocketLocal = clientSocket;
if (clientSocketLocal == null || !clientSocketLocal.isConnected() || clientSocketLocal.isClosed()) {
logger.debug("Simple TCP Stream connecting for {}", getLabel(null));
public AcquireModuleResult acquireSimpleProtocolModule(AudioFormat audioFormat)
throws IOException, InterruptedException {
if (closed) {
throw new IOException("Resource is closed");
}
@Nullable
SimpleProtocolTCPModule idleModule = null;
synchronized (idleModules) {
if (!initialized) {
// remove pre-existent modules for device in port range
pulseaudioHandler.clearSimpleProtocolTCPModules();
initialized = true;
}
logger.debug("idle modules: {}", idleModules.size());
var cachedModule = idleModules.stream() //
.filter(c -> c.audioFormat.equals(audioFormat)) //
.findFirst().orElse(null);
if (cachedModule != null) {
idleModule = cachedModule.module;
logger.debug("Will try to reuse idle module {}", idleModule.getId());
idleModules.remove(cachedModule);
}
}
logger.debug("loading simple protocol tcp module");
var optionalModule = pulseaudioHandler.loadSimpleProtocolModule(audioFormat, idleModule);
if (optionalModule.isEmpty()) {
return new AcquireModuleResult(Optional.empty(), () -> {
});
}
var spModule = optionalModule.get();
activeModules.add(spModule);
return new AcquireModuleResult(optionalModule, () -> releaseModule(audioFormat, spModule));
}
/**
* Connect to pulseaudio with the simple protocol
*
* @throws IOException
*/
public Socket connectIfNeeded(SimpleProtocolTCPModule spModule) throws IOException {
Socket spSocket = moduleSockets.get(spModule.getId());
if (spSocket == null || !spSocket.isConnected() || spSocket.isClosed()) {
logger.debug("Simple TCP Stream connecting to module {} in {}", spModule.getId(), getLabel(null));
String host = pulseaudioHandler.getHost();
int port = pulseaudioHandler.getSimpleTcpPortAndLoadModuleIfNecessary();
var clientSocketFinal = new Socket(host, port);
var clientSocketFinal = new Socket(host, spModule.getPort());
clientSocketFinal.setSoTimeout(pulseaudioHandler.getBasicProtocolSOTimeout());
clientSocket = clientSocketFinal;
scheduleDisconnectIfNoClient();
synchronized (moduleSockets) {
Socket prevSocket = moduleSockets.put(spModule.getId(), clientSocketFinal);
if (prevSocket != null) {
disconnect(prevSocket);
}
}
return clientSocketFinal;
}
return spSocket;
}
private void releaseModule(AudioFormat audioFormat, SimpleProtocolTCPModule spModule) {
logger.debug("releasing module: {}", spModule.getId());
ArrayList<SimpleProtocolTCPModule> modulesToRemove = new ArrayList<>();
activeModules.remove(spModule);
int maxModules = pulseaudioHandler.getMaxIdleModules();
if (!closed && maxModules > 0) {
synchronized (idleModules) {
logger.debug("keeping module {} idle", spModule.getId());
while (idleModules.size() > maxModules - 1) {
var moduleCache = idleModules.iterator().next();
idleModules.remove(moduleCache);
modulesToRemove.add(moduleCache.module);
}
idleModules.add(new ModuleCache(audioFormat, spModule));
logger.debug("idle modules: {}", idleModules.size());
Socket spSocket = moduleSockets.remove(spModule.getId());
if (spSocket != null) {
disconnect(spSocket);
}
}
} else {
modulesToRemove.add(spModule);
}
for (var module : modulesToRemove) {
try {
logger.debug("unloading module {}", module.getId());
Socket spSocket = moduleSockets.remove(module.getId());
if (spSocket != null) {
disconnect(spSocket);
}
pulseaudioHandler.unloadModule(module);
} catch (IOException e) {
logger.warn("IOException unloading module {}: {}", module.getId(), e.getMessage());
}
}
}
/**
* Disconnect the socket to pulseaudio simple protocol
* Disconnect the socket from pulseaudio simple protocol
*/
public void disconnect() {
final Socket clientSocketLocal = clientSocket;
if (clientSocketLocal != null) {
logger.debug("Simple TCP Stream disconnecting for {}", getLabel(null));
try {
clientSocketLocal.close();
} catch (IOException ignored) {
}
} else {
logger.debug("Stream still running or socket not open");
protected void disconnect(Socket spSocket) {
if (spSocket.isClosed()) {
return;
}
logger.debug("Simple TCP Stream disconnecting for {}", getLabel(null));
try {
spSocket.close();
} catch (IOException ignored) {
}
}
private void scheduleDisconnectIfNoClient() {
countClientLock.lock();
try {
if (countClient <= 0) {
var scheduledDisconnectionFinal = scheduledDisconnection;
if (scheduledDisconnectionFinal != null) {
logger.debug("Aborting next disconnect");
scheduledDisconnectionFinal.cancel(true);
}
int idleTimeout = pulseaudioHandler.getIdleTimeout();
if (idleTimeout > -1) {
if (idleTimeout == 0) {
this.disconnect();
} else {
logger.debug("Scheduling next disconnect");
scheduledDisconnection = scheduler.schedule(this::disconnect, idleTimeout,
TimeUnit.MILLISECONDS);
}
public void close() {
closed = true;
synchronized (moduleSockets) {
for (var socket : moduleSockets.values()) {
disconnect(socket);
}
moduleSockets.clear();
}
synchronized (idleModules) {
for (var moduleCached : idleModules) {
try {
pulseaudioHandler.unloadModule(moduleCached.module);
} catch (IOException e) {
logger.warn("IOException unloading module {}: {}", moduleCached.module.getId(), e.getMessage());
}
}
} finally {
countClientLock.unlock();
idleModules.clear();
}
synchronized (activeModules) {
for (var module : activeModules) {
try {
pulseaudioHandler.unloadModule(module);
} catch (IOException e) {
logger.warn("IOException unloading module {}: {}", module.getId(), e.getMessage());
}
}
activeModules.clear();
}
}
@ -132,35 +228,9 @@ public abstract class PulseaudioSimpleProtocolStream {
return label != null ? label : pulseaudioHandler.getThing().getUID().getId();
}
protected void addClientCount() {
countClientLock.lock();
try {
countClient += 1;
logger.debug("Adding new client for pulseaudio sink/source {}. Current count: {}", getLabel(null),
countClient);
if (countClient <= 0) { // safe against misuse
countClient = 1;
}
var scheduledDisconnectionFinal = scheduledDisconnection;
if (scheduledDisconnectionFinal != null) {
logger.debug("Aborting next disconnect");
scheduledDisconnectionFinal.cancel(true);
}
} finally {
countClientLock.unlock();
}
}
private record ModuleCache(AudioFormat audioFormat, SimpleProtocolTCPModule module) {
};
protected void minusClientCount() {
countClientLock.lock();
countClient -= 1;
logger.debug("Removing client for pulseaudio sink/source {}. Current count: {}", getLabel(null), countClient);
if (countClient < 0) { // safe against misuse
countClient = 0;
}
countClientLock.unlock();
if (countClient <= 0) {
scheduleDisconnectIfNoClient();
}
}
public record AcquireModuleResult(Optional<SimpleProtocolTCPModule> module, Runnable releaseModule) {
};
}

View File

@ -12,10 +12,13 @@
*/
package org.openhab.binding.pulseaudio.internal.cli;
import static org.openhab.binding.pulseaudio.internal.PulseaudioBindingConstants.MODULE_SIMPLE_PROTOCOL_TCP_NAME;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Hashtable;
import java.util.List;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -24,6 +27,7 @@ import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.pulseaudio.internal.PulseaudioClient;
import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig;
import org.openhab.binding.pulseaudio.internal.items.Module;
import org.openhab.binding.pulseaudio.internal.items.SimpleProtocolTCPModule;
import org.openhab.binding.pulseaudio.internal.items.Sink;
import org.openhab.binding.pulseaudio.internal.items.SinkInput;
import org.openhab.binding.pulseaudio.internal.items.Source;
@ -82,9 +86,20 @@ public class Parser {
}
}
if (properties.containsKey("name")) {
Module module = new Module(id, properties.get("name"));
if (properties.containsKey("argument")) {
module.setArgument(properties.get("argument"));
Module module;
if (MODULE_SIMPLE_PROTOCOL_TCP_NAME.equals(properties.get("name"))) {
String arguments = properties.get("argument");
Optional<String> portString = arguments != null ? extractArgumentFromLine("port", arguments)
: Optional.empty();
if (portString.isEmpty()) {
LOGGER.warn("Unable to parse module-simple-protocol-tcp module {} info it will not work", id);
module = new Module(id, properties.get("name"), properties.get("argument"));
} else {
int port = Integer.parseInt(portString.get());
module = new SimpleProtocolTCPModule(id, properties.get("name"), port, arguments);
}
} else {
module = new Module(id, properties.get("name"), properties.get("argument"));
}
modules.add(module);
}
@ -182,7 +197,7 @@ public class Parser {
try {
id = Integer.valueOf(lines[0].trim());
} catch (NumberFormatException e) {
// sometime the line feed is missing here
// some times the line feed is missing here
Matcher matcher = FALL_BACK_PATTERN.matcher(lines[0].trim());
if (matcher.find()) {
id = Integer.valueOf(matcher.group(1));
@ -243,7 +258,7 @@ public class Parser {
try {
id = Integer.valueOf(lines[0].trim());
} catch (NumberFormatException e) {
// sometime the line feed is missing here
// some times the line feed is missing here
Matcher matcher = FALL_BACK_PATTERN.matcher(lines[0].trim());
if (matcher.find()) {
id = Integer.valueOf(matcher.group(1));
@ -341,6 +356,22 @@ public class Parser {
return items;
}
public static Optional<String> extractArgumentFromLine(String argumentWanted, @Nullable String argumentLine) {
String argument = null;
if (argumentLine != null) {
int startPortIndex = argumentLine.indexOf(argumentWanted + "=");
if (startPortIndex != -1) {
startPortIndex = startPortIndex + argumentWanted.length() + 1;
int endPortIndex = argumentLine.indexOf(" ", startPortIndex);
if (endPortIndex == -1) {
endPortIndex = argumentLine.length();
}
argument = argumentLine.substring(startPortIndex, endPortIndex);
}
}
return Optional.ofNullable(argument);
}
/**
* converts the volume value given by the pulseaudio server
* to a percentage value. The pulseaudio server sends 2 values for left and right channel volume

View File

@ -34,9 +34,9 @@ import org.openhab.binding.pulseaudio.internal.PulseAudioAudioSink;
import org.openhab.binding.pulseaudio.internal.PulseAudioAudioSource;
import org.openhab.binding.pulseaudio.internal.PulseaudioBindingConstants;
import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig;
import org.openhab.binding.pulseaudio.internal.items.SimpleProtocolTCPModule;
import org.openhab.binding.pulseaudio.internal.items.Sink;
import org.openhab.binding.pulseaudio.internal.items.SinkInput;
import org.openhab.binding.pulseaudio.internal.items.Source;
import org.openhab.core.audio.AudioFormat;
import org.openhab.core.audio.AudioSink;
import org.openhab.core.audio.AudioSource;
@ -83,6 +83,8 @@ public class PulseaudioHandler extends BaseThingHandler {
private @Nullable DeviceIdentifier deviceIdentifier;
private @Nullable PulseAudioAudioSink audioSink;
private @Nullable PulseAudioAudioSource audioSource;
private int simpleProtocolMinPort;
private int simpleProtocolMaxPort;
private @Nullable Integer savedVolume;
private final Map<String, ServiceRegistration<AudioSink>> audioSinkRegistrations = new ConcurrentHashMap<>();
@ -104,6 +106,12 @@ public class PulseaudioHandler extends BaseThingHandler {
try {
deviceIdentifier = new DeviceIdentifier((String) config.get(DEVICE_PARAMETER_NAME_OR_DESCRIPTION),
(String) config.get(DEVICE_PARAMETER_ADDITIONAL_FILTERS));
simpleProtocolMinPort = (int) (config.containsKey(DEVICE_PARAMETER_MIN_PORT)
? config.get(DEVICE_PARAMETER_MIN_PORT)
: 1024);
simpleProtocolMaxPort = (int) (config.containsKey(DEVICE_PARAMETER_MAX_PORT)
? config.get(DEVICE_PARAMETER_MAX_PORT)
: 64512);
} catch (PatternSyntaxException p) {
deviceIdentifier = null;
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
@ -117,7 +125,11 @@ public class PulseaudioHandler extends BaseThingHandler {
return deviceIdentifier;
}
private void audioSinkSetup() {
public int getMaxIdleModules() {
return ((BigDecimal) thing.getConfiguration().get(DEVICE_PARAMETER_IDLE_MODULES)).intValue();
}
private synchronized void audioSinkSetup() {
if (audioSink != null) {
// Audio sink is already setup
return;
@ -130,23 +142,7 @@ public class PulseaudioHandler extends BaseThingHandler {
if (sinkActivated == null || !sinkActivated.booleanValue()) {
return;
}
final PulseaudioHandler thisHandler = this;
PulseAudioAudioSink audioSink = new PulseAudioAudioSink(thisHandler, scheduler, audioSinkUtils);
scheduler.submit(new Runnable() {
@Override
public void run() {
PulseaudioHandler.this.audioSink = audioSink;
try {
audioSink.connectIfNeeded();
} catch (IOException e) {
logger.warn("pulseaudio binding cannot connect to the module-simple-protocol-tcp on {} ({})",
getHost(), e.getMessage());
} catch (InterruptedException i) {
logger.info("Interrupted during sink audio connection: {}", i.getMessage());
return;
}
}
});
this.audioSink = new PulseAudioAudioSink(this, scheduler, audioSinkUtils);
// Register the sink as an audio sink in openhab
logger.trace("Registering an audio sink for pulse audio sink thing {}", thing.getUID());
@SuppressWarnings("unchecked")
@ -155,10 +151,10 @@ public class PulseaudioHandler extends BaseThingHandler {
audioSinkRegistrations.put(thing.getUID().toString(), reg);
}
private void audioSinkUnsetup() {
private synchronized void audioSinkUnsetup() {
PulseAudioAudioSink sink = audioSink;
if (sink != null) {
sink.disconnect();
sink.close();
audioSink = null;
}
// Unregister the potential pulse audio sink's audio sink
@ -169,7 +165,7 @@ public class PulseaudioHandler extends BaseThingHandler {
}
}
private void audioSourceSetup() {
private synchronized void audioSourceSetup() {
if (audioSource != null) {
// Audio source is already setup
return;
@ -182,23 +178,7 @@ public class PulseaudioHandler extends BaseThingHandler {
if (sourceActivated == null || !sourceActivated.booleanValue()) {
return;
}
final PulseaudioHandler thisHandler = this;
PulseAudioAudioSource audioSource = new PulseAudioAudioSource(thisHandler, scheduler);
scheduler.submit(new Runnable() {
@Override
public void run() {
PulseaudioHandler.this.audioSource = audioSource;
try {
audioSource.connectIfNeeded();
} catch (IOException e) {
logger.warn("pulseaudio binding cannot connect to the module-simple-protocol-tcp on {} ({})",
getHost(), e.getMessage());
} catch (InterruptedException i) {
logger.info("Interrupted during source audio connection: {}", i.getMessage());
return;
}
}
});
audioSource = new PulseAudioAudioSource(this, scheduler);
// Register the source as an audio source in openhab
logger.trace("Registering an audio source for pulse audio source thing {}", thing.getUID());
@SuppressWarnings("unchecked")
@ -207,10 +187,10 @@ public class PulseaudioHandler extends BaseThingHandler {
audioSourceRegistrations.put(thing.getUID().toString(), reg);
}
private void audioSourceUnsetup() {
public synchronized void audioSourceUnsetup() {
PulseAudioAudioSource source = audioSource;
if (source != null) {
source.disconnect();
source.close();
audioSource = null;
}
// Unregister the potential pulse audio source's audio sources
@ -438,14 +418,11 @@ public class PulseaudioHandler extends BaseThingHandler {
}
/**
* This method will scan the pulseaudio server to find the port on which the module/sink/source is listening
* If no module is listening, then it will command the module to load on the pulse audio server,
* Unload existing Simple Protocol TCP modules for this device on the remote pulseaudio.
*
* @return the port on which the pulseaudio server is listening for this sink/source
* @throws IOException when device info is not available
* @throws InterruptedException when interrupted during the loading module wait
* @throws IOException if unable to load device config
*/
public int getSimpleTcpPortAndLoadModuleIfNecessary() throws IOException, InterruptedException {
public void clearSimpleProtocolTCPModules() throws IOException {
var briHandler = getPulseaudioBridgeHandler();
if (briHandler == null) {
throw new IOException("bridge is not ready");
@ -455,18 +432,36 @@ public class PulseaudioHandler extends BaseThingHandler {
throw new IOException(
"missing device info, device " + safeGetDeviceNameOrDescription() + " appears to be offline");
}
String simpleTcpPortPrefName = (device instanceof Source) ? DEVICE_PARAMETER_AUDIO_SOURCE_PORT
: DEVICE_PARAMETER_AUDIO_SINK_PORT;
BigDecimal simpleTcpPortPref = ((BigDecimal) getThing().getConfiguration().get(simpleTcpPortPrefName));
int simpleTcpPort = simpleTcpPortPref != null ? simpleTcpPortPref.intValue()
: MODULE_SIMPLE_PROTOCOL_TCP_DEFAULT_PORT;
String simpleFormat = ((String) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_FORMAT));
BigDecimal simpleRate = (BigDecimal) getThing().getConfiguration().get(DEVICE_PARAMETER_AUDIO_SOURCE_RATE);
BigDecimal simpleChannels = (BigDecimal) getThing().getConfiguration()
.get(DEVICE_PARAMETER_AUDIO_SOURCE_CHANNELS);
return briHandler.getClient()
.loadModuleSimpleProtocolTcpIfNeeded(device, simpleTcpPort, simpleFormat, simpleRate, simpleChannels)
.orElse(simpleTcpPort);
briHandler.getClient().getSimpleProtocolTCPModulesByDevice(device, simpleProtocolMinPort, simpleProtocolMaxPort)
.forEach(spModule -> {
try {
unloadModule(spModule);
} catch (IOException e) {
logger.warn("IOException unloading module {}: ", spModule.getId(), e);
}
});
}
/**
* Creates a new Simple Protocol TCP module instance on the server or reuse an idle one if still available.
*
* @return the Simple Protocol module instance
* @throws IOException when device info is not available
* @throws InterruptedException when interrupted during the loading module wait
*/
public Optional<SimpleProtocolTCPModule> loadSimpleProtocolModule(AudioFormat audioFormat,
@Nullable SimpleProtocolTCPModule module) throws IOException, InterruptedException {
var briHandler = getPulseaudioBridgeHandler();
if (briHandler == null) {
throw new IOException("bridge is not ready");
}
AbstractAudioDeviceConfig device = briHandler.getDevice(deviceIdentifier);
if (device == null) {
throw new IOException(
"missing device info, device " + safeGetDeviceNameOrDescription() + " appears to be offline");
}
return briHandler.getClient().loadModuleSimpleProtocolTcpIfNeeded(device, audioFormat, simpleProtocolMinPort,
simpleProtocolMaxPort, module);
}
public AudioFormat getSourceAudioFormat() {
@ -516,19 +511,12 @@ public class PulseaudioHandler extends BaseThingHandler {
}
}
public int getIdleTimeout() {
var idleTimeout = 3000;
var handler = getPulseaudioBridgeHandler();
if (handler != null) {
AbstractAudioDeviceConfig device = handler.getDevice(deviceIdentifier);
String idleTimeoutPropName = (device instanceof Source) ? DEVICE_PARAMETER_AUDIO_SOURCE_IDLE_TIMEOUT
: DEVICE_PARAMETER_AUDIO_SINK_IDLE_TIMEOUT;
var idleTimeoutB = (BigDecimal) getThing().getConfiguration().get(idleTimeoutPropName);
if (idleTimeoutB != null) {
idleTimeout = idleTimeoutB.intValue();
}
public void unloadModule(SimpleProtocolTCPModule module) throws IOException {
var briHandler = getPulseaudioBridgeHandler();
if (briHandler == null) {
throw new IOException("bridge is not ready");
}
return idleTimeout;
briHandler.getClient().unloadModule(module);
}
private String safeGetDeviceNameOrDescription() {

View File

@ -21,24 +21,22 @@ import org.eclipse.jdt.annotation.Nullable;
* be able to remove sinks from the pulseaudio server.
*
* @author Tobias Bräutigam - Initial contribution
* @author Miguel Álvarez Díez - Make arguments final
*/
@NonNullByDefault
public class Module extends AbstractDeviceConfig {
private @Nullable String argument;
private final @Nullable String argument;
public Module(int id, String name) {
public Module(int id, String name, @Nullable String argument) {
super(id, name);
this.argument = argument;
}
public @Nullable String getArgument() {
return argument;
}
public void setArgument(String argument) {
this.argument = argument;
}
@Override
public String toString() {
return name;

View File

@ -0,0 +1,35 @@
/**
* Copyright (c) 2010-2024 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.binding.pulseaudio.internal.items;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Represents a simple protocol tcp module instance
*
* @author Miguel Álvarez Díez - Initial contribution
*/
@NonNullByDefault
public class SimpleProtocolTCPModule extends Module {
private final int port;
public SimpleProtocolTCPModule(int id, String name, int port, @Nullable String arguments) {
super(id, name, arguments);
this.port = port;
}
public int getPort() {
return port;
}
}

View File

@ -45,12 +45,14 @@ thing-type.config.pulseaudio.sink.additionalFilters.label = Additional Filters
thing-type.config.pulseaudio.sink.additionalFilters.description = Additional filters to select the proper device on the pulseaudio server, in case of ambiguity. To be selected, the device should have at least a property value matching this regular expression. You can use multiple regular expressions (separator is ###).
thing-type.config.pulseaudio.sink.name.label = Name
thing-type.config.pulseaudio.sink.name.description = The name of one specific device. You can also use the description.
thing-type.config.pulseaudio.sink.simpleProtocolIdleModules.label = Simple Protocol Idle Modules
thing-type.config.pulseaudio.sink.simpleProtocolIdleModules.description = Number of Simple Protocol TCP Socket modules to keep loaded in the server
thing-type.config.pulseaudio.sink.simpleProtocolMaxPort.label = Simple Protocol Max Port
thing-type.config.pulseaudio.sink.simpleProtocolMaxPort.description = Max port used by simple protocol module instances created by the binding on the pulseaudio host.
thing-type.config.pulseaudio.sink.simpleProtocolMinPort.label = Simple Protocol Min Port
thing-type.config.pulseaudio.sink.simpleProtocolMinPort.description = Min port used by simple protocol module instances created by the binding on the pulseaudio host.
thing-type.config.pulseaudio.sink.simpleProtocolSOTimeout.label = Simple Protocol SO Timeout
thing-type.config.pulseaudio.sink.simpleProtocolSOTimeout.description = Socket SO timeout when connecting to pulseaudio server though module-simple-protocol-tcp. You can tune this option if the socket disconnect frequently.
thing-type.config.pulseaudio.sink.simpleProtocolSinkIdleTimeout.label = Idle Timeout
thing-type.config.pulseaudio.sink.simpleProtocolSinkIdleTimeout.description = Timeout in ms after which the connection will be closed when no stream is running. This ensures that your speaker is not on all the time and the pulseaudio sink can go to idle mode. -1 for no disconnection (improve latency). 0 for immediate disconnection.
thing-type.config.pulseaudio.sink.simpleProtocolSinkPort.label = Simple Protocol Port
thing-type.config.pulseaudio.sink.simpleProtocolSinkPort.description = Default Port to allocate for use by module-simple-protocol-tcp on the pulseaudio server
thing-type.config.pulseaudio.sinkInput.additionalFilters.label = Additional Filters
thing-type.config.pulseaudio.sinkInput.additionalFilters.description = Additional filters to select the proper device on the pulseaudio server, in case of ambiguity. To be selected, the device should have at least a property value matching this regular expression. You can use multiple regular expressions (separator is ###).
thing-type.config.pulseaudio.sinkInput.name.label = Name
@ -61,6 +63,12 @@ thing-type.config.pulseaudio.source.additionalFilters.label = Additional Filters
thing-type.config.pulseaudio.source.additionalFilters.description = Additional filters to select the proper device on the pulseaudio server, in case of ambiguity. To be selected, the device should have at least a property value matching this regular expression. You can use multiple regular expressions (separator is ###).
thing-type.config.pulseaudio.source.name.label = Name
thing-type.config.pulseaudio.source.name.description = The name of one specific device. You can also use the description.
thing-type.config.pulseaudio.source.simpleProtocolIdleModules.label = Simple Protocol Idle Modules
thing-type.config.pulseaudio.source.simpleProtocolIdleModules.description = Number of Simple Protocol TCP Socket modules to keep loaded in the server to reuse
thing-type.config.pulseaudio.source.simpleProtocolMaxPort.label = Simple Protocol Max Port
thing-type.config.pulseaudio.source.simpleProtocolMaxPort.description = Max port used by simple protocol module instances created by the binding on the pulseaudio host.
thing-type.config.pulseaudio.source.simpleProtocolMinPort.label = Simple Protocol Min Port
thing-type.config.pulseaudio.source.simpleProtocolMinPort.description = Min port used by simple protocol module instances created by the binding on the pulseaudio host.
thing-type.config.pulseaudio.source.simpleProtocolSOTimeout.label = Simple Protocol SO Timeout
thing-type.config.pulseaudio.source.simpleProtocolSOTimeout.description = Socket SO timeout when connecting to pulseaudio server though module-simple-protocol-tcp. You can tune this option if the socket disconnect frequently.
thing-type.config.pulseaudio.source.simpleProtocolSourceChannels.label = Simple Protocol Channels
@ -74,10 +82,6 @@ thing-type.config.pulseaudio.source.simpleProtocolSourceFormat.option.s24le = PC
thing-type.config.pulseaudio.source.simpleProtocolSourceFormat.option.s24be = PCM signed 24-bit big-endian
thing-type.config.pulseaudio.source.simpleProtocolSourceFormat.option.s32le = PCM signed 32-bit little-endian
thing-type.config.pulseaudio.source.simpleProtocolSourceFormat.option.s32be = PCM signed 32-bit big-endian
thing-type.config.pulseaudio.source.simpleProtocolSourceIdleTimeout.label = Idle Timeout
thing-type.config.pulseaudio.source.simpleProtocolSourceIdleTimeout.description = Timeout in ms after which the connection will be closed when no stream is running. This ensures that your mic is not on all the time and the pulseaudio source can go to idle mode. -1 for no disconnection. 0 for immediate disconnection (recommended value to avoid capturing unwanted buffered audio).
thing-type.config.pulseaudio.source.simpleProtocolSourcePort.label = Simple Protocol Port
thing-type.config.pulseaudio.source.simpleProtocolSourcePort.description = Default Port to allocate to be used by module-simple-protocol-tcp on the pulseaudio server
thing-type.config.pulseaudio.source.simpleProtocolSourceRate.label = Simple Protocol Rate
thing-type.config.pulseaudio.source.simpleProtocolSourceRate.description = The audio sample rate to be used by module-simple-protocol-tcp on the pulseaudio server
thing-type.config.pulseaudio.sourceOutput.additionalFilters.label = Additional Filters

View File

@ -37,18 +37,10 @@
multiple
regular expressions (separator is ###).</description>
</parameter>
<parameter name="simpleProtocolSinkPort" type="integer" required="false">
<label>Simple Protocol Port</label>
<description>Default Port to allocate for use by module-simple-protocol-tcp on the pulseaudio server</description>
<default>4711</default>
</parameter>
<parameter name="simpleProtocolSinkIdleTimeout" type="integer" required="false">
<label>Idle Timeout</label>
<description>Timeout in ms after which the connection will be closed when no stream is running. This ensures that
your speaker is not on all the time and the pulseaudio sink can go to idle mode. -1 for no disconnection (improve
latency). 0 for immediate disconnection.
</description>
<default>30000</default>
<parameter name="simpleProtocolIdleModules" type="integer" required="false">
<label>Simple Protocol Idle Modules</label>
<description>Number of Simple Protocol TCP Socket modules to keep loaded in the server</description>
<default>2</default>
</parameter>
<parameter name="simpleProtocolSOTimeout" type="integer" min="250" max="2000">
<label>Simple Protocol SO Timeout</label>
@ -57,6 +49,18 @@
<default>500</default>
<advanced>true</advanced>
</parameter>
<parameter name="simpleProtocolMinPort" type="integer" min="1024" max="64512">
<label>Simple Protocol Min Port</label>
<description>Min port used by simple protocol module instances created by the binding on the pulseaudio host.</description>
<default>1024</default>
<advanced>true</advanced>
</parameter>
<parameter name="simpleProtocolMaxPort" type="integer" min="1024" max="64512">
<label>Simple Protocol Max Port</label>
<description>Max port used by simple protocol module instances created by the binding on the pulseaudio host.</description>
<default>64512</default>
<advanced>true</advanced>
</parameter>
</config-description>
</thing-type>

View File

@ -36,20 +36,6 @@
pulseaudio server)</description>
<default>false</default>
</parameter>
<parameter name="simpleProtocolSourcePort" type="integer" required="false">
<label>Simple Protocol Port</label>
<description>Default Port to allocate to be used by module-simple-protocol-tcp on the pulseaudio server</description>
<default>4710</default>
</parameter>
<parameter name="simpleProtocolSourceIdleTimeout" type="integer" required="false">
<advanced>true</advanced>
<label>Idle Timeout</label>
<description>Timeout in ms after which the connection will be closed when no stream is running. This ensures that
your mic is not on all the time and the pulseaudio source can go to idle mode. -1 for no disconnection. 0 for
immediate disconnection (recommended value to avoid capturing unwanted buffered audio)
</description>
<default>0</default>
</parameter>
<parameter name="simpleProtocolSourceFormat" type="text">
<label>Simple Protocol Format</label>
<description>The audio format to be used by module-simple-protocol-tcp on the pulseaudio server</description>
@ -65,6 +51,12 @@
<option value="s32be">PCM signed 32-bit big-endian</option>
</options>
</parameter>
<parameter name="simpleProtocolIdleModules" type="integer" required="false">
<label>Simple Protocol Idle Modules</label>
<description>Number of Simple Protocol TCP Socket modules to keep loaded in the server to reuse</description>
<default>1</default>
<advanced>true</advanced>
</parameter>
<parameter name="simpleProtocolSourceRate" type="integer" min="0">
<label>Simple Protocol Rate</label>
<description>The audio sample rate to be used by module-simple-protocol-tcp on the pulseaudio server</description>
@ -84,6 +76,18 @@
<default>500</default>
<advanced>true</advanced>
</parameter>
<parameter name="simpleProtocolMinPort" type="integer" min="1024" max="64512">
<label>Simple Protocol Min Port</label>
<description>Min port used by simple protocol module instances created by the binding on the pulseaudio host.</description>
<default>1024</default>
<advanced>true</advanced>
</parameter>
<parameter name="simpleProtocolMaxPort" type="integer" min="1024" max="64512">
<label>Simple Protocol Max Port</label>
<description>Max port used by simple protocol module instances created by the binding on the pulseaudio host.</description>
<default>64512</default>
<advanced>true</advanced>
</parameter>
</config-description>
</thing-type>