001package jmri.jmrit.audio;
002
003import javax.sound.sampled.Clip;
004import javax.sound.sampled.DataLine;
005import javax.sound.sampled.FloatControl;
006import javax.sound.sampled.LineUnavailableException;
007import javax.sound.sampled.Mixer;
008import javax.vecmath.Vector3f;
009import jmri.InstanceManager;
010import org.slf4j.Logger;
011import org.slf4j.LoggerFactory;
012
013/**
014 * JavaSound implementation of the Audio Source sub-class.
015 * <p>
016 * For now, no system-specific implementations are foreseen - this will remain
017 * internal-only
018 * <p>
019 * For more information about the JavaSound API, visit
020 * <a href="http://java.sun.com/products/java-media/sound/">http://java.sun.com/products/java-media/sound/</a>
021 *
022 * <hr>
023 * This file is part of JMRI.
024 * <p>
025 * JMRI is free software; you can redistribute it and/or modify it under the
026 * terms of version 2 of the GNU General Public License as published by the Free
027 * Software Foundation. See the "COPYING" file for a copy of this license.
028 * <p>
029 * JMRI is distributed in the hope that it will be useful, but WITHOUT ANY
030 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
031 * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
032 *
033 * @author Matthew Harris copyright (c) 2009
034 */
035public class JavaSoundAudioSource extends AbstractAudioSource {
036
037    /**
038     * Reference to JavaSound mixer object
039     */
040    private static Mixer mixer = JavaSoundAudioFactory.getMixer();
041
042    /**
043     * Reference to current active AudioListener
044     */
045    private AudioListener activeAudioListener = loadAudioListener();
046
047    private AudioListener loadAudioListener() {
048        AudioFactory audioFact = InstanceManager.getDefault(jmri.AudioManager.class).getActiveAudioFactory();
049        if (audioFact != null) {
050            return audioFact.getActiveAudioListener();
051        }
052        log.error("no AudioListener found");
053        return null;
054    }
055
056    /**
057     * True if we've been initialised
058     */
059    private boolean initialised = false;
060
061    /**
062     * Used for playing back sound source
063     */
064    private transient Clip clip = null;
065
066    /**
067     * Holds reference to the JavaSoundAudioChannel object
068     */
069    private transient JavaSoundAudioChannel audioChannel = null;
070
071    private boolean jsState;
072
073    /**
074     * Constructor for new JavaSoundAudioSource with system name
075     *
076     * @param systemName AudioSource object system name (e.g. IAS1)
077     */
078    public JavaSoundAudioSource(String systemName) {
079        super(systemName);
080        log.debug("New JavaSoundAudioSource: {}", systemName);
081        initialised = init();
082    }
083
084    /**
085     * Constructor for new JavaSoundAudioSource with system name and user name
086     *
087     * @param systemName AudioSource object system name (e.g. IAS1)
088     * @param userName   AudioSource object user name
089     */
090    public JavaSoundAudioSource(String systemName, String userName) {
091        super(systemName, userName);
092        log.debug("New JavaSoundAudioSource: {} ({})", userName, systemName);
093        initialised = init();
094    }
095
096    /**
097     * Initialise this AudioSource
098     *
099     * @return True if initialised
100     */
101    private boolean init() {
102        return true;
103    }
104
105//    @SuppressWarnings("SleepWhileInLoop")
106    @Override
107    boolean bindAudioBuffer(AudioBuffer audioBuffer) {
108        // First check we've been initialised
109        if (!initialised) {
110            return false;
111        }
112
113        // Wait for AudioBuffer to be loaded, or 20 seconds
114        long startTime = System.currentTimeMillis();
115        while (audioBuffer.getState() != AudioBuffer.STATE_LOADED
116                && System.currentTimeMillis() - startTime < 20000) {
117            try {
118                Thread.sleep(50);
119            } catch (InterruptedException ex) {
120                log.debug("bindAudioBuffer was interruped");
121            }
122        }
123
124        if (audioBuffer instanceof JavaSoundAudioBuffer
125                && audioBuffer.getState() == AudioBuffer.STATE_LOADED) {
126            // Cast to JavaSoundAudioBuffer to enable easier access to specific methods
127            JavaSoundAudioBuffer buffer = (JavaSoundAudioBuffer) audioBuffer;
128
129            // Get a JavaSound DataLine and Clip
130            DataLine.Info lineInfo;
131            lineInfo = new DataLine.Info(Clip.class, buffer.getAudioFormat());
132            Clip newClip;
133            try {
134                newClip = (Clip) mixer.getLine(lineInfo);
135            } catch (LineUnavailableException ex) {
136                log.warn("Error binding JavaSoundSource ({}) to AudioBuffer ({}) ",
137                        this.getSystemName(), this.getAssignedBufferName(), ex);
138                return false;
139            }
140
141            this.clip = newClip;
142
143            try {
144                clip.open(buffer.getAudioFormat(),
145                        buffer.getDataStorageBuffer(),
146                        0,
147                        buffer.getDataStorageBuffer().length);
148            } catch (LineUnavailableException ex) {
149                log.warn("Error binding JavaSoundSource ({}) to AudioBuffer ({}) ",
150                        this.getSystemName(), this.getAssignedBufferName(), ex);
151            }
152            if (log.isDebugEnabled()) {
153                log.debug("Bind JavaSoundAudioSource ({}) to JavaSoundAudioBuffer ({})",
154                        this.getSystemName(), audioBuffer.getSystemName());
155            }
156            return true;
157        } else {
158            log.warn("AudioBuffer not loaded error when binding JavaSoundSource ({}) to AudioBuffer ({})",
159                    this.getSystemName(), this.getAssignedBufferName());
160            return false;
161        }
162    }
163
164    @Override
165    protected void changePosition(Vector3f pos) {
166        if (initialised && isBound() && audioChannel != null) {
167            calculateGain();
168            calculatePan();
169        }
170    }
171
172    @Override
173    public void setGain(float gain) {
174        super.setGain(gain);
175        if (initialised && isBound() && audioChannel != null) {
176            calculateGain();
177        }
178    }
179
180    @Override
181    public void setPitch(float pitch) {
182        super.setPitch(pitch);
183        if (initialised && isBound() && audioChannel != null) {
184            calculatePitch();
185        }
186    }
187
188    @Override
189    public void setReferenceDistance(float referenceDistance) {
190        super.setReferenceDistance(referenceDistance);
191        if (initialised && isBound() && audioChannel != null) {
192            calculateGain();
193        }
194    }
195
196    @Override
197    public void setOffset(long offset) {
198        super.setOffset(offset);
199        if (initialised && isBound() && audioChannel != null) {
200            this.clip.setFramePosition((int) offset);
201        }
202    }
203
204    @Override
205    public int getState() {
206        boolean old = jsState;
207        jsState = (this.clip != null && this.clip.isActive());
208        if (jsState != old) {
209            if (jsState) {
210                this.setState(STATE_PLAYING);
211            } else {
212                this.setState(STATE_STOPPED);
213            }
214        }
215        return super.getState();
216    }
217
218    @Override
219    public void stateChanged(int oldState) {
220        super.stateChanged(oldState);
221        if (initialised && isBound() && audioChannel != null) {
222            calculateGain();
223            calculatePan();
224            calculatePitch();
225        } else {
226            initialised = init();
227        }
228    }
229
230    @Override
231    protected void doPlay() {
232        log.debug("Play JavaSoundAudioSource ({})", this.getSystemName());
233        if (initialised && isBound()) {
234            doRewind();
235            doResume();
236        }
237    }
238
239    @Override
240    protected void doStop() {
241        log.debug("Stop JavaSoundAudioSource ({})", this.getSystemName());
242        if (initialised && isBound()) {
243            doPause();
244            doRewind();
245        }
246    }
247
248    @Override
249    protected void doPause() {
250        if (log.isDebugEnabled()) {
251            log.debug("Pause JavaSoundAudioSource ({})", this.getSystemName());
252        }
253        if (initialised && isBound()) {
254            this.clip.stop();
255            if (audioChannel != null) {
256                if (log.isDebugEnabled()) {
257                    log.debug("Remove JavaSoundAudioChannel for Source {}", this.getSystemName());
258                }
259                audioChannel = null;
260            }
261        }
262        this.setState(STATE_STOPPED);
263    }
264
265    @Override
266    protected void doResume() {
267        if (log.isDebugEnabled()) {
268            log.debug("Resume JavaSoundAudioSource ({})", this.getSystemName());
269        }
270        if (initialised && isBound()) {
271            if (audioChannel == null) {
272                if (log.isDebugEnabled()) {
273                    log.debug("Create JavaSoundAudioChannel for Source {}", this.getSystemName());
274                }
275                audioChannel = new JavaSoundAudioChannel(this);
276            }
277            this.clip.loop(this.getNumLoops());
278            this.setState(STATE_PLAYING);
279        }
280    }
281
282    @Override
283    protected void doRewind() {
284        if (log.isDebugEnabled()) {
285            log.debug("Rewind JavaSoundAudioSource ({})", this.getSystemName());
286        }
287        if (initialised && isBound()) {
288            this.clip.setFramePosition(0);
289        }
290    }
291
292    @Override
293    protected void doFadeIn() {
294        if (log.isDebugEnabled()) {
295            log.debug("Fade-in JavaSoundAudioSource ({})", this.getSystemName());
296        }
297        if (initialised && isBound()) {
298            doPlay();
299            AudioSourceFadeThread asft = new AudioSourceFadeThread(this);
300            asft.start();
301        }
302    }
303
304    @Override
305    protected void doFadeOut() {
306        if (log.isDebugEnabled()) {
307            log.debug("Fade-out JavaSoundAudioSource ({})", this.getSystemName());
308        }
309        if (initialised && isBound()) {
310            AudioSourceFadeThread asft = new AudioSourceFadeThread(this);
311            asft.start();
312        }
313    }
314
315    @Override
316    protected void cleanup() {
317        if (initialised && isBound()) {
318            this.clip.stop();
319            this.clip.close();
320            this.clip = null;
321        }
322        if (log.isDebugEnabled()) {
323            log.debug("Cleanup JavaSoundAudioSource ({})", this.getSystemName());
324        }
325    }
326
327    /**
328     * Calculate the panning of this Source between fully left (-1.0f) and
329     * fully right (1.0f)
330     * <p>
331     * Calculated internally from the relative positions of this source and the
332     * listener.
333     */
334    protected void calculatePan() {
335        Vector3f side = new Vector3f();
336        side.cross(activeAudioListener.getOrientation(UP), activeAudioListener.getOrientation(AT));
337        side.normalize();
338        Vector3f vecX = new Vector3f(this.getCurrentPosition());
339        Vector3f vecZ = new Vector3f(this.getCurrentPosition());
340        float x = vecX.dot(side);
341        float z = vecZ.dot(activeAudioListener.getOrientation(AT));
342        float angle = (float) Math.atan2(x, z);
343        float pan = (float) -Math.sin(angle);
344
345        // If playing, update the pan
346        if (audioChannel != null) {
347            audioChannel.setPan(pan);
348        }
349        if (log.isDebugEnabled()) {
350            log.debug("Set pan of JavaSoundAudioSource {} to {}", this.getSystemName(), pan);
351        }
352    }
353
354    @Override
355    protected void calculateGain() {
356
357        // Calculate distance from listener
358        Vector3f distance = new Vector3f(this.getCurrentPosition());
359        if (!this.isPositionRelative()) {
360            distance.sub(activeAudioListener.getCurrentPosition());
361        }
362
363        float distanceFromListener
364                = (float) Math.sqrt(distance.dot(distance));
365        if (log.isDebugEnabled()) {
366            log.debug("Distance of JavaSoundAudioSource {} from Listener = {}", this.getSystemName(), distanceFromListener);
367        }
368
369        // Default value to start with (used for no distance attenuation)
370        float currentGain = 1.0f;
371
372        AudioFactory audioFact = InstanceManager.getDefault(jmri.AudioManager.class).getActiveAudioFactory();
373        if (audioFact != null && audioFact.isDistanceAttenuated()) {
374            // Calculate gain of this source using clamped inverse distance
375            // attenuation model
376
377            distanceFromListener = Math.max(distanceFromListener, this.getReferenceDistance());
378            if (log.isDebugEnabled()) {
379                log.debug("After initial clamping, distance of JavaSoundAudioSource {} from Listener = {}", this.getSystemName(), distanceFromListener);
380            }
381            distanceFromListener = Math.min(distanceFromListener, this.getMaximumDistance());
382            if (log.isDebugEnabled()) {
383                log.debug("After final clamping, distance of JavaSoundAudioSource {} from Listener = {}", this.getSystemName(), distanceFromListener);
384            }
385
386            currentGain
387                    = activeAudioListener.getMetersPerUnit()
388                    * (this.getReferenceDistance()
389                    / (this.getReferenceDistance() + this.getRollOffFactor()
390                    * (distanceFromListener - this.getReferenceDistance())));
391            if (log.isDebugEnabled()) {
392                log.debug("Calculated for JavaSoundAudioSource {} gain = {}", this.getSystemName(), currentGain);
393            }
394
395            // Ensure that gain is between 0 and 1
396            if (currentGain > 1.0f) {
397                currentGain = 1.0f;
398            } else if (currentGain < 0.0f) {
399                currentGain = 0.0f;
400            }
401        }
402
403        // Finally, adjust based on master gain for this source, the gain
404        // of listener and any calculated fade gains
405        currentGain *= this.getGain() * activeAudioListener.getGain() * this.getFadeGain();
406
407        // If playing, update the gain
408        if (audioChannel != null) {
409            audioChannel.setGain(currentGain);
410            if (log.isDebugEnabled()) {
411                log.debug("Set current gain of JavaSoundAudioSource {} to {}", this.getSystemName(), currentGain);
412            }
413        }
414    }
415
416    /**
417     * Internal method used to calculate the pitch.
418     */
419    protected void calculatePitch() {
420        // If playing, update the pitch
421        if (audioChannel != null) {
422            audioChannel.setPitch(this.getPitch());
423        }
424    }
425
426    private static final Logger log = LoggerFactory.getLogger(JavaSoundAudioSource.class);
427
428    private static class JavaSoundAudioChannel {
429
430        /**
431         * Control for changing the gain of this AudioSource
432         */
433        private FloatControl gainControl = null;
434
435        /**
436         * Control for changing the pan of this AudioSource
437         */
438        private FloatControl panControl = null;
439
440        /**
441         * Control for changing the sample rate of this AudioSource
442         */
443        private FloatControl sampleRateControl = null;
444
445        /**
446         * Holds the initial sample rate setting
447         */
448        private float initialSampleRate = 0.0f;
449
450        /**
451         * Holds the initial gain setting
452         */
453        private float initialGain = 0.0f;
454
455        /**
456         * Holds reference to the parent AudioSource object
457         */
458        private final JavaSoundAudioSource audio;
459
460        /**
461         * Holds reference to the JavaSound clip
462         */
463        private final Clip clip;
464
465        /**
466         * Constructor for creating an AudioChannel for a specific
467         * JavaSoundAudioSource.
468         *
469         * @param audio the specific JavaSoundAudioSource
470         */
471        public JavaSoundAudioChannel(JavaSoundAudioSource audio) {
472
473            this.audio = audio;
474            this.clip = this.audio.clip;
475
476            // Check if changing gain is supported
477            if (this.clip.isControlSupported(FloatControl.Type.MASTER_GAIN)) {
478                // Yes, so create a new gain control
479                this.gainControl = (FloatControl) this.clip.getControl(FloatControl.Type.MASTER_GAIN);
480                this.initialGain = this.gainControl.getValue();
481                if (log.isDebugEnabled()) {
482                    log.debug("JavaSound gain control created");
483                    log.debug("Initial Gain = {}", this.initialGain);
484                }
485            } else {
486                log.info("Gain control is not supported");
487                this.gainControl = null;
488            }
489
490            // Check if changing pan is supported
491            if (this.clip.isControlSupported(FloatControl.Type.PAN)) {
492                // Yes, so create a new pan control
493                this.panControl = (FloatControl) this.clip.getControl(FloatControl.Type.PAN);
494                log.debug("JavaSound pan control created");
495            } else {
496                log.info("Pan control is not supported");
497                this.panControl = null;
498            }
499
500            // Check if changing pitch is supported
501            if (this.clip.isControlSupported(FloatControl.Type.SAMPLE_RATE)) {
502                // Yes, so create a new pitch control
503                this.sampleRateControl = (FloatControl) this.clip.getControl(FloatControl.Type.SAMPLE_RATE);
504                this.initialSampleRate = this.sampleRateControl.getValue();
505                if (log.isDebugEnabled()) {
506                    log.debug("JavaSound pitch control created");
507                    log.debug("Initial Sample Rate = {}", this.initialSampleRate);
508                }
509            } else {
510                log.info("Sample Rate control is not supported");
511                this.sampleRateControl = null;
512                this.initialSampleRate = 0;
513            }
514        }
515
516        /**
517         * Set the gain of this AudioChannel.
518         *
519         * @param gain the gain (0.0f to 1.0f)
520         */
521        protected void setGain(float gain) {
522            if (this.gainControl != null) {
523                // Ensure gain is within limits
524                if (gain <= 0.0f) {
525                    gain = 0.0001f;
526                } else if (gain > 1.0f) {
527                    gain = 1.0f;
528                }
529
530                // Convert this linear gain to a decibel value
531                float dB = (float) (Math.log(gain) / Math.log(10.0) * 20.0);
532
533                this.gainControl.setValue(dB);
534                if (log.isDebugEnabled()) {
535                    log.debug("Actual gain value of JavaSoundAudioSource {} is {}", this.audio.getDebugString(), this.gainControl.getValue());
536                }
537            }
538            log.debug("Set gain of JavaSoundAudioSource {} to {}", this.audio.getDebugString(), gain);
539        }
540
541        /**
542         * Set the pan of this AudioChannel.
543         *
544         * @param pan the pan (-1.0f to 1.0f)
545         */
546        protected void setPan(float pan) {
547            if (this.panControl != null) {
548                this.panControl.setValue(pan);
549            }
550            log.debug("Set pan of JavaSoundAudioSource {} to {}", this.audio.getDebugString(), pan);
551        }
552
553        /**
554         * Set the pitch of this AudioChannel.
555         * <p>
556         * Calculated as a ratio of the initial sample rate
557         *
558         * @param pitch the pitch
559         */
560        protected void setPitch(float pitch) {
561            if (this.sampleRateControl != null) {
562                this.sampleRateControl.setValue(pitch * this.initialSampleRate);
563            }
564            log.debug("Set pitch of JavaSoundAudioSource {} to {}", this.audio.getDebugString(), pitch);
565        }
566
567    }
568
569}