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.TimeUnit;
26 import javazoom.spi.mpeg.sampled.convert.MpegFormatConversionProvider;
27 import javazoom.spi.mpeg.sampled.file.MpegAudioFileReader;
29 import javax.sound.sampled.AudioFileFormat;
30 import javax.sound.sampled.AudioInputStream;
31 import javax.sound.sampled.AudioSystem;
32 import javax.sound.sampled.UnsupportedAudioFileException;
34 import org.eclipse.jdt.annotation.NonNullByDefault;
35 import org.eclipse.jdt.annotation.Nullable;
36 import org.openhab.binding.pulseaudio.internal.handler.PulseaudioHandler;
37 import org.openhab.core.audio.AudioFormat;
38 import org.openhab.core.audio.AudioSink;
39 import org.openhab.core.audio.AudioStream;
40 import org.openhab.core.audio.FixedLengthAudioStream;
41 import org.openhab.core.audio.UnsupportedAudioFormatException;
42 import org.openhab.core.audio.UnsupportedAudioStreamException;
43 import org.openhab.core.library.types.PercentType;
44 import org.slf4j.Logger;
45 import org.slf4j.LoggerFactory;
46 import org.tritonus.share.sampled.file.TAudioFileFormat;
49 * The audio sink for openhab, implemented by a connection to a pulseaudio sink
51 * @author Gwendal Roulleau - Initial contribution
55 public class PulseAudioAudioSink implements AudioSink {
57 private final Logger logger = LoggerFactory.getLogger(PulseAudioAudioSink.class);
59 private static final HashSet<AudioFormat> SUPPORTED_FORMATS = new HashSet<>();
60 private static final HashSet<Class<? extends AudioStream>> SUPPORTED_STREAMS = new HashSet<>();
62 private PulseaudioHandler pulseaudioHandler;
63 private ScheduledExecutorService scheduler;
65 private @Nullable Socket clientSocket;
67 private boolean isIdle = true;
70 SUPPORTED_FORMATS.add(AudioFormat.WAV);
71 SUPPORTED_FORMATS.add(AudioFormat.MP3);
72 SUPPORTED_STREAMS.add(FixedLengthAudioStream.class);
75 public PulseAudioAudioSink(PulseaudioHandler pulseaudioHandler, ScheduledExecutorService scheduler) {
76 this.pulseaudioHandler = pulseaudioHandler;
77 this.scheduler = scheduler;
81 public String getId() {
82 return pulseaudioHandler.getThing().getUID().toString();
86 public @Nullable String getLabel(@Nullable Locale locale) {
87 return pulseaudioHandler.getThing().getLabel();
91 * Convert MP3 to PCM, as this is the only possible format
96 private @Nullable AudioStreamAndDuration getPCMStreamFromMp3Stream(InputStream input) {
99 MpegAudioFileReader mpegAudioFileReader = new MpegAudioFileReader();
102 if (input instanceof FixedLengthAudioStream) {
103 final Long audioFileLength = ((FixedLengthAudioStream) input).length();
104 AudioFileFormat audioFileFormat = mpegAudioFileReader.getAudioFileFormat(input);
105 if (audioFileFormat instanceof TAudioFileFormat) {
106 Map<String, Object> taudioFileFormatProperties = ((TAudioFileFormat) audioFileFormat).properties();
107 if (taudioFileFormatProperties.containsKey("mp3.framesize.bytes")
108 && taudioFileFormatProperties.containsKey("mp3.framerate.fps")) {
109 Integer frameSize = (Integer) taudioFileFormatProperties.get("mp3.framesize.bytes");
110 Float frameRate = (Float) taudioFileFormatProperties.get("mp3.framerate.fps");
111 if (frameSize != null && frameRate != null) {
112 duration = Math.round((audioFileLength / (frameSize * frameRate)) * 1000);
119 AudioInputStream sourceAIS = mpegAudioFileReader.getAudioInputStream(input);
120 javax.sound.sampled.AudioFormat sourceFormat = sourceAIS.getFormat();
122 MpegFormatConversionProvider mpegconverter = new MpegFormatConversionProvider();
123 javax.sound.sampled.AudioFormat convertFormat = new javax.sound.sampled.AudioFormat(
124 javax.sound.sampled.AudioFormat.Encoding.PCM_SIGNED, sourceFormat.getSampleRate(), 16,
125 sourceFormat.getChannels(), sourceFormat.getChannels() * 2, sourceFormat.getSampleRate(), false);
127 AudioInputStream audioInputStreamConverted = mpegconverter.getAudioInputStream(convertFormat, sourceAIS);
128 return new AudioStreamAndDuration(audioInputStreamConverted, duration);
130 } catch (IOException | UnsupportedAudioFileException e) {
131 logger.warn("Cannot convert this mp3 stream to pcm stream: {}", e.getMessage());
137 * Connect to pulseaudio with the simple protocol
139 * @throws IOException
140 * @throws InterruptedException when interrupted during the loading module wait
142 public void connectIfNeeded() throws IOException, InterruptedException {
143 Socket clientSocketLocal = clientSocket;
144 if (clientSocketLocal == null || !clientSocketLocal.isConnected() || clientSocketLocal.isClosed()) {
145 String host = pulseaudioHandler.getHost();
146 int port = pulseaudioHandler.getSimpleTcpPort();
147 clientSocket = new Socket(host, port);
148 clientSocket.setSoTimeout(500);
153 * Disconnect the socket to pulseaudio simple protocol
155 public void disconnect() {
156 final Socket clientSocketLocal = clientSocket;
157 if (clientSocketLocal != null && isIdle) {
158 logger.debug("Disconnecting");
160 clientSocketLocal.close();
161 } catch (IOException e) {
164 logger.debug("Stream still running or socket not open");
168 private AudioStreamAndDuration getWavAudioAndDuration(AudioStream audioStream) {
170 if (audioStream instanceof FixedLengthAudioStream) {
171 final Long audioFileLength = ((FixedLengthAudioStream) audioStream).length();
173 AudioInputStream audioInputStream = AudioSystem.getAudioInputStream(audioStream);
174 int frameSize = audioInputStream.getFormat().getFrameSize();
175 float frameRate = audioInputStream.getFormat().getFrameRate();
176 float durationInSeconds = (audioFileLength / (frameSize * frameRate));
177 duration = Math.round(durationInSeconds * 1000);
178 } catch (UnsupportedAudioFileException | IOException e) {
179 logger.warn("Error when getting duration information from AudioFile");
182 return new AudioStreamAndDuration(audioStream, duration);
186 public void process(@Nullable AudioStream audioStream)
187 throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
189 if (audioStream == null) {
193 AudioStreamAndDuration audioInputStreamAndDuration = null;
196 if (AudioFormat.MP3.isCompatible(audioStream.getFormat())) {
197 audioInputStreamAndDuration = getPCMStreamFromMp3Stream(audioStream);
198 } else if (AudioFormat.WAV.isCompatible(audioStream.getFormat())) {
199 audioInputStreamAndDuration = getWavAudioAndDuration(audioStream);
201 throw new UnsupportedAudioFormatException("pulseaudio audio sink can only play pcm or mp3 stream",
202 audioStream.getFormat());
205 for (int countAttempt = 1; countAttempt <= 2; countAttempt++) { // two attempts allowed
208 final Socket clientSocketLocal = clientSocket;
209 if (audioInputStreamAndDuration != null && clientSocketLocal != null) {
210 // send raw audio to the socket and to pulse audio
212 Instant start = Instant.now();
213 audioInputStreamAndDuration.inputStream.transferTo(clientSocketLocal.getOutputStream());
214 if (audioInputStreamAndDuration.duration != -1) { // ensure, if the sound has a duration
215 // that we let at least this time for the system to play
216 Instant end = Instant.now();
217 long millisSecondTimedToSendAudioData = Duration.between(start, end).toMillis();
218 if (millisSecondTimedToSendAudioData < audioInputStreamAndDuration.duration) {
219 long timeToSleep = audioInputStreamAndDuration.duration
220 - millisSecondTimedToSendAudioData;
221 logger.debug("Sleep time to let the system play sound : {}", timeToSleep);
222 Thread.sleep(timeToSleep);
227 } catch (IOException e) {
228 disconnect(); // disconnect force to clear connection in case of socket not cleanly shutdown
229 if (countAttempt == 2) { // we won't retry : log and quit
230 if (logger.isWarnEnabled()) {
231 String port = clientSocket != null ? Integer.toString(clientSocket.getPort()) : "unknown";
233 "Error while trying to send audio to pulseaudio audio sink. Cannot connect to {}:{}, error: {}",
234 pulseaudioHandler.getHost(), port, e.getMessage());
238 } catch (InterruptedException ie) {
239 logger.info("Interrupted during sink audio connection: {}", ie.getMessage());
245 if (audioInputStreamAndDuration != null) {
246 audioInputStreamAndDuration.inputStream.close();
249 scheduleDisconnect();
250 } catch (IOException e) {
256 public void scheduleDisconnect() {
257 logger.debug("Scheduling disconnect");
258 scheduler.schedule(this::disconnect, pulseaudioHandler.getIdleTimeout(), TimeUnit.MILLISECONDS);
262 public Set<AudioFormat> getSupportedFormats() {
263 return SUPPORTED_FORMATS;
267 public Set<Class<? extends AudioStream>> getSupportedStreams() {
268 return SUPPORTED_STREAMS;
272 public PercentType getVolume() {
273 return new PercentType(pulseaudioHandler.getLastVolume());
277 public void setVolume(PercentType volume) {
278 pulseaudioHandler.setVolume(volume.intValue());
281 private static class AudioStreamAndDuration {
282 private InputStream inputStream;
283 private int duration;
285 public AudioStreamAndDuration(InputStream inputStream, int duration) {
287 this.inputStream = inputStream;
288 this.duration = duration + 200; // introduce some delay