]> git.basschouten.com Git - openhab-addons.git/blob
5fd5a35d4847cd1ca656b5cacb0abf6f12f61ca3
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 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.util.Map;
18 import javazoom.spi.mpeg.sampled.convert.MpegFormatConversionProvider;
19 import javazoom.spi.mpeg.sampled.file.MpegAudioFileReader;
20
21 import javax.sound.sampled.AudioFileFormat;
22 import javax.sound.sampled.AudioInputStream;
23 import javax.sound.sampled.AudioSystem;
24 import javax.sound.sampled.UnsupportedAudioFileException;
25
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.openhab.core.audio.AudioFormat;
29 import org.openhab.core.audio.AudioStream;
30 import org.openhab.core.audio.FixedLengthAudioStream;
31 import org.openhab.core.audio.UnsupportedAudioFormatException;
32 import org.slf4j.Logger;
33 import org.slf4j.LoggerFactory;
34 import org.tritonus.share.sampled.file.TAudioFileFormat;
35
36 /**
37  * This class convert a stream to the normalized pcm
38  * format wanted by the pulseaudio sink
39  *
40  * @author Gwendal Roulleau - Initial contribution
41  */
42 @NonNullByDefault
43 public class ConvertedInputStream extends InputStream {
44
45     private final Logger logger = LoggerFactory.getLogger(ConvertedInputStream.class);
46
47     private static final javax.sound.sampled.AudioFormat TARGET_FORMAT = new javax.sound.sampled.AudioFormat(
48             javax.sound.sampled.AudioFormat.Encoding.PCM_SIGNED, 44100, 16, 2, 4, 44100, false);
49
50     private final AudioFormat audioFormat;
51     private AudioInputStream pcmNormalizedInputStream;
52
53     private long duration = -1;
54     private long length = -1;
55
56     public ConvertedInputStream(AudioStream innerInputStream)
57             throws UnsupportedAudioFormatException, UnsupportedAudioFileException, IOException {
58
59         this.audioFormat = innerInputStream.getFormat();
60
61         if (innerInputStream instanceof FixedLengthAudioStream) {
62             length = ((FixedLengthAudioStream) innerInputStream).length();
63         }
64
65         pcmNormalizedInputStream = getPCMStreamNormalized(getPCMStream(new ResetableInputStream(innerInputStream)));
66     }
67
68     @Override
69     public int read(byte @Nullable [] b) throws IOException {
70         return pcmNormalizedInputStream.read(b);
71     }
72
73     @Override
74     public int read(byte @Nullable [] b, int off, int len) throws IOException {
75         return pcmNormalizedInputStream.read(b, off, len);
76     }
77
78     @Override
79     public byte[] readAllBytes() throws IOException {
80         return pcmNormalizedInputStream.readAllBytes();
81     }
82
83     @Override
84     public byte[] readNBytes(int len) throws IOException {
85         return pcmNormalizedInputStream.readNBytes(len);
86     }
87
88     @Override
89     public int readNBytes(byte @Nullable [] b, int off, int len) throws IOException {
90         return pcmNormalizedInputStream.readNBytes(b, off, len);
91     }
92
93     @Override
94     public int read() throws IOException {
95         return pcmNormalizedInputStream.read();
96     }
97
98     @Override
99     public void close() throws IOException {
100         pcmNormalizedInputStream.close();
101     }
102
103     /**
104      * Ensure right PCM format by converting if needed (sample rate, channel)
105      *
106      * @param pcmInputStream
107      *
108      * @return A PCM normalized stream (2 channel, 44100hz, 16 bit signed)
109      */
110     private AudioInputStream getPCMStreamNormalized(AudioInputStream pcmInputStream) {
111
112         javax.sound.sampled.AudioFormat format = pcmInputStream.getFormat();
113         if (format.getChannels() != 2
114                 || !format.getEncoding().equals(javax.sound.sampled.AudioFormat.Encoding.PCM_SIGNED)
115                 || Math.abs(format.getFrameRate() - 44100) > 1000) {
116             logger.debug("Sound is not in the target format. Trying to reencode it");
117             return AudioSystem.getAudioInputStream(TARGET_FORMAT, pcmInputStream);
118         } else {
119             return pcmInputStream;
120         }
121     }
122
123     public long getDuration() {
124         return duration;
125     }
126
127     /**
128      * If necessary, this method convert MP3 to PCM, and try to
129      * extract duration information.
130      *
131      * @param resetableInnerInputStream A stream supporting reset operation
132      *            (reset is mandatory to parse formation without loosing data)
133      *
134      * @return PCM stream
135      * @throws UnsupportedAudioFileException
136      * @throws IOException
137      * @throws UnsupportedAudioFormatException
138      */
139     private AudioInputStream getPCMStream(InputStream resetableInnerInputStream)
140             throws UnsupportedAudioFileException, IOException, UnsupportedAudioFormatException {
141
142         if (AudioFormat.MP3.isCompatible(audioFormat)) {
143             MpegAudioFileReader mpegAudioFileReader = new MpegAudioFileReader();
144
145             if (length > 0) { // compute duration if possible
146                 AudioFileFormat audioFileFormat = mpegAudioFileReader.getAudioFileFormat(resetableInnerInputStream);
147                 if (audioFileFormat instanceof TAudioFileFormat) {
148                     Map<String, Object> taudioFileFormatProperties = ((TAudioFileFormat) audioFileFormat).properties();
149                     if (taudioFileFormatProperties.containsKey("mp3.framesize.bytes")
150                             && taudioFileFormatProperties.containsKey("mp3.framerate.fps")) {
151                         Integer frameSize = (Integer) taudioFileFormatProperties.get("mp3.framesize.bytes");
152                         Float frameRate = (Float) taudioFileFormatProperties.get("mp3.framerate.fps");
153                         if (frameSize != null && frameRate != null) {
154                             duration = Math.round((length / (frameSize * frameRate)) * 1000);
155                             logger.debug("Duration of input stream : {}", duration);
156                         }
157                     }
158                 }
159                 resetableInnerInputStream.reset();
160             }
161
162             logger.debug("Sound is a MP3. Trying to reencode it");
163             // convert MP3 to PCM :
164             AudioInputStream sourceAIS = mpegAudioFileReader.getAudioInputStream(resetableInnerInputStream);
165             javax.sound.sampled.AudioFormat sourceFormat = sourceAIS.getFormat();
166
167             MpegFormatConversionProvider mpegconverter = new MpegFormatConversionProvider();
168             javax.sound.sampled.AudioFormat convertFormat = new javax.sound.sampled.AudioFormat(
169                     javax.sound.sampled.AudioFormat.Encoding.PCM_SIGNED, sourceFormat.getSampleRate(), 16,
170                     sourceFormat.getChannels(), sourceFormat.getChannels() * 2, sourceFormat.getSampleRate(), false);
171
172             return mpegconverter.getAudioInputStream(convertFormat, sourceAIS);
173
174         } else if (AudioFormat.WAV.isCompatible(audioFormat)) {
175             // return the same input stream, but try to compute the duration first
176             AudioInputStream audioInputStream = AudioSystem.getAudioInputStream(resetableInnerInputStream);
177             if (length > 0) {
178                 int frameSize = audioInputStream.getFormat().getFrameSize();
179                 float frameRate = audioInputStream.getFormat().getFrameRate();
180                 float durationInSeconds = (length / (frameSize * frameRate));
181                 duration = Math.round(durationInSeconds * 1000);
182                 logger.debug("Duration of input stream : {}", duration);
183             }
184             return audioInputStream;
185         } else {
186             throw new UnsupportedAudioFormatException("Pulseaudio audio sink can only play pcm or mp3 stream",
187                     audioFormat);
188         }
189     }
190
191     /**
192      * This class add reset capability (on the first bytes only)
193      * to an AudioStream. This is necessary for the parsing / format detection.
194      *
195      */
196     public static class ResetableInputStream extends InputStream {
197
198         private static final int BUFFER_LENGTH = 10000;
199
200         private final InputStream originalInputStream;
201
202         private int position = -1;
203         private int markPosition = -1;
204         private int maxPreviousPosition = -2;
205
206         private byte[] startingBuffer = new byte[BUFFER_LENGTH + 1];
207
208         public ResetableInputStream(InputStream originalInputStream) {
209             this.originalInputStream = originalInputStream;
210         }
211
212         @Override
213         public void close() throws IOException {
214             originalInputStream.close();
215         }
216
217         @Override
218         public int read() throws IOException {
219             if (position >= BUFFER_LENGTH || originalInputStream.markSupported()) {
220                 return originalInputStream.read();
221             } else {
222                 position++;
223                 if (position <= maxPreviousPosition) {
224                     return Byte.toUnsignedInt(startingBuffer[position]);
225                 } else {
226                     int currentByte = originalInputStream.read();
227                     startingBuffer[position] = (byte) currentByte;
228                     maxPreviousPosition = position;
229                     return currentByte;
230                 }
231             }
232         }
233
234         @Override
235         public synchronized void mark(int readlimit) {
236             if (originalInputStream.markSupported()) {
237                 originalInputStream.mark(readlimit);
238             }
239             markPosition = position;
240         }
241
242         @Override
243         public boolean markSupported() {
244             return true;
245         }
246
247         @Override
248         public synchronized void reset() throws IOException {
249             if (originalInputStream.markSupported()) {
250                 originalInputStream.reset();
251             } else if (position >= BUFFER_LENGTH) {
252                 throw new IOException("mark/reset not supported above " + BUFFER_LENGTH + " bytes");
253             }
254             position = markPosition;
255         }
256     }
257 }