2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.pulseaudio.internal;
15 import java.io.IOException;
16 import java.io.InputStream;
17 import java.net.Socket;
18 import java.time.Duration;
19 import java.time.Instant;
20 import java.util.HashSet;
21 import java.util.Locale;
24 import java.util.concurrent.ScheduledExecutorService;
25 import java.util.concurrent.ScheduledFuture;
26 import java.util.concurrent.TimeUnit;
27 import javazoom.spi.mpeg.sampled.convert.MpegFormatConversionProvider;
28 import javazoom.spi.mpeg.sampled.file.MpegAudioFileReader;
30 import javax.sound.sampled.AudioFileFormat;
31 import javax.sound.sampled.AudioInputStream;
32 import javax.sound.sampled.AudioSystem;
33 import javax.sound.sampled.UnsupportedAudioFileException;
35 import org.eclipse.jdt.annotation.NonNullByDefault;
36 import org.eclipse.jdt.annotation.Nullable;
37 import org.openhab.binding.pulseaudio.internal.handler.PulseaudioHandler;
38 import org.openhab.core.audio.AudioFormat;
39 import org.openhab.core.audio.AudioSink;
40 import org.openhab.core.audio.AudioStream;
41 import org.openhab.core.audio.FixedLengthAudioStream;
42 import org.openhab.core.audio.UnsupportedAudioFormatException;
43 import org.openhab.core.audio.UnsupportedAudioStreamException;
44 import org.openhab.core.library.types.PercentType;
45 import org.slf4j.Logger;
46 import org.slf4j.LoggerFactory;
47 import org.tritonus.share.sampled.file.TAudioFileFormat;
50 * The audio sink for openhab, implemented by a connection to a pulseaudio sink
52 * @author Gwendal Roulleau - Initial contribution
56 public class PulseAudioAudioSink implements AudioSink {
58 private final Logger logger = LoggerFactory.getLogger(PulseAudioAudioSink.class);
60 private static final HashSet<AudioFormat> SUPPORTED_FORMATS = new HashSet<>();
61 private static final HashSet<Class<? extends AudioStream>> SUPPORTED_STREAMS = new HashSet<>();
63 private PulseaudioHandler pulseaudioHandler;
64 private ScheduledExecutorService scheduler;
66 private @Nullable Socket clientSocket;
68 private boolean isIdle = true;
70 private @Nullable ScheduledFuture<?> scheduledDisconnection;
73 SUPPORTED_FORMATS.add(AudioFormat.WAV);
74 SUPPORTED_FORMATS.add(AudioFormat.MP3);
75 SUPPORTED_STREAMS.add(FixedLengthAudioStream.class);
78 public PulseAudioAudioSink(PulseaudioHandler pulseaudioHandler, ScheduledExecutorService scheduler) {
79 this.pulseaudioHandler = pulseaudioHandler;
80 this.scheduler = scheduler;
84 public String getId() {
85 return pulseaudioHandler.getThing().getUID().toString();
89 public @Nullable String getLabel(@Nullable Locale locale) {
90 return pulseaudioHandler.getThing().getLabel();
94 * Convert MP3 to PCM, as this is the only possible format
99 private @Nullable AudioStreamAndDuration getPCMStreamFromMp3Stream(InputStream input) {
102 MpegAudioFileReader mpegAudioFileReader = new MpegAudioFileReader();
105 if (input instanceof FixedLengthAudioStream) {
106 final Long audioFileLength = ((FixedLengthAudioStream) input).length();
107 AudioFileFormat audioFileFormat = mpegAudioFileReader.getAudioFileFormat(input);
108 if (audioFileFormat instanceof TAudioFileFormat) {
109 Map<String, Object> taudioFileFormatProperties = ((TAudioFileFormat) audioFileFormat).properties();
110 if (taudioFileFormatProperties.containsKey("mp3.framesize.bytes")
111 && taudioFileFormatProperties.containsKey("mp3.framerate.fps")) {
112 Integer frameSize = (Integer) taudioFileFormatProperties.get("mp3.framesize.bytes");
113 Float frameRate = (Float) taudioFileFormatProperties.get("mp3.framerate.fps");
114 if (frameSize != null && frameRate != null) {
115 duration = Math.round((audioFileLength / (frameSize * frameRate)) * 1000);
122 AudioInputStream sourceAIS = mpegAudioFileReader.getAudioInputStream(input);
123 javax.sound.sampled.AudioFormat sourceFormat = sourceAIS.getFormat();
125 MpegFormatConversionProvider mpegconverter = new MpegFormatConversionProvider();
126 javax.sound.sampled.AudioFormat convertFormat = new javax.sound.sampled.AudioFormat(
127 javax.sound.sampled.AudioFormat.Encoding.PCM_SIGNED, sourceFormat.getSampleRate(), 16,
128 sourceFormat.getChannels(), sourceFormat.getChannels() * 2, sourceFormat.getSampleRate(), false);
130 AudioInputStream audioInputStreamConverted = mpegconverter.getAudioInputStream(convertFormat, sourceAIS);
131 return new AudioStreamAndDuration(audioInputStreamConverted, duration);
133 } catch (IOException | UnsupportedAudioFileException e) {
134 logger.warn("Cannot convert this mp3 stream to pcm stream: {}", e.getMessage());
140 * Connect to pulseaudio with the simple protocol
142 * @throws IOException
143 * @throws InterruptedException when interrupted during the loading module wait
145 public void connectIfNeeded() throws IOException, InterruptedException {
146 Socket clientSocketLocal = clientSocket;
147 if (clientSocketLocal == null || !clientSocketLocal.isConnected() || clientSocketLocal.isClosed()) {
148 String host = pulseaudioHandler.getHost();
149 int port = pulseaudioHandler.getSimpleTcpPort();
150 clientSocket = new Socket(host, port);
151 clientSocket.setSoTimeout(500);
156 * Disconnect the socket to pulseaudio simple protocol
158 public void disconnect() {
159 final Socket clientSocketLocal = clientSocket;
160 if (clientSocketLocal != null && isIdle) {
161 logger.debug("Disconnecting");
163 clientSocketLocal.close();
164 } catch (IOException e) {
167 logger.debug("Stream still running or socket not open");
171 private AudioStreamAndDuration getWavAudioAndDuration(AudioStream audioStream) {
173 if (audioStream instanceof FixedLengthAudioStream) {
174 final Long audioFileLength = ((FixedLengthAudioStream) audioStream).length();
176 AudioInputStream audioInputStream = AudioSystem.getAudioInputStream(audioStream);
177 int frameSize = audioInputStream.getFormat().getFrameSize();
178 float frameRate = audioInputStream.getFormat().getFrameRate();
179 float durationInSeconds = (audioFileLength / (frameSize * frameRate));
180 duration = Math.round(durationInSeconds * 1000);
181 } catch (UnsupportedAudioFileException | IOException e) {
182 logger.warn("Error when getting duration information from AudioFile");
185 return new AudioStreamAndDuration(audioStream, duration);
189 public void process(@Nullable AudioStream audioStream)
190 throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
192 if (audioStream == null) {
196 AudioStreamAndDuration audioInputStreamAndDuration = null;
199 if (AudioFormat.MP3.isCompatible(audioStream.getFormat())) {
200 audioInputStreamAndDuration = getPCMStreamFromMp3Stream(audioStream);
201 } else if (AudioFormat.WAV.isCompatible(audioStream.getFormat())) {
202 audioInputStreamAndDuration = getWavAudioAndDuration(audioStream);
204 throw new UnsupportedAudioFormatException("pulseaudio audio sink can only play pcm or mp3 stream",
205 audioStream.getFormat());
208 for (int countAttempt = 1; countAttempt <= 2; countAttempt++) { // two attempts allowed
211 final Socket clientSocketLocal = clientSocket;
212 if (audioInputStreamAndDuration != null && clientSocketLocal != null) {
213 // send raw audio to the socket and to pulse audio
215 Instant start = Instant.now();
216 audioInputStreamAndDuration.inputStream.transferTo(clientSocketLocal.getOutputStream());
217 if (audioInputStreamAndDuration.duration != -1) { // ensure, if the sound has a duration
218 // that we let at least this time for the system to play
219 Instant end = Instant.now();
220 long millisSecondTimedToSendAudioData = Duration.between(start, end).toMillis();
221 if (millisSecondTimedToSendAudioData < audioInputStreamAndDuration.duration) {
222 long timeToSleep = audioInputStreamAndDuration.duration
223 - millisSecondTimedToSendAudioData;
224 logger.debug("Sleep time to let the system play sound : {}", timeToSleep);
225 Thread.sleep(timeToSleep);
230 } catch (IOException e) {
231 disconnect(); // disconnect force to clear connection in case of socket not cleanly shutdown
232 if (countAttempt == 2) { // we won't retry : log and quit
233 if (logger.isWarnEnabled()) {
234 String port = clientSocket != null ? Integer.toString(clientSocket.getPort()) : "unknown";
236 "Error while trying to send audio to pulseaudio audio sink. Cannot connect to {}:{}, error: {}",
237 pulseaudioHandler.getHost(), port, e.getMessage());
241 } catch (InterruptedException ie) {
242 logger.info("Interrupted during sink audio connection: {}", ie.getMessage());
248 if (audioInputStreamAndDuration != null) {
249 audioInputStreamAndDuration.inputStream.close();
252 scheduleDisconnect();
253 } catch (IOException e) {
259 public void scheduleDisconnect() {
260 if (scheduledDisconnection != null) {
261 scheduledDisconnection.cancel(true);
263 int idleTimeout = pulseaudioHandler.getIdleTimeout();
264 if (idleTimeout > -1) {
265 logger.debug("Scheduling disconnect");
266 scheduledDisconnection = scheduler.schedule(this::disconnect, idleTimeout, TimeUnit.MILLISECONDS);
271 public Set<AudioFormat> getSupportedFormats() {
272 return SUPPORTED_FORMATS;
276 public Set<Class<? extends AudioStream>> getSupportedStreams() {
277 return SUPPORTED_STREAMS;
281 public PercentType getVolume() {
282 return new PercentType(pulseaudioHandler.getLastVolume());
286 public void setVolume(PercentType volume) {
287 pulseaudioHandler.setVolume(volume.intValue());
290 private static class AudioStreamAndDuration {
291 private InputStream inputStream;
292 private int duration;
294 public AudioStreamAndDuration(InputStream inputStream, int duration) {
296 this.inputStream = inputStream;
297 this.duration = duration + 200; // introduce some delay