/*
* opsu! - an open-source osu! client
* Copyright (C) 2014-2017 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.ErrorHandler;
import itdelatrisu.opsu.audio.HitSound.SampleSet;
import itdelatrisu.opsu.beatmap.HitObject;
import itdelatrisu.opsu.downloads.Download;
import itdelatrisu.opsu.downloads.Download.DownloadListener;
import itdelatrisu.opsu.options.Options;
import itdelatrisu.opsu.ui.NotificationManager.NotificationListener;
import itdelatrisu.opsu.ui.UI;
import java.awt.Desktop;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
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.LineListener;
import javax.sound.sampled.LineUnavailableException;
import org.newdawn.slick.Color;
import org.newdawn.slick.SlickException;
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 MultiClip getClip();
}
/** The current track being played, if any. */
private static MultiClip currentTrack;
/** Sample volume multiplier, from timing points [0, 1]. */
private static float sampleVolumeMultiplier = 1f;
/** Whether all sounds are muted. */
private static boolean isMuted;
/** 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 MultiClip loadClip(String ref) {
try {
URL url = ResourceLoader.getResource(ref);
// check for 0 length files
InputStream in = url.openStream();
if (in.available() == 0) {
in.close();
return new MultiClip(ref, null);
}
in.close();
AudioInputStream audioIn = AudioSystem.getAudioInputStream(url);
return loadClip(ref, audioIn);
} catch (Exception e) {
ErrorHandler.error(String.format("Failed to load audio file '%s'.", ref), e, true);
return null;
}
}
/**
* Loads and returns a Clip from an audio input stream.
* @param ref the resource name
* @param audioIn the audio input stream
* @return the loaded and opened clip
*/
private static MultiClip loadClip(String ref, AudioInputStream audioIn)
throws IOException, LineUnavailableException {
AudioFormat format = audioIn.getFormat();
String encoding = format.getEncoding().toString();
if (encoding.startsWith("MPEG")) {
// decode MP3
AudioFormat decodedFormat = new AudioFormat(
AudioFormat.Encoding.PCM_SIGNED, format.getSampleRate(), 16,
format.getChannels(), format.getChannels() * 2, format.getSampleRate(), false);
AudioInputStream decodedAudioIn = AudioSystem.getAudioInputStream(decodedFormat, audioIn);
format = decodedFormat;
audioIn = decodedAudioIn;
} else if (encoding.startsWith("GSM")) {
// Currently there's no way to decode GSM in WAV containers in Java.
// http://www.jsresources.org/faq_audio.html#gsm_in_wav
Log.warn(
"Failed to load audio file.\n" +
"Java cannot decode GSM in WAV containers; " +
"please re-encode this file to PCM format or remove it:\n" + ref
);
return null;
}
DataLine.Info info = new DataLine.Info(Clip.class, format);
if (AudioSystem.isLineSupported(info))
return new MultiClip(ref, audioIn);
// try to find closest matching line
Clip clip = AudioSystem.getClip();
AudioFormat[] formats = ((DataLine.Info) clip.getLineInfo()).getFormats();
int bestIndex = -1;
float bestScore = 0;
float sampleRate = format.getSampleRate();
if (sampleRate < 0)
sampleRate = clip.getFormat().getSampleRate();
float oldSampleRate = sampleRate;
while (true) {
for (int i = 0; i < formats.length; i++) {
AudioFormat curFormat = formats[i];
AudioFormat newFormat = new AudioFormat(
sampleRate, curFormat.getSampleSizeInBits(),
curFormat.getChannels(), true, curFormat.isBigEndian());
formats[i] = newFormat;
DataLine.Info newLine = new DataLine.Info(Clip.class, newFormat);
if (AudioSystem.isLineSupported(newLine) &&
AudioSystem.isConversionSupported(newFormat, format)) {
float score = 1
+ (newFormat.getSampleRate() == sampleRate ? 5 : 0)
+ (newFormat.getSampleSizeInBits() == format.getSampleSizeInBits() ? 5 : 0)
+ (newFormat.getChannels() == format.getChannels() ? 5 : 0)
+ (newFormat.isBigEndian() == format.isBigEndian() ? 1 : 0)
+ newFormat.getSampleRate() / 11025
+ newFormat.getChannels()
+ newFormat.getSampleSizeInBits() / 8;
if (score > bestScore) {
bestIndex = i;
bestScore = score;
}
}
}
if (bestIndex < 0) {
if (oldSampleRate < 44100) {
if (sampleRate > 44100)
break;
sampleRate *= 2;
} else {
if (sampleRate < 44100)
break;
sampleRate /= 2;
}
} else
break;
}
if (bestIndex >= 0)
return new MultiClip(ref, AudioSystem.getAudioInputStream(formats[bestIndex], audioIn));
// still couldn't find anything, try the default clip format
return new MultiClip(ref, AudioSystem.getAudioInputStream(clip.getFormat(), audioIn));
}
/**
* Returns the sound file name, with extension, by first looking through
* the skins directory and then the default resource locations.
* @param filename the base file name
* @return the full file name, or null if no file found
*/
private static String getSoundFileName(String filename) {
String wav = String.format("%s.wav", filename), mp3 = String.format("%s.mp3", filename);
File skinDir = Options.getSkin().getDirectory();
if (skinDir != null) {
File skinWAV = new File(skinDir, wav), skinMP3 = new File(skinDir, mp3);
if (skinWAV.isFile())
return skinWAV.getAbsolutePath();
if (skinMP3.isFile())
return skinMP3.getAbsolutePath();
}
if (ResourceLoader.resourceExists(wav))
return wav;
if (ResourceLoader.resourceExists(mp3))
return mp3;
return null;
}
/**
* Loads all sound files.
*/
public static void init() {
if (Options.isSoundDisabled())
return;
currentFileIndex = 0;
int failedCount = 0;
// menu and game sounds
for (SoundEffect s : SoundEffect.values()) {
if ((currentFileName = getSoundFileName(s.getFileName())) == null) {
ErrorHandler.error(String.format("Could not find sound file '%s'.", s.getFileName()), null, false);
continue;
}
MultiClip newClip = loadClip(currentFileName);
if (newClip == null)
failedCount++;
if (s.getClip() != null) { // clip previously loaded (e.g. program restart)
if (newClip != null) {
s.getClip().destroy(); // destroy previous clip
s.setClip(newClip);
}
} else
s.setClip(newClip);
currentFileIndex++;
}
// hit sounds
for (SampleSet ss : SampleSet.values()) {
for (HitSound s : HitSound.values()) {
String filename = String.format("%s-%s", ss.getName(), s.getFileName());
if ((currentFileName = getSoundFileName(filename)) == null) {
ErrorHandler.error(String.format("Could not find hit sound file '%s'.", filename), null, false);
continue;
}
MultiClip newClip = loadClip(currentFileName);
if (newClip == null)
failedCount++;
if (s.getClip(ss) != null) { // clip previously loaded (e.g. program restart)
if (newClip != null) {
s.getClip(ss).destroy(); // destroy previous clip
s.setClip(ss, newClip);
}
} else
s.setClip(ss, newClip);
currentFileIndex++;
}
}
currentFileName = null;
currentFileIndex = -1;
// show a notification if any files failed to load
if (failedCount > 0) {
String text = String.format("Failed to load %d audio file%s.", failedCount, failedCount == 1 ? "" : "s");
NotificationListener listener = null;
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.OPEN)) {
text += "\nClick for details.";
listener = new NotificationListener() {
@Override
public void click() {
try {
Desktop.getDesktop().open(Options.LOG_FILE);
} catch (Exception e) {}
}
};
}
UI.getNotificationManager().sendNotification(text, Color.red, listener);
}
}
/**
* 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]
* @param listener the line listener
*/
private static void playClip(MultiClip clip, float volume, LineListener listener) {
if (clip == null) // clip failed to load properly
return;
if (volume > 0f && !isMuted) {
try {
clip.start(volume, listener);
} catch (LineUnavailableException e) {
ErrorHandler.error(String.format("Could not start a clip '%s'.", clip.getName()), e, true);
}
}
}
/**
* Plays a sound.
* @param s the sound effect
*/
public static void playSound(SoundComponent s) {
playClip(s.getClip(), Options.getEffectVolume() * Options.getMasterVolume(), null);
}
/**
* Plays hit sound(s) using a HitObject bitmask.
* @param hitSound the hit sound (bitmask)
* @param sampleSet the sample set
* @param additionSampleSet the 'addition' sample set
*/
public static void playHitSound(byte hitSound, byte sampleSet, byte additionSampleSet) {
if (hitSound < 0)
return;
float volume = Options.getHitSoundVolume() * sampleVolumeMultiplier * Options.getMasterVolume();
if (volume == 0f)
return;
// play all sounds
if (hitSound == HitObject.SOUND_NORMAL || Options.getSkin().isLayeredHitSounds()) {
HitSound.setSampleSet(sampleSet);
playClip(HitSound.NORMAL.getClip(), volume, null);
}
if (hitSound != HitObject.SOUND_NORMAL) {
HitSound.setSampleSet(additionSampleSet);
if ((hitSound & HitObject.SOUND_WHISTLE) > 0)
playClip(HitSound.WHISTLE.getClip(), volume, null);
if ((hitSound & HitObject.SOUND_FINISH) > 0)
playClip(HitSound.FINISH.getClip(), volume, null);
if ((hitSound & HitObject.SOUND_CLAP) > 0)
playClip(HitSound.CLAP.getClip(), volume, null);
}
}
/**
* Plays a hit sound.
* @param s the hit sound
*/
public static void playHitSound(SoundComponent s) {
playClip(s.getClip(), Options.getHitSoundVolume() * sampleVolumeMultiplier * Options.getMasterVolume(), null);
}
/**
* Mutes or unmutes all sounds (hit sounds and sound effects).
* @param mute true to mute, false to unmute
*/
public static void mute(boolean mute) { isMuted = mute; }
/**
* 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));
}
/**
* Plays a track from a remote URL.
* If a track is currently playing, it will be stopped.
* @param url the remote URL
* @param filename the track file name
* @param listener the line listener
* @return true if playing, false otherwise
* @throws SlickException if any error occurred
*/
public static synchronized boolean playTrack(String url, String filename, LineListener listener)
throws SlickException {
// stop previous track
stopTrack();
// download new track
File dir = Options.TEMP_DIR;
if (!dir.isDirectory())
dir.mkdir();
final File downloadFile = new File(dir, filename);
boolean complete;
if (downloadFile.isFile()) {
complete = true; // file already downloaded
} else {
Download download = new Download(url, downloadFile.getAbsolutePath());
download.setListener(new DownloadListener() {
@Override
public void completed() {}
@Override
public void error() {
UI.getNotificationManager().sendBarNotification("Failed to download track preview.");
}
});
try {
download.start().join();
} catch (InterruptedException e) {}
complete = (download.getStatus() == Download.Status.COMPLETE);
}
// play the track
if (complete) {
try {
AudioInputStream audioIn = AudioSystem.getAudioInputStream(downloadFile);
currentTrack = loadClip(filename, audioIn);
playClip(currentTrack, Options.getMusicVolume() * Options.getMasterVolume(), listener);
return true;
} catch (Exception e) {
throw new SlickException(String.format("Failed to load clip '%s'.", url));
}
}
return false;
}
/**
* Stops the current track playing, if any.
*/
public static synchronized void stopTrack() {
if (currentTrack != null) {
currentTrack.destroy();
currentTrack = null;
}
}
}