/* * Copyright (C) 2004 Keith Stribley <tech@thanlwinsoft.org> * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. * * This library 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 * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, * MA 02110-1301 USA */ package org.thanlwinsoft.languagetest.sound; import java.io.File; import java.io.IOException; import javax.sound.sampled.AudioInputStream; import javax.sound.sampled.AudioSystem; import javax.sound.sampled.AudioFormat; import javax.sound.sampled.DataLine; import javax.sound.sampled.UnsupportedAudioFileException; /** * Handles input into the java program i.e. playback of sound files * @author keith */ public class InputLineController implements Runnable { final static int PAUSED = 0; final static int SEEKING = 1; final static int PLAYING = 2; final static int PLAY_AFTER_SEEK = 3; final static int SEEK_TO_START = 4; final static int PLAY_FROM_START = 5; private final static long MAX_SINGLE_SEEK = 1; // seconds private final static int MARK_INVALID_SIZE = 16777216; private final static int CACHE_SIZE = 30; // seconds private final static float READ_BUFFER_LENGTH_SEC = (float)1; // 1 second private boolean stop = false; private long startByteOffset = 0; private long stopByteOffset = -1; private long bytesPerFrame = -1; private float framesPerSec = -1; private long bytesPerSec = -1; private long markPosition = -1; private int maxSeek = 0; private int state = SEEK_TO_START; private long playPosition = 0; private long readPosition = 0; private long seekTarget = 0; private long seekStart = 0; private long totalLength = -1; private long boundsBegin = 0; private long boundsDuration = -1; //private long preBuffer = 0; private File audioFile = null; private AudioInputStream input = null; private AudioInputStream fileStream = null; private OutputLineController outputController = null; private CacheBuffer cacheBuffer = null; private byte [] readBuffer = null; private int unwrittenStart = 0; private int unwrittenLength = 0; private AudioPlayListener listener = null; private AudioFormat outputFormat = null; private LineController lineController = null; private long msLengthHint = AudioSystem.NOT_SPECIFIED; /** Creates a new instance of InputLineController */ public InputLineController(File file, long msLengthHint, LineController lineController) { this.audioFile = file; this.lineController = lineController; this.outputFormat = lineController.getPlayLineFormat(); this.msLengthHint = msLengthHint; } protected void init() throws IOException, UnsupportedAudioFileException { while (lineController.getSourceDataLine()==null) { if (stop) return; sleep(); } openInputStream(audioFile); bytesPerFrame = input.getFormat().getFrameSize(); framesPerSec = input.getFormat().getFrameRate(); bytesPerSec = bytesPerFrame * (int)framesPerSec; System.out.println("Frame size: " + bytesPerFrame + " Frame Rate: " + framesPerSec); maxSeek = (int)(MAX_SINGLE_SEEK * bytesPerSec); // reset offsets startByteOffset = 0; if (input.getFrameLength() > 0) { stopByteOffset = input.getFrameLength() * bytesPerFrame; totalLength = stopByteOffset; } else if (msLengthHint > 0) // use hint for now { stopByteOffset = msToBytes(msLengthHint); totalLength = stopByteOffset; } else { stopByteOffset = -1; totalLength = -1; } System.out.println("Input file length = " + bytesToMs(totalLength)); //input obtained, now mark start so we can go back to it markPosition = 0; if (input.markSupported()) { input.mark(MARK_INVALID_SIZE); } else { try { cacheBuffer = new CacheBuffer((int)(bytesPerSec * CACHE_SIZE)); } catch (OutOfMemoryError e) { System.out.println(e.getMessage()); } } int bufferLength = (int)(bytesPerSec * READ_BUFFER_LENGTH_SEC); readBuffer = new byte[roundOffsetToFrame(bufferLength)]; unwrittenStart = 0; unwrittenLength = 0; // set bounds properly now parameters are known setBounds(boundsBegin, boundsDuration); } public File getAudioFile() { return audioFile; } public synchronized void setOutputController(OutputLineController c) { if (state == PLAYING) { state = PAUSED; } this.outputController = c; // if (c != null) // { // this.preBuffer = c.getBufferSize() / 2; // } } protected void openInputStream(File audioFile) throws IOException, UnsupportedAudioFileException { fileStream = AudioSystem.getAudioInputStream(audioFile); AudioFormat audioFormat = fileStream.getFormat(); System.out.println( "Play input audio format=" + audioFormat); System.out.println("Frame length=" + fileStream.getFrameLength()); // Convert compressed audio data to uncompressed PCM format. if (lineController != null && !audioFormat.matches(lineController.getPlayLineFormat())) { // perhaps it supports the format dirrectly if (((DataLine.Info) lineController.getSourceDataLine().getLineInfo()) .isFormatSupported(audioFormat)) { // line supports this format directly input = fileStream; outputFormat = audioFormat; if (outputController != null) { outputController.setFormat(audioFormat); outputController.open(); } } else { // make a PCM_SIGNED version of the format and see if that is // supported int frameSize = lineController.getPlayLineFormat().getSampleSizeInBits() * audioFormat.getChannels() / 8; frameSize = lineController.getPlayLineFormat().getFrameSize(); outputFormat = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED, audioFormat.getSampleRate(), lineController.getPlayLineFormat().getSampleSizeInBits(), // not sure about this one audioFormat.getChannels(), frameSize, audioFormat.getSampleRate(), lineController.getPlayLineFormat().isBigEndian()); if (AudioSystem.isConversionSupported(outputFormat, audioFormat) && ((DataLine.Info) lineController.getSourceDataLine().getLineInfo()) .isFormatSupported(outputFormat)) { System.out.println( "Converting audio format to " + outputFormat ); input = AudioSystem.getAudioInputStream( outputFormat, fileStream ); if (outputController != null) { outputController.setFormat(outputFormat); outputController.open(); } } else { System.out.println("Conversion not supported " + audioFormat + "->" + outputFormat); input = null; throw new UnsupportedAudioFileException("Conversion not supported " + audioFormat + "->" + outputFormat); } } } else { outputFormat = lineController.getPlayLineFormat(); input = fileStream; if (outputController != null) { outputController.setFormat(audioFormat); outputController.open(); } } playPosition = 0; readPosition = 0; } public void stopThread() { stop = true; } public synchronized void stop() { switch (state) { case PLAYING: if (outputController != null) { outputController.stop(); } // fall through for rest of actions case PAUSED: // if it was playing or just paused then output should be // flushed if (outputController != null) { outputController.flush(); } // fall through for rest of actions - the remaining cases should // already have been flushed or drained case SEEKING: case PLAY_AFTER_SEEK: state = SEEK_TO_START; unwrittenStart = 0; unwrittenLength = 0; setSeekTarget(startByteOffset); break; case SEEK_TO_START: break; case PLAY_FROM_START: state = SEEK_TO_START; break; default: break; } } public void run() { int oldState = 0; try { init(); while (!stop) { int instantaneousState; synchronized (this) { instantaneousState = state; } if (oldState != instantaneousState) { System.out.println("InputLineController state " + stateToString(instantaneousState) + " " + this.hashCode()); oldState = instantaneousState; } switch (instantaneousState) { case PAUSED: showPlayPosition(); sleep(); break; case SEEKING: case PLAY_AFTER_SEEK: case SEEK_TO_START: case PLAY_FROM_START: showPlayPosition(); if (input.markSupported()) { seekWithMark(); } else { seekWithCache(); } break; case PLAYING: playState(); break; default: sleep(); } } // tidy up before thread exits if (input != null) input.close(); // are we direcly using the fileStream or is it going through // a second stream that we need to close as well if (input != fileStream) { fileStream.close(); fileStream = null; } input = null; } // TBD give user warning catch (UnsupportedAudioFileException uafe) { System.out.println(uafe.getMessage()); } catch (IOException ioe) { System.out.println(ioe.getMessage()); } if (outputController != null) { outputController.stop(); } // set the buffers to null to free memory if (cacheBuffer != null) { cacheBuffer.clear(); cacheBuffer = null; } readBuffer = null; outputController = null; listener = null; audioFile = null; System.out.println("InputLineController thread finished"); } public synchronized void seek(long amount) { switch (state) { case PAUSED: // a seek after a pause should be flushed if (outputController != null) { outputController.flush(); } seekTarget = playPosition; state = SEEKING; case SEEKING: case PLAY_AFTER_SEEK: case SEEK_TO_START: case PLAY_FROM_START: long newSeek = seekTarget + msToBytes(amount); if (newSeek >= startByteOffset && (newSeek < stopByteOffset || stopByteOffset < 0)) { setSeekTarget(newSeek); } break; default: // do nothing break; } } public synchronized void pause() { switch (state) { case PAUSED: case SEEKING: case SEEK_TO_START: if (outputController != null) { outputController.stop(); } break; case PLAY_AFTER_SEEK: state = SEEKING; break; case PLAY_FROM_START: state = SEEK_TO_START; break; case PLAYING: state = PAUSED; if (outputController != null) { outputController.stop(); } break; default: break; } } void setSeekTarget(long target) { seekStart = playPosition; if (seekStart > target) seekStart = 0; seekTarget = roundOffsetToFrame(target); } public synchronized void play() { // if there is still data in the buffer use that up // we must have paused before the current state if (outputController != null && (outputController.getBufferSize() != outputController.writeSpaceAvailable())) { switch (state) { case PAUSED: state = PLAYING; outputController.keepPlaying(); break; case SEEKING: outputController.playToEnd(); break; case SEEK_TO_START: outputController.playToEnd(); break; case PLAY_AFTER_SEEK: break; case PLAY_FROM_START: break; case PLAYING: break; default: break; } } else { switch (state) { case PAUSED: state = PLAYING; break; case SEEKING: state = PLAY_AFTER_SEEK; break; case SEEK_TO_START: state = PLAY_FROM_START; break; case PLAY_AFTER_SEEK: break; case PLAY_FROM_START: break; case PLAYING: break; default: break; } } } public void sleep() { try { Thread.sleep(10); } catch (InterruptedException e) { // just return immediately } } protected void seekWithMark() throws IOException, UnsupportedAudioFileException { //boolean seekFinished = false; long thisSeek = seekTarget - playPosition; if (thisSeek == 0) { if (playPosition == startByteOffset) { input.mark(MARK_INVALID_SIZE); markPosition = playPosition; } synchronized (this) { // change state switch (state) { case PLAY_AFTER_SEEK: case PLAY_FROM_START: state = PLAYING; break; default: state = PAUSED; } showInitialisationProgress(); } } if (thisSeek < 0) // rewind { if (input.markSupported()) { if (markPosition > 0 && markPosition <= seekTarget) { input.reset(); playPosition = markPosition; } else { openInputStream(audioFile); } } else if (cacheBuffer.isCached(seekTarget) && readPosition == cacheBuffer.getWriteOffset()) { playPosition = seekTarget; } else { openInputStream(audioFile); } } else // forward skip { if (markPosition > seekTarget) { input.reset(); playPosition = markPosition; } else { if (thisSeek > maxSeek) thisSeek = maxSeek; thisSeek = roundOffsetToFrame(thisSeek); // don't skip over start. Break the skip so start can be marked if ((playPosition + thisSeek) > startByteOffset) { thisSeek = startByteOffset - playPosition; long skipped = input.skip(thisSeek); if (skipped == thisSeek) input.mark(MARK_INVALID_SIZE); playPosition += skipped; markPosition = playPosition; } else { long skipped = input.skip(thisSeek); playPosition += skipped; } } } } protected void seekWithCache() throws IOException, UnsupportedAudioFileException { //boolean seekFinished = false; int thisSeek = (int)(seekTarget - playPosition); thisSeek = roundOffsetToFrame(thisSeek); if (thisSeek == 0) { synchronized (this) { // change state switch (state) { case PLAY_AFTER_SEEK: case PLAY_FROM_START: state = PLAYING; break; default: state = PAUSED; } showInitialisationProgress(); if (cacheBuffer != null && cacheBuffer.getWriteOffset() != playPosition && !cacheBuffer.isCached(playPosition)) { // if cache is not already initialised for current play // position, init it now ready for playing cacheBuffer.reinit(playPosition); readPosition = playPosition; } } } else if (thisSeek < 0) // rewind { if (cacheBuffer != null && cacheBuffer.isCached(seekTarget) && readPosition == cacheBuffer.getWriteOffset()) { playPosition = seekTarget; } else { openInputStream(audioFile); } } else // forward skip { // use cache if (cacheBuffer != null && cacheBuffer.isCached(seekTarget) && readPosition == cacheBuffer.getWriteOffset()) { playPosition = seekTarget; } else // real seek { if (thisSeek > maxSeek) thisSeek = maxSeek; thisSeek = roundOffsetToFrame(thisSeek); // don't skip over start. Break the skip so cache can be started if ((playPosition < startByteOffset)&& (playPosition + thisSeek >= startByteOffset)) { thisSeek = (int)(startByteOffset - playPosition); // reinit cache long skipped = input.skip(thisSeek); if (cacheBuffer != null && skipped == thisSeek) { cacheBuffer.reinit(startByteOffset); } playPosition += skipped; readPosition = playPosition; } // after start, read into cache else if(cacheBuffer != null && (readPosition == cacheBuffer.getWriteOffset()) && (readPosition >= startByteOffset)) { int skipped = input.read(readBuffer, 0, thisSeek); if (skipped > 0) { cacheBuffer.write(readBuffer, skipped); playPosition += skipped; readPosition = playPosition; } else // end of buffer reached { endOfBufferReached(); } } else // plain skip { int skipped = (int)input.skip(thisSeek); if (skipped == 0) // try a read instead { skipped = input.read(readBuffer, 0, thisSeek); } // check that there wasn't a failed read (-1 return) if (skipped > 0) { playPosition += skipped; readPosition = playPosition; } else // end of buffer reached { endOfBufferReached(); sleep(); } } } } showInitialisationProgress(); } protected void endOfBufferReached() { synchronized (this) { state = SEEK_TO_START; totalLength = playPosition; if (startByteOffset > totalLength) { startByteOffset = totalLength; } if (stopByteOffset > totalLength) { stopByteOffset = totalLength; } } } protected void playState() throws IOException { if (outputController != null && outputController.open()) { if (unwrittenLength > 0) { synchronized (this) { if (outputController != null) { // write may fail if not playing! outputController.keepPlaying(); int written = outputController.write(readBuffer, unwrittenStart, unwrittenLength); if (written == unwrittenLength) { unwrittenStart = 0; unwrittenLength = 0; } else { unwrittenStart += written; unwrittenLength -= written; } } } if (unwrittenLength > 0) sleep(); return; } int bytesToRead = outputController.writeSpaceAvailable(); if (bytesToRead > readBuffer.length) { bytesToRead = readBuffer.length; } synchronized (this) { if (outputController != null) { if (stopByteOffset > -1 && playPosition + bytesToRead >= stopByteOffset) { bytesToRead = (int)(stopByteOffset - playPosition); outputController.playToEnd(); }/* else if (outputController.getBufferSize() - outputController.writeSpaceAvailable() < preBuffer) { // don't start playing yet, wait for more data in output // buffer }*/ else { outputController.keepPlaying(); } } } bytesToRead = roundOffsetToFrame(bytesToRead); // if buffer is full try sleeping if (bytesToRead == 0) sleep(); if (input.markSupported()) { // check mark will still be valid after read if (markPosition + MARK_INVALID_SIZE <= playPosition + bytesToRead) { // remark so we still have a mark for small rewinds markPosition = playPosition; input.mark(MARK_INVALID_SIZE); } } // otherwise the data may be read straight from cache else if (cacheBuffer != null && cacheBuffer.isCached(playPosition)) { int read = 0; if (bytesToRead + playPosition <= readPosition) { read = cacheBuffer.read(readBuffer, playPosition, (int)bytesToRead); } else // limit this write to date in cache { read = cacheBuffer.read(readBuffer, playPosition, (int)(readPosition - playPosition)); } playPosition += read; synchronized (this) { if (outputController != null) { int wrote = outputController.write(readBuffer, 0, read); if (wrote != read) { unwrittenStart = wrote; unwrittenLength = read - wrote; System.out.println("Output failed to write " + (read - wrote) + " bytes"); outputController.flush(); } } } // already finished reading bytesToRead = 0; } // data is not in cache so need to read it directly from stream if (bytesToRead > 0) { int read = input.read(readBuffer, 0, bytesToRead); if (read == -1) { // end of stream totalLength = playPosition; synchronized (this) { stopByteOffset = totalLength; } } else { int bytesToWrite = roundOffsetToFrame(read); if (bytesToWrite != read) { System.out.println("Only read " + read + " using " + bytesToWrite); } playPosition += read; synchronized (this) { if (outputController != null) { int wrote = outputController.write(readBuffer, 0, bytesToWrite); if (wrote != read) { unwrittenStart = wrote; unwrittenLength = read - wrote; System.out.println("Output failed to write " + (read - wrote) + " bytes"); outputController.flush(); } } } // sort out cache if (cacheBuffer != null && cacheBuffer.getWriteOffset() == readPosition) { cacheBuffer.write(readBuffer, read); readPosition = playPosition; } } } else { sleep(); } showPlayPosition(); } else { sleep(); // wait for open } if (stopByteOffset > -1 && (playPosition > stopByteOffset - bytesPerFrame)) { synchronized (this) { seekTarget = startByteOffset; state = SEEK_TO_START; // reset unwritten unwrittenStart = 0; unwrittenLength = 0; } } } /** * Method to restrict play of the sound file to the specified time window * within the file. The method will adjust the offsets appropriately if they * are out of range. * The actual offsets used can be retrieved using the @see getStartMs() * and @see getDurationMs() methods. * @param start Start time in ms from the beginning of the file * @param duration Length of play window in ms */ public void setBounds(long start, long duration) { long newStart; long newStop; // store bounds for later use if parameters not initialised yet boundsBegin = start; boundsDuration = duration; // can't set properly if bytesPerFrame and framesPerSec not initialised // setBounds will be called again when they are if (bytesPerFrame < 0 || framesPerSec < 0) return; if (start > 0) { newStart = roundOffsetToFrame(msToBytes(start)); } else { newStart = 0; } if (duration > 0) { newStop = roundOffsetToFrame(newStart + msToBytes(duration)); } else { if (totalLength > 0) { newStop = totalLength; } else { newStop = -1; } } // protect the section where we change the valuses used by the // play thread. synchronized (this) { if (newStart != startByteOffset || newStop != stopByteOffset) { startByteOffset = newStart; stopByteOffset = newStop; switch (state) { case PAUSED: if ((playPosition > stopByteOffset && stopByteOffset > -1) || playPosition < startByteOffset) { if (outputController != null) { outputController.flush(); } setSeekTarget (startByteOffset); state = SEEK_TO_START; } break; case PLAY_FROM_START: case SEEK_TO_START: setSeekTarget(startByteOffset); break; case SEEKING: if ((seekTarget > stopByteOffset) && (stopByteOffset > -1)) { setSeekTarget(startByteOffset); state = SEEK_TO_START; } if (seekTarget < startByteOffset) { setSeekTarget(startByteOffset); state = SEEK_TO_START; } break; case PLAY_AFTER_SEEK: if (seekTarget > stopByteOffset && stopByteOffset > -1) { setSeekTarget(startByteOffset); state = PLAY_FROM_START; } if (seekTarget < startByteOffset) { setSeekTarget(startByteOffset); state = PLAY_FROM_START; } break; case PLAYING: if ((playPosition > stopByteOffset && stopByteOffset > -1)|| playPosition < startByteOffset) { if (outputController != null) { outputController.stop(); outputController.flush(); } setSeekTarget(startByteOffset); state = SEEK_TO_START; } break; } } } } protected int roundOffsetToFrame(int newOffset) { return (int)(Math.floor((float)newOffset / (float)bytesPerFrame)) * (int)bytesPerFrame; } protected long roundOffsetToFrame(long newOffset) { return (long)(Math.floor((float)newOffset / (float)bytesPerFrame)) * bytesPerFrame; } protected long bytesToMs(long value) { // treat -1 specially if (value < 0) return AudioSystem.NOT_SPECIFIED; float byteOffset = (float)value; float secondsOffset = byteOffset / (float)(bytesPerFrame * framesPerSec); return (long)(secondsOffset * 1000); } protected long msToBytes(long value) { // if neg values aren't handled then rewind doesn't work! //if (value < 0) return AudioSystem.NOT_SPECIFIED; float secOffset = ((float)value) / 1000; float byteOffset = secOffset * bytesPerFrame * framesPerSec; return (long)(byteOffset); } public synchronized long getStartMs() { return bytesToMs(startByteOffset); } public synchronized long getDurationMs() { return bytesToMs(stopByteOffset - startByteOffset); } /** Method calls the playPosition method on each registered * AudioPlayListener. */ protected synchronized void showPlayPosition() { if (listener != null) { if (outputController != null && outputController.isOpen()) { listener.initialisationProgress(100); } // correct position for data still in buffer long buffered = outputController.getBufferSize() - outputController.writeSpaceAvailable(); long position = playPosition - buffered; // if rewind has already started then playPosition is invalid if (playPosition <= startByteOffset && buffered > 0) { //System.out.println("still " + playPosition + " " + buffered); position = stopByteOffset - buffered; } listener.playPosition(bytesToMs(position), bytesToMs(totalLength)); } } public synchronized void setListener(AudioPlayListener listener) { this.listener = listener; } protected void showInitialisationProgress() { if (listener != null) { long buffered = outputController.getBufferSize() - outputController.writeSpaceAvailable(); // play hasn't finished yet, so don't show init progress yet if (buffered > 0) { showPlayPosition(); } else { int progress = 0; if (seekTarget == seekStart) progress = 100; else { progress = (int)(100 * (playPosition - seekStart) / (seekTarget - seekStart)); } listener.initialisationProgress(progress); } } } protected String stateToString(int s) { String stateName = "Unknown"; switch (s) { case PAUSED: stateName = "PAUSED"; break; case SEEKING: stateName = "SEEKING"; break; case PLAYING: stateName = "PLAYING"; break; case PLAY_AFTER_SEEK: stateName = "PLAY_AFTER_SEEK"; break; case SEEK_TO_START: stateName = "SEEK_TO_START"; break; case PLAY_FROM_START: stateName = "PLAY_FROM_START"; break; } return stateName; } }