EvidenceBundleExporter.java

package org.hammer.audio.export;

import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import javax.imageio.ImageIO;
import org.hammer.audio.analysis.SpectrumSnapshot;
import org.hammer.audio.core.AudioBlock;
import org.hammer.audio.diagnosis.DiagnosisFinding;
import org.hammer.audio.diagnosis.DiagnosisSnapshot;
import org.hammer.audio.localization.StereoDelaySnapshot;
import org.hammer.audio.spectrogram.SpectrogramFrame;
import org.hammer.audio.spectrogram.SpectrogramHistory;

/**
 * Self-contained evidence bundle writer.
 *
 * <p>Given a parent directory and an immutable {@link EvidenceData} payload, writes a {@code
 * measurement-YYYYMMDD-HHMMSS} sub-directory containing:
 *
 * <ul>
 *   <li>{@code screenshot.png} (if screenshot supplied)
 *   <li>{@code samples.csv} per-frame, per-channel sample table
 *   <li>{@code spectrum.csv} one-sided FFT magnitudes
 *   <li>{@code spectrogram.csv} time-major spectrogram frames
 *   <li>{@code stereo-delay.csv} stereo delay measurement
 *   <li>{@code diagnosis.txt} human-readable diagnosis findings
 *   <li>{@code metadata.json} bundle metadata (timestamp, formats, sizes, etc.)
 * </ul>
 *
 * <p>Any optional artifact whose source data is {@code null} is omitted; an empty bundle (no
 * artifacts at all) is rejected.
 */
public final class EvidenceBundleExporter {

  private static final DateTimeFormatter DIR_TIMESTAMP =
      DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss", Locale.ROOT);

  private final ZoneId zoneId;

  /** Create an exporter using the system default time zone. */
  public EvidenceBundleExporter() {
    this(ZoneId.systemDefault());
  }

  /**
   * Create an exporter with a fixed time zone.
   *
   * @param zoneId zone used to format the bundle directory name
   */
  public EvidenceBundleExporter(ZoneId zoneId) {
    this.zoneId = zoneId;
  }

  /**
   * Write an evidence bundle.
   *
   * @param parentDirectory directory under which the bundle directory is created
   * @param data evidence payload; must not be entirely empty
   * @return the path to the created bundle directory
   * @throws IOException if writing any artifact fails
   * @throws IllegalArgumentException if the payload contains no usable data
   */
  public Path export(Path parentDirectory, EvidenceData data) throws IOException {
    if (data == null) {
      throw new IllegalArgumentException("data must not be null");
    }
    if (!data.hasAnything()) {
      throw new IllegalArgumentException("Evidence payload contains no data to export");
    }
    Files.createDirectories(parentDirectory);
    Instant now = data.timestamp() != null ? data.timestamp() : Instant.now();
    String baseName = "measurement-" + LocalDateTime.ofInstant(now, zoneId).format(DIR_TIMESTAMP);
    Path bundleDir = parentDirectory.resolve(baseName);
    int suffix = 1;
    while (Files.exists(bundleDir)) {
      bundleDir = parentDirectory.resolve(baseName + "-" + suffix);
      suffix++;
    }
    Files.createDirectory(bundleDir);
    String name = bundleDir.getFileName().toString();

    if (data.screenshot() != null) {
      writeScreenshot(bundleDir.resolve("screenshot.png"), data.screenshot());
    }
    if (data.block() != null) {
      writeSamples(bundleDir.resolve("samples.csv"), data.block());
    }
    if (data.spectrum() != null) {
      writeSpectrum(bundleDir.resolve("spectrum.csv"), data.spectrum());
    }
    if (data.spectrogram() != null && !data.spectrogram().isEmpty()) {
      writeSpectrogram(bundleDir.resolve("spectrogram.csv"), data.spectrogram());
    }
    if (data.stereoDelay() != null) {
      writeStereoDelay(bundleDir.resolve("stereo-delay.csv"), data.stereoDelay());
    }
    if (data.diagnosis() != null) {
      writeDiagnosis(bundleDir.resolve("diagnosis.txt"), data.diagnosis());
    }
    writeMetadata(bundleDir.resolve("metadata.json"), name, now, data);
    return bundleDir;
  }

  private static void writeScreenshot(Path file, BufferedImage image) throws IOException {
    if (!ImageIO.write(image, "png", file.toFile())) {
      throw new IOException("No PNG writer available for screenshot");
    }
  }

  private static void writeSamples(Path file, AudioBlock block) throws IOException {
    try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(file, StandardCharsets.UTF_8))) {
      w.print("frame");
      for (int c = 0; c < block.channels(); c++) {
        w.print(",channel");
        w.print(c);
      }
      w.println();
      float[][] samples = block.samples();
      for (int frame = 0; frame < block.frames(); frame++) {
        w.print(frame);
        for (int c = 0; c < block.channels(); c++) {
          w.printf(Locale.ROOT, ",%.9f", samples[c][frame]);
        }
        w.println();
      }
    }
  }

  private static void writeSpectrum(Path file, SpectrumSnapshot spectrum) throws IOException {
    try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(file, StandardCharsets.UTF_8))) {
      w.println("bin,frequencyHz,magnitude");
      for (int bin = 0; bin < spectrum.binCount(); bin++) {
        w.printf(
            Locale.ROOT,
            "%d,%.6f,%.9f%n",
            bin,
            spectrum.frequencyOfBin(bin),
            spectrum.magnitude(bin));
      }
    }
  }

  private static void writeSpectrogram(Path file, SpectrogramHistory history) throws IOException {
    List<SpectrogramFrame> frames = history.snapshot();
    try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(file, StandardCharsets.UTF_8))) {
      w.print("frameIndex,timestampNanos");
      int bins = history.binCount();
      for (int bin = 0; bin < bins; bin++) {
        w.printf(Locale.ROOT, ",bin%d", bin);
      }
      w.println();
      for (SpectrogramFrame f : frames) {
        w.print(f.sourceFrameIndex());
        w.print(",");
        w.print(f.sourceTimestampNanos());
        float[] view = f.magnitudesView();
        for (int bin = 0; bin < bins; bin++) {
          float m = bin < view.length ? view[bin] : 0f;
          w.printf(Locale.ROOT, ",%.9f", m);
        }
        w.println();
      }
    }
  }

  private static void writeStereoDelay(Path file, StereoDelaySnapshot delay) throws IOException {
    try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(file, StandardCharsets.UTF_8))) {
      w.println("key,value");
      w.printf(Locale.ROOT, "status,%s%n", delay.status());
      w.printf(Locale.ROOT, "valid,%s%n", delay.valid());
      w.printf(Locale.ROOT, "delaySamples,%d%n", delay.delaySamples());
      w.printf(Locale.ROOT, "delayMillis,%.6f%n", delay.delayMillis());
      w.printf(
          Locale.ROOT, "pathLengthDifferenceMeters,%.6f%n", delay.pathLengthDifferenceMeters());
      w.printf(Locale.ROOT, "angleDegrees,%.3f%n", delay.angleDegrees());
      w.printf(Locale.ROOT, "confidence,%.4f%n", delay.confidence());
      w.printf(Locale.ROOT, "microphoneSpacingMeters,%.4f%n", delay.microphoneSpacingMeters());
      w.printf(
          Locale.ROOT, "speedOfSoundMetersPerSecond,%.3f%n", delay.speedOfSoundMetersPerSecond());
      w.println();
      w.println("lagSamples,correlation");
      float[] correlations = delay.correlationByLag();
      for (int i = 0; i < correlations.length; i++) {
        w.printf(Locale.ROOT, "%d,%.6f%n", delay.correlationLagForIndex(i), correlations[i]);
      }
    }
  }

  private static void writeDiagnosis(Path file, DiagnosisSnapshot diagnosis) throws IOException {
    try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(file, StandardCharsets.UTF_8))) {
      w.printf(Locale.ROOT, "sourceFrameIndex: %d%n", diagnosis.sourceFrameIndex());
      w.printf(Locale.ROOT, "sourceTimestampNanos: %d%n", diagnosis.sourceTimestampNanos());
      w.printf(Locale.ROOT, "findings: %d%n", diagnosis.findings().size());
      w.println();
      if (diagnosis.findings().isEmpty()) {
        w.println("No findings.");
        return;
      }
      for (DiagnosisFinding finding : diagnosis.findings()) {
        w.printf(
            Locale.ROOT,
            "[%s] %s (type=%s, confidence=%.2f",
            finding.severity(),
            finding.message(),
            finding.type(),
            finding.confidence());
        if (!Double.isNaN(finding.frequencyHz())) {
          w.printf(Locale.ROOT, ", frequencyHz=%.3f", finding.frequencyHz());
        }
        if (!Double.isNaN(finding.value())) {
          w.printf(Locale.ROOT, ", value=%.6f", finding.value());
        }
        w.println(")");
      }
    }
  }

  private static void writeMetadata(
      Path file, String bundleName, Instant timestamp, EvidenceData data) throws IOException {
    try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(file, StandardCharsets.UTF_8))) {
      w.println("{");
      w.printf(Locale.ROOT, "  \"bundleName\": \"%s\",%n", escape(bundleName));
      w.printf(Locale.ROOT, "  \"createdAt\": \"%s\",%n", timestamp.toString());
      w.printf(Locale.ROOT, "  \"hasScreenshot\": %s,%n", data.screenshot() != null);
      AudioBlock block = data.block();
      if (block != null) {
        w.println("  \"audio\": {");
        w.printf(Locale.ROOT, "    \"sampleRate\": %.3f,%n", block.format().sampleRate());
        w.printf(Locale.ROOT, "    \"channels\": %d,%n", block.channels());
        w.printf(Locale.ROOT, "    \"frames\": %d,%n", block.frames());
        w.printf(Locale.ROOT, "    \"frameIndex\": %d%n", block.frameIndex());
        w.println("  },");
      }
      SpectrumSnapshot spectrum = data.spectrum();
      if (spectrum != null) {
        w.println("  \"spectrum\": {");
        w.printf(Locale.ROOT, "    \"fftSize\": %d,%n", spectrum.fftSize());
        w.printf(Locale.ROOT, "    \"bins\": %d,%n", spectrum.binCount());
        w.printf(Locale.ROOT, "    \"binWidthHz\": %.6f%n", spectrum.binWidthHz());
        w.println("  },");
      }
      SpectrogramHistory history = data.spectrogram();
      if (history != null && !history.isEmpty()) {
        w.println("  \"spectrogram\": {");
        w.printf(Locale.ROOT, "    \"frames\": %d,%n", history.size());
        w.printf(Locale.ROOT, "    \"capacity\": %d,%n", history.capacity());
        w.printf(Locale.ROOT, "    \"bins\": %d,%n", history.binCount());
        w.printf(Locale.ROOT, "    \"sampleRate\": %.3f%n", history.sampleRate());
        w.println("  },");
      }
      DiagnosisSnapshot diagnosis = data.diagnosis();
      if (diagnosis != null) {
        w.printf(Locale.ROOT, "  \"diagnosisFindings\": %d,%n", diagnosis.findings().size());
      }
      w.printf(
          Locale.ROOT, "  \"notes\": \"%s\"%n", escape(data.notes() == null ? "" : data.notes()));
      w.println("}");
    }
  }

  private static String escape(String s) {
    StringBuilder out = new StringBuilder(s.length() + 8);
    for (int i = 0; i < s.length(); i++) {
      char c = s.charAt(i);
      switch (c) {
        case '\\' -> out.append("\\\\");
        case '"' -> out.append("\\\"");
        case '\b' -> out.append("\\b");
        case '\f' -> out.append("\\f");
        case '\n' -> out.append("\\n");
        case '\r' -> out.append("\\r");
        case '\t' -> out.append("\\t");
        default -> {
          if (c < 0x20) {
            out.append(String.format(Locale.ROOT, "\\u%04x", (int) c));
          } else {
            out.append(c);
          }
        }
      }
    }
    return out.toString();
  }
}