]> git.basschouten.com Git - openhab-addons.git/blob
9de4d3cc7ccc7909361e4c261418a5b4d4012d17
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.pulseaudio.internal;
14
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;
22 import java.util.Map;
23 import java.util.Set;
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;
29
30 import javax.sound.sampled.AudioFileFormat;
31 import javax.sound.sampled.AudioInputStream;
32 import javax.sound.sampled.AudioSystem;
33 import javax.sound.sampled.UnsupportedAudioFileException;
34
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;
48
49 /**
50  * The audio sink for openhab, implemented by a connection to a pulseaudio sink
51  *
52  * @author Gwendal Roulleau - Initial contribution
53  *
54  */
55 @NonNullByDefault
56 public class PulseAudioAudioSink implements AudioSink {
57
58     private final Logger logger = LoggerFactory.getLogger(PulseAudioAudioSink.class);
59
60     private static final HashSet<AudioFormat> SUPPORTED_FORMATS = new HashSet<>();
61     private static final HashSet<Class<? extends AudioStream>> SUPPORTED_STREAMS = new HashSet<>();
62
63     private PulseaudioHandler pulseaudioHandler;
64     private ScheduledExecutorService scheduler;
65
66     private @Nullable Socket clientSocket;
67
68     private boolean isIdle = true;
69
70     private @Nullable ScheduledFuture<?> scheduledDisconnection;
71
72     static {
73         SUPPORTED_FORMATS.add(AudioFormat.WAV);
74         SUPPORTED_FORMATS.add(AudioFormat.MP3);
75         SUPPORTED_STREAMS.add(FixedLengthAudioStream.class);
76     }
77
78     public PulseAudioAudioSink(PulseaudioHandler pulseaudioHandler, ScheduledExecutorService scheduler) {
79         this.pulseaudioHandler = pulseaudioHandler;
80         this.scheduler = scheduler;
81     }
82
83     @Override
84     public String getId() {
85         return pulseaudioHandler.getThing().getUID().toString();
86     }
87
88     @Override
89     public @Nullable String getLabel(@Nullable Locale locale) {
90         return pulseaudioHandler.getThing().getLabel();
91     }
92
93     /**
94      * Convert MP3 to PCM, as this is the only possible format
95      *
96      * @param input
97      * @return
98      */
99     private @Nullable AudioStreamAndDuration getPCMStreamFromMp3Stream(InputStream input) {
100         try {
101
102             MpegAudioFileReader mpegAudioFileReader = new MpegAudioFileReader();
103
104             int duration = -1;
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);
116                         }
117                     }
118                 }
119                 input.reset();
120             }
121
122             AudioInputStream sourceAIS = mpegAudioFileReader.getAudioInputStream(input);
123             javax.sound.sampled.AudioFormat sourceFormat = sourceAIS.getFormat();
124
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);
129
130             AudioInputStream audioInputStreamConverted = mpegconverter.getAudioInputStream(convertFormat, sourceAIS);
131             return new AudioStreamAndDuration(audioInputStreamConverted, duration);
132
133         } catch (IOException | UnsupportedAudioFileException e) {
134             logger.warn("Cannot convert this mp3 stream to pcm stream: {}", e.getMessage());
135         }
136         return null;
137     }
138
139     /**
140      * Connect to pulseaudio with the simple protocol
141      *
142      * @throws IOException
143      * @throws InterruptedException when interrupted during the loading module wait
144      */
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);
152         }
153     }
154
155     /**
156      * Disconnect the socket to pulseaudio simple protocol
157      */
158     public void disconnect() {
159         final Socket clientSocketLocal = clientSocket;
160         if (clientSocketLocal != null && isIdle) {
161             logger.debug("Disconnecting");
162             try {
163                 clientSocketLocal.close();
164             } catch (IOException e) {
165             }
166         } else {
167             logger.debug("Stream still running or socket not open");
168         }
169     }
170
171     private AudioStreamAndDuration getWavAudioAndDuration(AudioStream audioStream) {
172         int duration = -1;
173         if (audioStream instanceof FixedLengthAudioStream) {
174             final Long audioFileLength = ((FixedLengthAudioStream) audioStream).length();
175             try {
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");
183             }
184         }
185         return new AudioStreamAndDuration(audioStream, duration);
186     }
187
188     @Override
189     public void process(@Nullable AudioStream audioStream)
190             throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
191
192         if (audioStream == null) {
193             return;
194         }
195
196         AudioStreamAndDuration audioInputStreamAndDuration = null;
197         try {
198
199             if (AudioFormat.MP3.isCompatible(audioStream.getFormat())) {
200                 audioInputStreamAndDuration = getPCMStreamFromMp3Stream(audioStream);
201             } else if (AudioFormat.WAV.isCompatible(audioStream.getFormat())) {
202                 audioInputStreamAndDuration = getWavAudioAndDuration(audioStream);
203             } else {
204                 throw new UnsupportedAudioFormatException("pulseaudio audio sink can only play pcm or mp3 stream",
205                         audioStream.getFormat());
206             }
207
208             for (int countAttempt = 1; countAttempt <= 2; countAttempt++) { // two attempts allowed
209                 try {
210                     connectIfNeeded();
211                     final Socket clientSocketLocal = clientSocket;
212                     if (audioInputStreamAndDuration != null && clientSocketLocal != null) {
213                         // send raw audio to the socket and to pulse audio
214                         isIdle = false;
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);
226                             }
227                         }
228                         break;
229                     }
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";
235                             logger.warn(
236                                     "Error while trying to send audio to pulseaudio audio sink. Cannot connect to {}:{}, error: {}",
237                                     pulseaudioHandler.getHost(), port, e.getMessage());
238                         }
239                         break;
240                     }
241                 } catch (InterruptedException ie) {
242                     logger.info("Interrupted during sink audio connection: {}", ie.getMessage());
243                     break;
244                 }
245             }
246         } finally {
247             try {
248                 if (audioInputStreamAndDuration != null) {
249                     audioInputStreamAndDuration.inputStream.close();
250                 }
251                 audioStream.close();
252                 scheduleDisconnect();
253             } catch (IOException e) {
254             }
255         }
256         isIdle = true;
257     }
258
259     public void scheduleDisconnect() {
260         if (scheduledDisconnection != null) {
261             scheduledDisconnection.cancel(true);
262         }
263         int idleTimeout = pulseaudioHandler.getIdleTimeout();
264         if (idleTimeout > -1) {
265             logger.debug("Scheduling disconnect");
266             scheduledDisconnection = scheduler.schedule(this::disconnect, idleTimeout, TimeUnit.MILLISECONDS);
267         }
268     }
269
270     @Override
271     public Set<AudioFormat> getSupportedFormats() {
272         return SUPPORTED_FORMATS;
273     }
274
275     @Override
276     public Set<Class<? extends AudioStream>> getSupportedStreams() {
277         return SUPPORTED_STREAMS;
278     }
279
280     @Override
281     public PercentType getVolume() {
282         return new PercentType(pulseaudioHandler.getLastVolume());
283     }
284
285     @Override
286     public void setVolume(PercentType volume) {
287         pulseaudioHandler.setVolume(volume.intValue());
288     }
289
290     private static class AudioStreamAndDuration {
291         private InputStream inputStream;
292         private int duration;
293
294         public AudioStreamAndDuration(InputStream inputStream, int duration) {
295             super();
296             this.inputStream = inputStream;
297             this.duration = duration + 200; // introduce some delay
298         }
299     }
300 }