RecordedAudioCaptureService.java

package org.hammer.audio;

import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.sound.sampled.AudioFormat;
import org.hammer.audio.buffer.AudioRingBuffer;
import org.hammer.audio.core.AudioBlock;
import org.hammer.audio.core.AudioFormatDescriptor;
import org.hammer.audio.recording.AudioBlockRecordingReader;
import org.hammer.audio.snapshot.WaveformSnapshot;
import org.hammer.audio.ui.WaveformRenderer;

/**
 * {@link AudioCaptureService} that replays a previously recorded {@code .aar} file. Blocks are
 * published at their natural sample-rate pace and exposed exactly like live or demo capture, so the
 * rest of the application (waveform panel, spectrum, diagnosis, evidence export, ...) is unaware of
 * the origin of the audio data.
 *
 * <p>When the recording is exhausted the service automatically stops, mirroring the behavior of
 * pressing "Stop" on a live capture.
 */
public final class RecordedAudioCaptureService implements AudioCaptureService {

  private static final int RING_BUFFER_CAPACITY = 64;

  private final List<AudioBlock> blocks;
  private final AudioFormatDescriptor descriptor;
  private final AudioFormat format;
  private final AudioRingBuffer<AudioBlock> ringBuffer =
      new AudioRingBuffer<>(RING_BUFFER_CAPACITY);
  private final AtomicBoolean running = new AtomicBoolean(false);
  private final boolean loop;

  private volatile WaveformModel latestModel = WaveformModel.EMPTY;
  private volatile AudioBlock latestBlock;
  private volatile int divisor = 1;
  private volatile int panelWidth = 640;
  private volatile int panelHeight = 200;

  private ExecutorService workerExecutor;

  /** Open a recording file and load its blocks into memory. */
  public static RecordedAudioCaptureService open(Path file, boolean loop) throws IOException {
    Objects.requireNonNull(file, "file");
    List<AudioBlock> blocks = AudioBlockRecordingReader.readAll(file);
    if (blocks.isEmpty()) {
      throw new IOException("recording contains no blocks: " + file);
    }
    return new RecordedAudioCaptureService(blocks, loop);
  }

  /**
   * @param blocks non-empty list of blocks to replay (all must share the same {@link
   *     AudioFormatDescriptor})
   * @param loop if true, replay continuously; otherwise stop after the last block
   */
  public RecordedAudioCaptureService(List<AudioBlock> blocks, boolean loop) {
    Objects.requireNonNull(blocks, "blocks");
    if (blocks.isEmpty()) {
      throw new IllegalArgumentException("blocks must be non-empty");
    }
    this.descriptor = blocks.get(0).format();
    for (AudioBlock b : blocks) {
      if (!descriptor.equals(b.format())) {
        throw new IllegalArgumentException(
            "all blocks must share the same format; first=" + descriptor + " block=" + b.format());
      }
    }
    this.blocks = List.copyOf(blocks);
    this.loop = loop;
    this.format =
        new AudioFormat(
            descriptor.sampleRate(),
            descriptor.sourceSampleSizeInBits(),
            descriptor.channels(),
            true,
            false);
  }

  /**
   * @return true if replay restarts at the beginning after reaching the end
   */
  public boolean isLooping() {
    return loop;
  }

  /**
   * @return the number of blocks in this recording
   */
  public int blockCount() {
    return blocks.size();
  }

  @Override
  public void start() {
    if (running.getAndSet(true)) {
      return;
    }
    workerExecutor =
        Executors.newSingleThreadExecutor(
            runnable -> {
              Thread worker = new Thread(runnable, "RecordedAudioCaptureWorker");
              worker.setDaemon(true);
              return worker;
            });
    workerExecutor.submit(this::replayLoop);
  }

  @Override
  public void stop() {
    if (!running.getAndSet(false)) {
      return;
    }
    if (workerExecutor != null) {
      workerExecutor.shutdownNow();
      try {
        if (!workerExecutor.awaitTermination(1, TimeUnit.SECONDS)) {
          workerExecutor.shutdownNow();
        }
      } catch (InterruptedException interruptedException) {
        Thread.currentThread().interrupt();
      }
      workerExecutor = null;
    }
  }

  @Override
  public boolean isRunning() {
    return running.get();
  }

  @Override
  public WaveformModel getLatestModel() {
    return latestModel != null ? latestModel : WaveformModel.EMPTY;
  }

  @Override
  public AudioFormat getFormat() {
    return format;
  }

  @Override
  public AudioFormatDescriptor getDescriptor() {
    return descriptor;
  }

  @Override
  public AudioBlock getLatestBlock() {
    return latestBlock;
  }

  @Override
  public AudioRingBuffer<AudioBlock> getRingBuffer() {
    return ringBuffer;
  }

  @Override
  public void setDivisor(int divisor) {
    if (divisor < 1) {
      throw new IllegalArgumentException("Divisor must be >= 1");
    }
    this.divisor = divisor;
  }

  @Override
  public int getDivisor() {
    return divisor;
  }

  @Override
  public void recomputeLayout(int width, int height) {
    panelWidth = width;
    panelHeight = height;
    AudioBlock block = latestBlock;
    if (block != null) {
      latestModel = buildLegacyModel(block);
    }
  }

  private void replayLoop() {
    int index = 0;
    while (running.get() && !Thread.currentThread().isInterrupted()) {
      AudioBlock block = blocks.get(index);
      latestBlock = block;
      ringBuffer.offer(block);
      latestModel = buildLegacyModel(block);
      int sleepMillis =
          Math.max(5, Math.round((1000f * block.frames()) / Math.max(1f, descriptor.sampleRate())));
      try {
        Thread.sleep(sleepMillis);
      } catch (InterruptedException interruptedException) {
        Thread.currentThread().interrupt();
        return;
      }
      index++;
      if (index >= blocks.size()) {
        if (!loop) {
          running.set(false);
          return;
        }
        index = 0;
      }
    }
  }

  private WaveformModel buildLegacyModel(AudioBlock block) {
    WaveformSnapshot snapshot =
        WaveformSnapshot.wrap(
            block.samples(),
            block.format().sampleRate(),
            block.frameIndex(),
            block.timestampNanos());
    int[] xPoints = WaveformRenderer.computeXPoints(snapshot.frames(), panelWidth);
    int[][] yPoints;
    if (panelHeight <= 0) {
      yPoints = new int[snapshot.channels()][0];
    } else {
      yPoints = WaveformRenderer.computeYPointsAllChannels(snapshot, panelHeight);
    }
    int dataSizeBytes =
        snapshot.frames()
            * snapshot.channels()
            * Math.max(1, descriptor.sourceSampleSizeInBits() / 8);
    return new WaveformModel(xPoints, yPoints, 0, dataSizeBytes);
  }
}