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