package ch.retorte.intervalmusiccompositor.audiofile; import static ch.retorte.intervalmusiccompositor.audiofile.AudioFileStatus.EMPTY; import static ch.retorte.intervalmusiccompositor.audiofile.AudioFileStatus.ERROR; import static ch.retorte.intervalmusiccompositor.audiofile.AudioFileStatus.IN_PROGRESS; import static ch.retorte.intervalmusiccompositor.audiofile.AudioFileStatus.OK; import static ch.retorte.intervalmusiccompositor.audiofile.AudioFileStatus.QUEUED; import static ch.retorte.intervalmusiccompositor.commons.Utf8Bundle.getBundle; import static com.google.common.collect.Lists.newArrayList; import java.io.File; import java.io.IOException; import java.text.SimpleDateFormat; import java.util.Collection; import java.util.Date; import java.util.List; import java.util.UUID; import javax.sound.sampled.AudioFileFormat; import javax.sound.sampled.AudioInputStream; import javax.sound.sampled.UnsupportedAudioFileException; import ch.retorte.intervalmusiccompositor.ChangeListener; import org.tritonus.sampled.file.WaveAudioFileReader; import org.tritonus.sampled.file.WaveAudioFileWriter; import ch.retorte.intervalmusiccompositor.commons.MessageFormatBundle; import ch.retorte.intervalmusiccompositor.messagebus.DebugMessage; import ch.retorte.intervalmusiccompositor.spi.bpm.BPMCalculator; import ch.retorte.intervalmusiccompositor.spi.bpm.BPMReaderWriter; import ch.retorte.intervalmusiccompositor.spi.decoder.AudioFileDecoder; import ch.retorte.intervalmusiccompositor.spi.messagebus.MessageProducer; import ch.retorte.intervalmusiccompositor.util.SoundHelper; /** * @author nw */ public class AudioFile extends File implements IAudioFile { private UUID uuid = UUID.randomUUID(); private MessageFormatBundle bundle = getBundle("core_imc"); private Long startCutOffInMilliseconds = Long.parseLong(bundle.getString("imc.audio.cutoff.start")); private Long endCutOffInMilliseconds = Long.parseLong(bundle.getString("imc.audio.cutoff.end")); private static final long serialVersionUID = 6154514892883792308L; private Long duration = 0L; private Float volume; private int bpm = -1; private String displayName; private String errorMessage; private File cache = null; private AudioFileStatus status = AudioFileStatus.EMPTY; // Indicates if the currently available bpm value is considered reliable // This is the case if: // a) it was read from the files meta data, or // b) it was entered manually private Boolean isBpmReliable = false; // Indicates if the currently available bpm value is equivalent to the one // stored in the meta data of the file private Boolean isBpmStored = false; private SoundHelper soundHelper; private List<AudioFileDecoder> audioFileDecoders; private BPMReaderWriter bpmReaderWriter; private BPMCalculator bpmCalculator; private MessageProducer messageProducer; private Collection<ChangeListener<IAudioFile>> changeListeners = newArrayList(); AudioFile(String pathname, SoundHelper soundHelper, List<AudioFileDecoder> audioFileDecoders, BPMReaderWriter bpmReaderWriter, BPMCalculator bpmCalculator, MessageProducer messageProducer) { super(pathname); this.soundHelper = soundHelper; this.audioFileDecoders = audioFileDecoders; this.bpmReaderWriter = bpmReaderWriter; this.bpmCalculator = bpmCalculator; this.messageProducer = messageProducer; displayName = super.getName(); } public String getErrorMessage() { return errorMessage; } @Override public Long getDuration() { return duration; } /** * Starts the process of calculating the duration of the audio file. */ private void calculateDuration() { try { // Obtain result in seconds and make milliseconds out of it duration = (long) (soundHelper.getStreamLengthInSeconds(getAudioInputStream()) * 1000); notifyChangeListeners(); addDebugMessage(getDisplayName() + " duration: " + duration + " ms"); } catch (IOException e) { addDebugMessage("Problems on calculating volume: " + e.getMessage()); } } @Override public Float getVolumeRatio() { return volume; } /** * Calculates the 'volume' ratio of the audio track, that is, the proportion between the tracks average amplitude and a certain preset maximum value. If this * track is equally 'loud' as the preset, the value returned is 1. If it is, say, two times as loud as the maximum value, then 0.5 is returned. This volume * value may be directly used as a setting for the output volume, achieving that all tracks appear to be equally loud in the output. */ private void calculateVolumeRatio() { int averageAmplitude = 0; int maximalAverageAmplitude = Integer.parseInt(bundle.getString("imc.audio.volume.max_average_amplitude")); int averageAmplitudeWindowSize = Integer.parseInt(bundle.getString("imc.audio.volume.window_size")); try { averageAmplitude = soundHelper.getAvgAmplitude(getAudioInputStream(), averageAmplitudeWindowSize); } catch (IOException e) { addDebugMessage("Problems when calculating amplitude: " + e.getMessage()); } volume = ((float) maximalAverageAmplitude / (float) averageAmplitude); notifyChangeListeners(); addDebugMessage(getDisplayName() + " volume ratio: " + volume); } public String getDisplayName() { return displayName; } public void createCache() throws UnsupportedAudioFileException, IOException { setStatus(AudioFileStatus.IN_PROGRESS); File temporaryFile = null; try { temporaryFile = File.createTempFile(getName(), bundle.getString("imc.temporaryFile.suffix")); } catch (IOException e) { addDebugMessage(e.getMessage()); } if (temporaryFile != null) { cache = temporaryFile; AudioInputStream ais = null; try { ais = decodeSourceFile(); new WaveAudioFileWriter().write(ais, AudioFileFormat.Type.WAVE, cache); } catch (UnsupportedAudioFileException e) { setStatus(AudioFileStatus.ERROR); errorMessage = "Audio format not supported!"; cache.delete(); cache = null; throw e; } catch (IOException e) { setStatus(AudioFileStatus.ERROR); errorMessage = "Read / write error!"; cache.delete(); cache = null; throw e; } finally { if (ais != null) { ais.close(); } } } else { setStatus(AudioFileStatus.ERROR); throw new IOException("Was not able to create temporary cache file."); } calculateVolumeRatio(); calculateDuration(); readBpm(); Long startCutOff = Long.parseLong(bundle.getString("imc.audio.cutoff.start")); Long endCutOff = Long.parseLong(bundle.getString("imc.audio.cutoff.end")); // Now check if the track is long enough if (duration < startCutOff + endCutOff) { setStatus(AudioFileStatus.ERROR); errorMessage = "Track too short! (Duration: " + getFormattedTime(duration) + " s)"; } else { setStatus(AudioFileStatus.OK); } } private AudioInputStream decodeSourceFile() throws UnsupportedAudioFileException, IOException { for (AudioFileDecoder decoder : audioFileDecoders) { try { return decoder.decode(this); } catch (Exception e) { addDebugMessage("Audio decoder complained for file '" + getDisplayName() + "': " + e.getMessage()); // If there is trouble, we just try the next decoder. } } throw new UnsupportedAudioFileException("Audio file not recognized by any decoder: " + getDisplayName()); } private String getFormattedTime(long milliseconds) { return new SimpleDateFormat("HH:mm:ss").format(new Date(milliseconds)); } public void removeCache() { if (hasCache()) { addDebugMessage("Deleting cache file: " + cache); cache.delete(); } setStatus(EMPTY); } public void setQueuedStatus() { setStatus(QUEUED); } public void setInProgressStatus() { setStatus(IN_PROGRESS); } public int getBpm() { return bpm; } public void setBpm(int bpm) { addDebugMessage("Set BPM to " + bpm + " of " + getDisplayName()); this.bpm = bpm; isBpmReliable = true; isBpmStored = false; notifyChangeListeners(); } /** * Starts the process of reading meta information (e.g. ID3v2 tags in MP3) in the source file. Upon success, the value should be stored somehow and be * retrieved by the {@link IAudioFile#getBpm()} method. Furthermore, the {@link IAudioFile#hasBpm()} should return true in that case. */ private void readBpm() { // Read bpm info int loadedBpm; int calculatedBpm = -1; // Get threshold data from property file int bpmBottomThreshold = Integer.valueOf(bundle.getString("imc.audio.bpm.bottom_threshold")); int bpmTopThreshold = Integer.valueOf(bundle.getString("imc.audio.bpm.top_threshold")); // If the reading of bpm is supported, retrieve value from meta data if (isBpmSupported()) { loadedBpm = readBpmInternal(); // Only set bpm value if it is in valid range if (bpmBottomThreshold <= loadedBpm && loadedBpm <= bpmTopThreshold) { bpm = loadedBpm; isBpmReliable = true; isBpmStored = true; } } // If there was none, calculate it if (bpm == -1) { int bpmExtractLength = Integer.valueOf(bundle.getString("imc.audio.bpm.trackLength")); int bpmExtractStart; if (getDuration() / 1000 < bpmExtractLength) { bpmExtractLength = (int) (getDuration() / 1000); } bpmExtractStart = (int) (((getDuration() / 1000) - bpmExtractLength) / 2); try { calculatedBpm = calculateBpm(bpmExtractStart, bpmExtractLength); addDebugMessage(getDisplayName() + " BPM: " + calculatedBpm); } catch (OutOfMemoryError e) { addDebugMessage("Problems when calculating BPM information: " + e.getMessage()); } // Only set bpm value if it is in valid range. if (bpmBottomThreshold <= calculatedBpm && calculatedBpm <= bpmTopThreshold) { bpm = calculatedBpm; notifyChangeListeners(); } } } private int readBpmInternal() { Integer readBPMValue = bpmReaderWriter.readBPMFrom(this); if (readBPMValue == null) { return -1; } addDebugMessage("Reading BPM from file: " + readBPMValue); return readBPMValue; } @Override public Boolean hasBpm() { return 0 <= bpm; } /** * Calculates the bpm value of the file on an extract starting at extractStart and having a length of extractLength. * * @return The calculated bpm value * @param extractLength * Length of the inspected track extract in seconds * @param extractStart * Starting point of the inspected track extract in seconds */ private int calculateBpm(int extractStart, int extractLength) { int result = -1; try { AudioInputStream shortInputStream = soundHelper.getStreamExtract(getAudioInputStream(), extractStart, extractLength); result = bpmCalculator.calculateBPM(shortInputStream); } catch (Exception e) { addDebugMessage("Problems with calculating BPM: " + e.getMessage()); } return result; } @Override public AudioInputStream getAudioInputStream() throws IOException { try { if (!hasCache()) { createCache(); } /* We need to select the WaveFileReader explicitly here. If we let the AudioSystem choose, the JAAD variant is chosen which does not work. */ return new WaveAudioFileReader().getAudioInputStream(cache); } catch (UnsupportedAudioFileException e) { addDebugMessage("Problems with reading cache audio file: " + e.getMessage()); throw new IOException(e.getMessage()); } } private boolean hasCache() { return cache != null && cache.exists(); } @Override public Boolean isBpmReliable() { return isBpmReliable; } @Override public Boolean isBpmStored() { return isBpmStored; } @Override public Boolean isBpmSupported() { return bpmReaderWriter != null; } @Override public void writeBpm(int bpm) throws IOException { bpmReaderWriter.writeBPMTo(bpm, this); isBpmReliable = true; isBpmStored = true; notifyChangeListeners(); } @Override public boolean isOK() { return status.equals(OK); } @Override public boolean isLoading() { return status.equals(IN_PROGRESS); } @Override public AudioFileStatus getStatus() { return status; } private void setStatus(AudioFileStatus status) { this.status = status; notifyChangeListeners(); } @Override public File getSource() { return this; } @Override public void addChangeListener(ChangeListener<IAudioFile> changeListener) { changeListeners.add(changeListener); } private void notifyChangeListeners() { changeListeners.forEach(changeListener -> changeListener.changed(this)); } private void addDebugMessage(String message) { messageProducer.send(new DebugMessage(this, message)); } public boolean isLongEnoughFor(int extractInSeconds) { return extractInSeconds <= ((getDuration() - startCutOffInMilliseconds - endCutOffInMilliseconds) / 1000); } @Override public boolean equals(Object obj) { if (obj instanceof AudioFile) { if (hasCache()) { return cache.equals(((AudioFile) obj).cache); } else { return uuid.equals(((AudioFile) obj).uuid); } } else { return super.equals(obj); } } @Override public int hashCode() { return cache.hashCode(); } }