/*
 * File    : JavaSoundAudioBuffer.java
 * Created : 29-may-2002 16:24
 * By      : fbusquets
 *
 * JClic - Authoring and playing system for educational activities
 *
 * Copyright (C) 2000 - 2018 Francesc Busquets & Departament
 * d'Educacio de la Generalitat de Catalunya
 *
 * This program 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 2 of the License, or
 * (at your option) any later version.
 *
 * This program 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 (see the LICENSE file).
 */

package edu.xtec.jclic.media;

import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.Timer;
import java.util.TimerTask;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.Line.Info;
import javax.sound.sampled.SourceDataLine;
import javax.sound.sampled.TargetDataLine;

/**
 * This class extends {@link AudioBuffer} using the javax.sound.sampled package.
 *
 * @author Francesc Busquets (fbusquets@xtec.cat)
 * @version 13.09.10
 */
public class JavaSoundAudioBuffer extends AudioBuffer {

  /** Default sample rate used in recordings */
  public static final float RATE = 11025.0F;

  /** Default resolution used in recordings */
  public static final int BITS = 16;

  /** Default number of channels used in recordings */
  public static final int CHANNELS = 1;

  /** Default buffer size */
  public static final int LINE_BUFFER = 10000;

  /** Default size for small buffer used in read loops */
  public static final int STEP_BUFFER = 5000;

  /** Line used for recording audio */
  private static TargetDataLine m_targetLine;

  /** Line used for playing recorded audio */
  private static SourceDataLine m_sourceLine;

  /**
   * Thread used to record audio. Static because only one instance can stay making
   * use of the sound hardware.
   */
  private static RecordThread recordThread;

  /**
   * Thread used to play audio. Static because only one instance can stay making
   * use of the audio hardware.
   */
  private static PlayThread playThread;

  /** Timer used to stop the record thread when the maximum time is achieved */
  private static Timer recordTimer;

  /** <CODE>true</CODE> only when all {@link Line} members are build */
  private static boolean initialized;

  /** Array that holds the recorded audio data */
  private byte[] m_buffer;

  /**
   * Creates new JavaSoundAudioBuffer
   *
   * @param seconds Maximum amount of seconds allowed for recording
   * @throws Exception If something goes wrong...
   */
  public JavaSoundAudioBuffer(int seconds) throws Exception {
    super(seconds);
    initialize();
  }

  public static void initialize() throws Exception {
    if (!initialized)
      buildLines();
  }

  /**
   * Looks for available formats and lines, and builds <CODE>m_sourceLine</CODE>
   * and <CODE>
   * m_targetLine</CODE> members
   *
   * @throws Exception If it was unable to build the lines
   */
  protected static void buildLines() throws Exception {

    // Get lists of all and optimal audio formats
    ArrayList<AudioFormat> vOptimal = new ArrayList<AudioFormat>();
    ArrayList<AudioFormat> vAll = new ArrayList<AudioFormat>();
    for (Info info : AudioSystem.getTargetLineInfo(new Info(TargetDataLine.class))) {
      if (info instanceof javax.sound.sampled.DataLine.Info) {
        for (AudioFormat format : ((javax.sound.sampled.DataLine.Info) info).getFormats()) {
          if (format.getSampleRate() == AudioSystem.NOT_SPECIFIED || format.getSampleRate() >= 8000.0F)
            vAll.add(format);
          if (format.getChannels() == CHANNELS && format.getSampleSizeInBits() == BITS
              && (format.getSampleRate() == AudioSystem.NOT_SPECIFIED || format.getSampleRate() == RATE))
            vOptimal.add(format);
        }
      }
    }

    if (vAll.isEmpty()) {
      throw new Exception("Unable to find any available TargetDataLine for recording");
    }

    // Build m_targetLine
    javax.sound.sampled.DataLine.Info dli;
    AudioFormat[] af;

    // try with optimal formats
    if (!vOptimal.isEmpty()) {
      af = vOptimal.toArray(new AudioFormat[vOptimal.size()]);
      dli = new javax.sound.sampled.DataLine.Info(TargetDataLine.class, af, LINE_BUFFER, LINE_BUFFER + 1000);
      try {
        m_targetLine = (TargetDataLine) AudioSystem.getLine(dli);
        m_targetLine.open();
      } catch (Exception ex) {
        m_targetLine = null;
      }
    }

    // optimal failed: try with all formats
    if (m_targetLine == null) {
      af = vAll.toArray(new AudioFormat[vAll.size()]);
      dli = new javax.sound.sampled.DataLine.Info(TargetDataLine.class, af, LINE_BUFFER, LINE_BUFFER + 1000);
      m_targetLine = (TargetDataLine) AudioSystem.getLine(dli);
      m_targetLine.open();
    }

    // build m_sourceLine
    dli = new javax.sound.sampled.DataLine.Info(SourceDataLine.class, m_targetLine.getFormat());
    m_sourceLine = (SourceDataLine) AudioSystem.getLine(dli);
    m_sourceLine.open();
    initialized = true;
  }

  /**
   * Used for recording data. The thread stops itself when <CODE>running</CODE> is
   * set to <CODE>
   * false</CODE>.
   */
  class RecordThread extends Thread {

    /** When <CODE>false</CODE>, the thread must be stopped as soon as possible. */
    public boolean running;

    /** Creates new RecordThread */
    public RecordThread() {
      running = false;
    }

    /** Thread main method */
    @Override
    public void run() {
      AudioBuffer.activeAudioBuffer = JavaSoundAudioBuffer.this;
      running = true;
      m_buffer = null;
      ByteArrayOutputStream out = new ByteArrayOutputStream(LINE_BUFFER);
      try {
        int avail, numBytesRead;
        byte[] data = new byte[STEP_BUFFER];
        m_targetLine.start();

        do {
          avail = m_targetLine.available();
          if (avail > 0) {
            numBytesRead = m_targetLine.read(data, 0, Math.min(avail, data.length));
            out.write(data, 0, numBytesRead);
          }
          Thread.yield();
        } while (running);

        if (recordTimer != null) {
          recordTimer.cancel();
          recordTimer = null;
        }

      } catch (Exception ex) {
        System.err.println("JavaSound recording error:\n" + ex);
      }
      m_targetLine.stop();
      m_targetLine.flush();
      m_buffer = out.toByteArray();
      AudioBuffer.hideRecordingCursor();
      AudioBuffer.activeAudioBuffer = null;
      recordThread = null;
      running = false;
    }
  }

  /**
   * Used for playing data. The thread stops itself when <CODE>running</CODE> is
   * set to <CODE>false
   * </CODE>, or when all audio data was played.
   */
  class PlayThread extends Thread {
    /** When <CODE>false</CODE>, the thread must be stopped as soon as possible. */
    public boolean running;

    /**
     * In some cases, the data stored in <CODE>m_buffer</CODE> was corrupted after
     * calling to <CODE>
     * m_sourceLine.write</CODE>. A workaround to this JavaSond bug is to make a
     * duplicate of the data and discard it after used.
     */
    byte[] buf;

    /** Creates new PlayThread */
    public PlayThread() {
      running = false;
      buf = new byte[m_buffer.length];
      // make a copy of the audio data
      System.arraycopy(m_buffer, 0, buf, 0, m_buffer.length);
    }

    /** Thread main method */
    @Override
    public void run() {
      running = true;
      try {
        m_sourceLine.start();
        int l = m_sourceLine.getBufferSize() / 2;
        int p = 0;
        int remainingData = buf.length;
        while (running && remainingData > 0) {
          int k = m_sourceLine.write(buf, p, Math.min(l, remainingData));
          p += k;
          remainingData -= k;
          Thread.yield();
        }
      } catch (Exception ex) {
        System.err.println("JavaSound playing error:\n" + ex);
      }
      m_sourceLine.drain();
      m_sourceLine.stop();
      playThread = null;
      running = false;
    }
  }

  /**
   * Starts recording
   *
   * @throws Exception If something goes wrong.
   */
  public void record() throws Exception {
    if (!initialized) {
      AudioBuffer.hideRecordingCursor();
      AudioBuffer.activeAudioBuffer = null;
      return;
    }
    stop();
    recordThread = new RecordThread();
    recordThread.start();
    recordTimer = new Timer();
    recordTimer.schedule(new TimerTask() {
      public void run() {
        recordTimer = null;
        if (recordThread != null) {
          recordThread.running = false;
        }
      }
    }, m_seconds * 1000);
  }

  /**
   * Plays recorded audio data, if any.
   *
   * @throws Exception If something goes wrong
   */
  public void play() throws Exception {
    if (!initialized)
      return;
    stop();
    if (m_buffer != null && m_buffer.length > 0) {
      playThread = new PlayThread();
      playThread.start();
    }
  }

  /**
   * Checks if the AudioBuffer is currently playing or recording sound.
   *
   * @return <CODE>true</CODE> if playing or recording, <CODE>false</CODE>
   *         otherwise
   */
  private boolean isRunning() {
    return (playThread != null || recordThread != null);
  }

  /** If running, gently stops play and record threads */
  public void stop() {
    if (recordThread != null)
      recordThread.running = false;
    if (playThread != null)
      playThread.running = false;
    while (recordThread != null || playThread != null) {
      Thread.yield();
    }
  }

  /** Stops playing or recording and clears all recorded data */
  protected void clear() {
    stop();
    m_buffer = null;
  }
}
