From 88255d3df339052f46d47d469f3e00f645d94b4e Mon Sep 17 00:00:00 2001 From: Argent77 <4519923+Argent77@users.noreply.github.com> Date: Sun, 1 Dec 2024 14:03:26 +0100 Subject: [PATCH] New audio player class for non-buffered sounds and various improvements Added new interface for generalized audio player functionality: - StreamingAudioPlayback: controls for streamed/non-buffered audio data New class: StreamingAudioPlayer - provides audio playback functionality for non-buffered audio data Updated InfinityAmpPlus and MUS resource viewer to use new StreamingAudioPlayer class. Various sound-related improvements and bugfixes. --- src/org/infinity/datatype/ResourceRef.java | 3 +- src/org/infinity/gui/InfinityAmp.java | 6 + src/org/infinity/gui/InfinityAmpPlus.java | 457 +++++++++------- src/org/infinity/gui/SoundPanel.java | 60 +-- src/org/infinity/resource/mus/Entry.java | 9 +- .../resource/mus/MusResourceHandler.java | 425 +++++++++++++++ src/org/infinity/resource/mus/Viewer.java | 416 +++++++++------ .../resource/sound/AbstractAudioPlayer.java | 53 +- .../resource/sound/AudioPlayback.java | 6 +- .../infinity/resource/sound/AudioPlayer.java | 4 + .../resource/sound/AudioStateEvent.java | 27 +- .../resource/sound/AudioStateListener.java | 2 +- .../resource/sound/BufferedAudioPlayback.java | 4 + .../resource/sound/BufferedAudioPlayer.java | 40 +- .../resource/sound/EmptyQueueException.java | 43 ++ .../sound/StreamingAudioPlayback.java | 38 ++ .../resource/sound/StreamingAudioPlayer.java | 505 ++++++++++++++++++ 17 files changed, 1672 insertions(+), 426 deletions(-) create mode 100644 src/org/infinity/resource/mus/MusResourceHandler.java create mode 100644 src/org/infinity/resource/sound/EmptyQueueException.java create mode 100644 src/org/infinity/resource/sound/StreamingAudioPlayback.java create mode 100644 src/org/infinity/resource/sound/StreamingAudioPlayer.java diff --git a/src/org/infinity/datatype/ResourceRef.java b/src/org/infinity/datatype/ResourceRef.java index 69de2c2a7..a80c878b7 100644 --- a/src/org/infinity/datatype/ResourceRef.java +++ b/src/org/infinity/datatype/ResourceRef.java @@ -65,8 +65,7 @@ public class ResourceRef extends Datatype private static final Comparator IGNORE_CASE_EXT_COMPARATOR = new IgnoreCaseExtComparator(); /** List of resource types that are can be used to display associated icons. */ - private static final HashSet ICON_EXTENSIONS = new HashSet<>( - Arrays.asList(new String[] { "BMP", "ITM", "SPL" })); + private static final HashSet ICON_EXTENSIONS = new HashSet<>(Arrays.asList("BMP", "ITM", "SPL")); /** Special constant that represents absense of resource in the field. */ private static final ResourceRefEntry NONE = new ResourceRefEntry("None"); diff --git a/src/org/infinity/gui/InfinityAmp.java b/src/org/infinity/gui/InfinityAmp.java index 638fe42ae..a261e72cc 100644 --- a/src/org/infinity/gui/InfinityAmp.java +++ b/src/org/infinity/gui/InfinityAmp.java @@ -42,6 +42,12 @@ import org.infinity.util.SimpleListModel; import org.infinity.util.io.StreamUtils; +// TODO: remove class from project +/** + * A music player for MUS files. + * + * @deprecated Superseded by {@link InfinityAmpPlus}. + */ @Deprecated public final class InfinityAmp extends ChildFrame implements ActionListener, ListSelectionListener, Runnable, Closeable { diff --git a/src/org/infinity/gui/InfinityAmpPlus.java b/src/org/infinity/gui/InfinityAmpPlus.java index 920f03511..04ebddb28 100644 --- a/src/org/infinity/gui/InfinityAmpPlus.java +++ b/src/org/infinity/gui/InfinityAmpPlus.java @@ -26,7 +26,6 @@ import java.awt.event.MouseListener; import java.io.File; import java.io.IOException; -import java.nio.ByteBuffer; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -79,6 +78,7 @@ import javax.swing.filechooser.FileFilter; import javax.swing.filechooser.FileNameExtensionFilter; +import org.infinity.NearInfinity; import org.infinity.gui.ButtonPopupMenu.Align; import org.infinity.icon.Icons; import org.infinity.resource.Closeable; @@ -86,8 +86,10 @@ import org.infinity.resource.key.FileResourceEntry; import org.infinity.resource.key.ResourceEntry; import org.infinity.resource.mus.Entry; -import org.infinity.resource.sound.AudioBuffer; -import org.infinity.resource.sound.AudioPlayer; +import org.infinity.resource.mus.MusResourceHandler; +import org.infinity.resource.sound.AudioStateEvent; +import org.infinity.resource.sound.AudioStateListener; +import org.infinity.resource.sound.StreamingAudioPlayer; import org.infinity.util.InputKeyHelper; import org.infinity.util.Logger; import org.infinity.util.Misc; @@ -95,14 +97,13 @@ import org.infinity.util.StopWatch; import org.infinity.util.Threading; import org.infinity.util.io.FileManager; -import org.infinity.util.io.StreamUtils; import org.infinity.util.tuples.Couple; /** - * A global music player for MUS files found in all supported IE games. It supercedes the original {@link InfinityAmp} + * A global music player for MUS files found in all supported IE games. It supersedes the original {@link InfinityAmp} * music player. */ -public class InfinityAmpPlus extends ChildFrame implements Runnable, Closeable { +public class InfinityAmpPlus extends ChildFrame implements Closeable { // static window title private static final String TITLE_DEFAULT = "InfinityAmp"; // format string for window title: resource name, directory @@ -185,26 +186,26 @@ public class InfinityAmpPlus extends ChildFrame implements Runnable, Closeable { private final JRadioButtonMenuItem rbmiPathAsSuffix = new JRadioButtonMenuItem("Format: ()"); private final JRadioButtonMenuItem rbmiPathAsPrefix = new JRadioButtonMenuItem("Format: () "); + // Stores the previously played back MUS entries if Shuffle mode is enabled + private final List undoPlayListItems = new ArrayList<>(); private final Listeners listeners = new Listeners(); - private final AudioPlayer player = new AudioPlayer(); private final Random random = new Random(); private final StopWatch timer = new StopWatch(1000L, false); - // Stores the previously played back MUS entries if Shuffle mode is enabled - private final List undoPlayListItems = new ArrayList<>(); + private final StreamingAudioPlayer player; - private boolean playing = false; - private boolean paused = false; + private MusResourceHandler musHandler; private boolean requestNextItem = false; public InfinityAmpPlus() { super(TITLE_DEFAULT, false, false); + player = initPlayer(true); init(); } @Override public void close() { - stopPlayback(); + closePlayer(); Entry.clearCache(); KeyboardFocusManager.getCurrentKeyboardFocusManager().removeKeyEventDispatcher(listeners::dispatchKeyEvent); savePreferences(); @@ -216,29 +217,6 @@ protected void gameReset(boolean refreshOnly) { refreshAllResourceEntries(); } - @Override - public void run() { - int index = Threading.invokeInEventThread(this::setCurrentPlayListItem, 0); - if (index < 0) { - Threading.invokeInEventThread(this::stopPlayback, false); - return; - } - - while (isPlaying() && !isNextItemRequested() && index >= 0) { - final MusicResourceEntry musFile = playListModel.get(index); - playMusic(musFile); - if (isPlaying() && !isNextItemRequested()) { - index = Threading.invokeInEventThread(this::setNextPlayListItem, -1); - } - } - - if (isNextItemRequested()) { - acceptNextItem(); - } else { - Threading.invokeInEventThread(this::stopPlayback, false); - } - } - /** Scans available music list and playlist for non-existing files. */ public void refreshAllResourceEntries() { for (int i = availableListModel.size() - 1; i >= 0; i--) { @@ -282,15 +260,16 @@ public void updatePathAsPrefix(boolean b) { /** Returns whether playback is currently active. Paused state does not affect the result. */ public boolean isPlaying() { - return playing; + return player.isPlaying(); } public synchronized void setPlaying(boolean play) { if (play != isPlaying()) { if (play) { - startPlayback(); + player.setPlaying(true); } else { - stopPlayback(); + player.setPlaying(false); + player.clearAudioQueue(); } } } @@ -301,22 +280,11 @@ public synchronized void setPlaying(boolean play) { * @return {@code true} only if playback is enabled but paused, {@code false} otherwise. */ public boolean isPaused() { - return isPlaying() && paused; + return player.isPaused(); } public synchronized void setPaused(boolean pause) { - if (isPlaying()) { - if (pause != isPaused()) { - player.setPaused(pause); - paused = pause; - if (paused) { - timer.pause(); - } else { - timer.resume(); - } - updatePlayListButtons(); - } - } + player.setPaused(pause); } /** Returns whether Loop mode is enabled. */ @@ -502,49 +470,6 @@ public int setExclusionFilters(Collection filterSet) { return retVal; } - /** - * Plays back the specified MUS resource. This method is called internally by the playback thread. - * - * @param musEntry {@link MusicResourceEntry} of the MUS file to play back. - */ - private void playMusic(MusicResourceEntry musEntry) { - if (musEntry == null || !Files.isRegularFile(musEntry.getActualPath())) { - return; - } - - try { - final List entryList = parseMusFile(musEntry); - int index = 0; - timer.pause(); - timer.reset(); - if (isPlaying()) { - Threading.invokeInEventThread(() -> updateTimeDisplay(musEntry)); - updateTimeDisplay(musEntry); - } - while (isPlaying() && !isNextItemRequested()) { - timer.resume(); - final Entry entry = entryList.get(index); - if (!isSoundExcluded(entry)) { - final AudioBuffer audio = entry.getAudioBuffer(); - player.playContinuous(audio); - } - if (entry.getNextNr() <= index || entry.getNextNr() >= entryList.size()) { - break; - } - index = entry.getNextNr(); - } - - final Entry entry = entryList.get(index); - if (isPlaying() && !isNextItemRequested() && entry.getEndBuffer() != null && !isSoundExcluded(entry)) { - player.play(entry.getEndBuffer()); - } - } catch (Exception e) { - JOptionPane.showMessageDialog(this, String.format("Error playing %s\n%s", musEntry, e.getMessage()), - "Error", JOptionPane.ERROR_MESSAGE); - Logger.error(e); - } - } - /** Calculates duration of all available song entries. */ private void calculateDurations() { for (int i = 0, size = availableListModel.size(); i < size; i++) { @@ -561,7 +486,7 @@ private boolean isNextItemRequested() { private synchronized void requestNextItem() { if (isPlaying() && !isNextItemRequested()) { requestNextItem = true; - stopPlayback(); + setPlaying(false); } } @@ -569,70 +494,10 @@ private synchronized void requestNextItem() { private synchronized void acceptNextItem() { if (isNextItemRequested()) { requestNextItem = false; - startPlayback(); + setPlaying(true); } } - /** - * Starts playback of the first selected playlist item. - * - * @return {@code true} if playback is started by this method call, {@code false} otherwise. - */ - private boolean startPlayback() { - if (isPlaying()) { - return false; - } - - int index = playList.getSelectedIndex(); - if (index < 0 && !playListModel.isEmpty()) { - index = 0; - } - if (index < 0) { - return false; - } - - playList.setSelectedIndex(index); - playing = true; - new Thread(this).start(); - timer.reset(); - timer.resume(); - availableList.setEnabled(false); - playList.setEnabled(false); - elapsedTimeLabel.setEnabled(true); - updateAvailableListButtons(); - updatePlayListButtons(); - updateTransferButtons(); - updateTimeDisplay(playList.getSelectedValue()); - updateWindowTitle(); - return true; - } - - /** - * Stops current playback. - * - * @return {@code true} if playback was enabled and has been stopped by this method call, {@code false} otherwise. - */ - private boolean stopPlayback() { - if (!isPlaying()) { - return false; - } - - paused = false; - playing = false; - player.stopPlay(); - timer.pause(); - timer.reset(); - availableList.setEnabled(true); - playList.setEnabled(true); - elapsedTimeLabel.setEnabled(false); - updateWindowTitle(); - updateTimeDisplay(null); - updateAvailableListButtons(); - updatePlayListButtons(); - updateTransferButtons(); - return true; - } - /** * Finds the specified {@link MusicResourceEntry} in the available music list and returns the item index. * @@ -796,7 +661,8 @@ private int addMusFiles(Path musicDir) throws Exception { try (final Stream fstream = Files.list(musicDir)) { final List fileList = fstream .map(file -> { - if (Files.isRegularFile(file) && !inAvailableList(file)) { + if (Files.isRegularFile(file) && file.getFileName().toString().toLowerCase().endsWith(".mus") && + !inAvailableList(file)) { try { return new MusicResourceEntry(file, isPathAsPrefix()); } catch (Exception e) { @@ -1008,7 +874,7 @@ private void updateTransferButtons() { fromPlayListButton.setEnabled(false); moveUpButton.setEnabled(false); moveDownButton.setEnabled(false); - } else { + } else if (!isNextItemRequested()) { toPlayListButton.setEnabled(!availableList.getSelectionModel().isSelectionEmpty()); fromPlayListButton.setEnabled(!playList.getSelectionModel().isSelectionEmpty()); @@ -1043,6 +909,14 @@ private void updateAvailableListTitle() { /** Updates the media buttons for the playlist. */ private void updatePlayListButtons() { + if (!isPlayerAvailable()) { + prevMusicButton.setEnabled(false); + nextMusicButton.setEnabled(false); + playMusicButton.setEnabled(false); + stopMusicButton.setEnabled(false); + return; + } + final int[] indices = playList.getSelectedIndices(); if (playListModel.isEmpty() || indices.length == 0) { prevMusicButton.setEnabled(false); @@ -1089,13 +963,13 @@ private void updateFileInfo() { */ private void updateTimeDisplay(MusicResourceEntry resourceEntry) { if (resourceEntry != null) { - final long elapsed = StopWatch.toSeconds(timer.elapsed()); - final long elapsedSec = elapsed % 60L; - final long elapsedMin = elapsed / 60L; + final int elapsed = (int) StopWatch.toSeconds(timer.elapsed()); + final int elapsedMin = elapsed / 60; + final int elapsedSec = elapsed % 60; if (resourceEntry.isDurationAvailable()) { - final long duration = resourceEntry.getDuration() / 1000L; - final long totalSec = duration % 60L; - final long totalMin = duration / 60L; + final int duration = resourceEntry.getDurationSeconds(); + final int totalMin = duration / 60; + final int totalSec = duration % 60; elapsedTimeLabel.setText(String.format(PLAYTIME_FMT, elapsedMin, elapsedSec, totalMin, totalSec)); } else { elapsedTimeLabel.setText(String.format(PLAYTIME_SHORT_FMT, elapsedMin, elapsedSec)); @@ -1121,6 +995,117 @@ private void updateWindowTitle() { setTitle(title); } + /** Called when the audio player triggers an {@code OPEN} event. */ + private void handleAudioOpenEvent(Object value) { + // nothing to do + } + + /** Called when the audio player triggers a {@code CLOSE} event. */ + private void handleAudioCloseEvent(Object value) { + // nothing to do + } + + /** Called when the audio player triggers a {@code START} event. */ + private void handleAudioStartEvent() { + timer.reset(); + timer.resume(); + availableList.setEnabled(false); + playList.setEnabled(false); + elapsedTimeLabel.setEnabled(true); + updateAvailableListButtons(); + updatePlayListButtons(); + updateTransferButtons(); + updateTimeDisplay(playList.getSelectedValue()); + updateWindowTitle(); + } + + /** Called when the audio player triggers a {@code STOP} event. */ + private void handleAudioStopEvent() { + if (musHandler != null) { + try { + musHandler.close(); + } catch (Exception e) { + Logger.error(e); + } + musHandler = null; + } + + if (isNextItemRequested()) { + acceptNextItem(); + } else { + timer.pause(); + timer.reset(); + availableList.setEnabled(true); + playList.setEnabled(true); + elapsedTimeLabel.setEnabled(false); + updateWindowTitle(); + updateTimeDisplay(null); + updateAvailableListButtons(); + updatePlayListButtons(); + updateTransferButtons(); + } + } + + /** Called when the audio player triggers a {@code PAUSE} event. */ + private void handleAudioPauseEvent(Object value) { + timer.pause(); + updatePlayListButtons(); + } + + /** Called when the audio player triggers a {@code RESUME} event. */ + private void handleAudioResumeEvent(Object value) { + timer.resume(); + updatePlayListButtons(); + } + + /** Called when the audio player triggers a {@code BUFFER_EMPTY} event. */ + private void handleAudioBufferEmptyEvent(Object value) { + if (musHandler == null) { + // loading currently selected MUS resource + int index = setCurrentPlayListItem(); + if (index < 0) { + setPlaying(false); + return; + } + + try { + musHandler = new MusResourceHandler(playListModel.get(index), 0, true, false); + } catch (Exception e) { + handleAudioErrorEvent(e); + return; + } + } + + boolean advanced; + for (advanced = musHandler.advance(); advanced; advanced = musHandler.advance()) { + if (!isSoundExcluded(musHandler.getCurrentEntry())) { + player.addAudioBuffer(musHandler.getAudioBuffer()); + break; + } + } + + if (!advanced) { + if (setNextPlayListItem() >= 0) { + requestNextItem(); + } else { + setPlaying(false); + } + } + } + + /** Called when the audio player triggers an {@code ERROR} event. */ + private void handleAudioErrorEvent(Object value) { + requestNextItem = false; + setPlaying(false); + + final Exception e = (value instanceof Exception) ? (Exception)value : null; + if (e != null) { + Logger.error(e); + } + final String msg = (e != null) ? "Error during playback:\n" + e.getMessage() : "Error during playback."; + JOptionPane.showMessageDialog(this, msg, "Error", JOptionPane.ERROR_MESSAGE); + } + private void init() { availableList.setCellRenderer(availableListRenderer); playList.setCellRenderer(playListRenderer); @@ -1166,10 +1151,12 @@ private void init() { removeMusicButton.addActionListener(listeners); clearMusicButton.addActionListener(listeners); - prevMusicButton.addActionListener(listeners); - playMusicButton.addActionListener(listeners); - stopMusicButton.addActionListener(listeners); - nextMusicButton.addActionListener(listeners); + if (isPlayerAvailable()) { + prevMusicButton.addActionListener(listeners); + playMusicButton.addActionListener(listeners); + stopMusicButton.addActionListener(listeners); + nextMusicButton.addActionListener(listeners); + } importPlayListButton.setToolTipText("Import music entries from M3U playlist file."); importPlayListButton.addActionListener(listeners); @@ -1396,6 +1383,48 @@ private void init() { setIconImage(Icons.ICON_VOLUME_16.getIcon().getImage()); } + /** + * Returns a new and fully initialized {@link StreamingAudioPlayer} instance. + * + * @param showError Whether to show an error message box if the player instance could not be created. + * @return {@link StreamingAudioPlayer} instance if successful, {@code null} otherwise. + */ + private StreamingAudioPlayer initPlayer(boolean showError) { + try { + return new StreamingAudioPlayer(listeners); + } catch (Exception e) { + Logger.error(e); + if (showError) { + JOptionPane.showMessageDialog(NearInfinity.getInstance(), + "Failed to initialize audio backend:\n" + e.getMessage() + "\n\nPlayback will be disabled.", + "Error", JOptionPane.ERROR_MESSAGE); + } + } + return null; + } + + /** + * Returns whether the global {@link StreamingAudioPlayer} instance is available. + * + * @return {@code true} if audio player instance is available, {@code false} otherwise. + */ + private boolean isPlayerAvailable() { + return Objects.nonNull(player); + } + + /** Closes the {@link StreamingAudioPlayer} instance. */ + private void closePlayer() { + if (!isPlayerAvailable()) { + return; + } + + try { + player.close(); + } catch (Exception e) { + Logger.error(e); + } + } + /** * Loads the window position and size from the preferences. * @@ -1564,7 +1593,8 @@ private static Preferences getPreferences() { /** * Returns a {@link MusicResourceEntry} object for the specified MUS file. * - * @param musicFilePath {@link Path} to the MUS file. + * @param musicFilePath {@link Path} to the MUS file. The path string can optionally be appended by the music duration + * in milliseconds, separated by semicolon. * @return A {@link MusicResourceEntry} object for the MUS file. Returns {@code null} if not available. */ private static MusicResourceEntry getMusicResource(String musicFilePath, boolean pathAsPrefix) { @@ -1598,36 +1628,10 @@ private static MusicResourceEntry getMusicResource(String musicFilePath, boolean return retVal; } - /** Creates a parsed list of sound entries from the specified MUS resource. */ - private static List parseMusFile(MusicResourceEntry resource) { - Objects.requireNonNull(resource); - List retVal = new ArrayList<>(); - try { - final ByteBuffer bb = resource.getResourceBuffer(); - final String[] lines = StreamUtils.readString(bb, bb.limit()).split("\r?\n"); - int idx = 0; - final String acmFolder = lines[0].trim(); - final int numEntries = Integer.parseInt(lines[1].trim()); - for (int i = 2, counter = 0, size = Math.min(numEntries + 2, lines.length); i < size; i++) { - final String line = lines[idx++].trim(); - if (!line.isEmpty()) { - retVal.add(new Entry(resource, acmFolder, retVal, lines[i], counter)); - counter++; - } - } - for (final Entry entry : retVal) { - entry.init(); - } - } catch (Exception e) { - Logger.error(e, "Resource: " + resource); - } - return retVal; - } - // -------------------------- INNER CLASSES -------------------------- - private class Listeners - implements ActionListener, ListSelectionListener, ItemListener, MouseListener, KeyListener, ComponentListener { + private class Listeners implements ActionListener, ListSelectionListener, ItemListener, AudioStateListener, + MouseListener, KeyListener, ComponentListener { public Listeners() { } @@ -1786,6 +1790,38 @@ public void itemStateChanged(ItemEvent e) { } } + @Override + public void audioStateChanged(AudioStateEvent event) { +// Logger.trace("{}.audioStateChanged: state={}({})", InfinityAmpPlus.class.getSimpleName(), event.getAudioState(), +// event.getValue()); + switch (event.getAudioState()) { + case OPEN: + handleAudioOpenEvent(event.getValue()); + break; + case CLOSE: + handleAudioCloseEvent(event.getValue()); + break; + case START: + handleAudioStartEvent(); + break; + case STOP: + handleAudioStopEvent(); + break; + case PAUSE: + handleAudioPauseEvent(event.getValue()); + break; + case RESUME: + handleAudioResumeEvent(event.getValue()); + break; + case BUFFER_EMPTY: + handleAudioBufferEmptyEvent(event.getValue()); + break; + case ERROR: + handleAudioErrorEvent(event.getValue()); + break; + } + } + @Override public void mouseClicked(MouseEvent e) { if (e.getSource() == availableList) { @@ -1866,7 +1902,7 @@ public void componentShown(ComponentEvent e) { @Override public void componentHidden(ComponentEvent e) { - stopPlayback(); + setPlaying(false); } /** Called by a {@link KeyEventDispatcher} instance. */ @@ -1989,6 +2025,16 @@ public boolean isDurationAvailable() { return (duration != null); } + /** Returns the duration of the music file in seconds if available, -1 otherwise. */ + public int getDurationSeconds() { + final long duration = getDuration(); + if (duration >= 0) { + // duration is rounded to the nearest full second + return (int)((duration + 500L) / 1000L); + } + return -1; + } + /** Returns the duration of the music file in milliseconds if available, -1 otherwise. */ public long getDuration() { return isDurationAvailable() ? duration : -1L; @@ -2018,7 +2064,7 @@ public void calculateDuration() { } try { - final List entries = parseMusFile(this); + final List entries = MusResourceHandler.parseMusFile(this); long timeMs = 0L; int index = 0; while (true) { @@ -2040,7 +2086,7 @@ public void calculateDuration() { entries.clear(); return timeMs; } catch (Throwable t) { - Logger.debug(t); + Logger.debug(t, "Resource: " + this); } return -1L; }; @@ -2064,12 +2110,11 @@ public String toString() { final String time; if (isDurationAvailable()) { - final long duration = getDuration(); - if (duration >= 0L) { - final int len = (int) ((duration + 500L) / 1000L); // in seconds, rounded up - final int hours = len / 3600; - final int minutes = (len / 60) % 60; - final int seconds = len % 60; + final int duration = getDurationSeconds(); + if (duration >= 0) { + final int hours = duration / 3600; + final int minutes = (duration / 60) % 60; + final int seconds = duration % 60; time = String.format("%02d:%02d:%02d", hours, minutes, seconds); } else { time = ""; diff --git a/src/org/infinity/gui/SoundPanel.java b/src/org/infinity/gui/SoundPanel.java index ead24d45d..1d668cdec 100644 --- a/src/org/infinity/gui/SoundPanel.java +++ b/src/org/infinity/gui/SoundPanel.java @@ -126,7 +126,7 @@ public enum DisplayFormat { */ private final String fmt; - private DisplayFormat(String fmt) { + DisplayFormat(String fmt) { this.fmt = fmt; } @@ -239,7 +239,7 @@ public SoundPanel(ResourceEntry entry, Option... options) throws ResourceNotFoun * * @param entry {@link ResourceEntry} of the sound resource. * @param format {@link DisplayFormat} enum with the format description. {@code null} resolves to - * {@link DisplayFormat#NONE}. + * {@link DisplayFormat#ELAPSED_TOTAL}. * @param options A set of {@link Option} values that controls visibility of optional control elements. * @throws ResourceNotFoundException if the resource referenced by the {@code ResourceEntry} parameter does not exist. * @throws NullPointerException if {@code entry} is {@code null}. @@ -253,7 +253,7 @@ public SoundPanel(ResourceEntry entry, DisplayFormat format, Option... options) * * @param entry {@link ResourceEntry} of the sound resource. * @param format {@link DisplayFormat} enum with the format description. {@code null} resolves to - * {@link DisplayFormat#NONE}. + * {@link DisplayFormat#ELAPSED_TOTAL}. * @param playback Specifies whether sound playback should start automatically. * @param options A set of {@link Option} values that controls visibility of optional control elements. * @throws ResourceNotFoundException if the resource referenced by the {@code ResourceEntry} parameter does not exist. @@ -313,7 +313,7 @@ public void loadSound(ResourceEntry entry, Consumer onCompleted) throws final Supplier operation = () -> { try { AudioBuffer.AudioOverride override = null; - AudioBuffer buffer = null; + AudioBuffer buffer; // ignore # channels in ACM headers if (entry.getExtension().equalsIgnoreCase("ACM")) { @@ -365,6 +365,7 @@ public void unload() { try { runner.setAudio(null); } catch (Exception e) { + Logger.debug(e); } } @@ -583,20 +584,15 @@ private void fireAudioStateEvent(AudioStateEvent.State state, Object value) { // collecting stateListeners.clear(); final Object[] entries = getAudioStateListeners(); - AudioStateEvent evt = null; for (int i = entries.length - 2; i >= 0; i -= 2) { if (entries[i] == AudioStateListener.class) { - // event object is lazily created - if (evt == null) { - evt = new AudioStateEvent(this, state, value); - } stateListeners.add((AudioStateListener) entries[i + 1]); } } // executing if (!stateListeners.isEmpty()) { - final AudioStateEvent event = evt; + final AudioStateEvent event = new AudioStateEvent(this, state, value); SwingUtilities.invokeLater(() -> stateListeners.forEach(l -> l.audioStateChanged(event))); } } @@ -625,16 +621,16 @@ private void fireSoundStopped() { /** Fires a {@link AudioStateEvent} when entering the paused state. */ private void fireSoundPaused() { - fireAudioStateEvent(AudioStateEvent.State.PAUSE, Long.valueOf(runner.getElapsedTime())); + fireAudioStateEvent(AudioStateEvent.State.PAUSE, runner.getSoundPosition()); } /** Fires a {@link AudioStateEvent} when resuming from the paused state. */ private void fireSoundResumed() { - fireAudioStateEvent(AudioStateEvent.State.RESUME, Long.valueOf(runner.getElapsedTime())); + fireAudioStateEvent(AudioStateEvent.State.RESUME, runner.getSoundPosition()); } private void updateLabel() { - final long elapsedTime = runner.getElapsedTime(); + final long elapsedTime = runner.getSoundPosition(); final long totalTime = runner.getTotalLength(); final String displayString = getDisplayFormat().toString(elapsedTime, totalTime); displayLabel.setText(displayString); @@ -645,6 +641,8 @@ private void updateLabel() { /** Updates playback UI controls to reflect the current state. */ private void updateControls() { +// Logger.trace("SoundPanel.updateControls: isAvailable={}, isPlaying={}, isPaused={}", runner.isAvailable(), +// runner.isPlaying(), runner.isPaused()); if (runner.isAvailable()) { if (runner.isPlaying()) { if (combinedPlayPause) { @@ -685,11 +683,11 @@ private void initSliderRange() { if (duration < 45_000) { // major: per ten seconds, minor: per second progressSlider.setMajorTickSpacing(10_000); - progressSlider.setMinorTickSpacing(1_000); + progressSlider.setMinorTickSpacing(1000); } else { // major: per minute, minor: per ten seconds progressSlider.setMajorTickSpacing(30_000); - progressSlider.setMinorTickSpacing(5_000); + progressSlider.setMinorTickSpacing(5000); } initSliderTickLabels(); } else { @@ -710,7 +708,7 @@ private void initSliderTickLabels() { final Hashtable labels = new Hashtable<>(); final int spacing = progressSlider.getMajorTickSpacing(); for (int pos = progressSlider.getMinimum(); pos < progressSlider.getMaximum(); pos += spacing) { - labels.put(Integer.valueOf(pos), createSliderTickLabel(pos)); + labels.put(pos, createSliderTickLabel(pos)); } // add label for end of range if suitable @@ -724,7 +722,7 @@ private void initSliderTickLabels() { } final int remainingSpace = (progressSlider.getMaximum() - progressSlider.getMinimum()) % spacing; if (remainingSpace > minSpace) { - labels.put(Integer.valueOf(progressSlider.getMaximum()), createSliderTickLabel(progressSlider.getMaximum())); + labels.put(progressSlider.getMaximum(), createSliderTickLabel(progressSlider.getMaximum())); } if (!labels.isEmpty()) { @@ -735,7 +733,7 @@ private void initSliderTickLabels() { /** Creates a label for a slider tick with the specified time value. */ private static JLabel createSliderTickLabel(int timeMs) { final int min = timeMs / 60_000; - final int sec = (timeMs / 1_000) % 60; + final int sec = (timeMs / 1000) % 60; final JLabel label = new JLabel(String.format("%02d:%02d", min, sec)); final Font font = label.getFont(); label.setFont(font.deriveFont(Font.PLAIN, font.getSize2D() * 0.75f)); @@ -915,14 +913,14 @@ public Runner() { } /** - * Assigns a new {@link BufferedAudioPlayer} instance to the runner. The old player instance, if any, is properly closed - * before the new instance is assigned. + * Assigns a new {@link BufferedAudioPlayer} instance to the runner. The old player instance, if any, is properly + * closed before the new instance is assigned. * - * @param newBuffer - * @throws IOException if an I/O error occurs. + * @param newBuffer the {@link AudioBuffer} object to load. + * @throws IOException if an I/O error occurs. * @throws UnsupportedAudioFileException if the audio data is incompatible with the player. - * @throws LineUnavailableException if the audio line is not available due to resource restrictions. - * @throws IllegalArgumentException if the audio data is invalid. + * @throws LineUnavailableException if the audio line is not available due to resource restrictions. + * @throws IllegalArgumentException if the audio data is invalid. */ public void setAudio(AudioBuffer newBuffer) throws Exception{ lock.lock(); @@ -954,7 +952,7 @@ public boolean isAvailable() { /** Returns whether loop mode is enabled. */ @SuppressWarnings("unused") public boolean isLooped() { - return (player instanceof BufferedAudioPlayback) ? ((BufferedAudioPlayback)player).isLooped() : false; + return player instanceof BufferedAudioPlayback && ((BufferedAudioPlayback) player).isLooped(); } /** Enables or disable looped playback. */ @@ -969,7 +967,7 @@ public void setLooped(boolean loop) { * Returns {@code false} if the player is not initialized. */ public boolean isPlaying() { - return (player != null) ? player.isPlaying() : false; + return (player != null) && player.isPlaying(); } /** @@ -987,7 +985,7 @@ public void setPlaying(boolean play) { * state. Always returns {@code false} if playback is stopped or the player is not initialized. */ public boolean isPaused() { - return (player != null) ? player.isPaused() : false; + return (player != null) && player.isPaused(); } /** @@ -1012,8 +1010,8 @@ public long getTotalLength() { * Returns the elapsed playback time of the sound clip, in milliseconds. Returns {@code 0} if the player is not * initialized. */ - public long getElapsedTime() { - return (player != null) ? player.getElapsedTime() : 0L; + public long getSoundPosition() { + return (player != null) ? player.getSoundPosition() : 0L; } /** @@ -1044,6 +1042,7 @@ public void terminate() { try { setAudio(null); } catch (Exception e) { + Logger.debug(e); } updatePanel(); } @@ -1077,6 +1076,7 @@ public void run() { @Override public void audioStateChanged(AudioStateEvent event) { +// Logger.trace("{}.audioStateChanged({})", SoundPanel.class.getSimpleName(), event); switch (event.getAudioState()) { case START: SoundPanel.this.fireSoundStarted(); @@ -1245,7 +1245,7 @@ public void setValue(int n) { if (newValue + getExtent() > getMaximum()) { newValue = getMaximum() - getExtent(); } - adjustedValue = getValidatedValue(n); + adjustedValue = getValidatedValue(newValue); fireStateChanged(); } else { super.setValue(n); diff --git a/src/org/infinity/resource/mus/Entry.java b/src/org/infinity/resource/mus/Entry.java index c00d12276..c297f80b1 100644 --- a/src/org/infinity/resource/mus/Entry.java +++ b/src/org/infinity/resource/mus/Entry.java @@ -124,7 +124,14 @@ public void close() { @Override public String toString() { - return line; + final String time; + if (audioBuffer != null) { + final int duration = (int)(audioBuffer.getDuration() / 1_000L); + time = String.format("[%02d:%02d] ", duration / 60, duration % 60); + } else { + time = "[??:??] "; + } + return time + line; } public AudioBuffer getEndBuffer() { diff --git a/src/org/infinity/resource/mus/MusResourceHandler.java b/src/org/infinity/resource/mus/MusResourceHandler.java new file mode 100644 index 000000000..51cdaef3d --- /dev/null +++ b/src/org/infinity/resource/mus/MusResourceHandler.java @@ -0,0 +1,425 @@ +package org.infinity.resource.mus; + +import java.io.FileNotFoundException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +import org.infinity.resource.Closeable; +import org.infinity.resource.key.ResourceEntry; +import org.infinity.resource.sound.AudioBuffer; +import org.infinity.util.Logger; +import org.infinity.util.io.StreamUtils; + +/** + * Customizable helper class for handling the details of MUS playback progress. + */ +public class MusResourceHandler implements Closeable { + /** List of sound segments of the loaded MUS resource. */ + private List entryList; + /** The initial sound entry index. */ + private int startEntryIndex; + /** Index of the current sound entry. */ + private int currentEntryIndex; + /** Whether to auto-switch to "end" segment when advancing from last regular sound segment. */ + private boolean allowEnding; + /** Whether "ending" flag should be set by the next advancement. */ + private boolean signalEnding; + /** Whether "end" sound segment is selected. */ + private boolean ending; + /** Whether advancing sound segments should consider looping back to previous segments. */ + private boolean loop; + + /** + * Loads the specified MUS resource for playback with the specified options. + * + * @param musEntry {@link ResourceEntry} of the MUS resource. + * @param startIndex The initially selected sound segment index. + * @param allowEnding Whether to switch to the "end" sound segment automatically when the last regular sound segment + * was processed. Setting this option has only an effect if {@code looping} is disabled or the + * MUS resource does not loop. + * @param looping Whether soundtrack is alloed to loop back to previously played entries. + * @throws NullPointerException if {@code musEntry} is {@code null}. + * @throws FileNotFoundException if the MUS resource does not exist. + * @throws Exception if the MUS resource could not be parsed. + */ + public MusResourceHandler(ResourceEntry musEntry, int startIndex, boolean allowEnding, boolean looping) + throws Exception { + if (Objects.isNull(musEntry)) { + throw new NullPointerException("musEntry is null"); + } + if (!Files.isRegularFile(musEntry.getActualPath())) { + throw new FileNotFoundException("MUS file not found: " + musEntry.getActualPath()); + } + init(MusResourceHandler.parseMusFile(musEntry), startIndex, allowEnding, looping); + } + + /** + * Loads a parsed MUS entry list for playback with the specified options. + * + * @param musEntries Collection of {@link Entry} instances of a MUS resource. + * @param startIndex The initially selected sound segment index. + * @param allowEnding Whether to switch to the "end" sound segment automatically when the last regular sound segment + * was processed. Setting this option has only an effect if {@code looping} is disabled or the + * MUS resource does not loop. + * @param looping Whether soundtrack is alloed to loop back to previously played entries. + * @throws NullPointerException if {@code musEntries} is {@code null}. + */ + public MusResourceHandler(Collection musEntries, int startIndex, boolean allowEnding, boolean looping) { + if (Objects.isNull(musEntries)) { + throw new NullPointerException("musEntries is null"); + } + init(musEntries, startIndex, allowEnding, looping); + } + + /** + * Returns the number of sound segments defined by this MUS resource, not counting the special "end" segment. + * + * @return Number of available sound segments in the MUS resource. + */ + public int size() { + return entryList.size(); + } + + /** + * Returns whether a subsequent call of {@link #advance()} method will return successfully with the current configuration. + * + * @return {@code true} if the current sound segment is the last segment in the sound list, {@code false} otherwise. + */ + public boolean hasNextEntry() { + if (currentEntryIndex == Integer.MIN_VALUE) { + return validIndex(startEntryIndex); + } else if (!isEnding() && validIndex(currentEntryIndex)) { + final int index = entryList.get(currentEntryIndex).getNextNr(); + boolean retVal = (isLooping() || (index > currentEntryIndex || (isAllowEnding() && getCurrentEntry().getEndBuffer() != null))); + retVal = retVal && ((isAllowEnding() && getCurrentEntry().getEndBuffer() != null) || validIndex(index)); + return retVal; + } + return false; + } + + /** + * Returns whether advancing the soundtrack should allow to return to previously selected sound segments + * + * @return {@code true} if looping back to previous sound segments is allowed, {@code false} otherwise. + */ + public boolean isLooping() { + return loop; + } + + /** + * Specify whether looping back to previously selected sound segments should be allowed by the {@link #advance()} + * method. + * + * @param loop Specify {@code true} to allow looping, {@code false} to end advancement when reaching the last sound + * segment. + */ + public void setLooping(boolean loop) { + this.loop = loop; + } + + /** + * Returns whether the "end" sound segment is automatically returned when the last regular segment has been + * processed. + * + * @return {@code true} to allow advancing to "end" sound segment + */ + public boolean isAllowEnding() { + return allowEnding; + } + + /** + * Specify whether the "end" sound segment should be automatically selected after the last regular sound segment has + * been processed. + * + * @param allow Whether to enable or disable automatic availability of the "end" sound segment. + */ + public void setAllowEnding(boolean allow) { + allowEnding = allow; + } + + /** + * Returns whether the "end" sound segment is enabled as current sound segment. Advancing sound segments if "end" + * segment is enabled finishes advancement instantly. + * + * @return {@code true} if "end" sound segment is enabled, {@code false} otherwise. + */ + public boolean isEnding() { + return ending; + } + + /** + * Specify to activate the "end" sound segment for the currently selected entry. This flag indicates which audio + * segment is returned by a call of {@link #getAudioBuffer()}. + * + * @param ending whether to activate or deactivate "end" sound segment. + */ + public void setEnding(boolean ending) { + this.ending = ending; + if (this.ending) { + signalEnding = false; + } + } + + /** + * Returns whether the handler has been signaled to enable the "end" sound segment flag with the next call of + * {@link #advance()}. + *

+ * Note: The signal is cleared automatically when the ending flag has been set. + *

+ * + * @return {@code true} if signal has been set, {@code false} otherwise. + */ + public boolean isEndingSignaled() { + return signalEnding; + } + + /** + * Specifies whether to signal the handler to set the ending flag at the next call of {@link #advance()}. Does nothing + * if the ending flag has already been enabled. + *

+ * Note: The signal is cleared automatically when the ending flag has been set. + *

+ */ + public void setSignalEnding(boolean signal) { + if (signal != signalEnding && (!signal || !ending)) { + signalEnding = signal; + } + } + + /** + * Returns the sound segment index that was explicitly or implicitly passed to the constructor of this + * {@link MusResourceHandler} instance. + * + * @return initial sound segment index. + */ + public int getStartIndex() { + return startEntryIndex; + } + + /** + * Specify the initially selected sound segment index when resetting the handler instance. + * + * @param newIndex index value. + */ + public void setStartIndex(int newIndex) { + startEntryIndex = Math.max(0, Math.min(entryList.size() - 1, newIndex)); + } + + /** + * Returns the index of the current sound segment. + * + * @return Index of current sound segment. Returns {@code -1} if {@link #advance()} was not yet called after constructing or + * resetting this {@link MusResourceHandler} instance. Returns {@link #size()} if no more sound segments are + * available. + */ + public int getCurrentIndex() { + return (currentEntryIndex == Integer.MIN_VALUE) ? -1 : currentEntryIndex; + } + + /** + * Specify the index for the currently selected sound segment. + * + * @param newIndex new sound segment index. Specify {@code -1} to reset advancement. Specify {@link #size()} to + * indicate that the soundtrack has ended. + * @throws IndexOutOfBoundsException if {@code newIndex} is out of bounds. + * @throws IllegalArgumentException if {@link #isEnding()} is set and the end buffer is {@code null}. + */ + public void setCurrentIndex(int newIndex) { + if (!validIndex(newIndex) && newIndex != -1 && newIndex != size()) { + throw new IndexOutOfBoundsException("index = " + newIndex); + } + if (isEnding() && newIndex != -1 && newIndex != size() && entryList.get(newIndex).getEndBuffer() == null) { + throw new IllegalArgumentException("end buffer is null for index = " + newIndex); + } + currentEntryIndex = (newIndex == -1) ? Integer.MIN_VALUE : newIndex; + } + + /** + * Returns the {@link Entry} instance of the current sound segment. + * + * @return {@link Entry} of the current segment if available, {@code null} otherwise. + */ + public Entry getCurrentEntry() { + if (validIndex(currentEntryIndex)) { + return entryList.get(currentEntryIndex); + } + return null; + } + + /** + * Returns the {@link Entry} instance at the specified position in the sound entry list. + * + * @param index Index of the entry instance. + * @return the {@link Entry} instance. + * @throws IndexOutOfBoundsException if the index is out of range. + */ + public Entry getEntry(int index) { + return entryList.get(index); + } + + /** + * Returns the audio buffer of the current sound segment. Returns the buffer of the "end" sound segment if + * {@link #isEnding()} is {@code true}. + * + * @return {@link AudioBuffer} of the current sound segment. Returns {@code null} if audio buffer is not available. + */ + public AudioBuffer getAudioBuffer() { + final Entry entry = getCurrentEntry(); + if (Objects.nonNull(entry)) { + return isEnding() ? entry.getEndBuffer() : entry.getAudioBuffer(); + } + return null; + } + + /** + * Advances MUS segment list to the next sound entry. Enables playback of "end" sound segment if + * {@link #isAllowEnding()} is set and the last sound segment was selected by a previous call of {@code #advance()}. + * + * @return {@code true} if the soundtrack could be advanced to the next segment, {@code false} otherwise. + */ + public boolean advance() { + if (!hasNextEntry()) { + currentEntryIndex = size(); + } else if (currentEntryIndex == Integer.MIN_VALUE) { + currentEntryIndex = startEntryIndex; + } else if (validIndex(currentEntryIndex)) { + if (signalEnding) { + if (entryList.get(currentEntryIndex).getEndBuffer() != null) { + ending = true; + } else { + currentEntryIndex = size(); + } + } else { + // advance to next sound segment or switch to end segment if needed + final int index = entryList.get(currentEntryIndex).getNextNr(); + if ((!isLooping() && index <= currentEntryIndex) || !validIndex(index)) { + if (isAllowEnding() && !isEnding()) { + // switch to "end" segment after processing the last regular sound segment + ending = true; + } else { + currentEntryIndex = size(); + } + } else { + currentEntryIndex = index; + } + } + } else { + // no further sound segments available + currentEntryIndex = size(); + } + + signalEnding = false; + + return validIndex(currentEntryIndex); + } + + /** + * Resets the playback position back to the index that was explicitly or implicitly passed to the constructor. + */ + public void reset() { + currentEntryIndex = Integer.MIN_VALUE; + ending = false; + } + + /** Releases all sound segment resources. */ + @Override + public void close() throws Exception { + ending = false; + currentEntryIndex = 0; + for (int i = entryList.size() - 1; i >= 0; i--) { + final Entry entry = entryList.get(i); + entry.close(); + entryList.remove(i); + } + } + + /** Returns {@code true} only if {@code index} is a valid list index. */ + private boolean validIndex(int index) { + return (index >= 0 && index < entryList.size()); + } + + /** Initializes the MusResourceHandler instance. */ + private void init(Collection entries, int startIndex, boolean allowEnding, boolean looping) { + if (Objects.isNull(entries)) { + throw new NullPointerException("MUS entry list is null"); + } + + entryList = new ArrayList<>(entries); + currentEntryIndex = Integer.MIN_VALUE; + setStartIndex(startIndex); + setAllowEnding(allowEnding); + setEnding(false); + setLooping(looping); + } + + /** + * Creates a parsed list of sound entries from the specified MUS resource. + * + * @param resource MUS resource as {@link ResourceEntry} instance. + * @return List of MUS {@link Entry} objects for each of the parsed MUS sound segments. + * @throws NullPointerException if {@code resource} is {@code null}. + * @throws Exception if the MUS resource could not be parsed. + */ + public static List parseMusFile(ResourceEntry resource) throws Exception { + Objects.requireNonNull(resource); + List retVal = new ArrayList<>(); + + final ByteBuffer bb = resource.getResourceBuffer(); + final String[] lines = StreamUtils.readString(bb, bb.limit()).split("\r?\n"); + int idx = 0; + + String acmFolder = null; + while (acmFolder == null && idx < lines.length) { + final String s = getNormalizedString(lines[idx++]); + if (!s.isEmpty()) { + acmFolder = s; + } + } + + int numEntries = 0; + while (idx < lines.length) { + final String s = getNormalizedString(lines[idx++]); + if (!s.isEmpty()) { + numEntries = Integer.parseInt(s); + break; + } + } + + int counter = 0; + while (idx < lines.length && counter < numEntries) { + String line = getNormalizedString(lines[idx++]); + if (!line.isEmpty()) { + retVal.add(new Entry(resource, acmFolder, retVal, line, counter)); + counter++; + } + } + + if (counter != numEntries) { + Logger.warn("{}: Unexpected number of parsed sound segments (found: {}, expected: {})", resource, counter, + numEntries); + } + + for (final Entry entry : retVal) { + entry.init(); + } + + return retVal; + } + + private static String getNormalizedString(String s) { + if (s == null) { + return ""; + } + String retVal = s; + final int pos = retVal.indexOf('#'); + if (pos >= 0) { + retVal = retVal.substring(0, pos); + } + retVal = retVal.trim(); + return retVal; + } +} diff --git a/src/org/infinity/resource/mus/Viewer.java b/src/org/infinity/resource/mus/Viewer.java index 8ffe83718..f31762fc1 100644 --- a/src/org/infinity/resource/mus/Viewer.java +++ b/src/org/infinity/resource/mus/Viewer.java @@ -5,56 +5,58 @@ package org.infinity.resource.mus; import java.awt.BorderLayout; +import java.awt.Dimension; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; -import java.awt.GridLayout; import java.awt.Insets; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; -import java.util.HashMap; -import java.util.List; -import java.util.StringTokenizer; -import java.util.Vector; import javax.swing.BorderFactory; -import javax.swing.Icon; +import javax.swing.ImageIcon; import javax.swing.JButton; import javax.swing.JLabel; import javax.swing.JList; import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JScrollPane; +import javax.swing.SwingConstants; import javax.swing.SwingWorker; import javax.swing.UIManager; +import org.infinity.gui.ViewerUtil; import org.infinity.gui.menu.BrowserMenuBar; import org.infinity.icon.Icons; -import org.infinity.resource.sound.AudioPlayer; +import org.infinity.resource.sound.AudioStateEvent; +import org.infinity.resource.sound.AudioStateListener; +import org.infinity.resource.sound.StreamingAudioPlayer; import org.infinity.util.Logger; import org.infinity.util.Misc; import org.infinity.util.SimpleListModel; +import org.infinity.util.StopWatch; -public class Viewer extends JPanel implements Runnable, ActionListener { - /** Provides quick access to the "play" and "pause" image icon. */ - private static final HashMap PLAY_ICONS = new HashMap<>(); +public class Viewer extends JPanel implements ActionListener, AudioStateListener { + private static final ImageIcon ICON_PLAY = Icons.ICON_PLAY_16.getIcon(); + private static final ImageIcon ICON_PAUSE = Icons.ICON_PAUSE_16.getIcon(); + private static final ImageIcon ICON_END = Icons.ICON_END_16.getIcon(); + private static final ImageIcon ICON_STOP = Icons.ICON_STOP_16.getIcon(); - static { - PLAY_ICONS.put(true, Icons.ICON_PLAY_16.getIcon()); - PLAY_ICONS.put(false, Icons.ICON_PAUSE_16.getIcon()); - } + /** Display format of elapsed time (minutes, seconds) */ + private static final String DISPLAY_TIME_FORMAT = "Elapsed time: %02d:%02d"; private final SimpleListModel listModel = new SimpleListModel<>(); private final JList list = new JList<>(listModel); - private final AudioPlayer player = new AudioPlayer(); - private final List entryList = new Vector<>(); + private final StopWatch elapsedTimer = new StopWatch(1000L, false); + private MusResourceHandler musHandler; + private StreamingAudioPlayer player; private JLabel playList; private JButton bPlay; private JButton bEnd; private JButton bStop; - private boolean play= false; - private boolean end = false; - private boolean closed = false; + private JLabel displayLabel; + + private boolean closed; public Viewer(MusResource mus) { initGUI(); @@ -65,22 +67,30 @@ public Viewer(MusResource mus) { @Override public void actionPerformed(ActionEvent event) { - if (event.getSource() == bPlay) { - if (player == null || !player.isRunning()) { - new Thread(this).start(); - } else if (player.isRunning()) { - setPlayButtonState(player.isPaused()); + if (event.getSource() == elapsedTimer) { + updateTimeLabel(); + } else if (event.getSource() == bPlay) { + if (player == null) { + try { + player = new StreamingAudioPlayer(this); + } catch (Exception e) { + updateControls(); + Logger.error(e); + JOptionPane.showMessageDialog(this, "Error during playback:\n" + e.getMessage(), "Error", + JOptionPane.ERROR_MESSAGE); + } + } + if (player.isPlaying()) { player.setPaused(!player.isPaused()); + } else { + musHandler.setStartIndex(list.getSelectedIndex()); + player.setPlaying(true); } } else if (event.getSource() == bStop) { - bStop.setEnabled(false); - bEnd.setEnabled(false); - setPlayButtonState(false); - play = false; - player.stopPlay(); + player.setPlaying(false); } else if (event.getSource() == bEnd) { - bEnd.setEnabled(false); - end = true; + musHandler.setSignalEnding(true); + updateControls(); } } @@ -89,60 +99,54 @@ public void actionPerformed(ActionEvent event) { // --------------------- Begin Interface Runnable --------------------- @Override - public void run() { - setPlayButtonState(true); - bStop.setEnabled(true); - bEnd.setEnabled(true); - list.setEnabled(false); - int nextnr = list.getSelectedIndex(); - if (nextnr == -1) { - nextnr = 0; - } - play = true; - end = false; - try { - while (play) { - if (!end) { - list.setSelectedIndex(nextnr); - list.ensureIndexIsVisible(nextnr); - list.repaint(); - player.playContinuous(entryList.get(nextnr).getAudioBuffer()); - } else if (entryList.get(nextnr).getEndBuffer() != null) { - player.play(entryList.get(nextnr).getEndBuffer()); - play = false; - } - if (!end) { - nextnr = entryList.get(nextnr).getNextNr(); - if (nextnr == -1 || nextnr == entryList.size()) { - play = false; - } - } - } - } catch (Exception e) { - JOptionPane.showMessageDialog(this, "Error during playback", "Error", JOptionPane.ERROR_MESSAGE); - Logger.error(e); + public void audioStateChanged(AudioStateEvent event) { +// Logger.trace("{}.audioStateChanged({})", Viewer.class.getName(), event); + switch (event.getAudioState()) { + case OPEN: + handleAudioOpenEvent(event.getValue()); + break; + case CLOSE: + handleAudioCloseEvent(event.getValue()); + break; + case START: + handleAudioStartEvent(); + break; + case STOP: + handleAudioStopEvent(); + break; + case PAUSE: + handleAudioPauseEvent(event.getValue()); + break; + case RESUME: + handleAudioResumeEvent(event.getValue()); + break; + case BUFFER_EMPTY: + handleAudioBufferEmptyEvent(event.getValue()); + break; + case ERROR: + handleAudioErrorEvent(event.getValue()); + break; } - player.stopPlay(); - setPlayButtonState(false); - bStop.setEnabled(false); - bEnd.setEnabled(false); - list.setEnabled(true); - list.setSelectedIndex(0); - list.ensureIndexIsVisible(0); } // --------------------- End Interface Runnable --------------------- + /** Closes the MUS resource viewer and all releases resource. */ public void close() { - setClosed(true); - stopPlay(); - for (final Entry entry : entryList) { - entry.close(); + closed = true; + resetPlayer(); + try { + musHandler.close(); + } catch (Exception e) { + Logger.error(e); } - entryList.clear(); + updateControls(); } - // Creates a new music list and loads all associated soundtracks + /** + * Creates a new music list and loads all associated soundtracks. Load operation is performed in a background task to + * prevent the UI from blocking. + */ public void loadMusResource(final MusResource mus) { if (mus != null) { // Parse and load soundtracks in a separate thread @@ -155,126 +159,220 @@ public Boolean doInBackground() { } } + /** Parses the specified {@link MusResource} instances for playback. */ private boolean parseMusFile(MusResource mus) { if (!isClosed()) { - stopPlay(); + resetPlayer(); bPlay.setEnabled(false); list.setEnabled(false); - StringTokenizer tokenizer = new StringTokenizer(mus.getText(), "\r\n"); - String dir = getNextToken(tokenizer, true); listModel.clear(); - entryList.clear(); - int count = Integer.parseInt(getNextToken(tokenizer, true)); - for (int i = 0; i < count; i++) { - if (isClosed()) { - return false; - } - Entry entry = new Entry(mus.getResourceEntry(), dir, entryList, getNextToken(tokenizer, true), i); - entryList.add(entry); - listModel.addElement(entry); - } - list.setSelectedIndex(0); - validate(); - - for (final Entry entry : entryList) { - if (isClosed()) { - return false; + try { + musHandler = new MusResourceHandler(mus.getResourceEntry(), 0, false, true); + for (int i = 0, size = musHandler.size(); i < size; i++) { + listModel.add(musHandler.getEntry(i)); } - try { - entry.init(); - } catch (Exception e) { - Logger.error(e); - JOptionPane.showMessageDialog(getTopLevelAncestor(), - "Error loading " + entry.toString() + '\n' + e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); - } - } - - boolean enable = (!entryList.isEmpty() && entryList.get(0).getAudioBuffer() != null); - bPlay.setEnabled(enable); - list.setEnabled(enable); - return true; - } - return false; - } - - /** - * Returns the next valid token from the given {@code StringTokenizer}. - * - * @param tokenizer {@link StringTokenizer} containing string tokens. - * @param ignoreComments Whether comments should be skipped. - * @return The next string token if available, an empty string otherwise. - */ - private String getNextToken(StringTokenizer tokenizer, boolean ignoreComments) { - String retVal = ""; - while (tokenizer != null && tokenizer.hasMoreTokens()) { - retVal = tokenizer.nextToken().trim(); - if (!ignoreComments || !retVal.startsWith("#")) { - break; + list.setSelectedIndex(0); + validate(); + } catch (Exception e) { + Logger.error(e); + JOptionPane.showMessageDialog(getTopLevelAncestor(), + "Error loading " + mus.getResourceEntry() + ":\n" + e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); } + updateControls(); } - return retVal; + return !isClosed(); } + /** Sets up the UI of the viewer. */ private void initGUI() { bPlay = new JButton(); - setPlayButtonState(false); bPlay.addActionListener(this); - bEnd = new JButton("Finish", Icons.ICON_END_16.getIcon()); - bEnd.setEnabled(false); + + // prevent Play button state change from affecting the overall layout + setPlayButtonState(true); + int minWidth = bPlay.getPreferredSize().width; + setPlayButtonState(false); + minWidth = Math.max(minWidth, bPlay.getPreferredSize().width); + bPlay.setPreferredSize(new Dimension(minWidth, bPlay.getPreferredSize().height)); + + bEnd = new JButton("Finish", ICON_END); bEnd.addActionListener(this); - bStop = new JButton("Stop", Icons.ICON_STOP_16.getIcon()); - bStop.setEnabled(false); + + bStop = new JButton("Stop", ICON_STOP); bStop.addActionListener(this); - JPanel buttonPanel = new JPanel(new GridLayout(1, 0, 6, 0)); - buttonPanel.add(bPlay); - buttonPanel.add(bEnd); - buttonPanel.add(bStop); + displayLabel = new JLabel("", SwingConstants.LEADING); + updateTimeLabel(0L); - list.setEnabled(false); list.setBorder(BorderFactory.createLineBorder(UIManager.getColor("controlShadow"))); list.setFont(Misc.getScaledFont(BrowserMenuBar.getInstance().getOptions().getScriptFont())); + JScrollPane listScroll = new JScrollPane(list); + playList = new JLabel("Playlist:"); - JScrollPane scroll = new JScrollPane(list); - JPanel centerPanel = new JPanel(); - GridBagLayout gbl = new GridBagLayout(); - GridBagConstraints gbc = new GridBagConstraints(); - centerPanel.setLayout(gbl); - gbc.insets = new Insets(3, 3, 3, 3); - gbc.gridwidth = GridBagConstraints.REMAINDER; - gbc.fill = GridBagConstraints.HORIZONTAL; - gbl.setConstraints(playList, gbc); - centerPanel.add(playList); - gbl.setConstraints(scroll, gbc); - centerPanel.add(scroll); - gbl.setConstraints(buttonPanel, gbc); - centerPanel.add(buttonPanel); + elapsedTimer.addActionListener(this); + + final GridBagConstraints gbc = new GridBagConstraints(); + + final JPanel buttonPanel = new JPanel(new GridBagLayout()); + ViewerUtil.setGBC(gbc, 0, 0, 1, 1, 1.0, 0.0, GridBagConstraints.LINE_START, GridBagConstraints.HORIZONTAL, + new Insets(0, 0, 0, 0), 0, 0); + buttonPanel.add(bPlay, gbc); + ViewerUtil.setGBC(gbc, 1, 0, 1, 1, 1.0, 0.0, GridBagConstraints.LINE_START, GridBagConstraints.HORIZONTAL, + new Insets(0, 8, 0, 0), 0, 0); + buttonPanel.add(bEnd, gbc); + ViewerUtil.setGBC(gbc, 2, 0, 1, 1, 1.0, 0.0, GridBagConstraints.LINE_START, GridBagConstraints.HORIZONTAL, + new Insets(0, 8, 0, 0), 0, 0); + buttonPanel.add(bStop, gbc); + + final JPanel centerPanel = new JPanel(new GridBagLayout()); + ViewerUtil.setGBC(gbc, 0, 0, 1, 1, 0.0, 0.0, GridBagConstraints.LINE_START, GridBagConstraints.HORIZONTAL, + new Insets(0, 0, 0, 0), 0, 0); + centerPanel.add(playList, gbc); + ViewerUtil.setGBC(gbc, 0, 1, 1, 1, 0.0, 0.0, GridBagConstraints.LINE_START, GridBagConstraints.BOTH, + new Insets(8, 0, 0, 0), 0, 0); + centerPanel.add(listScroll, gbc); + ViewerUtil.setGBC(gbc, 0, 2, 1, 1, 0.0, 0.0, GridBagConstraints.LINE_START, GridBagConstraints.HORIZONTAL, + new Insets(4, 0, 0, 0), 0, 0); + centerPanel.add(displayLabel, gbc); + ViewerUtil.setGBC(gbc, 0, 3, 1, 1, 0.0, 0.0, GridBagConstraints.LINE_START, GridBagConstraints.HORIZONTAL, + new Insets(8, 0, 0, 0), 0, 0); + centerPanel.add(buttonPanel, gbc); + + // default list height is rather small + final Dimension dim = listScroll.getPreferredSize(); + dim.height *= 2; + listScroll.setPreferredSize(dim); setLayout(new BorderLayout()); add(centerPanel, BorderLayout.CENTER); + + updateControls(); + } + + /** Called when the audio player triggers a {@code OPEN} event. */ + private void handleAudioOpenEvent(Object value) { + musHandler.reset(); + } + + /** Called when the audio player triggers a {@code CLOSE} event. */ + private void handleAudioCloseEvent(Object value) { + // nothing to do + } + + /** Called when the audio player triggers a {@code START} event. */ + private void handleAudioStartEvent() { + elapsedTimer.reset(); + elapsedTimer.resume(); + updateTimeLabel(); + updateControls(); + } + + /** Called when the audio player triggers a {@code STOP} event. */ + private void handleAudioStopEvent() { + if (player == null) { + return; + } + + player.clearAudioQueue(); + elapsedTimer.pause(); + elapsedTimer.reset(); + updateTimeLabel(); + updateControls(); + list.setSelectedIndex(0); + list.ensureIndexIsVisible(0); + musHandler.reset(); + } + + /** Called when the audio player triggers a {@code PAUSE} event. */ + private void handleAudioPauseEvent(Object value) { + elapsedTimer.pause(); + setPlayButtonState(false); } - public void stopPlay() { + /** Called when the audio player triggers a {@code RESUME} event. */ + private void handleAudioResumeEvent(Object value) { + elapsedTimer.resume(); + setPlayButtonState(true); + } + + /** Called when the audio player triggers a {@code BUFFER_EMPTY} event. */ + private void handleAudioBufferEmptyEvent(Object value) { + if (player == null) { + return; + } + + if (musHandler.advance()) { + player.addAudioBuffer(musHandler.getAudioBuffer()); + list.setSelectedIndex(musHandler.getCurrentIndex()); + list.ensureIndexIsVisible(musHandler.getCurrentIndex()); + } else { + player.setPlaying(false); + } + } + + /** Called when the audio player triggers an {@code ERROR} event. */ + private void handleAudioErrorEvent(Object value) { if (player != null) { - play = false; - player.stopPlay(); + player.setPlaying(false); } + final Exception e = (value instanceof Exception) ? (Exception)value : null; + if (e != null) { + Logger.error(e); + } + final String msg = (e != null) ? "Error during playback:\n" + e.getMessage() : "Error during playback."; + JOptionPane.showMessageDialog(this, msg, "Error", JOptionPane.ERROR_MESSAGE); } - private synchronized void setClosed(boolean b) { - if (b != closed) { - closed = b; + /** Closes the audio player and releases associated resources. */ + private void resetPlayer() { + if (player != null) { + try { + player.close(); + } catch (Exception e) { + Logger.debug(e); + } + player = null; } } - private synchronized boolean isClosed() { + /** Returns whether the MUS resource viewer has been closed. */ + private boolean isClosed() { return closed; } - // Sets icon and text for the Play button according to the specified parameter. + /** Updates the elapsed time label with the elapsed playback time. */ + private void updateTimeLabel() { + updateTimeLabel(elapsedTimer.elapsed()); + } + + /** Updates the elapsed time label with the specified time value. */ + private void updateTimeLabel(long millis) { + final long minutes = millis / 60_000L; + final long seconds = (millis / 1000L) % 60L; + displayLabel.setText(String.format(DISPLAY_TIME_FORMAT, minutes, seconds)); + } + + /** Updates audio controls depending on current playback state. */ + private void updateControls() { + if (musHandler != null && player != null && player.isPlaying()) { + setPlayButtonState(!player.isPaused()); + bPlay.setEnabled(true); + bEnd.setEnabled(!musHandler.isEndingSignaled() && !musHandler.isEnding()); + bStop.setEnabled(true); + list.setEnabled(false); + } else { + setPlayButtonState(false); + bPlay.setEnabled(!listModel.isEmpty()); + bEnd.setEnabled(false); + bStop.setEnabled(false); + list.setEnabled(!listModel.isEmpty()); + } + } + + /** Sets icon and text for the Play button according to the specified parameter. */ private void setPlayButtonState(boolean paused) { - bPlay.setIcon(PLAY_ICONS.get(!paused)); + bPlay.setIcon(paused ? ICON_PAUSE : ICON_PLAY); bPlay.setText(paused ? "Pause" : "Play"); } } diff --git a/src/org/infinity/resource/sound/AbstractAudioPlayer.java b/src/org/infinity/resource/sound/AbstractAudioPlayer.java index 977dc0fbf..c549e3ab0 100644 --- a/src/org/infinity/resource/sound/AbstractAudioPlayer.java +++ b/src/org/infinity/resource/sound/AbstractAudioPlayer.java @@ -4,11 +4,17 @@ package org.infinity.resource.sound; +import javax.sound.sampled.AudioFormat; +import javax.sound.sampled.AudioSystem; import javax.sound.sampled.Line; import javax.sound.sampled.LineListener; +import javax.sound.sampled.Mixer; +import javax.sound.sampled.SourceDataLine; import javax.swing.SwingUtilities; import javax.swing.event.EventListenerList; +import org.infinity.util.Logger; + /** * Common base for audio player classes. */ @@ -44,13 +50,19 @@ protected void fireAudioStateEvent(AudioStateEvent.State state, Object value) { if (listeners.length > 0) { final AudioStateEvent event = new AudioStateEvent(this, state, value); SwingUtilities.invokeLater(() -> { - for (int i = 0; i < listeners.length; i++) - listeners[i].audioStateChanged(event); + for (AudioStateListener listener : listeners) { + listener.audioStateChanged(event); + } }); } } } + /** Fires if an unrecoverable error occurs during audio playback. */ + protected void fireError(Exception e) { + fireAudioStateEvent(AudioStateEvent.State.ERROR, e); + } + /** Fires when the the audio device is opened. */ protected void firePlayerOpened() { fireAudioStateEvent(AudioStateEvent.State.OPEN, null); @@ -73,12 +85,12 @@ protected void firePlaybackStopped() { /** Fires when playback is set to paused mode. */ protected void firePlaybackPaused() { - fireAudioStateEvent(AudioStateEvent.State.PAUSE, Long.valueOf(getElapsedTime())); + fireAudioStateEvent(AudioStateEvent.State.PAUSE, getSoundPosition()); } /** Fires when paused playback is resumed. */ protected void firePlaybackResumed() { - fireAudioStateEvent(AudioStateEvent.State.RESUME, Long.valueOf(getElapsedTime())); + fireAudioStateEvent(AudioStateEvent.State.RESUME, getSoundPosition()); } /** @@ -95,6 +107,11 @@ protected boolean isLineListenersEnabled() { /** Specifies whether {@link Line}'s status changes should be tracked by this class instance. */ protected void setLineListenersEnabled(boolean enable) { + if (getLine() == null) { + listenersEnabled = false; + return; + } + if (enable != listenersEnabled) { if (enable) { getLine().addLineListener(this); @@ -105,4 +122,32 @@ protected void setLineListenersEnabled(boolean enable) { } } + /** + * Diagnostic method that prints all available sound mixers and their supported audio formats to {@code stdout}. + */ + public static void printMixerInfo() { + final StringBuilder sb = new StringBuilder("Available sound mixers:\n"); + try { + final Mixer.Info[] mixersInfo = AudioSystem.getMixerInfo(); + for (final Mixer.Info mi : mixersInfo) { + final Mixer mixer = AudioSystem.getMixer(mi); + sb.append(" Mixer: ").append(mixer).append('\n'); + Line.Info[] sourceLinesInfo = mixer.getSourceLineInfo(); + for (final Line.Info sli : sourceLinesInfo) { + sb.append(" Line: ").append(sli).append('\n'); + if (sli instanceof SourceDataLine.Info) { + final SourceDataLine.Info info = (SourceDataLine.Info)sli; + final AudioFormat[] formats = info.getFormats(); + for (final AudioFormat af : formats) { + sb.append(" Format: ").append(af).append('\n'); + } + } + } + } + System.out.println(sb); + } catch (Throwable t) { + System.out.println(sb); + Logger.warn(t); + } + } } diff --git a/src/org/infinity/resource/sound/AudioPlayback.java b/src/org/infinity/resource/sound/AudioPlayback.java index bc477cdb4..12664b7c9 100644 --- a/src/org/infinity/resource/sound/AudioPlayback.java +++ b/src/org/infinity/resource/sound/AudioPlayback.java @@ -12,10 +12,14 @@ public interface AudioPlayback extends Closeable { /** * Returns the elapsed playback time of audio data. + *

+ * Caution: Depending on the implementation, return value may be inaccurate if source and target + * audio formats specify different sample rates. + *

* * @return Elapsed playback time, in milliseconds. */ - long getElapsedTime(); + long getSoundPosition(); /** * Returns whether playback is active. Pausing and resuming playback does not affect the result. diff --git a/src/org/infinity/resource/sound/AudioPlayer.java b/src/org/infinity/resource/sound/AudioPlayer.java index 9e76f3e5e..ccce637bc 100644 --- a/src/org/infinity/resource/sound/AudioPlayer.java +++ b/src/org/infinity/resource/sound/AudioPlayer.java @@ -15,10 +15,14 @@ import org.infinity.util.Logger; +// TODO: remove class from project /** * This class provides a conventional way to play back sound data. It is mostly suited for playing streamed sound data. * For playback of single sound clips the {@link BufferedAudioPlayer} class is more suited. + * + * @deprecated Superseded by {@link StreamingAudioPlayer} and {@link BufferedAudioPlayer}. */ +@Deprecated public class AudioPlayer { private final byte[] buffer = new byte[8196]; diff --git a/src/org/infinity/resource/sound/AudioStateEvent.java b/src/org/infinity/resource/sound/AudioStateEvent.java index 0469ea33b..58649c4dd 100644 --- a/src/org/infinity/resource/sound/AudioStateEvent.java +++ b/src/org/infinity/resource/sound/AudioStateEvent.java @@ -16,6 +16,8 @@ public class AudioStateEvent extends EventObject { /** Provides available audio states. */ public enum State { + /** An unrecoverable error was triggered during audio playback. Associated value: the thrown {@link Exception}. */ + ERROR, /** * A sound resource has been successfully opened and is ready for playback. Associated value: Sound resource name * {@link String}) if available, {@code null} otherwise. @@ -26,7 +28,10 @@ public enum State { * {@code null} otherwise. */ CLOSE, - /** Playback of the current sound clip has started from the beginning. Associated value: {@code null} */ + /** + * Playback of the current sound clip has started from the beginning. Associated value: {@code null} + *

Note: This state is only triggered if the audio line starts processing actual audio data.

+ */ START, /** Playback of the current sound clip has stopped. Associated value: {@code null} */ STOP, @@ -34,10 +39,15 @@ public enum State { PAUSE, /** Paused playback is resumed. Associated value: Elapsed time in milliseconds ({@link Long}) */ RESUME, + /** + * Streamed audio playback only: The current audio buffer contains no more data. Associated value: {@link Boolean} + * that indicates whether more audio buffers are queued. + */ + BUFFER_EMPTY, } - private AudioStateEvent.State audioState; - private Object value; + private final AudioStateEvent.State audioState; + private final Object value; public AudioStateEvent(Object source, AudioStateEvent.State audioState, Object value) { super(source); @@ -56,10 +66,9 @@ public Object getValue() { } public String toString() { - StringBuilder sb = new StringBuilder(getClass().getName()); - sb.append("[audioState=").append(getAudioState()); - sb.append("; value=").append(getValue()); - sb.append("; source=").append(getSource()); - return sb.append("]").toString(); + return getClass().getName() + "[audioState=" + getAudioState() + + "; value=" + getValue() + + "; source=" + getSource() + + "]"; } -} \ No newline at end of file +} diff --git a/src/org/infinity/resource/sound/AudioStateListener.java b/src/org/infinity/resource/sound/AudioStateListener.java index 67de00224..7398a8f80 100644 --- a/src/org/infinity/resource/sound/AudioStateListener.java +++ b/src/org/infinity/resource/sound/AudioStateListener.java @@ -17,4 +17,4 @@ public interface AudioStateListener extends EventListener { * @param event a {@link AudioStateEvent} that describes the changed state. */ void audioStateChanged(AudioStateEvent event); -} \ No newline at end of file +} diff --git a/src/org/infinity/resource/sound/BufferedAudioPlayback.java b/src/org/infinity/resource/sound/BufferedAudioPlayback.java index 2b59bb08a..72bb95fed 100644 --- a/src/org/infinity/resource/sound/BufferedAudioPlayback.java +++ b/src/org/infinity/resource/sound/BufferedAudioPlayback.java @@ -17,6 +17,10 @@ public interface BufferedAudioPlayback extends AudioPlayback { /** * Sets an explicit playback position. + *

+ * Caution: Depending on the implementation, specified value may point to the wrong position if + * source and target audio formats specify different sample rates. + *

* * @param position New playback position in milliseconds. Position is clamped to the available audio clip duration. */ diff --git a/src/org/infinity/resource/sound/BufferedAudioPlayer.java b/src/org/infinity/resource/sound/BufferedAudioPlayer.java index f15baa4fe..dc51a4ef0 100644 --- a/src/org/infinity/resource/sound/BufferedAudioPlayer.java +++ b/src/org/infinity/resource/sound/BufferedAudioPlayer.java @@ -39,6 +39,8 @@ public class BufferedAudioPlayer extends AbstractAudioPlayer implements Buffered private boolean paused; /** Indicates whether playback is looped. */ private boolean looped; + /** Marks pause/resume state changes internally to fire correct state events. */ + private boolean pauseResume; /** * Creates a new audio player and initializes it with the specified audio buffer. @@ -82,16 +84,16 @@ public long getTotalLength() { if (isClosed()) { return 0L; } - return audioClip.getMicrosecondLength() / 1_000L; + return audioClip.getMicrosecondLength() / 1000L; } @Override - public long getElapsedTime() { + public long getSoundPosition() { if (isClosed()) { return 0L; } long position = audioClip.getMicrosecondPosition() % audioClip.getMicrosecondLength(); - return position / 1_000L; + return position / 1000L; } @Override @@ -100,7 +102,7 @@ public void setSoundPosition(long position) { return; } - position = Math.max(0L, Math.min(audioClip.getMicrosecondLength(), position * 1_000L)); + position = Math.max(0L, Math.min(audioClip.getMicrosecondLength(), position * 1000L)); try { setLineListenersEnabled(false); @@ -197,12 +199,6 @@ public void close() throws Exception { playing = false; paused = false; - // removing listeners - final AudioStateListener[] items = getAudioStateListeners(); - for (int i = items.length - 1; i >= 0; i--) { - removeAudioStateListener(items[i]); - } - synchronized (audioClip) { if (audioClip.isRunning()) { audioClip.stop(); @@ -210,21 +206,37 @@ public void close() throws Exception { audioClip.flush(); audioClip.close(); } + try { audioStream.close(); } catch (IOException e) { Logger.warn(e); } audioStream = null; + + // removing listeners + final AudioStateListener[] items = getAudioStateListeners(); + for (int i = items.length - 1; i >= 0; i--) { + removeAudioStateListener(items[i]); + } } @Override public void update(LineEvent event) { if (event.getType() == LineEvent.Type.START) { - firePlaybackStarted(); + if (!pauseResume) { + firePlaybackStarted(); + } else { + pauseResume = false; + } } else if (event.getType() == LineEvent.Type.STOP) { - playing = paused; // override if paused to keep state consistent - firePlaybackStopped(); + if (!pauseResume) { + playing = false; + firePlaybackStopped(); + } else { + playing = true; // override if paused to keep state consistent + pauseResume = false; + } } else if (event.getType() == LineEvent.Type.OPEN) { firePlayerOpened(); } else if (event.getType() == LineEvent.Type.CLOSE) { @@ -301,6 +313,7 @@ private void pause() { synchronized (audioClip) { if (isPlaying() && !isPaused()) { + pauseResume = true; paused = true; audioClip.stop(); firePlaybackPaused(); @@ -319,6 +332,7 @@ private void resume() { synchronized (audioClip) { if (isPlaying() && isPaused()) { + pauseResume = true; paused = false; audioClip.start(); setLooped(); diff --git a/src/org/infinity/resource/sound/EmptyQueueException.java b/src/org/infinity/resource/sound/EmptyQueueException.java new file mode 100644 index 000000000..1fa59beec --- /dev/null +++ b/src/org/infinity/resource/sound/EmptyQueueException.java @@ -0,0 +1,43 @@ +// Near Infinity - An Infinity Engine Browser and Editor +// Copyright (C) 2001 Jon Olav Hauglid +// See LICENSE.txt for license information + +package org.infinity.resource.sound; + +/** + * An I/O exception that is thrown if the audio queue does not contain any more elements. + */ +public class EmptyQueueException extends Exception { + /** Constructs an {@code EmptyQueueException} with no detail message. */ + public EmptyQueueException() { + super(); + } + + /** + * Constructs an {@code EmptyQueueException} with the specified detail message. + * + * @param message the detail message. + */ + public EmptyQueueException(String message) { + super(message); + } + + /** + * Constructs an {@code EmptyQueueException} with the specified detail message and cause. + * + * @param message the detail message. + * @param cause the cause for this exception to be thrown. + */ + public EmptyQueueException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs an {@code EmptyQueueException} with a cause but no detail message. + * + * @param cause the cause for this exception to be thrown. + */ + public EmptyQueueException(Throwable cause) { + super(cause); + } +} diff --git a/src/org/infinity/resource/sound/StreamingAudioPlayback.java b/src/org/infinity/resource/sound/StreamingAudioPlayback.java new file mode 100644 index 000000000..ead229d5e --- /dev/null +++ b/src/org/infinity/resource/sound/StreamingAudioPlayback.java @@ -0,0 +1,38 @@ +// Near Infinity - An Infinity Engine Browser and Editor +// Copyright (C) 2001 Jon Olav Hauglid +// See LICENSE.txt for license information + +package org.infinity.resource.sound; + +/** + * Provides specialized playback functionality for streamed audio data. + */ +public interface StreamingAudioPlayback extends AudioPlayback { + /** + * Returns whether the audio queue is empty. Audio may still be available in the currently processed audio buffer. + * + * @return {@code true} if the audio queue is empty, {@code false} otherwise. + */ + boolean isAudioQueueEmpty(); + + /** Removes all remaining {@link AudioBuffer} instances in the audio queue. */ + void clearAudioQueue(); + + /** + * Adds more sound data to the audio queue. + * + * @param audioBuffer {@link AudioBuffer} to add. + * @throws NullPointerException if the {@code audioBuffer} argument or the associated audio data array is + * {@code null}. + * @throws IllegalArgumentException if the audio buffer contains no data. + */ + void addAudioBuffer(AudioBuffer audioBuffer); + + /** + * Removes a single instance of the specified {@link AudioBuffer} object if present in the audio queue. + * + * @param audioBuffer {@link AudioBuffer} object to be removed from the audio queue, if present. + * @return {@code true} if an element could be successfully removed, {@code false} otherwise. + */ + boolean removeAudioBuffer(AudioBuffer audioBuffer); +} diff --git a/src/org/infinity/resource/sound/StreamingAudioPlayer.java b/src/org/infinity/resource/sound/StreamingAudioPlayer.java new file mode 100644 index 000000000..0554bd80a --- /dev/null +++ b/src/org/infinity/resource/sound/StreamingAudioPlayer.java @@ -0,0 +1,505 @@ +// Near Infinity - An Infinity Engine Browser and Editor +// Copyright (C) 2001 Jon Olav Hauglid +// See LICENSE.txt for license information + +package org.infinity.resource.sound; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.sound.sampled.AudioFormat; +import javax.sound.sampled.AudioInputStream; +import javax.sound.sampled.AudioSystem; +import javax.sound.sampled.DataLine; +import javax.sound.sampled.LineEvent; +import javax.sound.sampled.LineUnavailableException; +import javax.sound.sampled.SourceDataLine; +import javax.sound.sampled.UnsupportedAudioFileException; +import javax.swing.SwingUtilities; + +import org.tinylog.Logger; + +/** + * A class for providing smooth playback of streamed audio data. + *

+ * None of the methods block execution. Playback state changes are propagated through {@link AudioStateEvent}s. + *

+ */ +public class StreamingAudioPlayer extends AbstractAudioPlayer implements StreamingAudioPlayback { + private final ConcurrentLinkedQueue audioBufferQueue = new ConcurrentLinkedQueue<>(); + private final Runner runner = new Runner(); + + private AudioFormat audioFormat; + private SourceDataLine dataLine; + private byte[] bufferBytes; + + private boolean closed; + private boolean playing; + private boolean paused; + + /** Marks pause/resume state changes internally to fire correct state events. */ + private boolean pauseResume; + + /** + * Creates a new audio player and initializes it with the specified audio buffers. + * + * @param audioBuffers Optional {@link AudioBuffer} instances to load into the playback queue. + * @throws NullPointerException if {@code audioBuffer} is {@code null}. + * @throws IOException if an I/O error occurs. + * @throws UnsupportedAudioFileException if the audio data is incompatible with the player. + * @throws LineUnavailableException if the audio line is not available due to resource restrictions. + * @throws IllegalArgumentException if the audio data is invalid. + */ + public StreamingAudioPlayer(AudioBuffer... audioBuffers) throws Exception { + this(null, audioBuffers); + } + + /** + * Creates a new audio player and initializes it with the specified audio buffers. + * + * @param listener {@link AudioStateListener} that receives audio state changes. + * @param audioBuffers Optional {@link AudioBuffer} instances to load into the playback queue. + * @throws NullPointerException if {@code audioBuffer} is {@code null}. + * @throws IOException if an I/O error occurs. + * @throws UnsupportedAudioFileException if the audio data is incompatible with the player. + * @throws LineUnavailableException if the audio line is not available due to resource restrictions. + * @throws IllegalArgumentException if the audio data is invalid. + */ + public StreamingAudioPlayer(AudioStateListener listener, AudioBuffer... audioBuffers) throws Exception { + super(); + addAudioStateListener(listener); + init(); + for (final AudioBuffer ab : audioBuffers) { + addAudioBuffer(ab); + } + } + + @Override + public boolean isAudioQueueEmpty() { + return audioBufferQueue.isEmpty(); + } + + @Override + public void clearAudioQueue() { + audioBufferQueue.clear(); + } + + @Override + public void addAudioBuffer(AudioBuffer audioBuffer) { + if (audioBuffer == null) { + throw new NullPointerException("audioBuffer is null"); + } + if (audioBuffer.getAudioData() == null) { + throw new NullPointerException("audio data is null"); + } + if (audioBuffer.getAudioData().length == 0) { + throw new IllegalArgumentException("No audio data"); + } + + audioBufferQueue.offer(audioBuffer); + runner.signalBufferAvailable(); + } + + @Override + public boolean removeAudioBuffer(AudioBuffer audioBuffer) { + return audioBufferQueue.remove(audioBuffer); + } + + // --------------------- Begin Interface AudioPlayback --------------------- + + @Override + public long getSoundPosition() { + if (isClosed() || dataLine == null) { + return 0L; + } + long position = dataLine.getMicrosecondPosition(); + return position / 1000L; + } + + @Override + public boolean isPlaying() { + return !isClosed() && playing; + } + + @Override + public void setPlaying(boolean play) { + if (isClosed()) { + return; + } + + if (play) { + play(); + } else { + stop(); + } + } + + @Override + public boolean isPaused() { + return isPlaying() && paused; + } + + @Override + public void setPaused(boolean pause) { + if (isClosed()) { + return; + } + + if (pause) { + pause(); + } else { + resume(); + } + } + + @Override + public boolean isClosed() { + return closed; + } + + // --------------------- End Interface AudioPlayback --------------------- + + // --------------------- Begin Interface Closeable --------------------- + + @Override + public void close() throws Exception { + if (isClosed()) { + return; + } + + closed = true; + playing = false; + paused = false; + + runner.close(); + dataLine.stop(); + dataLine.flush(); + dataLine.close(); + + dataLine = null; + audioFormat = null; + bufferBytes = null; + + // removing listeners + final AudioStateListener[] items = getAudioStateListeners(); + for (int i = items.length - 1; i >= 0; i--) { + removeAudioStateListener(items[i]); + } + } + + // --------------------- End Interface Closeable --------------------- + + // --------------------- Begin Interface LineListener --------------------- + + @Override + public void update(LineEvent event) { + if (event.getType() == LineEvent.Type.START) { + if (!pauseResume) { + firePlaybackStarted(); + } else { + pauseResume = false; + } + } else if (event.getType() == LineEvent.Type.STOP) { + if (!pauseResume) { + firePlaybackStopped(); + } else { + pauseResume = false; + } + } else if (event.getType() == LineEvent.Type.OPEN) { + firePlayerOpened(); + } else if (event.getType() == LineEvent.Type.CLOSE) { + firePlayerClosed(); + } + } + + // --------------------- End Interface LineListener --------------------- + + @Override + protected SourceDataLine getLine() { + return dataLine; + } + + /** Fires when the current audio buffer contains no more data. */ + protected void fireBufferEmpty() { + fireAudioStateEvent(AudioStateEvent.State.BUFFER_EMPTY, !isAudioQueueEmpty()); + } + + private void play() { + if (isClosed() || isPlaying()) { + return; + } + + if (dataLine.isRunning()) { + dataLine.stop(); + dataLine.flush(); + } + + playing = true; + paused = false; + dataLine.start(); + runner.signal(); + } + + private void stop() { + if (isClosed() || !isPlaying()) { + return; + } + + final boolean isPaused = isPaused(); + playing = false; + paused = false; + if (isPaused) { + // Pause mode is technically "stop" mode, so we need to trigger a "STOP" event manually + SwingUtilities + .invokeLater(() -> update(new LineEvent(dataLine, LineEvent.Type.STOP, dataLine.getLongFramePosition()))); + } else { + dataLine.stop(); + } + dataLine.flush(); + runner.signal(); + } + + private void pause() { + if (isClosed() || !isPlaying() || isPaused()) { + return; + } + + pauseResume = true; + paused = true; + dataLine.stop(); + firePlaybackPaused(); + } + + private void resume() { + if (isClosed() || !isPlaying() || !isPaused()) { + return; + } + + pauseResume = true; + paused = false; + dataLine.start(); + runner.signal(); + firePlaybackResumed(); + } + + /** + * Creates and returns a new {@link AudioInputStream} instance from the next available {@link AudioBuffer} in the + * audio queue. + * + * @return an {@link AudioInputStream} instance that can be fed directly to the source data line. + * @throws UnsupportedAudioFileException if the stream does not point to valid audio file data recognized by the + * system. + * @throws LineUnavailableException if a matching source data line is not available due to resource restrictions. + * @throws IllegalArgumentException if the audio buffer data is not compatible with the source data line. + * @throws EmptyQueueException if no further audio buffer is queued. + * @throws IOException if an I/O exception occurs. + */ + private AudioInputStream pollAudioBuffer() throws Exception { + final AudioBuffer audioBuffer = audioBufferQueue.poll(); + if (audioBuffer != null) { + final AudioInputStream sourceStream = AudioSystem.getAudioInputStream(new ByteArrayInputStream(audioBuffer.getAudioData())); + if (!AudioSystem.isConversionSupported(audioFormat, sourceStream.getFormat())) { + throw new IllegalArgumentException("Incompatible audio format: " + sourceStream.getFormat()); + } + return AudioSystem.getAudioInputStream(audioFormat, sourceStream); + } else { + throw new EmptyQueueException("No audio buffer available"); + } + } + + /** + * Initializes and opens the audio device for playback. + * + * @throws UnsupportedAudioFileException if the stream does not point to valid audio file data recognized by the + * system. + * @throws LineUnavailableException if a matching source data line is not available due to resource restrictions. + * @throws IllegalArgumentException if the system does not support at least one source data line supporting + * the specified audio format through any installed mixer. + * @throws IOException if an I/O exception occurs. + */ + private void init() throws Exception { + try { + audioFormat = getCompatibleAudioFormat(); + if (audioFormat == null) { + throw new LineUnavailableException("Could not find compatible audio format"); + } + + int bufferSize = getBufferSize(audioFormat, 100); + dataLine = AudioSystem.getSourceDataLine(audioFormat); + dataLine.addLineListener(this); + dataLine.open(audioFormat, bufferSize); + + // allocated buffer size may differ from requested buffer size + bufferSize = dataLine.getBufferSize(); + bufferBytes = new byte[bufferSize]; + } catch (Exception e) { + closed = true; + throw e; + } + } + + /** + * Attempts to find an audio format that can be universally used to play back streamed audio. + * + * @return {@code AudioFormat} definition that is compatible with the default {@link SourceDataLine}, {@code null} + * otherwise. + */ + private static AudioFormat getCompatibleAudioFormat() { + AudioFormat format = null; + + // arrays of sensible values, sorted by usability in descending order + final boolean[] bigEndianArray = { false, true }; + final AudioFormat.Encoding[] encodingArray = { AudioFormat.Encoding.PCM_SIGNED, AudioFormat.Encoding.PCM_UNSIGNED, + AudioFormat.Encoding.PCM_FLOAT }; + final int[] channelsArray = { 2, 1 }; + final int[] sampleBitsArray = { 16, 32, 8 }; + final float[] sampleRateArray = { 48000.0f, 44100.0f, 32000.0f, 24000.0f, 22050.0f }; + + // for-loops sorted by importance in ascending order (innermost loop: highest importance) + out: // label for outermost loop: saves us to put conditional breaks in any of the nested loops + for (final boolean bigEndian : bigEndianArray) { + for (final AudioFormat.Encoding encoding : encodingArray) { + for (final int channels : channelsArray) { + for (final int sampleBits : sampleBitsArray) { + for (final float sampleRate : sampleRateArray) { + final int frameSize = sampleBits * channels / 8; + final float frameRate = sampleRate * frameSize; + final AudioFormat f = new AudioFormat(encoding, sampleRate, sampleBits, channels, frameSize, frameRate, + bigEndian); + final DataLine.Info info = new DataLine.Info(SourceDataLine.class, f); + if (AudioSystem.isLineSupported(info)) { + format = f; + break out; + } + } + } + } + } + } + return format; + } + + /** + * Calculates the buffer size to hold {@code lagMs} millisecond worth of audio data. + * + * @param format {@link AudioFormat} of the data. + * @param lagMs Storage capacity of the internal audio buffer, in milliseconds. Supported range: 10 to 1000 + * @return Buffer size in bytes. Buffer size always represents an integral number of audio frames. Returns a default + * buffer size if the audio format could not be determined. + */ + private static int getBufferSize(AudioFormat format, int lagMs) { + int retVal = 0x4000; + + if (format != null) { + // allowed sound lag: 10..1000 ms + lagMs = Math.max(10, Math.min(1000, lagMs)); + int bufSize = (int)(format.getFrameRate() * format.getFrameSize() * lagMs / 1000.0f); + retVal = Math.max(0x100, Math.min(0x80000, bufSize)); + if (retVal % format.getFrameSize() != 0) { + retVal = (retVal / format.getFrameSize() * format.getFrameSize()) + format.getFrameSize(); + } + } + + return retVal; + } + + // -------------------------- INNER CLASSES -------------------------- + + /** + * Handles feeding audio data to the source line without blocking the main execution thread. + */ + private class Runner implements Runnable { + private final AtomicBoolean waitingForData = new AtomicBoolean(); + private final Thread thread; + + boolean running; + + public Runner() { + running = true; + thread = new Thread(this); + thread.start(); + } + + /** Returns whether the background task is active. */ + public boolean isRunning() { + return running; + } + + /** Signals the runner to terminate the background task. */ + public void close() { + running = false; + signal(); + } + + /** Specialized signal that is triggered only if the background task waits for more audio data to play back. */ + public void signalBufferAvailable() { + if (isRunning() && waitingForData.get()) { + signal(); + } + } + + /** Signals the background task to reevaluate state changes. */ + public void signal() { + if (isRunning()) { + thread.interrupt(); + } + } + + /** Puts the current thread to sleep until interrupted. */ + private void sleep() { + sleep(Long.MAX_VALUE); + } + + /** Puts the current thread to sleep until interrupted or the specified time has passed. */ + private void sleep(long millis) { + try { + Thread.sleep(Math.max(0, millis)); + } catch (InterruptedException e) { + // waking up + } + } + + @Override + public void run() { + while (isRunning()) { + if (isPlaying()) { + try (final AudioInputStream ais = pollAudioBuffer()) { + int readBytes = -1; + while (isPlaying() && (readBytes = ais.read(bufferBytes)) != -1) { + final int numWritten = dataLine.write(bufferBytes, 0, readBytes); + + if (isPaused()) { + sleep(); + if (isPlaying() && !isPaused() && numWritten < readBytes) { + // prevent clicks or "hickups" in audio playback + dataLine.write(bufferBytes, numWritten, readBytes - numWritten); + } + } + } + + if (readBytes == -1) { + fireBufferEmpty(); + } + } catch (EmptyQueueException e) { + // no audio queued + fireBufferEmpty(); + } catch (Exception e) { + fireError(e); + Logger.error(e); + } + + if (isPlaying() && !isPaused() && isAudioQueueEmpty()) { + // waiting for more audio data or a state change + waitingForData.set(true); + sleep(); + waitingForData.set(false); + } + } else { + sleep(); + } + } + } + } +}