Skip to content

Commit

Permalink
[pulseaudio] Add pulseaudio sink as openhab audio sink (openhab#1895)
Browse files Browse the repository at this point in the history
Signed-off-by: Gwendal Roulleau <gwendal.roulleau@gmail.com>

This add to the pulseaudio binding the capability to use "pulseaudio sink" as an "openhab sink" to output sound from openhab.
You need to load module-simple-protocol-tcp sink in addition to the usual module-cli-protocol-tcp, and enable the sink in the thing configuration
  • Loading branch information
dragonforcer committed Mar 27, 2021
1 parent 506ef2e commit 217b962
Show file tree
Hide file tree
Showing 6 changed files with 265 additions and 7 deletions.
15 changes: 15 additions & 0 deletions bundles/org.openhab.binding.pulseaudio/pom.xml
Expand Up @@ -14,4 +14,19 @@

<name>openHAB Add-ons :: Bundles :: Pulseaudio Binding</name>

<dependencies>
<dependency>
<groupId>com.googlecode.soundlibs</groupId>
<artifactId>mp3spi</artifactId>
<version> 1.9.5.4</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.googlecode.soundlibs</groupId>
<artifactId>jlayer</artifactId>
<version>1.0.1.4</version>
<scope>compile</scope>
</dependency>
</dependencies>

</project>
@@ -0,0 +1,152 @@
package org.openhab.binding.pulseaudio.internal;

import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;

import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.UnsupportedAudioFileException;

import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.pulseaudio.internal.handler.PulseaudioHandler;
import org.openhab.core.audio.AudioFormat;
import org.openhab.core.audio.AudioSink;
import org.openhab.core.audio.AudioStream;
import org.openhab.core.audio.FixedLengthAudioStream;
import org.openhab.core.audio.UnsupportedAudioFormatException;
import org.openhab.core.audio.UnsupportedAudioStreamException;
import org.openhab.core.library.types.PercentType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javazoom.spi.mpeg.sampled.convert.MpegFormatConversionProvider;
import javazoom.spi.mpeg.sampled.file.MpegAudioFileReader;

public class PulseAudioAudioSink implements AudioSink {

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

private static final HashSet<AudioFormat> SUPPORTED_FORMATS = new HashSet<>();
private static final HashSet<Class<? extends AudioStream>> SUPPORTED_STREAMS = new HashSet<>();

private PulseaudioHandler pulseaudioHandler;

private Socket clientSocket;

static {
SUPPORTED_FORMATS.add(AudioFormat.WAV);
SUPPORTED_FORMATS.add(AudioFormat.MP3);
SUPPORTED_STREAMS.add(FixedLengthAudioStream.class);
}

public PulseAudioAudioSink(PulseaudioHandler pulseaudioHandler) {
this.pulseaudioHandler = pulseaudioHandler;
}

@Override
public String getId() {
return pulseaudioHandler.getThing().getUID().toString();
}

@Override
public @Nullable String getLabel(@Nullable Locale locale) {
return pulseaudioHandler.getThing().getLabel();
}

private InputStream getPCMStreamFromMp3Stream(InputStream input) {
try {
MpegAudioFileReader mpegAudioFileReader = new MpegAudioFileReader();
AudioInputStream sourceAIS = mpegAudioFileReader.getAudioInputStream(input);
javax.sound.sampled.AudioFormat sourceFormat = sourceAIS.getFormat();

MpegFormatConversionProvider mpegconverter = new MpegFormatConversionProvider();
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);

return mpegconverter.getAudioInputStream(convertFormat, sourceAIS);

} catch (IOException | UnsupportedAudioFileException e) {
logger.error("Cannot convert this mp3 stream to pcm stream", e);
}
return null;
}

private void connect() throws UnknownHostException, IOException {
if (clientSocket == null || !clientSocket.isConnected() || clientSocket.isClosed()) {
String host = pulseaudioHandler.getHost();
int port = pulseaudioHandler.getSimpleTcpPort();
clientSocket = new Socket(host, port);
}
}

public void disconnect() {
if (clientSocket == null) {
try {
clientSocket.close();
} catch (IOException e) {
}
}
}

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

if (audioStream == null) {
return;
}

try {
InputStream audioInputStream = null;
if (AudioFormat.MP3.isCompatible(audioStream.getFormat())) {
audioInputStream = getPCMStreamFromMp3Stream(audioStream);
} else if (AudioFormat.WAV.isCompatible(audioStream.getFormat())) {
audioInputStream = audioStream;
}

if (audioInputStream == null) {
throw new UnsupportedAudioFormatException("pulseaudio audio sink can only play pcm or mp3 stream",
audioStream.getFormat());
}

try {
connect();
audioInputStream.transferTo(clientSocket.getOutputStream());
} catch (IOException e) {
logger.error("Error while trying to send audio to pulseaudio audio sink. Cannot connect to "
+ pulseaudioHandler.getHost() + ":" + pulseaudioHandler.getSimpleTcpPort());
}
} finally {
try {
audioStream.close();
} catch (IOException e) {
}
}
}

@Override
public Set<AudioFormat> getSupportedFormats() {
return SUPPORTED_FORMATS;
}

@Override
public Set<Class<? extends AudioStream>> getSupportedStreams() {
return SUPPORTED_STREAMS;
}

@Override
public PercentType getVolume() {
return new PercentType(pulseaudioHandler.getLastVolume());
}

@Override
public void setVolume(PercentType volume) {
pulseaudioHandler.setVolume(volume.intValue());
}

}
Expand Up @@ -51,6 +51,8 @@ public class PulseaudioBindingConstants {
public static final String BRIDGE_PARAMETER_REFRESH_INTERVAL = "refresh";

public static final String DEVICE_PARAMETER_NAME = "name";
public static final String DEVICE_PARAMETER_AUDIO_SINK_ACTIVATION = "activateSimpleProtocolSink";
public static final String DEVICE_PARAMETER_AUDIO_SINK_PORT = "simpleProtocolSinkPort";

public static final Map<String, Boolean> TYPE_FILTERS = new HashMap<>();

Expand Down
Expand Up @@ -19,12 +19,14 @@
import java.util.Hashtable;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.openhab.binding.pulseaudio.internal.discovery.PulseaudioDeviceDiscoveryService;
import org.openhab.binding.pulseaudio.internal.handler.PulseaudioBridgeHandler;
import org.openhab.binding.pulseaudio.internal.handler.PulseaudioHandler;
import org.openhab.core.audio.AudioSink;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.config.discovery.DiscoveryService;
import org.openhab.core.thing.Bridge;
Expand Down Expand Up @@ -56,6 +58,8 @@ public class PulseaudioHandlerFactory extends BaseThingHandlerFactory {

private final Map<ThingHandler, ServiceRegistration<?>> discoveryServiceReg = new HashMap<>();

private Map<String, ServiceRegistration<AudioSink>> audioSinkRegistrations = new ConcurrentHashMap<>();

@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
Expand Down Expand Up @@ -99,18 +103,44 @@ protected void removeHandler(ThingHandler thingHandler) {
discoveryServiceReg.get(thingHandler).unregister();
discoveryServiceReg.remove(thingHandler);
}

// Unregister the potential pulse audio sink's audio sink
ServiceRegistration<AudioSink> reg = audioSinkRegistrations.get(thingHandler.getThing().getUID().toString());
if (reg != null) {
logger.trace("Unregistering the audio sync service for pulse audio sink thing {}",
thingHandler.getThing().getUID());
reg.unregister();
}

super.removeHandler(thingHandler);
}

@Override
protected ThingHandler createHandler(Thing thing) {

ThingTypeUID thingTypeUID = thing.getThingTypeUID();

if (PulseaudioBridgeHandler.SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID)) {
PulseaudioBridgeHandler handler = new PulseaudioBridgeHandler((Bridge) thing);
registerDeviceDiscoveryService(handler);
return handler;
} else if (PulseaudioHandler.SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID)) {
return new PulseaudioHandler(thing);
PulseaudioHandler pulseaudioHandler = new PulseaudioHandler(thing);
if (PulseaudioBindingConstants.SINK_THING_TYPE.equals(thingTypeUID)) {
Boolean sinkActivated = (Boolean) thing.getConfiguration()
.get(PulseaudioBindingConstants.DEVICE_PARAMETER_AUDIO_SINK_ACTIVATION);
if (sinkActivated != null && sinkActivated) {
// Register the sink as an audio sink
logger.trace("Registering an audio sink for pulse audio sink thing {}", thing.getUID());
PulseAudioAudioSink audioSink = new PulseAudioAudioSink(pulseaudioHandler);
pulseaudioHandler.setAudioSink(audioSink);
@SuppressWarnings("unchecked")
ServiceRegistration<AudioSink> reg = (ServiceRegistration<AudioSink>) bundleContext
.registerService(AudioSink.class.getName(), audioSink, new Hashtable<>());
audioSinkRegistrations.put(thing.getUID().toString(), reg);
}
}
return pulseaudioHandler;
}

return null;
Expand Down
Expand Up @@ -14,6 +14,7 @@

import static org.openhab.binding.pulseaudio.internal.PulseaudioBindingConstants.*;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
Expand All @@ -23,6 +24,8 @@
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.openhab.binding.pulseaudio.internal.PulseAudioAudioSink;
import org.openhab.binding.pulseaudio.internal.PulseaudioBindingConstants;
import org.openhab.binding.pulseaudio.internal.items.AbstractAudioDeviceConfig;
import org.openhab.binding.pulseaudio.internal.items.Sink;
import org.openhab.binding.pulseaudio.internal.items.SinkInput;
Expand Down Expand Up @@ -68,6 +71,10 @@ public class PulseaudioHandler extends BaseThingHandler implements DeviceStatusL

private String name;

private PulseAudioAudioSink audioSink;

private Integer savedVolume;

public PulseaudioHandler(Thing thing) {
super(thing);
}
Expand Down Expand Up @@ -162,15 +169,15 @@ public void handleCommand(ChannelUID channelUID, Command command) {
// refresh to get the current volume level
bridge.getClient().update();
device = bridge.getDevice(name);
int volume = device.getVolume();
savedVolume = device.getVolume();
if (command.equals(IncreaseDecreaseType.INCREASE)) {
volume = Math.min(100, volume + 5);
savedVolume = Math.min(100, savedVolume + 5);
}
if (command.equals(IncreaseDecreaseType.DECREASE)) {
volume = Math.max(0, volume - 5);
savedVolume = Math.max(0, savedVolume - 5);
}
bridge.getClient().setVolumePercent(device, volume);
updateState = new PercentType(volume);
bridge.getClient().setVolumePercent(device, savedVolume);
updateState = new PercentType(savedVolume);
} else if (command instanceof PercentType) {
DecimalType volume = (DecimalType) command;
bridge.getClient().setVolumePercent(device, volume.intValue());
Expand Down Expand Up @@ -227,12 +234,37 @@ public void handleCommand(ChannelUID channelUID, Command command) {
}
}

/**
* Use last checked volume for faster access
*
* @return
*/
public int getLastVolume() {
if (savedVolume == null) {
PulseaudioBridgeHandler bridge = getPulseaudioBridgeHandler();
AbstractAudioDeviceConfig device = bridge.getDevice(name);
// refresh to get the current volume level
bridge.getClient().update();
device = bridge.getDevice(name);
savedVolume = device.getVolume();
}
return savedVolume == null ? 50 : savedVolume;
}

public void setVolume(int volume) {
PulseaudioBridgeHandler bridge = getPulseaudioBridgeHandler();
AbstractAudioDeviceConfig device = bridge.getDevice(name);
bridge.getClient().setVolumePercent(device, volume);
updateState(VOLUME_CHANNEL, new PercentType(volume));
}

@Override
public void onDeviceStateChanged(ThingUID bridge, AbstractAudioDeviceConfig device) {
if (device.getPaName().equals(name)) {
updateStatus(ThingStatus.ONLINE);
logger.debug("Updating states of {} id: {}", device, VOLUME_CHANNEL);
updateState(VOLUME_CHANNEL, new PercentType(device.getVolume()));
savedVolume = device.getVolume();
updateState(VOLUME_CHANNEL, new PercentType(savedVolume));
updateState(MUTE_CHANNEL, device.isMuted() ? OnOffType.ON : OnOffType.OFF);
updateState(STATE_CHANNEL,
device.getState() != null ? new StringType(device.getState().toString()) : new StringType("-"));
Expand All @@ -248,11 +280,22 @@ public void onDeviceStateChanged(ThingUID bridge, AbstractAudioDeviceConfig devi
}
}

@SuppressWarnings("null")
public String getHost() {
return (String) getBridge().getConfiguration().get(PulseaudioBindingConstants.BRIDGE_PARAMETER_HOST);
}

public int getSimpleTcpPort() {
return ((BigDecimal) getThing().getConfiguration()
.get(PulseaudioBindingConstants.DEVICE_PARAMETER_AUDIO_SINK_PORT)).intValue();
}

@Override
public void onDeviceRemoved(PulseaudioBridgeHandler bridge, AbstractAudioDeviceConfig device) {
if (device.getPaName().equals(name)) {
bridgeHandler.unregisterDeviceStatusListener(this);
bridgeHandler = null;
audioSink.disconnect();
updateStatus(ThingStatus.OFFLINE);
}
}
Expand All @@ -261,4 +304,8 @@ public void onDeviceRemoved(PulseaudioBridgeHandler bridge, AbstractAudioDeviceC
public void onDeviceAdded(Bridge bridge, AbstractAudioDeviceConfig device) {
logger.trace("new device discovered {} by {}", device, bridge);
}

public void setAudioSink(PulseAudioAudioSink audioSink) {
this.audioSink = audioSink;
}
}
Expand Up @@ -21,6 +21,18 @@
<label>Name</label>
<description>The name of one specific device.</description>
</parameter>
<parameter name="activateSimpleProtocolSink" type="boolean">
<label>Sink by simple-protocol-tcp</label>
<description>Activation of a corresponding sink in openHab (needs module-simple-protocol-tcp loaded on the server)</description>
<default>false</default>
<required>false</required>
</parameter>
<parameter name="simpleProtocolSinkPort" type="integer">
<label>Simple protocol port</label>
<description>Port of the module-simple-protocol-tcp listening for this sink</description>
<default>4711</default>
<required>false</required>
</parameter>
</config-description>
</thing-type>

Expand Down

0 comments on commit 217b962

Please sign in to comment.