SoundController refactoring.

- Added enum classes SoundEffect and HitSound.
- Added a SoundComponent interface for sound effects and hit sounds, and extended playSound() and playHitSound() methods to play any SoundComponent.
- Moved features related to sample sets to the HitSound class, and rewrote sample sets as an internal enum class.

Signed-off-by: Jeffrey Han <itdelatrisu@gmail.com>
This commit is contained in:
Jeffrey Han
2015-01-07 19:29:51 -05:00
parent 3127571886
commit 1e806bc9c6
17 changed files with 365 additions and 219 deletions

View File

@@ -0,0 +1,159 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu.audio;
import java.util.HashMap;
import javax.sound.sampled.Clip;
/**
* Hit sounds.
*/
public enum HitSound implements SoundController.SoundComponent {
CLAP ("hitclap"),
FINISH ("hitfinish"),
NORMAL ("hitnormal"),
WHISTLE ("hitwhistle"),
SLIDERSLIDE ("sliderslide"),
SLIDERTICK ("slidertick"),
SLIDERWHISTLE ("sliderwhistle");
/**
* Sound sample sets.
*/
public static enum SampleSet {
NORMAL ("normal", 1),
SOFT ("soft", 2),
DRUM ("drum", 3);
// TAIKO ("taiko", 4);
/**
* The sample set name.
*/
private String name;
/**
* The sample set index.
*/
private int index;
/**
* Total number of sample sets.
*/
public static final int SIZE = SampleSet.values().length;
/**
* Constructor.
* @param name the sample set name
*/
SampleSet(String name, int index) {
this.name = name;
this.index = index;
}
/**
* Returns the sample set name.
* @return the name
*/
public String getName() { return name; }
/**
* Returns the sample set index.
* @return the index
*/
public int getIndex() { return index; }
}
/**
* Current sample set.
*/
private static SampleSet currentSampleSet;
/**
* The file name.
*/
private String filename;
/**
* The Clip associated with the hit sound.
*/
private HashMap<SampleSet, Clip> clips;
/**
* Total number of hit sounds.
*/
public static final int SIZE = HitSound.values().length;
/**
* Constructor.
* @param filename the sound file name
*/
HitSound(String filename) {
this.filename = filename;
this.clips = new HashMap<SampleSet, Clip>();
}
/**
* Returns the file name.
* @return the file name
*/
public String getFileName() { return filename; }
@Override
public Clip getClip() {
return (currentSampleSet != null) ? clips.get(currentSampleSet) : null;
}
/**
* Sets the hit sound Clip for the sample type.
* @param s the sample set
* @param clip the Clip
*/
public void setClip(SampleSet s, Clip clip) {
clips.put(s, clip);
}
/**
* Sets the sample set to use when playing hit sounds.
* @param sampleSet the sample set ("None", "Normal", "Soft", "Drum")
*/
public static void setSampleSet(String sampleSet) {
currentSampleSet = null;
for (SampleSet ss : SampleSet.values()) {
if (sampleSet.equalsIgnoreCase(ss.getName())) {
currentSampleSet = ss;
return;
}
}
}
/**
* Sets the sample set to use when playing hit sounds.
* @param sampleType the sample set (0:none, 1:normal, 2:soft, 3:drum)
*/
public static void setSampleSet(byte sampleType) {
currentSampleSet = null;
for (SampleSet ss : SampleSet.values()) {
if (sampleType == ss.getIndex()) {
currentSampleSet = ss;
return;
}
}
}
}

View File

@@ -0,0 +1,341 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu.audio;
import itdelatrisu.opsu.OsuFile;
import itdelatrisu.opsu.OsuParser;
import itdelatrisu.opsu.states.Options;
import java.io.File;
import java.lang.reflect.Field;
import java.nio.IntBuffer;
import javazoom.jl.converter.Converter;
import org.lwjgl.BufferUtils;
import org.lwjgl.openal.AL;
import org.lwjgl.openal.AL10;
import org.newdawn.slick.Music;
import org.newdawn.slick.SlickException;
import org.newdawn.slick.openal.Audio;
import org.newdawn.slick.openal.SoundStore;
import org.newdawn.slick.util.Log;
/**
* Controller for all music.
*/
public class MusicController {
/**
* The current music track.
*/
private static Music player;
/**
* The last OsuFile passed to play().
*/
private static OsuFile lastOsu;
/**
* Temporary WAV file for file conversions (to be deleted).
*/
private static File wavFile;
/**
* Thread for loading tracks.
*/
private static Thread trackLoader;
/**
* Whether the theme song is currently playing.
*/
private static boolean themePlaying = false;
// This class should not be instantiated.
private MusicController() {}
/**
* Plays an audio file at the preview position.
*/
@SuppressWarnings("deprecation")
public static void play(final OsuFile osu, final boolean loop) {
boolean play = (lastOsu == null || !osu.audioFilename.equals(lastOsu.audioFilename));
lastOsu = osu;
if (play) {
themePlaying = false;
// TODO: properly interrupt instead of using deprecated Thread.stop();
// interrupt the conversion/track loading
if (isTrackLoading())
// trackLoader.interrupt();
trackLoader.stop();
if (wavFile != null)
wavFile.delete();
// releases all sources from previous tracks
destroyOpenAL();
switch (OsuParser.getExtension(osu.audioFilename.getName())) {
case "ogg":
trackLoader = new Thread() {
@Override
public void run() {
loadTrack(osu.audioFilename, osu.previewTime, loop);
}
};
trackLoader.start();
break;
case "mp3":
trackLoader = new Thread() {
@Override
public void run() {
convertMp3(osu.audioFilename);
// if (!Thread.currentThread().isInterrupted())
loadTrack(wavFile, osu.previewTime, loop);
}
};
trackLoader.start();
break;
default:
break;
}
}
}
/**
* Loads a track and plays it.
*/
private static void loadTrack(File file, int previewTime, boolean loop) {
try { // create a new player
player = new Music(file.getPath());
playAt((previewTime > 0) ? previewTime : 0, loop);
} catch (Exception e) {
Log.error(String.format("Could not play track '%s'.", file.getName()), e);
}
}
/**
* Plays the current track at the given position.
*/
public static void playAt(final int position, final boolean loop) {
if (trackExists()) {
SoundStore.get().setMusicVolume(Options.getMusicVolume());
player.setPosition(position / 1000f);
if (loop)
player.loop();
else
player.play();
}
}
/**
* Converts an MP3 file to a temporary WAV file.
*/
private static File convertMp3(File file) {
try {
wavFile = File.createTempFile(".osu", ".wav", Options.TMP_DIR);
wavFile.deleteOnExit();
Converter converter = new Converter();
converter.convert(file.getPath(), wavFile.getPath());
return wavFile;
} catch (Exception e) {
Log.error(String.format("Failed to play file '%s'.", file.getAbsolutePath()), e);
}
return wavFile;
}
/**
* Returns true if a track is being loaded.
*/
public static boolean isTrackLoading() {
return (trackLoader != null && trackLoader.isAlive());
}
/**
* Returns true if a track is loaded.
*/
public static boolean trackExists() {
return (player != null);
}
/**
* Returns the OsuFile associated with the current track.
*/
public static OsuFile getOsuFile() { return lastOsu; }
/**
* Returns the name of the current track.
*/
public static String getTrackName() {
if (!trackExists() || lastOsu == null)
return null;
return lastOsu.getTitle();
}
/**
* Returns the artist of the current track.
*/
public static String getArtistName() {
if (!trackExists() || lastOsu == null)
return null;
return lastOsu.getArtist();
}
/**
* Returns true if the current track is playing.
*/
public static boolean isPlaying() {
return (trackExists() && player.playing());
}
/**
* Pauses the current track.
*/
public static void pause() {
if (isPlaying())
player.pause();
}
/**
* Resumes the current track.
*/
public static void resume() {
if (trackExists()) {
player.resume();
player.setVolume(1.0f);
}
}
/**
* Stops the current track.
*/
public static void stop() {
if (isPlaying())
player.stop();
}
/**
* Fades out the track.
*/
public static void fadeOut(int duration) {
if (isPlaying())
player.fade(duration, 0f, true);
}
/**
* Returns the position in the current track, in ms.
* If no track is playing, 0 will be returned.
*/
public static int getPosition() {
if (isPlaying())
return Math.max((int) (player.getPosition() * 1000 + Options.getMusicOffset()), 0);
else
return 0;
}
/**
* Seeks to a position in the current track.
*/
public static boolean setPosition(int position) {
return (trackExists() && player.setPosition(position / 1000f));
}
/**
* Plays the theme song.
*/
public static void playThemeSong() {
OsuFile osu = Options.getOsuTheme();
if (osu != null) {
play(osu, true);
themePlaying = true;
}
}
/**
* Returns whether or not the current track, if any, is the theme song.
*/
public static boolean isThemePlaying() {
return (themePlaying && trackExists());
}
/**
* Stops and releases all sources, clears each of the specified Audio
* buffers, destroys the OpenAL context, and resets SoundStore for future use.
*
* Calling SoundStore.get().init() will re-initialize the OpenAL context
* after a call to destroyOpenAL (Note: AudioLoader.getXXX calls init for you).
*
* @author davedes (http://slick.ninjacave.com/forum/viewtopic.php?t=3920)
*/
private static void destroyOpenAL() {
if (!trackExists())
return;
stop();
try {
// get Music object's (private) Audio object reference
Field sound = player.getClass().getDeclaredField("sound");
sound.setAccessible(true);
Audio audio = (Audio) (sound.get(player));
// first clear the sources allocated by SoundStore
int max = SoundStore.get().getSourceCount();
IntBuffer buf = BufferUtils.createIntBuffer(max);
for (int i = 0; i < max; i++) {
int source = SoundStore.get().getSource(i);
buf.put(source);
// stop and detach any buffers at this source
AL10.alSourceStop(source);
AL10.alSourcei(source, AL10.AL_BUFFER, 0);
}
buf.flip();
AL10.alDeleteSources(buf);
int exc = AL10.alGetError();
if (exc != AL10.AL_NO_ERROR) {
throw new SlickException(
"Could not clear SoundStore sources, err: " + exc);
}
// delete any buffer data stored in memory, too...
if (audio != null && audio.getBufferID() != 0) {
buf = BufferUtils.createIntBuffer(1).put(audio.getBufferID());
buf.flip();
AL10.alDeleteBuffers(buf);
exc = AL10.alGetError();
if (exc != AL10.AL_NO_ERROR) {
throw new SlickException("Could not clear buffer "
+ audio.getBufferID()
+ ", err: "+exc);
}
}
// clear OpenAL
AL.destroy();
// reset SoundStore so that next time we create a Sound/Music, it will reinit
SoundStore.get().clear();
player = null;
} catch (Exception e) {
Log.error("Failed to destroy OpenAL.", e);
}
}
}

View File

@@ -0,0 +1,225 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu.audio;
import itdelatrisu.opsu.OsuHitObject;
import itdelatrisu.opsu.audio.HitSound.SampleSet;
import itdelatrisu.opsu.states.Options;
import java.io.IOException;
import java.net.URL;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.Clip;
import javax.sound.sampled.DataLine;
import javax.sound.sampled.FloatControl;
import javax.sound.sampled.LineUnavailableException;
import javax.sound.sampled.UnsupportedAudioFileException;
import org.newdawn.slick.util.Log;
import org.newdawn.slick.util.ResourceLoader;
/**
* Controller for all (non-music) sound components.
* Note: Uses Java Sound because OpenAL lags too much for accurate hit sounds.
*/
public class SoundController {
/**
* Interface for all (non-music) sound components.
*/
public interface SoundComponent {
/**
* Returns the Clip associated with the sound component.
* @return the Clip
*/
public Clip getClip();
}
/**
* Sample volume multiplier, from timing points [0, 1].
*/
private static float sampleVolumeMultiplier = 1f;
/**
* The name of the current sound file being loaded.
*/
private static String currentFileName;
/**
* The number of the current sound file being loaded.
*/
private static int currentFileIndex = -1;
// This class should not be instantiated.
private SoundController() {}
/**
* Loads and returns a Clip from a resource.
* @param ref the resource name
* @return the loaded and opened clip
*/
private static Clip loadClip(String ref) {
try {
URL url = ResourceLoader.getResource(ref);
AudioInputStream audioIn = AudioSystem.getAudioInputStream(url);
// GNU/Linux workaround
// Clip clip = AudioSystem.getClip();
AudioFormat format = audioIn.getFormat();
DataLine.Info info = new DataLine.Info(Clip.class, format);
Clip clip = (Clip) AudioSystem.getLine(info);
clip.open(audioIn);
return clip;
} catch (UnsupportedAudioFileException | IOException | LineUnavailableException | RuntimeException e) {
Log.error(String.format("Failed to load file '%s'.", ref), e);
}
return null;
}
/**
* Loads all sound files.
*/
public static void init() {
if (Options.isSoundDisabled())
return;
// TODO: support MP3 sounds?
currentFileIndex = 0;
// menu and game sounds
for (SoundEffect s : SoundEffect.values()) {
currentFileName = String.format("%s.wav", s.getFileName());
s.setClip(loadClip(currentFileName));
currentFileIndex++;
}
// hit sounds
for (SampleSet ss : SampleSet.values()) {
for (HitSound s : HitSound.values()) {
currentFileName = String.format("%s-%s.wav", ss.getName(), s.getFileName());
s.setClip(ss, loadClip(currentFileName));
currentFileIndex++;
}
}
currentFileName = null;
currentFileIndex = -1;
}
/**
* Sets the sample volume (modifies the global sample volume).
* @param volume the sample volume [0, 1]
*/
public static void setSampleVolume(float volume) {
if (volume >= 0f && volume <= 1f)
sampleVolumeMultiplier = volume;
}
/**
* Plays a sound clip.
* @param clip the Clip to play
* @param volume the volume [0, 1]
*/
private static void playClip(Clip clip, float volume) {
if (clip == null) // clip failed to load properly
return;
if (volume > 0f) {
// stop clip if running
if (clip.isRunning()) {
clip.stop();
clip.flush();
}
// PulseAudio does not support Master Gain
if (clip.isControlSupported(FloatControl.Type.MASTER_GAIN)) {
// set volume
FloatControl gainControl = (FloatControl) clip.getControl(FloatControl.Type.MASTER_GAIN);
float dB = (float) (Math.log(volume) / Math.log(10.0) * 20.0);
gainControl.setValue(dB);
}
// play clip
clip.setFramePosition(0);
clip.start();
}
}
/**
* Plays a sound.
* @param s the sound effect
*/
public static void playSound(SoundComponent s) {
playClip(s.getClip(), Options.getEffectVolume());
}
/**
* Plays hit sound(s) using an OsuHitObject bitmask.
* @param hitSound the hit sound (bitmask)
*/
public static void playHitSound(byte hitSound) {
if (hitSound < 0)
return;
float volume = Options.getHitSoundVolume() * sampleVolumeMultiplier;
if (volume == 0f)
return;
// play all sounds
if (hitSound == OsuHitObject.SOUND_NORMAL)
playClip(HitSound.NORMAL.getClip(), volume);
else {
if ((hitSound & OsuHitObject.SOUND_WHISTLE) > 0)
playClip(HitSound.WHISTLE.getClip(), volume);
if ((hitSound & OsuHitObject.SOUND_FINISH) > 0)
playClip(HitSound.FINISH.getClip(), volume);
if ((hitSound & OsuHitObject.SOUND_CLAP) > 0)
playClip(HitSound.CLAP.getClip(), volume);
}
}
/**
* Plays a hit sound.
* @param s the hit sound
*/
public static void playHitSound(SoundComponent s) {
playClip(s.getClip(), Options.getHitSoundVolume() * sampleVolumeMultiplier);
}
/**
* Returns the name of the current file being loaded, or null if none.
*/
public static String getCurrentFileName() {
return (currentFileName != null) ? currentFileName : null;
}
/**
* Returns the progress of sound loading, or -1 if not loading.
* @return the completion percent [0, 100] or -1
*/
public static int getLoadingProgress() {
if (currentFileIndex == -1)
return -1;
return currentFileIndex * 100 / (SoundEffect.SIZE + (HitSound.SIZE * SampleSet.SIZE));
}
}

View File

@@ -0,0 +1,83 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu.audio;
import javax.sound.sampled.Clip;
/**
* Sound effects.
*/
public enum SoundEffect implements SoundController.SoundComponent {
APPLAUSE ("applause"),
COMBOBREAK ("combobreak"),
// COUNT ("count"), // ?
COUNT1 ("count1s"),
COUNT2 ("count2s"),
COUNT3 ("count3s"),
FAIL ("failsound"),
GO ("gos"),
MENUBACK ("menuback"),
MENUCLICK ("menuclick"),
MENUHIT ("menuhit"),
READY ("readys"),
SECTIONFAIL ("sectionfail"),
SECTIONPASS ("sectionpass"),
SHUTTER ("shutter"),
SPINNERBONUS ("spinnerbonus"),
SPINNEROSU ("spinner-osu"),
SPINNERSPIN ("spinnerspin");
/**
* The file name.
*/
private String filename;
/**
* The Clip associated with the sound effect.
*/
private Clip clip;
/**
* Total number of sound effects.
*/
public static final int SIZE = SoundEffect.values().length;
/**
* Constructor.
* @param filename the sound file name
*/
SoundEffect(String filename) {
this.filename = filename;
}
/**
* Returns the file name.
* @return the file name
*/
public String getFileName() { return filename; }
@Override
public Clip getClip() { return clip; }
/**
* Sets the Clip for the sound.
* @param clip the clip
*/
public void setClip(Clip clip) { this.clip = clip; }
}