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 streamingStop = false; 168 if (streaming) { 169 Runnable streamSound = new StreamingSound(this.url); 170 Thread tStream = jmri.util.ThreadingUtil.newThread(streamSound); 171 String path = url.getPath(); 172 tStream.setName("Play " + path.substring(path.lastIndexOf('/') + 1)); 173 tStream.start(); 174 } else { 175 clipRef.updateAndGet(clip -> { 176 if (clip == null) { 177 clip = openClip(); 178 } 179 if (clip != null) { 180 if (autoClose) { 181 clip.addLineListener((event) -> { 182 if (event.getType() == LineEvent.Type.STOP) { 183 event.getLine().close(); 184 } 185 }); 186 } 187 clip.start(); 188 } 189 return clip; 190 }); 191 } 192 } 193 194 /** 195 * Play the sound as an endless loop 196 */ 197 public void loop() { 198 this.loop(Clip.LOOP_CONTINUOUSLY); 199 } 200 201 /** 202 * Play the sound in a loop count times. Use 203 * {@link javax.sound.sampled.Clip#LOOP_CONTINUOUSLY} to create an endless 204 * loop. 205 * 206 * @param count the number of times to loop 207 */ 208 public void loop(int count) { 209 streamingStop = false; 210 if (streaming) { 211 Runnable streamSound = new StreamingSound(this.url, count); 212 Thread tStream = jmri.util.ThreadingUtil.newThread(streamSound); 213 String path = url.getPath(); 214 tStream.setName("Loop " + path.substring(path.lastIndexOf('/') + 1) ); 215 tStream.start(); 216 } else { 217 clipRef.updateAndGet(clip -> { 218 if (clip == null) { 219 clip = openClip(); 220 } 221 if (clip != null) { 222 clip.loop(count); 223 } 224 return clip; 225 }); 226 } 227 } 228 229 /** 230 * Stop playing a loop. 231 */ 232 public void stop() { 233 if (streaming) { 234 streamingStop = true; 235 } else { 236 clipRef.updateAndGet(clip -> { 237 if (clip != null) { 238 clip.stop(); 239 } 240 return clip; 241 }); 242 } 243 } 244 245 private boolean needStreaming() throws URISyntaxException, IOException { 246 if (url != null) { 247 if ("file".equals(this.url.getProtocol())) { 248 return (new File(this.url.toURI()).length() > LARGE_SIZE); 249 } else { 250 return this.url.openConnection().getContentLengthLong() > LARGE_SIZE; 251 } 252 } 253 return false; 254 } 255 256 /** 257 * Play a sound from a buffer 258 * 259 * @param wavData data to play 260 */ 261 public static void playSoundBuffer(byte[] wavData) { 262 263 // get characteristics from buffer 264 float sampleRate = 11200.0f; 265 int sampleSizeInBits = 8; 266 int channels = 1; 267 boolean signed = (sampleSizeInBits > 8); 268 boolean bigEndian = true; 269 270 AudioFormat format = new AudioFormat(sampleRate, sampleSizeInBits, channels, signed, bigEndian); 271 SourceDataLine line; 272 DataLine.Info info = new DataLine.Info(SourceDataLine.class, format); // format is an AudioFormat object 273 if (!AudioSystem.isLineSupported(info)) { 274 // Handle the error. 275 log.warn("line not supported: {}", info); 276 return; 277 } 278 // Obtain and open the line. 279 try { 280 line = (SourceDataLine) AudioSystem.getLine(info); 281 line.open(format); 282 } catch (LineUnavailableException ex) { 283 // Handle the error. 284 log.error("error opening line", ex); 285 return; 286 } 287 line.start(); 288 // write(byte[] b, int off, int len) 289 line.write(wavData, 0, wavData.length); 290 291 } 292 293 /** 294 * Dispose this sound. 295 */ 296 public void dispose() { 297 if (!streaming) { 298 clipRef.updateAndGet(clip -> { 299 if (clip != null) { 300 clip.close(); 301 } 302 return null; 303 }); 304 } 305 } 306 307 public static class WavBuffer { 308 309 public WavBuffer(byte[] content) { 310 buffer = Arrays.copyOf(content, content.length); 311 312 // find fmt chunk and set offset 313 int index = 12; // skip RIFF header 314 while (index < buffer.length) { 315 // new chunk 316 if (buffer[index] == 0x66 317 && buffer[index + 1] == 0x6D 318 && buffer[index + 2] == 0x74 319 && buffer[index + 3] == 0x20) { 320 // found it 321 fmtOffset = index; 322 return; 323 } else { 324 // skip 325 index = index + 8 326 + buffer[index + 4] 327 + buffer[index + 5] * 256 328 + buffer[index + 6] * 256 * 256 329 + buffer[index + 7] * 256 * 256 * 256; 330 log.debug("index now {}", index); 331 } 332 } 333 log.error("Didn't find fmt chunk"); 334 335 } 336 337 // we maintain this, but don't use it for anything yet 338 @SuppressFBWarnings(value = "URF_UNREAD_FIELD") 339 int fmtOffset; 340 341 byte[] buffer; 342 343 float getSampleRate() { 344 return 11200.0f; 345 } 346 347 int getSampleSizeInBits() { 348 return 8; 349 } 350 351 int getChannels() { 352 return 1; 353 } 354 355 boolean getBigEndian() { 356 return false; 357 } 358 359 boolean getSigned() { 360 return (getSampleSizeInBits() > 8); 361 } 362 } 363 364 public class StreamingSound implements Runnable { 365 366 private final URL localUrl; 367 private AudioInputStream stream = null; 368 private AudioFormat format = null; 369 private SourceDataLine line = null; 370 private jmri.Sensor streamingSensor = null; 371 private final int count; 372 373 /** 374 * A runnable to stream in sound and play it This method does not read 375 * in an entire large sound file at one time, but instead reads in 376 * smaller chunks as needed. 377 * 378 * @param url the URL containing audio media 379 */ 380 public StreamingSound(URL url) { 381 this(url, 1); 382 } 383 384 /** 385 * A runnable to stream in sound and play it This method does not read 386 * in an entire large sound file at one time, but instead reads in 387 * smaller chunks as needed. 388 * 389 * @param url the URL containing audio media 390 * @param count the number of times to loop 391 */ 392 public StreamingSound(URL url, int count) { 393 this.localUrl = url; 394 this.count = count; 395 } 396 397 /** {@inheritDoc} */ 398 @Override 399 public void run() { 400 // Note: some of the following is based on code from 401 // "Killer Game Programming in Java" by A. Davidson. 402 // Set up the audio input stream from the sound file 403 try { 404 // link an audio stream to the sampled sound's file 405 stream = AudioSystem.getAudioInputStream(localUrl); 406 format = stream.getFormat(); 407 log.debug("Audio format: {}", format); 408 // convert ULAW/ALAW formats to PCM format 409 if ((format.getEncoding() == AudioFormat.Encoding.ULAW) 410 || (format.getEncoding() == AudioFormat.Encoding.ALAW)) { 411 AudioFormat newFormat 412 = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED, 413 format.getSampleRate(), 414 format.getSampleSizeInBits() * 2, 415 format.getChannels(), 416 format.getFrameSize() * 2, 417 format.getFrameRate(), true); // big endian 418 // update stream and format details 419 stream = AudioSystem.getAudioInputStream(newFormat, stream); 420 log.info("Converted Audio format: {}", newFormat); 421 format = newFormat; 422 log.debug("new converted Audio format: {}", format); 423 } 424 } catch (UnsupportedAudioFileException e) { 425 log.error("AudioFileException {}", e.getMessage()); 426 return; 427 } catch (IOException e) { 428 log.error("IOException {}", e.getMessage()); 429 return; 430 } 431 432 if (streamingSensor == null) { 433 streamingSensor = jmri.InstanceManager.sensorManagerInstance().provideSensor("ISSOUNDSTREAMING"); 434 } 435 436 setSensor(jmri.Sensor.ACTIVE); 437 438 if (!streamingStop) { 439 // set up the SourceDataLine going to the JVM's mixer 440 try { 441 // gather information for line creation 442 DataLine.Info info 443 = new DataLine.Info(SourceDataLine.class, format); 444 if (!AudioSystem.isLineSupported(info)) { 445 log.error("Audio play() does not support: {}", format); 446 return; 447 } 448 // get a line of the required format 449 line = (SourceDataLine) AudioSystem.getLine(info); 450 line.open(format); 451 } catch (Exception e) { 452 log.error("Exception while creating Audio out {}", e.getMessage()); 453 return; 454 } 455 } 456 if (streamingStop) { 457 if ( line != null ) { 458 line.close(); 459 } 460 setSensor(jmri.Sensor.INACTIVE); 461 return; 462 } 463 // Read the sound file in chunks of bytes into buffer, and 464 // pass them on through the SourceDataLine 465 int numRead; 466 byte[] buffer = new byte[line.getBufferSize()]; 467 log.debug("streaming sound buffer size = {}", line.getBufferSize()); 468 line.start(); 469 // read and play chunks of the audio 470 try { 471 if (stream.markSupported()) stream.mark(Integer.MAX_VALUE); 472 473 int i=0; 474 while (!streamingStop && ((i++ < count) || (count == Clip.LOOP_CONTINUOUSLY))) { 475 int offset; 476 while ((numRead = stream.read(buffer, 0, buffer.length)) >= 0) { 477 offset = 0; 478 while (offset < numRead) { 479 offset += line.write(buffer, offset, numRead - offset); 480 } 481 } 482 if (stream.markSupported()) { 483 stream.reset(); 484 } else { 485 stream.close(); 486 try { 487 stream = AudioSystem.getAudioInputStream(localUrl); 488 } catch (UnsupportedAudioFileException e) { 489 log.error("AudioFileException {}", e.getMessage()); 490 closeLine(); 491 return; 492 } catch (IOException e) { 493 log.error("IOException {}", e.getMessage()); 494 closeLine(); 495 return; 496 } 497 } 498 } 499 } catch (IOException e) { 500 log.error("IOException while reading sound file {}", e.getMessage()); 501 } 502 closeLine(); 503 } 504 505 private void closeLine() { 506 // wait until all data is played, then close the line 507 line.drain(); 508 line.stop(); 509 line.close(); 510 setSensor(jmri.Sensor.INACTIVE); 511 } 512 513 private void setSensor(int mode) { 514 if (streamingSensor != null) { 515 try { 516 streamingSensor.setState(mode); 517 } catch (jmri.JmriException ex) { 518 log.error("Exception while setting ISSOUNDSTREAMING sensor {} to {}", 519 streamingSensor.getDisplayName(), mode); 520 } 521 } 522 } 523 524 } 525 526 private final static org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(Sound.class); 527}