zeichenmaschine/src/main/java/schule/ngb/zm/media/Music.java

327 lines
8.2 KiB
Java

package schule.ngb.zm.media;
import schule.ngb.zm.util.Log;
import schule.ngb.zm.util.Validator;
import schule.ngb.zm.util.events.EventDispatcher;
import schule.ngb.zm.util.io.ResourceStreamProvider;
import schule.ngb.zm.util.tasks.TaskRunner;
import javax.sound.sampled.*;
import java.io.IOException;
import java.net.URL;
/**
* Eine Musik, die abgespielt werden kann.
* <p>
* Im Gegensatz zu einem {@link Sound} wird {@code Music} für längere
* Audiodateien benutzt, die zum Beispiel als Hintergrundmusik gespielt werden.
* Die Audiodaten werden daher nicht vollständig in den Speicher geladen,
* sondern direkt aus der Quelle gestreamt und direkt wiedergegeben.
* <p>
* Daher ist es nicht möglich, die länge der Musik im Vorfeld abzufragen oder zu
* einer bestimmten Stelle im Stream zu springen.
*
* <h2>MP3-Dateien verwenden</h2>
* Java kann nativ nur Waveform ({@code .wav}) Dateien wiedergeben. Um auch
* MP3-Dateien zu nutzen, müssen die Bibliotheken <a href="#">jlayer</a>, <a
* href="#">tritonus-share</a> und <a href="#">mp3spi</a> eingebunden werden.
* Details zur Verwendung können in der <a
* href="https://zeichenmaschine.xyz/installation/#unterstutzung-fur-mp3">Dokumentation
* der Zeichenmaschine</a> nachgelesen werden.
*/
// TODO: Wann sollten Listener beim Loopen informiert werden? Nach jedem Loop oder erst ganz am Ende?
@SuppressWarnings( "unused" )
public class Music implements Audio {
/**
* Größe des verwendeten Input-Puffers für die Audiodaten.
*/
private static final int BUFFER_SIZE = 4096;
/**
* Ob der Sound aktuell abgespielt wird.
*/
private boolean playing = false;
/**
* Ob der Sound aktuell in einer Schleife abgespielt wird.
*/
private boolean looping = false;
/**
* Die Quelle der Audiodaten.
*/
private String audioSource;
/**
* Der {@link AudioInputStream}, um die Audiosdaten zu lesen. {@code null},
* falls noch kein Stream geöffnet wurde.
*/
private AudioInputStream audioStream;
/**
* Die {@link SourceDataLine} für die Ausgabe. {@code null}, falls die
* Audiodatei noch nicht geöffnet wurde.
*/
private SourceDataLine audioLine;
/**
* Die aktuelle Lautstärke des Mediums.
*/
private float volume = 0.8f;
/**
* Dispatcher für Audio-Events (start und stop).
*/
EventDispatcher<Audio, AudioListener> eventDispatcher;
/**
* Erstellt eine Musik aus der angegebenen Audioquelle.
*
* @param audioSource Quelle der Audiodaten.
* @throws NullPointerException Falls die Quelle {@code null} ist.
* @see ResourceStreamProvider#getResourceURL(String)
*/
public Music( String audioSource ) {
Validator.requireNotNull(audioSource);
this.audioSource = audioSource;
}
@Override
public String getSource() {
return audioSource;
}
@Override
public boolean isPlaying() {
return playing;
}
@Override
public boolean isLooping() {
if( !playing ) {
looping = false;
}
return looping;
}
@Override
public void setVolume( double volume ) {
this.volume = volume < 0 ? 0f : (float) volume;
if( audioLine != null ) {
applyVolume();
}
}
@Override
public double getVolume() {
return volume;
}
/**
* Wendet die Lautstärke vor dem Abspielen auf den Audiostream an.
*/
private void applyVolume() {
FloatControl gainControl =
(FloatControl) audioLine.getControl(FloatControl.Type.MASTER_GAIN);
float vol = 20f * (float) Math.log10(volume);
// vol = (float) Constants.limit(vol, gainControl.getMinimum(), gainControl.getMaximum());
gainControl.setValue(vol);
}
@Override
public void play() {
if( openLine() ) {
TaskRunner.run(this::stream);
}
}
@Override
public void playAndWait() {
if( openLine() ) {
stream();
}
}
@Override
public void loop() {
looping = true;
play();
}
@Override
public void stop() {
playing = false;
looping = false;
dispose();
}
@Override
public synchronized void dispose() {
if( audioLine != null ) {
if( audioLine.isRunning() ) {
playing = false;
audioLine.stop();
}
if( audioLine.isOpen() ) {
audioLine.drain();
audioLine.close();
}
}
try {
if( audioStream != null ) {
audioStream.close();
}
} catch( IOException ex ) {
}
audioLine = null;
audioStream = null;
}
/**
* Startet den Stream der Audiodaten und damit die Wiedergabe.
* <p>
* Die {@link #audioLine} muss vorher mit {@link #openLine()} geöffnet
* werden, ansonsten passiert nichts.
*/
private synchronized void stream() {
if( audioLine == null ) {
return;
}
audioLine.start();
playing = true;
if( eventDispatcher != null ) {
eventDispatcher.dispatchEvent("start", Music.this);
}
byte[] bytesBuffer = new byte[BUFFER_SIZE];
int bytesRead = -1;
try {
while( playing && (bytesRead = audioStream.read(bytesBuffer)) != -1 ) {
audioLine.write(bytesBuffer, 0, bytesRead);
}
audioLine.drain();
audioLine.stop();
} catch( IOException ex ) {
LOG.warn(ex, "Error while playing Music source <%s>", audioSource);
}
// Wait for the remaining audio to play
/*while( audioLine.isRunning() ) {
try {
Thread.sleep(10);
} catch( InterruptedException ex ) {
// Just keep waiting ...
}
}*/
playing = false;
streamingStopped();
if( eventDispatcher != null ) {
eventDispatcher.dispatchEvent("stop", Music.this);
}
}
/**
* Öffnet eine {@link SourceDataLine} für die
* {@link #audioSource Audioquelle} und bereitet die Wiedergabe vor. Es wird
* noch nichts abgespielt.
*
* @return {@code true}, wenn die Line geöffnet werden konnte, {@code false}
* sonst.
*/
private boolean openLine() {
if( audioLine != null ) {
return true;
}
try {
URL url = ResourceStreamProvider.getResourceURL(audioSource);
if( url != null ) {
final AudioInputStream inStream = AudioSystem.getAudioInputStream(url);
AudioFormat format = inStream.getFormat();
final int ch = format.getChannels();
final float rate = format.getSampleRate();
AudioFormat outFormat = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED, rate, 16, ch, ch * 2, rate, false);
DataLine.Info info = new DataLine.Info(SourceDataLine.class, outFormat);
audioLine = (SourceDataLine) AudioSystem.getLine(info);
audioLine.open(outFormat);
applyVolume();
audioStream = AudioSystem.getAudioInputStream(outFormat, inStream);
return true;
} else {
LOG.warn("Sound source <%s> could not be played: No audio source found.", audioSource);
}
} catch( UnsupportedAudioFileException ex ) {
LOG.warn(ex, "Sound source <%s> could not be played: The specified audio file is not supported.", audioSource);
} catch( LineUnavailableException ex ) {
LOG.warn(ex, "Sound source <%s> could not be played: Audio line for playing back is unavailable.", audioSource);
} catch( IOException ex ) {
LOG.warn(ex, "Sound source <%s> could not be played: Error playing the audio file.", audioSource);
}
return false;
}
/**
* Wird aufgerufen, wenn die Wiedergabe beendet wurde. Entweder durch einen
* Aufruf von {@link #stop()} oder weil keine Audiodaten mehr vorhanden
* sind.
* <p>
* Nach dem Ende des Streams wird {@link #dispose()} aufgerufen und, falls
* das Musikstück in einer Schleife abgespielt wird, der Stream direkt
* wieder gestartet.
*/
private void streamingStopped() {
dispose();
if( looping ) {
if( openLine() ) {
stream();
} else {
playing = false;
looping = false;
}
} else {
playing = false;
}
}
@Override
public void addAudioListener( AudioListener listener ) {
initializeEventDispatcher().addListener(listener);
}
@Override
public void removeAudioListener( AudioListener listener ) {
initializeEventDispatcher().removeListener(listener);
}
/**
* Interne Methode, um den Listener-Mechanismus zu initialisieren. Wird erst
* aufgerufen, sobald sich der erste Listener anmelden möchte.
*
* @return Der {@code EventDispatcher} für dieses Objekt.
*/
private EventDispatcher<Audio, AudioListener> initializeEventDispatcher() {
if( eventDispatcher == null ) {
eventDispatcher = new EventDispatcher<>();
eventDispatcher.registerEventType("start", ( a, l ) -> l.playbackStarted(a));
eventDispatcher.registerEventType("stop", ( a, l ) -> l.playbackStopped(a));
}
return eventDispatcher;
}
private static final Log LOG = Log.getLogger(Music.class);
}