001package jmri.jmrit; 002 003import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 004import java.io.File; 005import java.io.IOException; 006import java.net.MalformedURLException; 007import java.net.URISyntaxException; 008import java.net.URL; 009import java.util.Arrays; 010import java.util.concurrent.atomic.AtomicReference; 011import javax.annotation.Nonnull; 012import javax.sound.sampled.AudioFormat; 013import javax.sound.sampled.AudioInputStream; 014import javax.sound.sampled.AudioSystem; 015import javax.sound.sampled.Clip; 016import javax.sound.sampled.DataLine; 017import javax.sound.sampled.LineEvent; 018import javax.sound.sampled.LineUnavailableException; 019import javax.sound.sampled.SourceDataLine; 020import javax.sound.sampled.UnsupportedAudioFileException; 021import jmri.util.FileUtil; 022 023/** 024 * Provide simple way to load and play sounds in JMRI. 025 * <p> 026 * This is placed in the jmri.jmrit package by process of elimination. It 027 * doesn't belong in the base jmri package, as it's not a basic interface. Nor 028 * is it a specific implementation of a basic interface, which would put it in 029 * jmri.jmrix. It seems most like a "tool using JMRI", or perhaps a tool for use 030 * with JMRI, so it was placed in jmri.jmrit. 031 * 032 * @see jmri.jmrit.sound 033 * 034 * @author Bob Jacobsen Copyright (C) 2004, 2006 035 * @author Dave Duchamp Copyright (C) 2011 - add streaming play of large files 036 */ 037public class Sound { 038 039 // files over this size will be streamed 040 public static final long LARGE_SIZE = 100000; 041 private final URL url; 042 private boolean streaming = false; 043 private boolean streamingStop = false; 044 private AtomicReference<Clip> clipRef = new AtomicReference<>(); 045 private boolean autoClose = true; 046 047 /** 048 * Create a Sound object using the media file at path 049 * 050 * @param path path, portable or absolute, to the media 051 * @throws NullPointerException if path cannot be converted into a URL by 052 * {@link jmri.util.FileUtilSupport#findURL(java.lang.String)} 053 */ 054 public Sound(@Nonnull String path) throws NullPointerException { 055 this(FileUtil.findURL(path)); 056 } 057 058 /** 059 * Create a Sound object using the media file 060 * 061 * @param file reference to the media 062 * @throws java.net.MalformedURLException if file cannot be converted into a 063 * valid URL 064 */ 065 public Sound(@Nonnull File file) throws MalformedURLException { 066 this(file.toURI().toURL()); 067 } 068 069 /** 070 * Create a Sound object using the media URL 071 * 072 * @param url path to the media 073 * @throws NullPointerException if URL is null 074 */ 075 public Sound(@Nonnull URL url) throws NullPointerException { 076 if (url == null) { 077 throw new NullPointerException(); 078 } 079 this.url = url; 080 try { 081 streaming = this.needStreaming(); 082 if (!streaming) { 083 clipRef.updateAndGet(clip -> { 084 return openClip(); 085 }); 086 } 087 } catch (URISyntaxException ex) { 088 streaming = false; 089 } catch (IOException ex) { 090 log.error("Unable to open {}", url); 091 } 092 } 093 094 private Clip openClip() { 095 Clip newClip = null; 096 try { 097 newClip = AudioSystem.getClip(null); 098 newClip.addLineListener(event -> { 099 if (LineEvent.Type.STOP.equals(event.getType())) { 100 if (autoClose) { 101 clipRef.updateAndGet(clip -> { 102 if (clip != null) { 103 clip.close(); 104 } 105 return null; 106 }); 107 } 108 } 109 }); 110 newClip.open(AudioSystem.getAudioInputStream(url)); 111 } catch (IOException ex) { 112 log.error("Unable to open {}", url); 113 } catch (LineUnavailableException ex) { 114 log.error("Unable to provide audio playback", ex); 115 } catch (UnsupportedAudioFileException ex) { 116 log.error("{} is not a recognised audio format", url); 117 } 118 119 return newClip; 120 } 121 122 /** 123 * Set if the clip be closed automatically. 124 * @param autoClose true if closed automatically 125 */ 126 public void setAutoClose(boolean autoClose) { 127 this.autoClose = autoClose; 128 } 129 130 /** 131 * Get if the clip is closed automatically. 132 * @return true if closed automatically 133 */ 134 public boolean getAutoClose() { 135 return autoClose; 136 } 137 138 /** 139 * Closes the sound. 140 */ 141 public void close() { 142 if (streaming) { 143 streamingStop = true; 144 } else { 145 clipRef.updateAndGet(clip -> { 146 if (clip != null) { 147 clip.close(); 148 } 149 return null; 150 }); 151 } 152 } 153 154 /** 155 * Play the sound once. 156 */ 157 public void play() { 158 play(false); 159 } 160 161 /** 162 * Play the sound once. 163 * @param autoClose true if auto close clip, false otherwise. Only 164 * valid for clips. For streams, autoClose is ignored. 165 */ 166 public void play(boolean autoClose) { 167 if (streaming) { 168 Runnable streamSound = new StreamingSound(this.url); 169 Thread tStream = jmri.util.ThreadingUtil.newThread(streamSound); 170 tStream.start(); 171 } else { 172 clipRef.updateAndGet(clip -> { 173 if (clip == null) { 174 clip = openClip(); 175 } 176 if (clip != null) { 177 if (autoClose) { 178 clip.addLineListener((event) -> { 179 if (event.getType() == LineEvent.Type.STOP) { 180 event.getLine().close(); 181 } 182 }); 183 } 184 clip.start(); 185 } 186 return clip; 187 }); 188 } 189 } 190 191 /** 192 * Play the sound as an endless loop 193 */ 194 public void loop() { 195 this.loop(Clip.LOOP_CONTINUOUSLY); 196 } 197 198 /** 199 * Play the sound in a loop count times. Use 200 * {@link javax.sound.sampled.Clip#LOOP_CONTINUOUSLY} to create an endless 201 * loop. 202 * 203 * @param count the number of times to loop 204 */ 205 public void loop(int count) { 206 if (streaming) { 207 Runnable streamSound = new StreamingSound(this.url, count); 208 Thread tStream = jmri.util.ThreadingUtil.newThread(streamSound); 209 tStream.start(); 210 } else { 211 clipRef.updateAndGet(clip -> { 212 if (clip == null) { 213 clip = openClip(); 214 } 215 if (clip != null) { 216 clip.loop(count); 217 } 218 return clip; 219 }); 220 } 221 } 222 223 /** 224 * Stop playing a loop. 225 */ 226 public void stop() { 227 if (streaming) { 228 streamingStop = true; 229 } else { 230 clipRef.updateAndGet(clip -> { 231 if (clip != null) { 232 clip.stop(); 233 } 234 return clip; 235 }); 236 } 237 } 238 239 private boolean needStreaming() throws URISyntaxException, IOException { 240 if (url != null) { 241 if ("file".equals(this.url.getProtocol())) { 242 return (new File(this.url.toURI()).length() > LARGE_SIZE); 243 } else { 244 return this.url.openConnection().getContentLengthLong() > LARGE_SIZE; 245 } 246 } 247 return false; 248 } 249 250 /** 251 * Play a sound from a buffer 252 * 253 * @param wavData data to play 254 */ 255 public static void playSoundBuffer(byte[] wavData) { 256 257 // get characteristics from buffer 258 float sampleRate = 11200.0f; 259 int sampleSizeInBits = 8; 260 int channels = 1; 261 boolean signed = (sampleSizeInBits > 8); 262 boolean bigEndian = true; 263 264 AudioFormat format = new AudioFormat(sampleRate, sampleSizeInBits, channels, signed, bigEndian); 265 SourceDataLine line; 266 DataLine.Info info = new DataLine.Info(SourceDataLine.class, format); // format is an AudioFormat object 267 if (!AudioSystem.isLineSupported(info)) { 268 // Handle the error. 269 log.warn("line not supported: {}", info); 270 return; 271 } 272 // Obtain and open the line. 273 try { 274 line = (SourceDataLine) AudioSystem.getLine(info); 275 line.open(format); 276 } catch (LineUnavailableException ex) { 277 // Handle the error. 278 log.error("error opening line", ex); 279 return; 280 } 281 line.start(); 282 // write(byte[] b, int off, int len) 283 line.write(wavData, 0, wavData.length); 284 285 } 286 287 /** 288 * Dispose this sound. 289 */ 290 public void dispose() { 291 if (!streaming) { 292 clipRef.updateAndGet(clip -> { 293 if (clip != null) { 294 clip.close(); 295 } 296 return null; 297 }); 298 } 299 } 300 301 public static class WavBuffer { 302 303 public WavBuffer(byte[] content) { 304 buffer = Arrays.copyOf(content, content.length); 305 306 // find fmt chunk and set offset 307 int index = 12; // skip RIFF header 308 while (index < buffer.length) { 309 // new chunk 310 if (buffer[index] == 0x66 311 && buffer[index + 1] == 0x6D 312 && buffer[index + 2] == 0x74 313 && buffer[index + 3] == 0x20) { 314 // found it 315 fmtOffset = index; 316 return; 317 } else { 318 // skip 319 index = index + 8 320 + buffer[index + 4] 321 + buffer[index + 5] * 256 322 + buffer[index + 6] * 256 * 256 323 + buffer[index + 7] * 256 * 256 * 256; 324 log.debug("index now {}", index); 325 } 326 } 327 log.error("Didn't find fmt chunk"); 328 329 } 330 331 // we maintain this, but don't use it for anything yet 332 @SuppressFBWarnings(value = "URF_UNREAD_FIELD") 333 int fmtOffset; 334 335 byte[] buffer; 336 337 float getSampleRate() { 338 return 11200.0f; 339 } 340 341 int getSampleSizeInBits() { 342 return 8; 343 } 344 345 int getChannels() { 346 return 1; 347 } 348 349 boolean getBigEndian() { 350 return false; 351 } 352 353 boolean getSigned() { 354 return (getSampleSizeInBits() > 8); 355 } 356 } 357 358 public class StreamingSound implements Runnable { 359 360 private final URL localUrl; 361 private AudioInputStream stream = null; 362 private AudioFormat format = null; 363 private SourceDataLine line = null; 364 private jmri.Sensor streamingSensor = null; 365 private final int count; 366 367 /** 368 * A runnable to stream in sound and play it This method does not read 369 * in an entire large sound file at one time, but instead reads in 370 * smaller chunks as needed. 371 * 372 * @param url the URL containing audio media 373 */ 374 public StreamingSound(URL url) { 375 this(url, 1); 376 } 377 378 /** 379 * A runnable to stream in sound and play it This method does not read 380 * in an entire large sound file at one time, but instead reads in 381 * smaller chunks as needed. 382 * 383 * @param url the URL containing audio media 384 * @param count the number of times to loop 385 */ 386 public StreamingSound(URL url, int count) { 387 this.localUrl = url; 388 this.count = count; 389 } 390 391 /** {@inheritDoc} */ 392 @Override 393 public void run() { 394 // Note: some of the following is based on code from 395 // "Killer Game Programming in Java" by A. Davidson. 396 // Set up the audio input stream from the sound file 397 try { 398 // link an audio stream to the sampled sound's file 399 stream = AudioSystem.getAudioInputStream(localUrl); 400 format = stream.getFormat(); 401 log.debug("Audio format: {}", format); 402 // convert ULAW/ALAW formats to PCM format 403 if ((format.getEncoding() == AudioFormat.Encoding.ULAW) 404 || (format.getEncoding() == AudioFormat.Encoding.ALAW)) { 405 AudioFormat newFormat 406 = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED, 407 format.getSampleRate(), 408 format.getSampleSizeInBits() * 2, 409 format.getChannels(), 410 format.getFrameSize() * 2, 411 format.getFrameRate(), true); // big endian 412 // update stream and format details 413 stream = AudioSystem.getAudioInputStream(newFormat, stream); 414 log.info("Converted Audio format: {}", newFormat); 415 format = newFormat; 416 log.debug("new converted Audio format: {}", format); 417 } 418 } catch (UnsupportedAudioFileException e) { 419 log.error("AudioFileException {}", e.getMessage()); 420 return; 421 } catch (IOException e) { 422 log.error("IOException {}", e.getMessage()); 423 return; 424 } 425 streamingStop = false; 426 if (streamingSensor == null) { 427 streamingSensor = jmri.InstanceManager.sensorManagerInstance().provideSensor("ISSOUNDSTREAMING"); 428 } 429 430 setSensor(jmri.Sensor.ACTIVE); 431 432 if (!streamingStop) { 433 // set up the SourceDataLine going to the JVM's mixer 434 try { 435 // gather information for line creation 436 DataLine.Info info 437 = new DataLine.Info(SourceDataLine.class, format); 438 if (!AudioSystem.isLineSupported(info)) { 439 log.error("Audio play() does not support: {}", format); 440 return; 441 } 442 // get a line of the required format 443 line = (SourceDataLine) AudioSystem.getLine(info); 444 line.open(format); 445 } catch (Exception e) { 446 log.error("Exception while creating Audio out {}", e.getMessage()); 447 return; 448 } 449 } 450 if (streamingStop) { 451 line.close(); 452 setSensor(jmri.Sensor.INACTIVE); 453 return; 454 } 455 // Read the sound file in chunks of bytes into buffer, and 456 // pass them on through the SourceDataLine 457 int numRead; 458 byte[] buffer = new byte[line.getBufferSize()]; 459 log.debug("streaming sound buffer size = {}", line.getBufferSize()); 460 line.start(); 461 // read and play chunks of the audio 462 try { 463 if (stream.markSupported()) stream.mark(Integer.MAX_VALUE); 464 465 int i=0; 466 while (!streamingStop && ((i++ < count) || (count == Clip.LOOP_CONTINUOUSLY))) { 467 int offset; 468 while ((numRead = stream.read(buffer, 0, buffer.length)) >= 0) { 469 offset = 0; 470 while (offset < numRead) { 471 offset += line.write(buffer, offset, numRead - offset); 472 } 473 } 474 if (stream.markSupported()) { 475 stream.reset(); 476 } else { 477 stream.close(); 478 try { 479 stream = AudioSystem.getAudioInputStream(localUrl); 480 } catch (UnsupportedAudioFileException e) { 481 log.error("AudioFileException {}", e.getMessage()); 482 closeLine(); 483 return; 484 } catch (IOException e) { 485 log.error("IOException {}", e.getMessage()); 486 closeLine(); 487 return; 488 } 489 } 490 } 491 } catch (IOException e) { 492 log.error("IOException while reading sound file {}", e.getMessage()); 493 } 494 closeLine(); 495 } 496 497 private void closeLine() { 498 // wait until all data is played, then close the line 499 line.drain(); 500 line.stop(); 501 line.close(); 502 setSensor(jmri.Sensor.INACTIVE); 503 } 504 505 private void setSensor(int mode) { 506 if (streamingSensor != null) { 507 try { 508 streamingSensor.setState(mode); 509 } catch (jmri.JmriException ex) { 510 log.error("Exception while setting ISSOUNDSTREAMING sensor {} to {}", streamingSensor.getDisplayName(), mode); 511 } 512 } 513 } 514 515 } 516 517 private final static org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(Sound.class); 518}