RecordingTap.java

package org.hammer.audio;

import java.io.IOException;
import java.nio.file.Path;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.Timer;
import org.hammer.audio.core.AudioBlock;
import org.hammer.audio.recording.AudioBlockRecordingWriter;

/**
 * Best-effort recorder that polls an {@link AudioCaptureService} on the Swing EDT and persists
 * every newly observed {@link AudioBlock} to an {@code .aar} file via {@link
 * AudioBlockRecordingWriter}.
 *
 * <p>Blocks are deduplicated by {@link AudioBlock#frameIndex()}. The poll interval should be at
 * least as fast as the capture service produces blocks; otherwise blocks may be missed. With the
 * default capture/demo configuration (≥10 ms per block) the standard UI refresh of a few tens of
 * milliseconds is fast enough for diagnostic recordings.
 *
 * <p>This class lives in {@code audio-app} because it relies on the Swing {@link Timer}; the
 * underlying file format and writer are in {@code audio-dsp}.
 */
public final class RecordingTap {

  private static final Logger LOGGER = Logger.getLogger(RecordingTap.class.getName());

  private final AudioCaptureService service;
  private final AudioBlockRecordingWriter writer;
  private final Timer pollTimer;
  private final AtomicBoolean closed = new AtomicBoolean(false);
  private final Path file;

  private long lastSeenFrameIndex = Long.MIN_VALUE;
  private long blocksWritten;
  private boolean firstBlockSeen;

  /**
   * Start recording from {@code service} into {@code file}.
   *
   * @param service capture service to poll
   * @param file destination file (will be created or truncated)
   * @param pollIntervalMs how often to poll for new blocks
   */
  public static RecordingTap start(AudioCaptureService service, Path file, int pollIntervalMs)
      throws IOException {
    Objects.requireNonNull(service, "service");
    Objects.requireNonNull(file, "file");
    if (pollIntervalMs < 1) {
      throw new IllegalArgumentException("pollIntervalMs must be >= 1, was " + pollIntervalMs);
    }
    AudioBlockRecordingWriter writer = AudioBlockRecordingWriter.open(file);
    RecordingTap tap = new RecordingTap(service, writer, file, pollIntervalMs);
    tap.pollTimer.start();
    return tap;
  }

  private RecordingTap(
      AudioCaptureService service,
      AudioBlockRecordingWriter writer,
      Path file,
      int pollIntervalMs) {
    this.service = service;
    this.writer = writer;
    this.file = file;
    this.pollTimer = new Timer(pollIntervalMs, e -> pollOnce());
    this.pollTimer.setRepeats(true);
  }

  /**
   * @return the destination file passed to {@link #start}
   */
  public Path file() {
    return file;
  }

  /**
   * @return number of blocks written so far
   */
  public long blocksWritten() {
    return blocksWritten;
  }

  /**
   * @return true if {@link #stop()} has been invoked
   */
  public boolean isClosed() {
    return closed.get();
  }

  private void pollOnce() {
    if (closed.get()) {
      return;
    }
    AudioBlock block = service.getLatestBlock();
    if (block == null) {
      return;
    }
    long frameIndex = block.frameIndex();
    if (firstBlockSeen && frameIndex == lastSeenFrameIndex) {
      return;
    }
    if (firstBlockSeen && frameIndex < lastSeenFrameIndex) {
      // Source restarted (e.g. service was stopped/started). Accept it.
      LOGGER.fine(() -> "frame index moved backwards; treating as restart");
    }
    try {
      writer.write(block);
      blocksWritten++;
      lastSeenFrameIndex = frameIndex;
      firstBlockSeen = true;
    } catch (IOException ex) {
      LOGGER.log(Level.WARNING, "failed to write block to recording, stopping tap", ex);
      stopQuietly();
    } catch (RuntimeException ex) {
      // Format mismatch (capture service reconfigured mid-recording), writer already closed,
      // etc. Stop the tap rather than letting the Swing Timer spam the EDT.
      LOGGER.log(Level.WARNING, "runtime error while writing block, stopping tap", ex);
      stopQuietly();
    }
  }

  /** Stop the tap and close the underlying writer. Safe to call multiple times. */
  public void stop() throws IOException {
    if (closed.getAndSet(true)) {
      return;
    }
    pollTimer.stop();
    writer.close();
  }

  private void stopQuietly() {
    try {
      stop();
    } catch (IOException ignored) {
      // already logged above
    }
  }
}