package games.strategy.sound; import java.io.File; import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.net.URLDecoder; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Enumeration; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.prefs.BackingStoreException; import java.util.prefs.Preferences; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import com.google.common.base.Throwables; import games.strategy.debug.ClientLogger; import games.strategy.engine.data.PlayerID; import games.strategy.engine.framework.GameRunner; import games.strategy.triplea.ResourceLoader; import games.strategy.util.UrlStreams; import javazoom.jl.player.AudioDevice; import javazoom.jl.player.FactoryRegistry; import javazoom.jl.player.advanced.AdvancedPlayer; /** * Utility for loading and playing sound clips. * Stores a preference in the user preferences for being silent. * The property will persist and be reloaded after the virtual machine * has been stopped and restarted. * <br> * <br> * <br> * <br> * <br> * How it works: <br> * <b>Sound.Default.Folder</b>=ww2 <br> * This is the "key" that tells the engine which sound folder to use as the DEFAULT sound folder. <br> * The default folders are as follows: <br> * "<b>ww2</b>" (which should cover ww1 - ww2 - ww3 sounds), <br> * "<b>preindustrial</b>" (anything from muskets/cannons (1500) to right before ww1 (1900), <br> * "<b>classical</b>" (the ancient era, anything before cannons became a mainstay (10,000 bce - 1500 ad) <br> * "<b>future</b>" (sci-fi, spaceships, lasers, etc) <br> * <br> * After this, you can specify specific sounds if you want, using the "sound key location" (aka: sound map folder). <br> * The sound key location is the exact folder name for a sound you want, located under the "generic" folder. * What I mean by this is that all sound key locations that triplea supports, are the names of all the folders in the * "assets/sounds/generic/" folder. <br> * example: <br> * <b>battle_aa_miss</b>=ww2/battle_aa_miss;future/battle_aa_miss/battle_aa_miss_01_ufo_flyby.mp3 <br> * "battle_aa_miss" is one of the folders under "generic", therefore it is a "sound location key" <br> * We can set this equal to any list of sounds paths, each separated by a semicolon (;). The engine will pick one at * random each time we * need to play this sound. <br> * The "sound path" can be a "folder" or a "file". If it is a folder, we will use all the sounds in that folder. * If it is a file, we will only use that file. We can use a file and folder and another file and another folder, all * together. <br> * Example: "<b>ww2/battle_aa_miss</b>" is the sound path for a folder, so we will use all the sounds in that folder. * "<b>future/battle_aa_miss/battle_aa_miss_01_ufo_flyby.mp3</b>" is a specific file, so we will use just this file. * Because we use both of these together, the engine will make a list of all the files in that folder, plus that single * file we specified, * then it will randomly pick one of this whole list every time it needs to play the "battle_aa_miss" sound. <br> * <br> * So, lets say that you want to play 2 sounds, for the "battle_land" sound key. * One of them is located at "tripleainstallfolder/assets/sounds/generic/battle_land_01_angry_drumming_noise.mp3". * The other is located at "tripleainstallfolder/assets/sounds/classical/battle_land_02_war_trumpets.mp3". Then the * entry would look like * this: <br> * battle_land=generic/battle_land_01_angry_drumming_noise.mp3;classical/battle_land_02_war_trumpets.mp3 <br> * If you wanted it to also play every single sound in the "tripleainstallfolder/assets/sounds/ww2/battle_land/" folder, * then you would add * that folder to path: <br> * battle_land=generic/battle_land_01_angry_drumming_noise.mp3;classical/battle_land_02_war_trumpets.mp3;ww2/battle_land * <br> * <br> * Furthermore, we can customize the sound key by adding "_nationName" onto the end of it. So if you want a specific * sound for a german land * attack, then use: <br> * battle_land<b>_Germans</b>=misc/battle_land/battle_land_Germans_panzers_and_yelling_in_german.mp3 <br> * You can use nation specific sound keys for almost all sounds, though things like game_start, or chat_message, will * never use them. <br> * <br> * <br> * <br> * <br> * <b>You do not need to specify every single "sound key". This is why/because we have the "Sound.Default.Folder".</b> * <br> * <br> * The logic is as follows: <br> * Engine needs to play the "game_start" sound. <br> * 1. Check for a sound.properties file. <br> * 2. If none exists, pretend that one exists and that it only contains this line: "Sound.Default.Folder=ww2" <br> * 3. Look in the sound.properties file for the specific sound key "game_start" <br> * 4. Create a list of all sounds that the key includes. * If no key, then just use all the sounds in "Sound.Default.Folder/sound_key/" (which for us would be "ww2/game_start/" * folder). <br> * 5. If no sounds are found, then use all the sounds located at "generic/sound_key/" (which for us would be * "generic/game_start"). * (if any sounds are found in step 4 above, then we ignore the generic folder completely) <br> * 6. Randomize the list's order, then pick one, and play the sound. */ public class ClipPlayer { protected static final String ASSETS_SOUNDS_FOLDER = "sounds"; private static final String SOUND_PREFERENCE_GLOBAL_SWITCH = "beSilent2"; private static final String SOUND_PREFERENCE_PREFIX = "sound_"; private static final boolean DEFAULT_SOUND_SILENCED_SWITCH_SETTING = false; private static final String MP3_SUFFIX = ".mp3"; protected final Map<String, List<URL>> sounds = new HashMap<>(); private final Set<String> mutedClips = new HashSet<>(); private boolean beSilent = false; private final ResourceLoader resourceLoader; private static ClipPlayer clipPlayer; public static synchronized ClipPlayer getInstance() { if (clipPlayer == null) { clipPlayer = new ClipPlayer(ResourceLoader.getGameEngineAssetLoader()); } return clipPlayer; } public static synchronized ClipPlayer getInstance(final ResourceLoader resourceLoader) { // make a new clip player if we switch resource loaders (ie: if we switch maps) if (clipPlayer == null || clipPlayer.resourceLoader != resourceLoader) { // make a new clip player with our new resource loader clipPlayer = new ClipPlayer(resourceLoader); } return clipPlayer; } private ClipPlayer(final ResourceLoader resourceLoader) { this.resourceLoader = resourceLoader; final Preferences prefs = Preferences.userNodeForPackage(ClipPlayer.class); beSilent = Boolean.parseBoolean(System.getProperty(GameRunner.TRIPLEA_HEADLESS, "false")) || prefs.getBoolean(SOUND_PREFERENCE_GLOBAL_SWITCH, DEFAULT_SOUND_SILENCED_SWITCH_SETTING); final Set<String> choices = SoundPath.getAllSoundOptions(); for (final String sound : choices) { final boolean muted = prefs.getBoolean(SOUND_PREFERENCE_PREFIX + sound, false); if (muted) { mutedClips.add(sound); } } } /** * If set to true, no sounds will play. * This property is persisted using the java.tools.prefs API, and will persist after the vm has stopped. * * @param aBool new value for m_beSilent */ protected static void setBeSilent(final boolean aBool) { final ClipPlayer clipPlayer = getInstance(); clipPlayer.beSilent = aBool; setBeSilentInPreferencesWithoutAffectingCurrent(aBool); } public static void setBeSilentInPreferencesWithoutAffectingCurrent(final boolean silentBool) { final Preferences prefs = Preferences.userNodeForPackage(ClipPlayer.class); final boolean current = prefs.getBoolean(SOUND_PREFERENCE_GLOBAL_SWITCH, DEFAULT_SOUND_SILENCED_SWITCH_SETTING); boolean setPref = silentBool != current; if (!setPref) { try { setPref = !Arrays.asList(prefs.keys()).contains(SOUND_PREFERENCE_GLOBAL_SWITCH); } catch (final BackingStoreException e) { ClientLogger.logQuietly(e); } } if (setPref) { prefs.putBoolean(SOUND_PREFERENCE_GLOBAL_SWITCH, silentBool); try { prefs.flush(); } catch (final BackingStoreException e) { ClientLogger.logQuietly(e); } } } protected static boolean getBeSilent() { final ClipPlayer clipPlayer = getInstance(); return clipPlayer.beSilent; } protected boolean isMuted(final String clipName) { if (mutedClips.contains(clipName)) { return true; } if (!SoundPath.getAllSoundOptions().contains(clipName)) { // for custom sound clips, with custom paths, silence based on more similar sound clip settings if (clipName.startsWith(SoundPath.CLIP_BATTLE_X_PREFIX) && clipName.endsWith(SoundPath.CLIP_BATTLE_X_HIT)) { return mutedClips.contains(SoundPath.CLIP_BATTLE_AA_HIT); } if (clipName.startsWith(SoundPath.CLIP_BATTLE_X_PREFIX) && clipName.endsWith(SoundPath.CLIP_BATTLE_X_MISS)) { return mutedClips.contains(SoundPath.CLIP_BATTLE_AA_MISS); } if (clipName.startsWith(SoundPath.CLIP_TRIGGERED_NOTIFICATION_SOUND)) { return mutedClips.contains(SoundPath.CLIP_TRIGGERED_NOTIFICATION_SOUND); } if (clipName.startsWith(SoundPath.CLIP_TRIGGERED_DEFEAT_SOUND)) { return mutedClips.contains(SoundPath.CLIP_TRIGGERED_DEFEAT_SOUND); } if (clipName.startsWith(SoundPath.CLIP_TRIGGERED_VICTORY_SOUND)) { return mutedClips.contains(SoundPath.CLIP_TRIGGERED_VICTORY_SOUND); } } return false; } protected void setMute(final String clipName, final boolean value) { // we want to avoid unnecessary calls to preferences final boolean isCurrentCorrect = mutedClips.contains(clipName) == value; if (isCurrentCorrect) { return; } if (value == true) { mutedClips.add(clipName); } else { mutedClips.remove(clipName); } final Preferences prefs = Preferences.userNodeForPackage(ClipPlayer.class); prefs.putBoolean(SOUND_PREFERENCE_PREFIX + clipName, value); } /** Flushes sounds preferences to persisted data store. This method is *slow* and resource expensive. */ protected void saveSoundPreferences() { final Preferences prefs = Preferences.userNodeForPackage(ClipPlayer.class); try { prefs.flush(); } catch (final BackingStoreException e) { ClientLogger.logQuietly(e); } } public static void play(final String clipName) { play(clipName, null); } /** * @param clipPath - the folder containing sound clips to be played. One of the sound clip files will be chosen at * random. * @param playerId - the name of the player, or null */ public static void play(String clipPath, PlayerID playerId) { getInstance().playClip(clipPath, playerId); } private void playClip(final String clipName, final PlayerID playerId) { if (beSilent || isMuted(clipName)) { return; } // run in a new thread, so that we do not delay the game String folder = clipName; if (playerId != null) { folder += "_" + playerId.getName(); } final URI clip = loadClip(folder).orElse(loadClip(clipName).orElse(null)); // clip may still be null, we try to load all phases/all sound, for example: clipName = "phase_technology", folder = // "phase_technology_Japanese" if (clip != null) { (new Thread(() -> { try { Optional<InputStream> inputStream = UrlStreams.openStream(clip.toURL()); if (inputStream.isPresent()) { final AudioDevice audioDevice = FactoryRegistry.systemRegistry().createAudioDevice(); new AdvancedPlayer(inputStream.get(), audioDevice).play(); } } catch (Exception e) { ClientLogger.logError("Failed to play: " + clip, e); } })).start(); } } private Optional<URI> loadClip(final String clipName) { if (beSilent || isMuted(clipName)) { return null; } return Optional.ofNullable(loadClipPath(clipName)); } private URI loadClipPath(final String pathName) { if (!sounds.containsKey(pathName)) { // parse sounds for the first time sounds.put(pathName, parseClipPaths(pathName)); } final List<URL> availableSounds = sounds.get(pathName); if (availableSounds == null || availableSounds.isEmpty()) { return null; } // we want to pick a random sound from this folder, as users don't like hearing the same ones over // and over again Collections.shuffle(availableSounds); try { return availableSounds.get(0).toURI(); } catch (URISyntaxException e) { throw Throwables.propagate(e); } } /** * The user may or may not have a sounds.properties file. If they do not, we should have a default folder (ww2) that * we use for sounds. * Because we do not want a lot of duplicate sound files, we also have a "generic" sound folder. * If a sound cannot be found for a soundpath using the sounds.properties or default folder, then we try to find one * in the generic * folder. * The sounds.properties file can specify all the sounds to use for a specific sound path (multiple per path). * If there is no key for that path, we try by the default way. <br> * <br> * Example sounds.properties keys:<br> * Sound.Default.Folder=ww2<br> * battle_aa_miss=ww2/battle_aa_miss/battle_aa_miss_01_aa_artillery_and_flyby.mp3;ww2/battle_aa_miss/ * battle_aa_miss_02_just_aa_artillery. * mp3<br> * phase_purchase_Germans=phase_purchase_Germans/game_start_Germans_01_anthem.mp3 */ private List<URL> parseClipPaths(final String pathName) { // Check if there is a sound.properties path override for this resource String resourcePath = SoundProperties.getInstance(resourceLoader).getProperty(pathName); if (resourcePath == null) { resourcePath = SoundProperties.getInstance(resourceLoader).getDefaultEraFolder() + "/" + pathName; } resourcePath = resourcePath.replace('\\', '/'); final List<URL> availableSounds = new ArrayList<>(); if ("NONE".equals(resourcePath)) { sounds.put(pathName, availableSounds); return availableSounds; } for (final String path : resourcePath.split(";")) { availableSounds.addAll(findClipFiles(ASSETS_SOUNDS_FOLDER + "/" + path)); } if (availableSounds.isEmpty()) { final String genericPath = SoundProperties.GENERIC_FOLDER + "/" + pathName; availableSounds.addAll(findClipFiles(ASSETS_SOUNDS_FOLDER + "/" + genericPath)); } return availableSounds; } /** * @param resourceAndPathURL * (URL uses '/', not File.separator or '\') */ protected List<URL> findClipFiles(final String resourceAndPathURL) { final List<URL> availableSounds = new ArrayList<>(); final URL thisSoundURL = resourceLoader.getResource(resourceAndPathURL); if (thisSoundURL == null) { return availableSounds; } URI thisSoundURI; File thisSoundFile; // we are checking to see if this is a file, to see if it is a directory, or a sound, or a zipped directory, or a // zipped sound. There // might be a better way to do this... try { thisSoundURI = thisSoundURL.toURI(); try { thisSoundFile = new File(thisSoundURI); } catch (final Exception e) { try { thisSoundFile = new File(thisSoundURI.getPath()); } catch (final Exception e3) { thisSoundFile = new File(thisSoundURL.getPath()); } } } catch (final URISyntaxException e1) { try { thisSoundFile = new File(thisSoundURL.getPath()); } catch (final Exception e4) { thisSoundFile = null; } } catch (final Exception e2) { thisSoundFile = null; } if (thisSoundFile == null || !thisSoundFile.exists()) { // final long startTime = System.currentTimeMillis(); // we are probably using zipped sounds. there might be a better way to do this... final String soundFilePath = thisSoundURL.getPath(); if (soundFilePath != null && soundFilePath.length() > 5 && soundFilePath.contains(".zip!")) { // so the URL with a zip or jar in it, will start with "file:", and unfortunately when you make a file and test // if it exists, if it // starts with that it doesn't exist final int index1 = Math.max(0, Math.min(soundFilePath.length(), soundFilePath.contains("file:") ? soundFilePath.indexOf("file:") + 5 : 0)); final String zipFilePath = soundFilePath.substring(index1, Math.max(index1, Math.min(soundFilePath.length(), soundFilePath.lastIndexOf("!")))); if (zipFilePath.length() > 5 && zipFilePath.endsWith(".zip")) { String decoded; try { // the file path may have spaces, which in a URL are equal to %20, but if we make a file using that it will // fail, so we need to decode decoded = URLDecoder.decode(zipFilePath, "UTF-8"); } catch (final UnsupportedEncodingException uee) { decoded = zipFilePath.replaceAll("%20", " "); } try { final File zipFile = new File(decoded); if (zipFile.exists()) { try (ZipFile zf = new ZipFile(zipFile)) { final Enumeration<? extends ZipEntry> zipEnumeration = zf.entries(); while (zipEnumeration.hasMoreElements()) { final ZipEntry zipElement = zipEnumeration.nextElement(); if (isZippedMp3(zipElement, resourceAndPathURL)) { try { final URL zipSoundURL = resourceLoader.getResource(zipElement.getName()); if (zipSoundURL == null) { continue; } availableSounds.add(zipSoundURL); } catch (final Exception e) { ClientLogger.logQuietly(e); } } } } } } catch (final Exception e) { ClientLogger.logQuietly(e); } } } } else { // we must be using unzipped sounds if (!thisSoundFile.isDirectory()) { if (!(isSoundFileNamed(thisSoundFile))) { return availableSounds; } availableSounds.add(thisSoundURL); } } if (thisSoundFile.isDirectory()) { for (final File soundFile : thisSoundFile.listFiles()) { if (isSoundFileNamed(soundFile)) { try { final URL individualSoundURL = soundFile.toURI().toURL(); availableSounds.add(individualSoundURL); } catch (final MalformedURLException e) { String msg = "Error " + e.getMessage() + " with sound file: " + soundFile.getPath(); ClientLogger.logQuietly(msg, e); } } } } else { if (!isSoundFileNamed(thisSoundFile)) { return availableSounds; } availableSounds.add(thisSoundURL); } return availableSounds; } private static boolean isZippedMp3(ZipEntry zipElement, String resourceAndPathURL) { return zipElement != null && zipElement.getName() != null && zipElement.getName().contains(resourceAndPathURL) && (zipElement.getName().endsWith(MP3_SUFFIX)); } private static boolean isSoundFileNamed(final File soundFile) { return soundFile.getName().endsWith(MP3_SUFFIX); } }