AudioBlockRecordingReader.java

package org.hammer.audio.recording;

import java.io.Closeable;
import java.io.DataInputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.hammer.audio.core.AudioBlock;
import org.hammer.audio.core.AudioFormatDescriptor;

/**
 * Reads {@link AudioBlock}s from a binary recording file written by {@link
 * AudioBlockRecordingWriter}.
 *
 * <p>Instances are <strong>not thread-safe</strong>.
 */
public final class AudioBlockRecordingReader implements Closeable {

  private final DataInputStream in;
  private final AudioFormatDescriptor format;
  private boolean closed;

  /** Open a reader for the given file and parse its header. */
  public static AudioBlockRecordingReader open(Path file) throws IOException {
    Objects.requireNonNull(file, "file");
    return new AudioBlockRecordingReader(Files.newInputStream(file));
  }

  /**
   * Read the entire recording into memory. Suitable for short recordings used by replay or A/B
   * comparison; long recordings should stream via repeated {@link #next()} calls instead.
   */
  public static List<AudioBlock> readAll(Path file) throws IOException {
    try (AudioBlockRecordingReader reader = open(file)) {
      List<AudioBlock> blocks = new ArrayList<>();
      Optional<AudioBlock> next;
      while ((next = reader.next()).isPresent()) {
        blocks.add(next.get());
      }
      return Collections.unmodifiableList(blocks);
    }
  }

  /**
   * Wrap an existing input stream. The stream's header is read immediately.
   *
   * @throws IOException if the header is missing, truncated or uses an unsupported format
   */
  public AudioBlockRecordingReader(InputStream stream) throws IOException {
    this.in = new DataInputStream(Objects.requireNonNull(stream, "stream"));
    this.format = readHeader();
  }

  private AudioFormatDescriptor readHeader() throws IOException {
    int magic = in.readInt();
    if (magic != AudioBlockRecordingFormat.MAGIC) {
      throw new IOException(
          String.format(
              "not an audio block recording: bad magic 0x%08x (expected 0x%08x)",
              magic, AudioBlockRecordingFormat.MAGIC));
    }
    int version = in.readUnsignedShort();
    if (version != AudioBlockRecordingFormat.VERSION) {
      throw new IOException(
          "unsupported recording version "
              + version
              + " (this build supports "
              + AudioBlockRecordingFormat.VERSION
              + ")");
    }
    int channels = in.readUnsignedShort();
    float sampleRate = in.readFloat();
    int sourceBits = in.readUnsignedShort();
    in.readUnsignedShort(); // reserved
    if (channels < 1 || sourceBits < 1 || !(sampleRate > 0f) || Float.isNaN(sampleRate)) {
      throw new IOException(
          "invalid header values: channels="
              + channels
              + " sampleRate="
              + sampleRate
              + " sourceBits="
              + sourceBits);
    }
    return new AudioFormatDescriptor(sampleRate, channels, sourceBits);
  }

  /**
   * @return the audio format descriptor parsed from the file header
   */
  public AudioFormatDescriptor format() {
    return format;
  }

  /**
   * Read the next block or {@link Optional#empty()} at end-of-file.
   *
   * @throws IOException if the file is truncated in the middle of a block
   */
  public Optional<AudioBlock> next() throws IOException {
    if (closed) {
      return Optional.empty();
    }
    int frames;
    try {
      frames = in.readInt();
    } catch (EOFException eof) {
      return Optional.empty();
    }
    if (frames < 0) {
      throw new IOException("negative frame count: " + frames);
    }
    long frameIndex = in.readLong();
    long timestampNanos = in.readLong();
    int channels = format.channels();
    float[][] samples = new float[channels][frames];
    for (int ch = 0; ch < channels; ch++) {
      for (int i = 0; i < frames; i++) {
        samples[ch][i] = in.readFloat();
      }
    }
    return Optional.of(new AudioBlock(format, samples, frameIndex, timestampNanos));
  }

  @Override
  public void close() throws IOException {
    if (closed) {
      return;
    }
    closed = true;
    in.close();
  }
}