ExperimentAudioAnalyseFrame.java

package org.hammer;

import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.EventQueue;
import java.awt.event.ActionListener;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Locale;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.JFileChooser;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;
import javax.swing.filechooser.FileNameExtensionFilter;
import org.hammer.audio.ActiveAudioCaptureRegistry;
import org.hammer.audio.AudioCaptureService;
import org.hammer.audio.RecordingPreflight;
import org.hammer.audio.RecordingTap;
import org.hammer.audio.recording.AudioBlockRecordingFormat;
import org.hammer.audio.recording.AudioBlockRecordingReader;
import org.hammer.audio.recording.RecordingInspection;
import org.hammer.audio.recording.RecordingIntegrity;
import org.hammer.audio.recording.runtime.RecordingState;
import org.hammer.audio.recording.runtime.RecordingStatus;
import org.hammer.audio.recording.runtime.RecordingStorageLevel;
import org.hammer.audio.recording.runtime.RecordingStorageStatus;
import org.hammer.audio.ui.theme.UiTheme;

/**
 * Production desktop host that augments the established analyzer frame with experiment-grade
 * recording controls and persistent recording health.
 */
public final class ExperimentAudioAnalyseFrame extends AudioAnalyseFrame {

  private static final long serialVersionUID = 1L;
  private static final Logger LOGGER =
      Logger.getLogger(ExperimentAudioAnalyseFrame.class.getName());
  private static final String RECORDING_DIALOG_TITLE = "Experiment recording";

  private final RecordingStatusPanel recordingStatusPanel = new RecordingStatusPanel();
  private transient RecordingTap recordingTap;

  /** Launch the experiment-aware desktop workbench. */
  public static void main(String[] args) {
    EventQueue.invokeLater(
        () -> {
          try {
            UiTheme.installDarkTheme();
            ExperimentAudioAnalyseFrame frame = new ExperimentAudioAnalyseFrame();
            frame.setVisible(true);
          } catch (RuntimeException exception) {
            LOGGER.log(Level.SEVERE, "Failed to start experiment desktop workbench", exception);
          }
        });
  }

  /** Create the established analyzer UI and install experiment recording controls. */
  public ExperimentAudioAnalyseFrame() {
    super();
    setTitle("AudioAnalyzer — Experiment Workbench");
    installStatusStrip();
    replaceRecordingActions();
    addWindowListener(
        new WindowAdapter() {
          @Override
          public void windowClosing(WindowEvent event) {
            stopRecordingQuietly();
          }
        });
  }

  private void installStatusStrip() {
    Component existingSouth =
        getContentPane().getLayout() instanceof BorderLayout
            ? ((BorderLayout) getContentPane().getLayout()).getLayoutComponent(BorderLayout.SOUTH)
            : null;
    JPanel south = new JPanel(new BorderLayout(4, 4));
    if (existingSouth != null) {
      getContentPane().remove(existingSouth);
      south.add(existingSouth, BorderLayout.NORTH);
    }
    recordingStatusPanel.setStopAction(this::stopExperimentRecording);
    south.add(recordingStatusPanel, BorderLayout.SOUTH);
    getContentPane().add(south, BorderLayout.SOUTH);
  }

  private void replaceRecordingActions() {
    JMenu fileMenu = findMenu("File");
    if (fileMenu == null) {
      throw new IllegalStateException("Desktop File menu is unavailable");
    }
    JMenuItem start = requireItem(fileMenu, "Start recording...");
    replaceListeners(start, event -> startExperimentRecording());
    start.setToolTipText(
        "Record every produced block into an integrity-protected ."
            + AudioBlockRecordingFormat.FILE_EXTENSION
            + " file.");

    JMenuItem stop = requireItem(fileMenu, "Stop recording");
    replaceListeners(stop, event -> stopExperimentRecording());

    JMenuItem inspect = new JMenuItem("Inspect or recover recording...");
    inspect.setToolTipText(
        "Inspect completion, continuity and checksum evidence or recover complete blocks from a"
            + " partial recording.");
    inspect.addActionListener(event -> inspectOrRecoverRecording());
    int stopIndex = stop.getParent().getComponentZOrder(stop);
    fileMenu.insert(inspect, Math.min(fileMenu.getItemCount(), stopIndex + 1));
  }

  private void startExperimentRecording() {
    if (recordingTap != null && !recordingTap.isClosed()) {
      JOptionPane.showMessageDialog(
          this,
          "A recording is already in progress: " + recordingTap.file(),
          RECORDING_DIALOG_TITLE,
          JOptionPane.WARNING_MESSAGE);
      return;
    }
    AudioCaptureService service = ActiveAudioCaptureRegistry.current().orElse(null);
    if (service == null) {
      JOptionPane.showMessageDialog(
          this,
          "Start a live, demo or replay source before starting the experiment recording.",
          RECORDING_DIALOG_TITLE,
          JOptionPane.WARNING_MESSAGE);
      return;
    }

    JFileChooser chooser = new JFileChooser();
    chooser.setDialogTitle("Start experiment recording");
    chooser.setFileFilter(
        new FileNameExtensionFilter(
            "AudioAnalyzer recording (*." + AudioBlockRecordingFormat.FILE_EXTENSION + ")",
            AudioBlockRecordingFormat.FILE_EXTENSION));
    chooser.setSelectedFile(new File("recording." + AudioBlockRecordingFormat.FILE_EXTENSION));
    if (chooser.showSaveDialog(this) != JFileChooser.APPROVE_OPTION) {
      return;
    }
    File selected =
        ensureExtension(chooser.getSelectedFile(), "." + AudioBlockRecordingFormat.FILE_EXTENSION);
    try {
      RecordingStorageStatus preflight = RecordingPreflight.inspect(service, selected.toPath());
      if (!preflight.writable() || preflight.level() == RecordingStorageLevel.CRITICAL) {
        JOptionPane.showMessageDialog(
            this,
            storagePreflightText(preflight),
            "Recording storage is not ready",
            JOptionPane.ERROR_MESSAGE);
        return;
      }
      if (preflight.level() == RecordingStorageLevel.WARNING && !confirmStorageWarning(preflight)) {
        return;
      }
      recordingTap = RecordingTap.start(service, selected.toPath());
      recordingTap.addStatusListener(
          status -> SwingUtilities.invokeLater(() -> recordingStatusPanel.updateStatus(status)));
    } catch (IOException exception) {
      LOGGER.log(Level.SEVERE, "Failed to start experiment recording", exception);
      JOptionPane.showMessageDialog(
          this,
          "Recording could not start: " + exception.getMessage(),
          RECORDING_DIALOG_TITLE,
          JOptionPane.ERROR_MESSAGE);
    }
  }

  private boolean confirmStorageWarning(RecordingStorageStatus preflight) {
    int choice =
        JOptionPane.showConfirmDialog(
            this,
            storagePreflightText(preflight) + "\n\nStart recording despite this warning?",
            "Recording storage warning",
            JOptionPane.YES_NO_OPTION,
            JOptionPane.WARNING_MESSAGE);
    return choice == JOptionPane.YES_OPTION;
  }

  private static String storagePreflightText(RecordingStorageStatus preflight) {
    return String.format(
        Locale.ROOT,
        "Destination: %s%nUsable space: %s%nEstimated safe duration: %s%nExpected write rate:"
            + " %s/s%nStatus: %s%s",
        preflight.destination(),
        formatBytes(preflight.usableBytes()),
        formatDuration(preflight.estimatedSafeSecondsRemaining()),
        formatBytes(Math.round(preflight.expectedBytesPerSecond())),
        preflight.level(),
        preflight.errorMessage().isBlank() ? "" : "%n" + preflight.errorMessage());
  }

  private static String formatBytes(long bytes) {
    if (bytes < 0L) {
      return "unknown";
    }
    double gibibytes = bytes / (1024.0 * 1024.0 * 1024.0);
    if (gibibytes >= 1.0) {
      return String.format(Locale.ROOT, "%.2f GiB", gibibytes);
    }
    return String.format(Locale.ROOT, "%.1f MiB", bytes / (1024.0 * 1024.0));
  }

  private static String formatDuration(long seconds) {
    if (seconds < 0L) {
      return "unknown";
    }
    long hours = seconds / 3600L;
    long minutes = (seconds % 3600L) / 60L;
    long remainingSeconds = seconds % 60L;
    return String.format(Locale.ROOT, "%02d:%02d:%02d", hours, minutes, remainingSeconds);
  }

  private void stopExperimentRecording() {
    RecordingTap tap = recordingTap;
    if (tap == null || tap.isClosed()) {
      return;
    }
    try {
      tap.stop();
      showCompletion(tap.status());
    } catch (IOException exception) {
      LOGGER.log(Level.SEVERE, "Failed to finalize experiment recording", exception);
      JOptionPane.showMessageDialog(
          this,
          "Recording finalization failed: " + exception.getMessage(),
          RECORDING_DIALOG_TITLE,
          JOptionPane.ERROR_MESSAGE);
    }
  }

  private void showCompletion(RecordingStatus status) {
    String summary =
        String.format(
            Locale.ROOT,
            "%s%n%d of %d blocks written%n%d dropped blocks%n%d continuity gaps%n%s",
            status.state(),
            status.writtenBlocks(),
            status.receivedBlocks(),
            status.droppedBlocks(),
            status.continuityGapCount(),
            status.destination());
    JOptionPane.showMessageDialog(
        this,
        summary,
        RECORDING_DIALOG_TITLE,
        status.state() == RecordingState.COMPLETED
            ? JOptionPane.INFORMATION_MESSAGE
            : JOptionPane.WARNING_MESSAGE);
  }

  private void inspectOrRecoverRecording() {
    JFileChooser chooser = new JFileChooser();
    chooser.setDialogTitle("Inspect AudioAnalyzer recording");
    chooser.setFileFilter(
        new FileNameExtensionFilter(
            "AudioAnalyzer recordings (*.aarec, *.aar)",
            AudioBlockRecordingFormat.FILE_EXTENSION,
            AudioBlockRecordingFormat.LEGACY_FILE_EXTENSION));
    if (chooser.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) {
      return;
    }
    Path source = chooser.getSelectedFile().toPath();
    try {
      RecordingInspection inspection = AudioBlockRecordingReader.inspect(source);
      if (recoverable(inspection.integrity())) {
        offerRecovery(source, inspection);
      } else {
        JOptionPane.showMessageDialog(
            this,
            inspectionText(inspection),
            "Recording inspection",
            inspection.complete() ? JOptionPane.INFORMATION_MESSAGE : JOptionPane.WARNING_MESSAGE);
      }
    } catch (IOException exception) {
      LOGGER.log(Level.WARNING, "Recording inspection failed", exception);
      JOptionPane.showMessageDialog(
          this,
          "Recording inspection failed: " + exception.getMessage(),
          "Recording inspection",
          JOptionPane.ERROR_MESSAGE);
    }
  }

  private static boolean recoverable(RecordingIntegrity integrity) {
    return integrity == RecordingIntegrity.RECOVERABLE_INCOMPLETE
        || integrity == RecordingIntegrity.TRUNCATED
        || integrity == RecordingIntegrity.CORRUPT;
  }

  private void offerRecovery(Path source, RecordingInspection inspection) throws IOException {
    int choice =
        JOptionPane.showConfirmDialog(
            this,
            inspectionText(inspection)
                + "\n\nRecover every complete block into a separate finalized file?",
            "Incomplete recording",
            JOptionPane.YES_NO_OPTION,
            JOptionPane.WARNING_MESSAGE);
    if (choice != JOptionPane.YES_OPTION) {
      return;
    }
    JFileChooser targetChooser = new JFileChooser();
    targetChooser.setDialogTitle("Save recovered recording");
    targetChooser.setSelectedFile(new File("recovered.aarec"));
    targetChooser.setFileFilter(
        new FileNameExtensionFilter("AudioAnalyzer recording (*.aarec)", "aarec"));
    if (targetChooser.showSaveDialog(this) != JFileChooser.APPROVE_OPTION) {
      return;
    }
    Path target = ensureExtension(targetChooser.getSelectedFile(), ".aarec").toPath();
    RecordingInspection recovered = AudioBlockRecordingReader.recover(source, target);
    JOptionPane.showMessageDialog(
        this,
        "Recovered " + recovered.blockCount() + " blocks to " + target,
        "Recording recovered",
        JOptionPane.INFORMATION_MESSAGE);
  }

  private static String inspectionText(RecordingInspection inspection) {
    return String.format(
        Locale.ROOT,
        "Integrity: %s%nFormat version: %d%nBlocks: %d%nFrames: %d%nContinuity gaps: %d%nSHA-256:"
            + " %s%n%s",
        inspection.integrity(),
        inspection.formatVersion(),
        inspection.blockCount(),
        inspection.totalFrames(),
        inspection.continuityGapCount(),
        inspection.sha256().isBlank() ? "not available" : inspection.sha256(),
        inspection.detail());
  }

  private void stopRecordingQuietly() {
    RecordingTap tap = recordingTap;
    if (tap == null || tap.isClosed()) {
      return;
    }
    try {
      tap.stop();
    } catch (IOException exception) {
      LOGGER.log(Level.WARNING, "Failed to finalize recording while closing window", exception);
    }
  }

  private JMenu findMenu(String text) {
    JMenuBar menuBar = getJMenuBar();
    if (menuBar == null) {
      return null;
    }
    for (int index = 0; index < menuBar.getMenuCount(); index++) {
      JMenu menu = menuBar.getMenu(index);
      if (menu != null && text.equals(menu.getText())) {
        return menu;
      }
    }
    return null;
  }

  private static JMenuItem requireItem(JMenu menu, String text) {
    for (int index = 0; index < menu.getItemCount(); index++) {
      JMenuItem item = menu.getItem(index);
      if (item != null && text.equals(item.getText())) {
        return item;
      }
    }
    throw new IllegalStateException("Menu item is unavailable: " + text);
  }

  private static void replaceListeners(JMenuItem item, ActionListener replacement) {
    for (ActionListener listener : item.getActionListeners()) {
      item.removeActionListener(listener);
    }
    item.addActionListener(replacement);
  }

  private static File ensureExtension(File file, String extension) {
    return file.getName().toLowerCase(Locale.ROOT).endsWith(extension)
        ? file
        : new File(file.getParentFile(), file.getName() + extension);
  }
}