source: trunk/src_apollo/org/apollo/audio/ApolloPlaybackMixer.java@ 375

Last change on this file since 375 was 375, checked in by bjn8, 16 years ago

Lots of minor improvements made prior to evluation study, and small things done afterwards for report :)

File size: 23.4 KB
Line 
1package org.apollo.audio;
2
3import java.util.ArrayList;
4import java.util.Collection;
5import java.util.LinkedList;
6import java.util.List;
7import java.util.Set;
8import java.util.TreeSet;
9
10import javax.sound.sampled.AudioFormat;
11import javax.sound.sampled.DataLine;
12import javax.sound.sampled.LineUnavailableException;
13import javax.sound.sampled.SourceDataLine;
14
15import org.apollo.mvc.AbstractSubject;
16import org.apollo.mvc.SubjectChangedEvent;
17import org.apollo.util.TrackModelHandler;
18import org.apollo.util.TrackModelLoadManager;
19
20/**
21 * A software playback mixer.
22 *
23 * @author Brook Novak
24 *
25 */
26public class ApolloPlaybackMixer extends AbstractSubject implements TrackModelHandler {
27
28 /** The timeline frame represents the global frame counter which all tracks synchronize to. */
29 private long timelineFrame = 0; // Wrapping ignored.
30
31 private TreeSet<TrackSequence> sequenceGraph = new TreeSet<TrackSequence>();
32
33 private PlaybackThread playbackThread = null;
34
35 private float masterVolume = 1.0f;
36 private boolean isMasterMuteOn = false;
37
38 private boolean isSoloEnable = false;
39
40 private static ApolloPlaybackMixer instance = new ApolloPlaybackMixer();
41 private ApolloPlaybackMixer() {
42 // When a TrackModel is loading - look in here to see if one is in memory
43 TrackModelLoadManager.getInstance().addTrackModelHandler(this);
44 }
45
46 public static ApolloPlaybackMixer getInstance() {
47 return instance;
48 }
49
50 /**
51 * Stops all playback... kills thread(s).
52 */
53 public void releaseResources() {
54
55 // Quickly stop playback thread
56 if (playbackThread != null)
57 playbackThread.stopPlayback(); // will die momenterially
58
59 // Release references .. dispose of track memory
60 stopAll();
61
62 }
63
64
65 /**
66 * Sets the master volume of the output mixer.
67 *
68 * The master volume is not adjusted directly in the hardware due to java sounds
69 * sketchy API... the master volume is simulated in software.
70 *
71 * An AudioControlValueChangedEvent event is fired with a FloatControl.Type.VOLUME
72 * type and the volume (a float, range of 0-1 clamped) is passed as the value
73 * of the new volume.
74 *
75 * The volumes are updated for all audios created with the SampledAudioManager
76 * to the new mix.
77 *
78 * @param volume The new volume to set it to. Ranges from 0-1.
79 */
80 public void setMasterVolume(float volume) {
81
82 // Clamp volume argument
83 if (volume < 0.0f)
84 volume = 0.0f;
85 else if (volume > 1.0f)
86 volume = 1.0f;
87
88 if (volume == masterVolume)
89 return;
90
91 masterVolume = volume;
92
93 // Notify obsevers
94 fireSubjectChanged(new SubjectChangedEvent(ApolloSubjectChangedEvent.VOLUME));
95 }
96
97
98 /**
99 * Sets the master mute of the output mixer.
100 *
101 * The master mute is not adjusted directly in the hardware due to java sounds
102 * sketchy API... the master mute is simulated in software.
103 *
104 * An AudioSubjectChangedEvent.MUTE event is fired with a BooleanControl.Type.MUTE
105 * type and the mute is passed as the value...
106 *
107 * The mutes are updated for all audios created with the SampledAudioManager
108 * to the new mix.
109 *
110 * @param mute
111 */
112 public void setMasterMute(boolean muteOn) {
113 if (muteOn == isMasterMuteOn)
114 return;
115
116 isMasterMuteOn = muteOn;
117
118 // Notify obsevers
119 fireSubjectChanged(new SubjectChangedEvent(ApolloSubjectChangedEvent.MUTE));
120
121 }
122
123 /**
124 * @return
125 * True if master mute is on.
126 */
127 public boolean isMasterMuteOn() {
128 return isMasterMuteOn;
129 }
130
131 /**
132 * @return
133 * The master volume. Always between 0 and 1.
134 */
135 public float getMasterVolume() {
136 return masterVolume;
137 }
138
139 /**
140 * @return
141 * True if the mixer is in solo mode.
142 */
143 public boolean isSoloEnable() {
144 return isSoloEnable;
145 }
146
147 /**
148 * In solomode, the only track sequences that are played are those with
149 * the solo flag set.
150 *
151 * @param isSoloEnable
152 * True to set into solo mode.
153 */
154 public void setSoloEnable(boolean isSoloEnable) {
155 this.isSoloEnable = isSoloEnable;
156 }
157
158 /**
159 * Sets all track sequences that are playing / queued to play - solo flag to false.
160 */
161 public void unsetAllSoloFlags() {
162 synchronized(sequenceGraph) {
163 for (TrackSequence ts : sequenceGraph) ts.isSolo = false;
164 }
165 }
166
167
168 /**
169 * Plays a track at the given relative initiation time to the current
170 * (global) playback position.
171 *
172 * If the track is already playing, or is about to play, then nothing will result in
173 * invoking this call.
174 *
175 * @param track
176 * The track to play.
177 *
178 * @return
179 * True if queued for playing. False if track already is in the track graph.
180 *
181 * @throws NullPointerException
182 * If track is null.
183 *
184 * @throws IllegalArgumentException
185 * If track has already been played before.
186 *
187 * @throws LineUnavailableException
188 * If failed to get data line to output device
189 */
190 public boolean play(TrackSequence track) throws LineUnavailableException {
191 if (track == null) throw new NullPointerException("track");
192 if (track.hasFinished()) throw new IllegalArgumentException("track is stale, must create new instance");
193
194 while (true) {
195 // Add to graph
196 synchronized(sequenceGraph) {
197
198 if (sequenceGraph.contains(track)) {
199 return false; // if already playing / queued to play then ignore
200 }
201
202 if (playbackThread == null || !playbackThread.isAlive() || !playbackThread.isStopping()) {
203 // Set initiation to commence relative to the current timeline.
204 track.initiationFrame = timelineFrame + track.getRelativeInitiationFrame();
205
206 sequenceGraph.add(track);
207
208 break;
209 }
210
211 }
212
213 // Cannot play if mixer is in a stopping state since it will stop all tracks when thread terminates
214 try {
215 playbackThread.join();
216 } catch (InterruptedException e) {
217 e.printStackTrace();
218 return false;
219 }
220 }
221
222 // Ensure that the added track will play
223 commencePlayback();
224
225 return true;
226
227 }
228
229 /**
230 * Plays a group of tracks exactly at the scheduled frame-time with respect to each other.
231 *
232 * For tracks in the given set that are already playing, or are queued for playing, then
233 * they will be ignored.
234 *
235 * @param tracks
236 * A set of sequence tracks to play together exactly at their relative initiation points.
237 *
238 * @throws NullPointerException
239 * If tracks is null.
240 *
241 * @throws LineUnavailableException
242 * If failed to get data line to output device
243 *
244 * @throws IllegalArgumentException
245 * If tracks is empty
246 */
247 public void playSynchronized(Set<TrackSequence> tracks) throws LineUnavailableException {
248 if (tracks == null) throw new NullPointerException("tracks");
249 if (tracks.isEmpty())
250 throw new IllegalArgumentException("tracks is empty");
251
252 while (true) {
253
254 // Add to graph
255 synchronized(sequenceGraph) {
256
257 if (playbackThread == null || !playbackThread.isAlive() || !playbackThread.isStopping()) {
258
259 long initiationTimeOffset = timelineFrame;
260
261 // If the playback thread is running... and the new tracks will begin playback automatically, then
262 // schedule the group of tracks to play in the next pass so they all begin together.
263 if (playbackThread != null && !playbackThread.isStopping() && playbackThread.isAlive()) {
264 initiationTimeOffset += playbackThread.bufferFrameLength;
265 }
266
267 for (TrackSequence ts : tracks) {
268
269 if (ts == null) continue;
270
271 if (sequenceGraph.contains(ts))
272 continue; // if already playing / queued to play then ignore
273
274 ts.initiationFrame = ts.getRelativeInitiationFrame() + initiationTimeOffset;
275 sequenceGraph.add(ts);
276
277 }
278
279 // Playback commencable
280 break;
281
282 }
283
284 }
285
286 // Cannot play if mixer is in a stopping state since it will stop all tracks when thread terminates
287 try {
288 playbackThread.join();
289 } catch (InterruptedException e) {
290 e.printStackTrace();
291 return;
292 }
293
294 }
295
296 // Ensure that the added tracks will play
297 commencePlayback();
298
299 }
300
301 /**
302 * Ensures that the playback thread is playing / will keep playing.
303 * If a new track is added top the sequence graph, then by calling this it will
304 * ensure that it will be played.
305 *
306 * @throws LineUnavailableException
307 * If failed to get data line to output device
308 */
309 private void commencePlayback() throws LineUnavailableException {
310
311 // Should not be in a stopping state...
312 assert (!(
313 playbackThread != null && playbackThread.isAlive() && playbackThread.isStopping()));
314
315 if (playbackThread != null && !playbackThread.isAlive()) playbackThread = null;
316
317 // If playbackThread is not null at this point, then it is assumed that it is still playing
318 // therefore does not need to be started/restarted.
319
320 // Before playback is commenced, ensure that the MIDI device is
321 Metronome.getInstance().release();
322
323 // If the playback thread is dead, create a new one to initiate playback
324 if (playbackThread == null) {
325
326 playbackThread = new PlaybackThread();
327 playbackThread.start();
328
329 }
330
331 }
332
333 /**
334 * Stops many track sequences. This is better than calling
335 * {@link #stop(TrackSequence)} because ensures that all tracks are stopped
336 * at the same time.
337 *
338 * @param tracks
339 * The tracks to stop.
340 */
341 public void stop(Collection<TrackSequence> tracks) {
342 if (tracks == null || playbackThread == null) return;
343
344
345 synchronized(sequenceGraph) {
346 for (TrackSequence track : tracks) {
347 stop(track);
348 }
349 }
350
351 }
352
353 /**
354 * Stops a track sequence. Nonblocking, the actual stopp will occur when the mixer has a change to
355 * respond.
356 *
357 * @param track
358 * The track to stop. If null then will return with no effect.
359 */
360 public void stop(TrackSequence track) {
361 if (track == null || playbackThread == null) return;
362
363 track.stopPending = true;
364 }
365
366 /**
367 * Stops all track sequences from playback.
368 */
369 public void stopAll() {
370
371 synchronized(sequenceGraph) {
372 for (TrackSequence track : sequenceGraph)
373 track.stopPending = true;
374 }
375
376 }
377
378 /**
379 * {@inheritDoc}
380 */
381 public SampledTrackModel getSharedSampledTrackModel(String localfilename) {
382 if (localfilename == null) return null;
383
384 // Get a snapshot of the graph
385 ArrayList<TrackSequence> snapshot = null;
386 synchronized(sequenceGraph) {
387 snapshot = new ArrayList<TrackSequence>(sequenceGraph);
388 }
389
390 // Look for SampledTrackModel-invoked sequences
391 for (TrackSequence ts : snapshot) {
392 Object invoker = ts.getInvoker();
393
394 if (invoker != null && invoker instanceof SampledTrackModel) {
395
396 // Match?
397 if (localfilename.equals(((SampledTrackModel)invoker).getLocalFilename())) {
398 return (SampledTrackModel)invoker; // found match
399 }
400 }
401
402 }
403
404 // Nothing matched
405 return null;
406 }
407
408
409 /**
410 * @return
411 * The actual frame position in the playback stream - that is, the amount of
412 * frames that have been rendered since playback commenced.
413 * Negative if there is no playback.
414 */
415 public long getLiveFramePosition() {
416
417 if (playbackThread != null) {
418 if (playbackThread.srcDataLine.isOpen()) {
419
420 // The timelineFrame should always be larger or equal to the live frame position
421 // assert(timelineFrame >= playbackThread.srcDataLine.getLongFramePosition());
422
423 return playbackThread.srcDataLine.getLongFramePosition();
424 }
425 }
426
427 return -1;
428 }
429
430 /**
431 * @return
432 * The audio format of the current playback data line. Null if not avilable - never
433 * null if in a playing state.
434 */
435 public AudioFormat getLiveAudioFormat() {
436 if (playbackThread != null) {
437 return playbackThread.srcDataLine.getFormat();
438 }
439 return null;
440 }
441
442 /**
443 * The Audio Mixing pipeline.
444 *
445 * All audio mixing math/logic is done within this thread.
446 * Keeps running until the sequenceGraph is empty.
447 * Removes tracks from the sequenceGraph automatically when they are finished.
448 *
449 *
450 * @author Brook Novak
451 *
452 */
453 private class PlaybackThread extends Thread {
454
455 private SourceDataLine srcDataLine; // never null
456
457 private boolean isStopping = false;
458
459 private int bufferFrameLength;
460 private boolean isOutputBigEndian;
461
462 /**
463 * Initantly prepares for audio playback: Opens the source data line for output
464 *
465 * @throws LineUnavailableException
466 */
467 PlaybackThread() throws LineUnavailableException {
468 super("Apollo Playback Mixer Thread");
469 super.setPriority(Thread.MAX_PRIORITY);
470
471 assert(playbackThread == null); // there should be only one instance of this ever.
472
473 // Upon creation, open a source data line
474 aquireSourceDataLine();
475
476
477 // Reset the global timeline frame to match the live frame position. i.e. wrap back at zero.
478 synchronized(sequenceGraph) { // probably will be empty, but just for safety...
479
480 for (TrackSequence ts : sequenceGraph) {
481 ts.initiationFrame -= timelineFrame;
482 if (ts.initiationFrame < 0)
483 ts.initiationFrame = 0;
484 }
485
486 timelineFrame = 0;
487 }
488 }
489
490 /**
491 * Opens the source data line for output.
492 *
493 * @throws LineUnavailableException
494 * If failed to acquire the source data line.
495 */
496 private void aquireSourceDataLine() throws LineUnavailableException {
497
498 // Select an audio output format
499 DataLine.Info info = new DataLine.Info(
500 SourceDataLine.class,
501 getAudioFormat());
502
503 // Get the source data line to output.
504 srcDataLine = (SourceDataLine)
505 SampledAudioManager.getInstance().getOutputMixure().getLine(info); // LineUnavailableException
506
507 srcDataLine.open(); // LineUnavailableException
508
509 // Cache useful data
510 bufferFrameLength = srcDataLine.getBufferSize() / 2;
511 isOutputBigEndian = srcDataLine.getFormat().isBigEndian();
512
513 assert(bufferFrameLength > 0);
514 assert(srcDataLine.getFormat().getSampleSizeInBits() == 16);
515
516 }
517
518 /**
519 * Closes the data line so that if currently writing bytes or the next time
520 * bytes are written, the thread will end.
521 * Does not block calling thread. Thus may take a little while for thread to
522 * end after call returned.
523 */
524 public void stopPlayback() {
525 srcDataLine.close();
526 }
527
528 /**
529 * Note: even if all tracks have been proccessed in the audio pipeline, it will
530 * commence another pass to check for new tracks added to the graph before finishing.
531 *
532 * @return True if stopping and will not play any tracks added to the queue.
533 */
534 public boolean isStopping() {
535 return isStopping || !srcDataLine.isOpen();
536 }
537
538
539 /**
540 * @return the best audio format for playback...
541 */
542 private AudioFormat getAudioFormat() {
543 return SampledAudioManager.getInstance().getDefaultPlaybackFormat();
544 }
545
546 /**
547 * The audio mixing pipeline
548 */
549 public void run() {
550
551 // Notify observers that some audio has started playing
552 ApolloPlaybackMixer.this.fireSubjectChangedLaterOnSwingThread(
553 new SubjectChangedEvent(ApolloSubjectChangedEvent.PLAYBACK_STARTED));
554
555 // All tracks to play per pass
556 List<TrackSequence> tracksToPlay = new LinkedList<TrackSequence>();
557
558 // Keeps track of tracks to remove
559 List<TrackSequence> completedTracks = new LinkedList<TrackSequence>();
560
561 // The buffer written directly to the source data line
562 byte[] sampleBuffer = new byte[2 * bufferFrameLength];
563
564 // The mixed frames, where each element refers to a frame
565 int[] mixedFrameBuffer = new int[bufferFrameLength];
566
567 // Helpers declared outside loop for mz efficiency
568 int msb, lsb;
569 int sample;
570 int totalFramesMixed;
571 int trackCount; // tracks to play at a given pass
572 boolean isMoreQueued; // True if there are more tracks queued.
573 int frameIndex;
574 int i;
575
576 // Begin writing to the source data line
577 if (srcDataLine.isOpen())
578 srcDataLine.start();
579 else return;
580
581 // keep playing as long as line is open (and there is something to play)
582 try
583 {
584 while (srcDataLine.isOpen()) { // The audio mixing pipline
585
586 // First decide on which tracks to play ... and remove any finished tracks.
587 synchronized(sequenceGraph) {
588
589 // If there are no more tracks queued for playing, then exit the
590 // playback thread.
591 if (sequenceGraph.isEmpty())
592 return;
593
594 isMoreQueued = false;
595 completedTracks.clear();
596 tracksToPlay.clear();
597
598 for (TrackSequence ts : sequenceGraph) {
599
600 // Has this track sequence finished?
601 if (ts.currentFrame > ts.endFrame || ts.stopPending)
602 completedTracks.add(ts);
603
604 // Is this track playing / is meant to start laying in this pass?
605 else if (ts.initiationFrame <= (timelineFrame + bufferFrameLength))
606 tracksToPlay.add(ts);
607
608 // If it is not time to play the track yet, then
609 // neither will it for all proceeding tracks either
610 // since they are ordered by their initiation time.
611 else break;
612
613 }
614
615 // Get rid of tracks that have finished playing. Notify models that they have stopped
616 for (TrackSequence staleTS : completedTracks) {
617
618 sequenceGraph.remove(staleTS);
619
620 staleTS.onStopped((staleTS.currentFrame > staleTS.endFrame)
621 ? staleTS.endFrame : staleTS.currentFrame);
622
623 //removeTrackFromGraph(staleTS, staleTS.endFrame);
624 }
625
626 trackCount = tracksToPlay.size();
627 isMoreQueued = sequenceGraph.size() > trackCount;
628
629 // If there is nothing queued and there are no tracks to play,
630 // then playback is finished.
631 if (!isMoreQueued && trackCount == 0)
632 return;
633
634 } // release lock
635
636 totalFramesMixed = 0; // this will be set to the maximum amount of frames that were mixed accross all tracks
637
638 // Clear audio buffer
639 for (i = 0; i < bufferFrameLength; i++) // TODO: Effecient way of clearing buffer?
640 mixedFrameBuffer[i] = 0;
641
642 // Perform Mixing :
643 // Convert the sample size to 16-bit always for best precision while
644 // proccessing audio in the mix pipeline....
645 for (TrackSequence ts : tracksToPlay) {
646
647 // Notify model that initiated
648 if (!ts.isPlaying()) ts.onInitiated(timelineFrame);
649
650 // Skip muted / unsoloed tracks - they add nothing to the sample mix
651 if (ts.isMuted || (isSoloEnable && !ts.isSolo)) {
652
653 // Make sure start where initiated, if not already initiated
654 if (ts.initiationFrame >= timelineFrame && ts.initiationFrame < (timelineFrame + bufferFrameLength)) {
655
656 // Get index in frame buffer where to initiate
657 frameIndex = (int)(ts.initiationFrame - timelineFrame);
658
659 // Calcuate the length of frames to buffer - adjust silent tracks position
660 ts.currentFrame += (bufferFrameLength - frameIndex);
661
662 } else { // skip full buffer of bytes ... silenced
663
664 ts.currentFrame += bufferFrameLength; // currentFrame can go outside endframe boundry of the track
665
666 }
667
668 totalFramesMixed = bufferFrameLength;
669
670 } else { // Get samples and add to mix
671
672 // If the track is yet to initiate - part way through the buffer, then start adding bytes
673 // at initiation point
674 if (ts.initiationFrame >= timelineFrame && ts.initiationFrame < (timelineFrame + bufferFrameLength)) {
675
676 frameIndex = (int)(ts.initiationFrame - timelineFrame);
677
678 } else {
679
680 frameIndex = 0;
681
682 }
683
684 // For each frame
685 for (;frameIndex < bufferFrameLength && ts.currentFrame <= ts.endFrame; frameIndex++) {
686
687 // Get sample according to byte order
688 if (ts.isBigEndian) {
689
690 // First byte is MSB (high order)
691 msb = (int)ts.playbackAudioBytes[ts.currentFrame + ts.currentFrame];
692
693 // Second byte is LSB (low order)
694 lsb = (int)ts.playbackAudioBytes[ts.currentFrame + ts.currentFrame + 1];
695
696 } else {
697
698 // First byte is LSB (low order)
699 lsb = (int)ts.playbackAudioBytes[ts.currentFrame + ts.currentFrame];
700
701 // Second byte is MSB (high order)
702 msb = (int)ts.playbackAudioBytes[ts.currentFrame + ts.currentFrame + 1];
703 }
704
705 sample = (msb << 0x8) | (0xFF & lsb);
706
707 // Apply track volume
708 sample = (int)(sample * ts.volume);
709
710 // Add to current mix
711 mixedFrameBuffer[frameIndex] += sample;
712
713 // Get next sample
714 ts.currentFrame++;
715 }
716
717
718 // Keep track of total frames mixed in buffer
719 if (frameIndex > totalFramesMixed)
720 totalFramesMixed = frameIndex;
721 }
722
723 } // Mix in next track
724
725 // totalFramesMixed is the amount of frames to play.
726 // If it is zero then it means that there are tracks yet to be intiated, and nothing currently playing
727 assert (totalFramesMixed <= bufferFrameLength);
728 assert (totalFramesMixed > 0 ||
729 (totalFramesMixed == 0 && trackCount == 0 && isMoreQueued));
730
731 // Post mix with master settings
732 if (isMasterMuteOn) { // Silence sample buffer if master mute is on
733
734 for (i = 0; i < sampleBuffer.length; i++) {
735 sampleBuffer[i] = 0;
736 }
737
738 // Let the muted bytes play
739 totalFramesMixed = bufferFrameLength;
740
741 } else { // otherwise apply master volume
742
743 for (i = 0; i < totalFramesMixed; i++) {
744
745 // Average tracks
746 //mixedFrameBuffer[i] /= trackCount; // depreciated
747
748 // Apply mastar volume
749 mixedFrameBuffer[i] = (int)(mixedFrameBuffer[i] * masterVolume);
750
751 // Clip
752 if (mixedFrameBuffer[i] > Short.MAX_VALUE) mixedFrameBuffer[i] = Short.MAX_VALUE;
753 else if (mixedFrameBuffer[i] < Short.MIN_VALUE) mixedFrameBuffer[i] = Short.MIN_VALUE;
754
755 // Convert to output format
756 lsb = (mixedFrameBuffer[i] & 0xFF);
757 msb = ((mixedFrameBuffer[i] >> 8) & 0xFF);
758
759 if (isOutputBigEndian) {
760 sampleBuffer[i+i] = (byte)msb;
761 sampleBuffer[i+i+1] = (byte)lsb;
762 } else {
763 sampleBuffer[i+i] = (byte)lsb;
764 sampleBuffer[i+i+1] = (byte)msb;
765 }
766
767 }
768
769 }
770
771 // Generate silence only if there are more tracks to be played.
772 // Note that this could be false, but a track might have been queued after
773 // setting the isMoreQueued flag. In such cases... silence is not wanted anyway!
774 if (isMoreQueued) {
775 for (i = totalFramesMixed; i < bufferFrameLength; i++) { // will skip if no need to generate silence
776 sampleBuffer[i+i] = 0;
777 sampleBuffer[i+i+1] = 0;
778 }
779 // Ensure that full buffer is played ... including the silence
780 totalFramesMixed = bufferFrameLength;
781 }
782
783 // Write proccessed bytes to line out stream and update the timeline frame
784 srcDataLine.write(
785 sampleBuffer,
786 0,
787 totalFramesMixed * 2);
788
789 // Update timeline counter for sequencing management
790 timelineFrame += totalFramesMixed;
791
792 // The timelineFrame should always be larger or equal to the live frame position
793 assert(timelineFrame >= srcDataLine.getLongFramePosition());
794
795 } // Next pass
796
797 } finally {
798
799 isStopping = true;
800
801 // Ensure line freed
802 if (srcDataLine.isOpen()) {
803 srcDataLine.drain(); // avoids chopping off last buffered chunk
804 srcDataLine.close();
805 }
806
807 // Clear sequence graph.
808 synchronized(sequenceGraph) {
809
810 for (TrackSequence track : sequenceGraph) {
811
812 track.onStopped((track.currentFrame > track.endFrame)
813 ? track.endFrame : track.currentFrame);
814 }
815
816 sequenceGraph.clear();
817
818 }
819
820 // Notify observers that playback has finished.
821 ApolloPlaybackMixer.this.fireSubjectChangedLaterOnSwingThread(
822 new SubjectChangedEvent(ApolloSubjectChangedEvent.PLAYBACK_STOPPED));
823
824 }
825
826 }
827
828
829
830 }
831
832
833}
Note: See TracBrowser for help on using the repository browser.