DocImageRenderer.java

package org.hammer.tools;

import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.geom.Path2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import javax.imageio.ImageIO;
import org.hammer.audio.analysis.MeasurementCalculator;
import org.hammer.audio.analysis.MeasurementSnapshot;
import org.hammer.audio.analysis.PeakHoldSpectrum;
import org.hammer.audio.analysis.SpectrumAnalyzer;
import org.hammer.audio.analysis.SpectrumAverager;
import org.hammer.audio.analysis.SpectrumSnapshot;
import org.hammer.audio.analysis.WaveformTrigger;
import org.hammer.audio.core.AudioBlock;
import org.hammer.audio.core.AudioFormatDescriptor;
import org.hammer.audio.diagnosis.DiagnosisAnalyzer;
import org.hammer.audio.diagnosis.DiagnosisFinding;
import org.hammer.audio.diagnosis.DiagnosisSnapshot;
import org.hammer.audio.signal.SineGenerator;
import org.hammer.audio.signal.SquareGenerator;
import org.hammer.audio.spectrogram.SpectrogramAnalyzer;
import org.hammer.audio.spectrogram.SpectrogramHistory;
import org.hammer.audio.ui.theme.PlotRenderTheme;

/**
 * Headless utility that renders deterministic PNG screenshots used in the README and feature
 * documentation.
 *
 * <p>Re-run with:
 *
 * <pre>
 *   ./mvnw -pl audio-app -am package -DskipTests
 *   java -cp "audio-app/target/classes:audio-app/target/lib/*" \
 *        org.hammer.tools.DocImageRenderer docs/images
 * </pre>
 *
 * <p>On Windows, replace {@code :} with {@code ;} in the classpath and use {@code ^} instead of
 * {@code \} for line continuation (or run the command on a single line).
 *
 * <p>The output directory defaults to {@code docs/images} when no argument is given. The README
 * screenshot is written to {@code screenshot.png}; feature images are written to the {@code
 * features/} child directory.
 */
@SuppressWarnings("PMD.CouplingBetweenObjects")
public final class DocImageRenderer {

  private static final int W = 760;
  private static final int H = 320;
  private static final int WB_H = 480;
  private static final int DASHBOARD_W = 1600;
  private static final int DASHBOARD_H = 1000;
  private static final int DASHBOARD_SPECTROGRAM_HISTORY_FRAMES = 180;
  private static final int DASHBOARD_SPECTROGRAM_SEED_BLOCKS = 64;
  private static final int DASHBOARD_WAVEFORM_VISIBLE_SAMPLES = 2200;
  private static final float SYNTHETIC_SPECTROGRAM_PULSE_BASE = 0.45f;
  private static final float SYNTHETIC_SPECTROGRAM_PULSE_SWING = 0.55f;
  private static final double SYNTHETIC_SPECTROGRAM_PULSE_PERIOD = 18.0;
  private static final float SYNTHETIC_SPECTROGRAM_BAND_POSITION = 0.72f;
  private static final float SYNTHETIC_SPECTROGRAM_BAND_WIDTH = 18f;
  private static final AudioFormatDescriptor MONO_44K = new AudioFormatDescriptor(44100f, 1, 16);
  private static final int FFT = 1024;

  private DocImageRenderer() {}

  /**
   * @param args optional output directory; defaults to {@code docs/images/features}
   * @throws IOException if any of the PNGs cannot be written
   */
  public static void main(String[] args) throws IOException {
    Path imageDir = Path.of(args.length > 0 ? args[0] : "docs/images");
    Path featureDir = imageDir.resolve("features");
    Files.createDirectories(featureDir);

    writePng(imageDir.resolve("screenshot.png"), renderDashboardScreenshot());
    writePng(featureDir.resolve("waveform-trigger.png"), renderTrigger());
    writePng(featureDir.resolve("spectrum-peak-hold.png"), renderSpectrumPeakHold());
    writePng(featureDir.resolve("recording-format.png"), renderRecordingFormat());
    writePng(featureDir.resolve("ab-comparison.png"), renderAbComparison());
    writePng(featureDir.resolve("simulation-workbench.png"), renderSimulationWorkbench());
    writePng(featureDir.resolve("playback-explorer.png"), renderPlaybackExplorer());
    writePng(
        featureDir.resolve("imported-recording-workbench.png"), renderImportedRecordingWorkbench());
    writePng(featureDir.resolve("generator-calibration.png"), renderGeneratorCalibration());
  }

  /**
   * Render the deterministic README dashboard screenshot.
   *
   * @return a 1600x1000 PNG-ready image showing a 440 Hz demo signal and the main dashboard panels
   */
  public static BufferedImage renderDashboardScreenshot() {
    SineGenerator gen = new SineGenerator(MONO_44K, 440.0, 0.7f);
    AudioBlock block = gen.nextBlock(4096);
    SpectrumAnalyzer spectrumAnalyzer = new SpectrumAnalyzer(FFT, 0, MONO_44K.sampleRate());
    SpectrumSnapshot spectrum = spectrumAnalyzer.analyze(block);
    MeasurementSnapshot measurement = new MeasurementCalculator().calculate(block, spectrum);
    SpectrogramAnalyzer spectrogramAnalyzer =
        new SpectrogramAnalyzer(
            FFT, 0, MONO_44K.sampleRate(), DASHBOARD_SPECTROGRAM_HISTORY_FRAMES);
    for (int i = 0; i < DASHBOARD_SPECTROGRAM_SEED_BLOCKS; i++) {
      spectrogramAnalyzer.analyze(gen.nextBlock(FFT));
    }
    SpectrogramHistory history = spectrogramAnalyzer.history();
    DiagnosisSnapshot diagnosis = new DiagnosisAnalyzer().analyze(block, spectrum, history, null);

    BufferedImage img = new BufferedImage(DASHBOARD_W, DASHBOARD_H, BufferedImage.TYPE_INT_RGB);
    Graphics2D g = img.createGraphics();
    try {
      applyHints(g);
      g.setColor(new Color(19, 24, 32));
      g.fillRect(0, 0, DASHBOARD_W, DASHBOARD_H);
      drawAppChrome(g);
      drawControlBar(g, measurement, spectrum);
      drawWaveform(g, new Rectangle(28, 150, 1544, 330), block);
      drawSpectrumPanel(
          g,
          new Rectangle(28, 508, 754, 245),
          spectrum,
          "Spectrum — 440 Hz sine demo",
          PlotRenderTheme.SPECTRUM_LINE);
      drawMeasurements(g, new Rectangle(810, 508, 762, 245), measurement);
      drawSpectrogram(g, new Rectangle(28, 782, 1050, 180));
      drawDiagnosis(g, new Rectangle(1104, 782, 468, 180), diagnosis);
    } finally {
      g.dispose();
    }
    return img;
  }

  private static void drawAppChrome(Graphics2D g) {
    g.setColor(new Color(31, 38, 48));
    g.fillRect(0, 0, DASHBOARD_W, 56);
    g.setColor(PlotRenderTheme.TEXT_PRIMARY);
    g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 18));
    g.drawString("Audio Analyzer", 24, 35);
    g.setFont(PlotRenderTheme.LABEL_FONT);
    g.setColor(PlotRenderTheme.TEXT_MUTED);
    g.drawString("File   View   Plugins   Help", 210, 35);
    g.setColor(new Color(52, 168, 83));
    g.fillRoundRect(DASHBOARD_W - 170, 16, 132, 24, 12, 12);
    g.setColor(Color.WHITE);
    g.drawString("Demo frozen", DASHBOARD_W - 148, 33);
  }

  private static void drawControlBar(
      Graphics2D g, MeasurementSnapshot measurement, SpectrumSnapshot spectrum) {
    Rectangle controls = new Rectangle(28, 76, 1544, 50);
    g.setColor(new Color(35, 43, 55));
    g.fillRoundRect(controls.x, controls.y, controls.width, controls.height, 14, 14);
    g.setColor(new Color(72, 84, 102));
    g.drawRoundRect(controls.x, controls.y, controls.width, controls.height, 14, 14);
    String[] items = {
      "Input: Demo mode",
      "Demo: Sine",
      "Format: 44.1 kHz / mono / 16-bit",
      String.format(Locale.ROOT, "Peak: %.1f Hz", strongestFrequency(spectrum)),
      String.format(Locale.ROOT, "RMS: %.3f", measurement.rms()),
      String.format(Locale.ROOT, "Level: %.2f", measurement.peakLevel())
    };
    g.setFont(PlotRenderTheme.LABEL_FONT);
    int x = controls.x + 18;
    for (String item : items) {
      drawPill(g, x, controls.y + 12, item);
      x += g.getFontMetrics().stringWidth(item) + 42;
    }
  }

  private static void drawPill(Graphics2D g, int x, int y, String text) {
    int width = g.getFontMetrics().stringWidth(text) + 20;
    g.setColor(new Color(47, 58, 74));
    g.fillRoundRect(x, y, width, 26, 13, 13);
    g.setColor(PlotRenderTheme.TEXT_PRIMARY);
    g.drawString(text, x + 10, y + 18);
  }

  private static void drawWaveform(Graphics2D g, Rectangle plot, AudioBlock block) {
    PlotRenderTheme.drawPlotBackground(g, plot.width, plot.height, plot);
    PlotRenderTheme.drawGrid(g, plot, 16, 8);
    PlotRenderTheme.drawTitle(g, plot.x + 12, plot.y + 22, "Waveform — reproducible 440 Hz sine");
    PlotRenderTheme.drawYAxisLabel(g, plot, "Amplitude [-1..1]");
    PlotRenderTheme.drawYTicks(
        g, plot, new double[] {0.0d, 0.5d, 1.0d}, new String[] {"+1", "0", "-1"});
    PlotRenderTheme.drawXAxisLabel(g, plot, "Time [ms]");
    float[] samples = block.channelView(0);
    int visible = Math.min(samples.length, DASHBOARD_WAVEFORM_VISIBLE_SAMPLES);
    double durationMs = 1000.0d * Math.max(0, visible - 1) / MONO_44K.sampleRate();
    PlotRenderTheme.drawXTicks(
        g,
        plot,
        new double[] {0.0d, 0.5d, 1.0d},
        new String[] {
          "0 ms",
          String.format(Locale.ROOT, "%.1f ms", durationMs / 2.0d),
          String.format(Locale.ROOT, "%.1f ms", durationMs)
        });
    int centerY = plot.y + plot.height / 2;
    int amplitude = plot.height / 2 - 38;
    Path2D path = new Path2D.Float();
    for (int i = 0; i < visible; i++) {
      double x = plot.x + (double) i * (plot.width - 1) / Math.max(1, visible - 1);
      double y = centerY - Math.max(-1f, Math.min(1f, samples[i])) * amplitude;
      if (i == 0) {
        path.moveTo(x, y);
      } else {
        path.lineTo(x, y);
      }
    }
    g.setColor(PlotRenderTheme.CENTER_LINE);
    g.drawLine(plot.x, centerY, plot.x + plot.width, centerY);
    g.setColor(PlotRenderTheme.WAVEFORM_LEFT);
    g.setStroke(PlotRenderTheme.TRACE_STROKE);
    g.draw(path);
    g.setColor(PlotRenderTheme.TEXT_MUTED);
    g.setFont(PlotRenderTheme.LABEL_FONT);
    g.drawString("Frozen demo buffer, amplitude 0.70, no clipping", plot.x + 12, plot.y + 44);
  }

  private static void drawMeasurements(
      Graphics2D g, Rectangle panel, MeasurementSnapshot measurement) {
    drawPanelShell(g, panel, "Measurements");
    String[] rows = {
      String.format(
          Locale.ROOT, "Dominant frequency     %.1f Hz", measurement.dominantFrequencyHz()),
      String.format(Locale.ROOT, "RMS level              %.3f", measurement.rms()),
      String.format(Locale.ROOT, "Peak level             %.3f", measurement.peakLevel()),
      "Clipping               no",
      "Stereo delay           n/a (mono demo)",
      "Confidence             n/a"
    };
    g.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 18));
    int y = panel.y + 66;
    for (String row : rows) {
      g.setColor(new Color(43, 53, 68));
      g.fillRoundRect(panel.x + 24, y - 24, panel.width - 48, 34, 10, 10);
      g.setColor(PlotRenderTheme.TEXT_PRIMARY);
      g.drawString(row, panel.x + 42, y);
      y += 34;
    }
  }

  private static void drawSpectrogram(Graphics2D g, Rectangle panel) {
    drawPanelShell(g, panel, "Spectrogram / waterfall");
    int x0 = panel.x + 20;
    int y0 = panel.y + 44;
    int w = panel.width - 40;
    int h = panel.height - 64;
    for (int x = 0; x < w; x++) {
      float pulse =
          SYNTHETIC_SPECTROGRAM_PULSE_BASE
              + SYNTHETIC_SPECTROGRAM_PULSE_SWING
                  * (float) Math.sin(x / SYNTHETIC_SPECTROGRAM_PULSE_PERIOD);
      for (int y = 0; y < h; y++) {
        float band =
            Math.max(
                    0f,
                    1f
                        - Math.abs(y - h * SYNTHETIC_SPECTROGRAM_BAND_POSITION)
                            / SYNTHETIC_SPECTROGRAM_BAND_WIDTH)
                * pulse;
        g.setColor(
            new Color(
                16,
                Math.min(210, 55 + (int) (band * 155)),
                Math.min(255, 90 + (int) (band * 165))));
        g.drawLine(x0 + x, y0 + y, x0 + x, y0 + y);
      }
    }
    Rectangle plot = new Rectangle(x0, y0, w, h);
    PlotRenderTheme.drawGrid(g, plot, 8, 4);
    PlotRenderTheme.drawYAxisLabel(g, plot, "Frequency [Hz]");
    PlotRenderTheme.drawYTicks(
        g, plot, new double[] {0.0d, 0.5d, 1.0d}, new String[] {"22.1 kHz", "11.0 kHz", "0 Hz"});
    PlotRenderTheme.drawXTicks(
        g, plot, new double[] {0.0d, 0.5d, 1.0d}, new String[] {"0.0 s", "0.5 s", "1.0 s"});
    PlotRenderTheme.drawXAxisLabel(g, plot, "Time [s; older → newer]");
    PlotRenderTheme.drawLabel(g, x0 + w - 132, y0 + 14, "Color: relative magnitude [-]");
  }

  private static void drawDiagnosis(Graphics2D g, Rectangle panel, DiagnosisSnapshot diagnosis) {
    drawPanelShell(g, panel, "Diagnosis");
    g.setFont(PlotRenderTheme.LABEL_FONT);
    int y = panel.y + 58;
    if (diagnosis.findings().isEmpty()) {
      g.setColor(new Color(150, 210, 180));
      g.drawString("INFO   Stable single-tone demo; no findings.", panel.x + 24, y);
      return;
    }
    for (DiagnosisFinding finding : diagnosis.findings()) {
      g.setColor(PlotRenderTheme.TEXT_PRIMARY);
      g.drawString(
          String.format(
              Locale.ROOT,
              "%s   %s (conf %.2f)",
              finding.severity(),
              finding.message(),
              finding.confidence()),
          panel.x + 24,
          y);
      y += 28;
      if (y > panel.y + panel.height - 22) {
        break;
      }
    }
  }

  private static void drawPanelShell(Graphics2D g, Rectangle panel, String title) {
    g.setColor(PlotRenderTheme.PANEL_BACKGROUND);
    g.fillRoundRect(panel.x, panel.y, panel.width, panel.height, 14, 14);
    g.setColor(new Color(72, 84, 102));
    g.drawRoundRect(panel.x, panel.y, panel.width, panel.height, 14, 14);
    g.setColor(PlotRenderTheme.TEXT_PRIMARY);
    g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 18));
    g.drawString(title, panel.x + 16, panel.y + 28);
  }

  private static double strongestFrequency(SpectrumSnapshot spectrum) {
    int peakBin = 0;
    float peak = 0f;
    for (int i = 1; i < spectrum.binCount(); i++) {
      if (spectrum.magnitude(i) > peak) {
        peak = spectrum.magnitude(i);
        peakBin = i;
      }
    }
    return spectrum.frequencyOfBin(peakBin);
  }

  static BufferedImage renderTrigger() {
    SineGenerator gen = new SineGenerator(MONO_44K, 220.0, 0.7f);
    AudioBlock block = gen.nextBlock(4096);
    WaveformTrigger trigger = new WaveformTrigger(1024);
    trigger.setMode(WaveformTrigger.Mode.NORMAL);
    trigger.setHoldoffFrames(64);
    WaveformTrigger.TriggeredView view = trigger.process(block, 0).orElseThrow();

    BufferedImage img = createImage();
    Graphics2D g = img.createGraphics();
    try {
      applyHints(g);
      Rectangle plot = new Rectangle(48, 24, W - 64, H - 58);
      PlotRenderTheme.drawPlotBackground(g, W, H, plot);
      PlotRenderTheme.drawGrid(g, plot, 10, 8);
      PlotRenderTheme.drawTitle(g, plot.x, 16, "Waveform (triggered)");
      PlotRenderTheme.drawYAxisLabel(g, plot, "Amplitude [-1..1]");
      PlotRenderTheme.drawYTicks(
          g, plot, new double[] {0.0d, 0.5d, 1.0d}, new String[] {"+1", "0", "-1"});
      PlotRenderTheme.drawXAxisLabel(g, plot, "Sample index");

      float[] samples = view.samplesView();
      int n = samples.length;
      int centerY = plot.y + plot.height / 2;
      int amplitude = plot.height / 2 - 8;
      int[] xs = new int[n];
      int[] ys = new int[n];
      for (int i = 0; i < n; i++) {
        xs[i] = plot.x + (int) ((long) i * (plot.width - 1) / Math.max(1, n - 1));
        ys[i] = centerY - (int) (Math.max(-1f, Math.min(1f, samples[i])) * amplitude);
      }
      g.setColor(PlotRenderTheme.CENTER_LINE);
      g.setStroke(PlotRenderTheme.AXIS_STROKE);
      g.drawLine(plot.x, centerY, plot.x + plot.width - 1, centerY);

      g.setColor(PlotRenderTheme.WAVEFORM_LEFT);
      g.setStroke(PlotRenderTheme.TRACE_STROKE);
      g.drawPolyline(xs, ys, n);

      g.setColor(PlotRenderTheme.TEXT_MUTED);
      g.setFont(PlotRenderTheme.LABEL_FONT);
      g.drawString(
          String.format(
              Locale.ROOT,
              "Trig: FIRED  Slope: rising  Level: %+.2f  view=1024 samples",
              view.level()),
          plot.x,
          32);
      PlotRenderTheme.drawXTicks(
          g,
          plot,
          new double[] {0.0d, 0.5d, 1.0d},
          new String[] {"0", Integer.toString(n / 2), Integer.toString(n - 1)});
    } finally {
      g.dispose();
    }
    return img;
  }

  static BufferedImage renderSpectrumPeakHold() {
    SineGenerator gen = new SineGenerator(MONO_44K, 1200.0, 0.6f);
    SpectrumAnalyzer analyzer = new SpectrumAnalyzer(FFT, 0, MONO_44K.sampleRate());
    SpectrumAverager avg = new SpectrumAverager(0.3f);
    PeakHoldSpectrum peak = new PeakHoldSpectrum(0.999f);

    for (int i = 0; i < 12; i++) {
      AudioBlock b = gen.nextBlock(FFT);
      SpectrumSnapshot s = analyzer.analyze(b);
      avg.update(s.magnitudesView());
      peak.update(s.magnitudesView());
    }
    SquareGenerator extra = new SquareGenerator(MONO_44K, 4400.0, 0.4f);
    AudioBlock burst = extra.nextBlock(FFT);
    SpectrumSnapshot burstSnap = analyzer.analyze(burst);
    peak.update(burstSnap.magnitudesView());

    BufferedImage img = createImage();
    Graphics2D g = img.createGraphics();
    try {
      applyHints(g);
      Rectangle plot = new Rectangle(52, 24, W - 68, H - 58);
      PlotRenderTheme.drawPlotBackground(g, W, H, plot);
      PlotRenderTheme.drawGrid(g, plot, 10, 8);
      PlotRenderTheme.drawTitle(g, plot.x, 16, "Spectrum (averaged + peak hold)");
      PlotRenderTheme.drawYAxisLabel(g, plot, "Magnitude [dB rel. peak]");
      PlotRenderTheme.drawYTicks(
          g, plot, new double[] {0.0d, 0.5d, 1.0d}, new String[] {"0 dB", "-40 dB", "-80 dB"});
      PlotRenderTheme.drawXTicks(
          g, plot, new double[] {0.0d, 0.5d, 1.0d}, new String[] {"0 Hz", "11025 Hz", "22050 Hz"});
      PlotRenderTheme.drawXAxisLabel(g, plot, "Frequency [Hz]");

      float[] live = avg.averageView();
      float[] held = peak.peaks();
      int bins = live.length;
      float maxMag = 1e-6f;
      for (float v : live) {
        if (Math.abs(v) > maxMag) {
          maxMag = Math.abs(v);
        }
      }
      for (float v : held) {
        if (Math.abs(v) > maxMag) {
          maxMag = Math.abs(v);
        }
      }
      int floor = plot.y + plot.height - 1;
      int top = plot.y + 36;
      int[] xs = new int[bins];
      int[] ysLive = new int[bins];
      int[] ysPeak = new int[bins];
      for (int i = 0; i < bins; i++) {
        xs[i] = plot.x + (int) ((long) i * (plot.width - 1) / Math.max(1, bins - 1));
        ysLive[i] = floor - (int) ((Math.abs(live[i]) / maxMag) * (floor - top));
        ysPeak[i] = floor - (int) ((Math.abs(held[i]) / maxMag) * (floor - top));
      }
      g.setColor(PlotRenderTheme.SPECTRUM_LINE);
      g.setStroke(PlotRenderTheme.TRACE_STROKE);
      g.drawPolyline(xs, ysLive, bins);
      g.setColor(PlotRenderTheme.HIGHLIGHT);
      g.setStroke(PlotRenderTheme.PEAK_STROKE);
      g.drawPolyline(xs, ysPeak, bins);

      g.setColor(PlotRenderTheme.TEXT_MUTED);
      g.setFont(PlotRenderTheme.LABEL_FONT);
      g.drawString("Legend: blue averaged live spectrum, orange peak hold", plot.x, 32);
    } finally {
      g.dispose();
    }
    return img;
  }

  static BufferedImage renderRecordingFormat() {
    BufferedImage img = createImage();
    Graphics2D g = img.createGraphics();
    try {
      applyHints(g);
      g.setColor(PlotRenderTheme.PANEL_BACKGROUND);
      g.fillRect(0, 0, W, H);
      g.setColor(PlotRenderTheme.TEXT_PRIMARY);
      g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 14));
      g.drawString("AudioAnalyzer .aar recording layout (big-endian)", 16, 26);

      int y = 56;
      int x = 16;
      int w = W - 32;
      int rowH = 28;

      drawBlock(g, x, y, w, rowH, "Header", PlotRenderTheme.SPECTRUM_LINE);
      y += rowH;
      drawField(g, x + 16, y, "u32 magic 'AAR1'", "u16 version", "u16 channels");
      y += rowH;
      drawField(g, x + 16, y, "f32 sampleRate", "u16 bitsPerSample", "u16 reserved=0");
      y += rowH + 12;

      drawBlock(g, x, y, w, rowH, "Frame record (repeats until EOF)", PlotRenderTheme.HIGHLIGHT);
      y += rowH;
      drawField(g, x + 16, y, "u32 frames", "i64 frameIndex", "i64 timestampNanos");
      y += rowH;
      drawField(g, x + 16, y, "f32 ch0 sample[0..frames)", "f32 ch1 sample[0..frames)", "...");
      y += rowH + 8;
      g.setColor(PlotRenderTheme.TEXT_MUTED);
      g.setFont(PlotRenderTheme.LABEL_FONT);
      g.drawString("Channels are stored non-interleaved within each frame record.", x + 4, y + 16);
      g.drawString(
          "Reader/writer live in audio-dsp; the format is stable from version 1 onward.",
          x + 4,
          y + 32);
    } finally {
      g.dispose();
    }
    return img;
  }

  private static void drawBlock(
      Graphics2D g, int x, int y, int w, int h, String label, Color color) {
    g.setColor(new Color(color.getRed(), color.getGreen(), color.getBlue(), 60));
    g.fillRect(x, y, w, h);
    g.setColor(color);
    g.setStroke(new BasicStroke(1.2f));
    g.drawRect(x, y, w, h);
    g.setColor(PlotRenderTheme.TEXT_PRIMARY);
    g.setFont(PlotRenderTheme.TITLE_FONT);
    g.drawString(label, x + 8, y + h - 9);
  }

  private static void drawField(Graphics2D g, int x, int y, String a, String b, String c) {
    int colW = (W - 64) / 3;
    drawCell(g, x, y, colW, a);
    drawCell(g, x + colW + 4, y, colW, b);
    drawCell(g, x + 2 * (colW + 4), y, colW, c);
  }

  private static void drawCell(Graphics2D g, int x, int y, int w, String label) {
    g.setColor(PlotRenderTheme.PLOT_BACKGROUND);
    g.fillRect(x, y, w, 24);
    g.setColor(PlotRenderTheme.AXIS_COLOR);
    g.drawRect(x, y, w, 24);
    g.setColor(PlotRenderTheme.TEXT_PRIMARY);
    g.setFont(PlotRenderTheme.LABEL_FONT);
    g.drawString(label, x + 6, y + 16);
  }

  static BufferedImage renderAbComparison() {
    SpectrumAnalyzer analyzer = new SpectrumAnalyzer(FFT, 0, MONO_44K.sampleRate());
    SineGenerator a = new SineGenerator(MONO_44K, 440.0, 0.6f);
    SineGenerator b = new SineGenerator(MONO_44K, 880.0, 0.6f);
    SpectrumSnapshot sa = null;
    SpectrumSnapshot sb = null;
    for (int i = 0; i < 6; i++) {
      sa = analyzer.analyze(a.nextBlock(FFT));
    }
    for (int i = 0; i < 6; i++) {
      sb = analyzer.analyze(b.nextBlock(FFT));
    }

    BufferedImage img = createImage();
    Graphics2D g = img.createGraphics();
    try {
      applyHints(g);
      g.setColor(PlotRenderTheme.PANEL_BACKGROUND);
      g.fillRect(0, 0, W, H);
      int halfW = W / 2 - 4;
      Rectangle leftPlot = new Rectangle(44, 24, halfW - 56, H - 58);
      Rectangle rightPlot = new Rectangle(halfW + 52, 24, halfW - 56, H - 58);

      drawSpectrumPanel(g, leftPlot, sa, "A — 440 Hz", PlotRenderTheme.SPECTRUM_LINE);
      drawSpectrumPanel(g, rightPlot, sb, "B — 880 Hz", PlotRenderTheme.WAVEFORM_RIGHT);

      g.setColor(PlotRenderTheme.HIGHLIGHT);
      g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 13));
      g.drawString("|delta dominant freq| ~ 440 Hz", W / 2 - 96, H - 12);
    } finally {
      g.dispose();
    }
    return img;
  }

  private static void drawSpectrumPanel(
      Graphics2D g, Rectangle plot, SpectrumSnapshot snap, String title, Color color) {
    g.setColor(PlotRenderTheme.PLOT_BACKGROUND);
    g.fillRect(plot.x, plot.y, plot.width, plot.height);
    PlotRenderTheme.drawGrid(g, plot, 10, 8);
    PlotRenderTheme.drawTitle(g, plot.x + 8, plot.y + 16, title);
    PlotRenderTheme.drawYAxisLabel(g, plot, "Magnitude [dB rel. peak]");
    PlotRenderTheme.drawYTicks(
        g, plot, new double[] {0.0d, 0.5d, 1.0d}, new String[] {"0 dB", "-40 dB", "-80 dB"});
    PlotRenderTheme.drawXTicks(
        g, plot, new double[] {0.0d, 0.5d, 1.0d}, new String[] {"0 Hz", "11025 Hz", "22050 Hz"});
    PlotRenderTheme.drawXAxisLabel(g, plot, "Frequency [Hz]");
    float[] mag = snap.magnitudesView();
    int bins = mag.length;
    float max = 1e-6f;
    for (float v : mag) {
      if (Math.abs(v) > max) {
        max = Math.abs(v);
      }
    }
    int floor = plot.y + plot.height - 1;
    int top = plot.y + 36;
    int[] xs = new int[bins];
    int[] ys = new int[bins];
    for (int i = 0; i < bins; i++) {
      xs[i] = plot.x + (int) ((long) i * (plot.width - 1) / Math.max(1, bins - 1));
      ys[i] = floor - (int) ((Math.abs(mag[i]) / max) * (floor - top));
    }
    g.setColor(color);
    g.setStroke(PlotRenderTheme.TRACE_STROKE);
    g.drawPolyline(xs, ys, bins);
  }

  private static BufferedImage createImage() {
    return new BufferedImage(W, H, BufferedImage.TYPE_INT_RGB);
  }

  private static BufferedImage createWbImage() {
    return new BufferedImage(W, WB_H, BufferedImage.TYPE_INT_RGB);
  }

  /**
   * Renders the acoustic localization simulation workbench screenshot.
   *
   * <p>Shows a 2-D top-down room layout for the {@code twoCloseFrequencies} scenario: a 3 × 2 m
   * anechoic room, the default 4-microphone array near the bottom edge, and two stationary 600/640
   * Hz tonal emitters at distinct positions. Microphones are drawn as filled circles with channel
   * labels; emitters are drawn as star markers with frequency labels.
   *
   * @return a 760 × 480 image
   */
  public static BufferedImage renderSimulationWorkbench() {
    // Hardcoded data from SimulationScenarios.twoCloseFrequencies()
    String scenarioName = "two-close-frequencies";
    double roomW = 3.0;
    double roomH = 2.0;
    String[] micIds = {"m0", "m1", "m2", "m3"};
    double[] micXm = {1.35, 1.65, 1.35, 1.65};
    double[] micYm = {0.0, 0.0, 0.3, 0.3};
    double[] emitterXm = {1.0, 2.0};
    double[] emitterYm = {1.0, 1.0};
    double[] emitterFreq = {600.0, 640.0};
    float sampleRate = 16_000.0f;

    BufferedImage img = createWbImage();
    Graphics2D g = img.createGraphics();
    try {
      applyHints(g);
      g.setColor(PlotRenderTheme.PANEL_BACKGROUND);
      g.fillRect(0, 0, W, WB_H);

      // Title bar
      g.setColor(new Color(31, 38, 48));
      g.fillRect(0, 0, W, 44);
      g.setColor(PlotRenderTheme.TEXT_PRIMARY);
      g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 14));
      g.drawString("Acoustic Localization Simulation Workbench", 16, 28);
      g.setColor(PlotRenderTheme.TEXT_MUTED);
      g.setFont(PlotRenderTheme.LABEL_FONT);
      g.drawString("Scenario: " + scenarioName, 16, 42);

      // Room map
      int mapX = 48;
      int mapY = 60;
      int mapW = W - 96;
      int mapH = WB_H - 130;

      g.setColor(PlotRenderTheme.PLOT_BACKGROUND);
      g.fillRect(mapX, mapY, mapW, mapH);
      g.setColor(PlotRenderTheme.AXIS_COLOR);
      g.setStroke(new BasicStroke(1.5f));
      g.drawRect(mapX, mapY, mapW, mapH);
      PlotRenderTheme.drawGrid(g, new Rectangle(mapX, mapY, mapW, mapH), 6, 4);

      // Axis labels
      g.setFont(PlotRenderTheme.LABEL_FONT);
      g.setColor(PlotRenderTheme.TEXT_MUTED);
      g.drawString(String.format(Locale.ROOT, "0 m"), mapX - 4, mapY + mapH + 14);
      g.drawString(String.format(Locale.ROOT, "%.1f m", roomW), mapX + mapW - 16, mapY + mapH + 14);
      g.drawString(String.format(Locale.ROOT, "0 m"), mapX - 36, mapY + mapH);
      g.drawString(String.format(Locale.ROOT, "%.1f m", roomH), mapX - 36, mapY + 10);

      // Draw microphones
      g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 11));
      for (int i = 0; i < micIds.length; i++) {
        int mx = mapX + (int) (micXm[i] / roomW * (mapW - 1));
        int my = mapY + mapH - 1 - (int) (micYm[i] / roomH * (mapH - 1));
        g.setColor(new Color(104, 188, 255, 200));
        g.fillOval(mx - 7, my - 7, 14, 14);
        g.setColor(PlotRenderTheme.TEXT_PRIMARY);
        g.drawOval(mx - 7, my - 7, 14, 14);
        g.setColor(PlotRenderTheme.TEXT_PRIMARY);
        drawClippedString(g, micIds[i], mx + 10, my + 4, 40);
      }

      // Draw emitters (star marker)
      Color[] emitterColors = {new Color(255, 164, 91, 220), new Color(102, 235, 187, 220)};
      for (int i = 0; i < emitterXm.length; i++) {
        int ex = mapX + (int) (emitterXm[i] / roomW * (mapW - 1));
        int ey = mapY + mapH - 1 - (int) (emitterYm[i] / roomH * (mapH - 1));
        drawStarMarker(g, ex, ey, 10, emitterColors[i % emitterColors.length]);
        g.setFont(PlotRenderTheme.LABEL_FONT);
        g.setColor(emitterColors[i % emitterColors.length]);
        drawClippedString(
            g,
            String.format(Locale.ROOT, "src%d %.0f Hz", i + 1, emitterFreq[i]),
            ex + 13,
            ey + 4,
            100);
      }

      // Legend
      int legendY = WB_H - 56;
      g.setFont(PlotRenderTheme.LABEL_FONT);
      g.setColor(new Color(104, 188, 255, 200));
      g.fillOval(16, legendY - 6, 10, 10);
      g.setColor(PlotRenderTheme.TEXT_MUTED);
      g.drawString("Microphone", 30, legendY + 4);
      drawStarMarker(g, 120, legendY - 1, 7, new Color(255, 164, 91, 220));
      g.setColor(PlotRenderTheme.TEXT_MUTED);
      g.drawString("Sound emitter", 132, legendY + 4);

      // Status bar
      int statusY = WB_H - 28;
      g.setColor(new Color(35, 43, 55));
      g.fillRect(0, statusY, W, 28);
      g.setFont(PlotRenderTheme.LABEL_FONT);
      g.setColor(PlotRenderTheme.TEXT_MUTED);
      g.drawString(
          String.format(
              Locale.ROOT,
              "Room: %.1f × %.1f m  |  Mics: %d  |  Emitters: %d  |  Sample rate: %.0f Hz",
              roomW,
              roomH,
              micIds.length,
              emitterXm.length,
              sampleRate),
          12,
          statusY + 17);
    } finally {
      g.dispose();
    }
    return img;
  }

  private static void drawStarMarker(Graphics2D g, int cx, int cy, int r, Color color) {
    int[] xs = new int[10];
    int[] ys = new int[10];
    for (int i = 0; i < 10; i++) {
      double angle = Math.PI / 2.0 + i * Math.PI / 5.0;
      int radius = (i % 2 == 0) ? r : r / 2;
      xs[i] = cx + (int) (radius * Math.cos(angle));
      ys[i] = cy - (int) (radius * Math.sin(angle));
    }
    g.setColor(color);
    g.fillPolygon(xs, ys, 10);
    g.setColor(color.brighter());
    g.drawPolygon(xs, ys, 10);
  }

  /**
   * Renders the playback explorer screenshot.
   *
   * <p>Shows a waveform panel with a playback cursor frozen at one-quarter of the buffer, and a
   * transport control bar with Play, Stop and loop indicators.
   *
   * @return a 760 × 320 image
   */
  public static BufferedImage renderPlaybackExplorer() {
    SineGenerator gen = new SineGenerator(MONO_44K, 330.0, 0.55f);
    AudioBlock block = gen.nextBlock(4096);
    float[] samples = block.channelView(0);

    BufferedImage img = createImage();
    Graphics2D g = img.createGraphics();
    try {
      applyHints(g);
      g.setColor(PlotRenderTheme.PANEL_BACKGROUND);
      g.fillRect(0, 0, W, H);

      // File info bar
      g.setColor(new Color(31, 38, 48));
      g.fillRect(0, 0, W, 36);
      g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 13));
      g.setColor(PlotRenderTheme.TEXT_PRIMARY);
      g.drawString("Playback Explorer", 12, 23);
      g.setFont(PlotRenderTheme.LABEL_FONT);
      g.setColor(PlotRenderTheme.TEXT_MUTED);
      g.drawString("demo-recording.aar  |  44 100 Hz / mono / 16-bit  |  0.09 s", 148, 23);

      // Waveform area
      Rectangle plot = new Rectangle(48, 48, W - 64, H - 100);
      PlotRenderTheme.drawPlotBackground(g, W, H, plot);
      PlotRenderTheme.drawGrid(g, plot, 10, 6);
      PlotRenderTheme.drawTitle(g, plot.x + 6, plot.y + 16, "Waveform — 330 Hz demo");
      PlotRenderTheme.drawYAxisLabel(g, plot, "Amplitude [-1..1]");
      PlotRenderTheme.drawYTicks(
          g, plot, new double[] {0.0d, 0.5d, 1.0d}, new String[] {"+1", "0", "-1"});
      PlotRenderTheme.drawXAxisLabel(g, plot, "Time [ms]");
      int n = Math.min(samples.length, 2048);
      double durationMs = 1000.0d * (n - 1) / MONO_44K.sampleRate();
      PlotRenderTheme.drawXTicks(
          g,
          plot,
          new double[] {0.0d, 0.5d, 1.0d},
          new String[] {
            "0 ms",
            String.format(Locale.ROOT, "%.1f ms", durationMs / 2.0d),
            String.format(Locale.ROOT, "%.1f ms", durationMs)
          });
      int centerY = plot.y + plot.height / 2;
      int amplitude = plot.height / 2 - 12;
      Path2D path = new Path2D.Float();
      for (int i = 0; i < n; i++) {
        double x = plot.x + (double) i * (plot.width - 1) / Math.max(1, n - 1);
        double y = centerY - Math.max(-1f, Math.min(1f, samples[i])) * amplitude;
        if (i == 0) path.moveTo(x, y);
        else path.lineTo(x, y);
      }
      g.setColor(PlotRenderTheme.CENTER_LINE);
      g.drawLine(plot.x, centerY, plot.x + plot.width, centerY);
      g.setColor(PlotRenderTheme.WAVEFORM_LEFT);
      g.setStroke(PlotRenderTheme.TRACE_STROKE);
      g.draw(path);

      // Playback cursor at 25 %
      int cursorX = plot.x + plot.width / 4;
      g.setColor(new Color(255, 220, 60, 180));
      g.setStroke(
          new BasicStroke(
              1.5f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10f, new float[] {4f, 3f}, 0f));
      g.drawLine(cursorX, plot.y, cursorX, plot.y + plot.height);
      g.setStroke(PlotRenderTheme.AXIS_STROKE);
      g.setColor(new Color(255, 220, 60, 200));
      g.setFont(PlotRenderTheme.LABEL_FONT);
      g.drawString(
          String.format(Locale.ROOT, "%.1f ms", durationMs / 4.0d), cursorX + 4, plot.y + 14);

      // Transport controls
      int ctrlY = H - 38;
      g.setColor(new Color(35, 43, 55));
      g.fillRect(0, ctrlY, W, 38);
      g.setColor(new Color(72, 84, 102));
      g.drawLine(0, ctrlY, W, ctrlY);
      drawTransportButton(g, 16, ctrlY + 7, "▶ Play");
      drawTransportButton(g, 84, ctrlY + 7, "■ Stop");
      drawTransportButton(g, 152, ctrlY + 7, "↺ Loop: OFF");

      // Position slider track
      int trackX = 270;
      int trackW = W - trackX - 16;
      g.setColor(new Color(50, 61, 76));
      g.fillRoundRect(trackX, ctrlY + 14, trackW, 8, 4, 4);
      g.setColor(new Color(104, 188, 255, 160));
      g.fillRoundRect(trackX, ctrlY + 14, trackW / 4, 8, 4, 4);
      g.setColor(new Color(222, 226, 234));
      g.fillOval(trackX + trackW / 4 - 6, ctrlY + 10, 12, 16);

      // Time readout
      g.setFont(PlotRenderTheme.LABEL_FONT);
      g.setColor(PlotRenderTheme.TEXT_MUTED);
      g.drawString(
          String.format(Locale.ROOT, "%.1f ms / %.1f ms", durationMs / 4.0d, durationMs),
          trackX + trackW + 4,
          ctrlY + 22);
    } finally {
      g.dispose();
    }
    return img;
  }

  private static void drawTransportButton(Graphics2D g, int x, int y, String label) {
    FontMetrics fm = g.getFontMetrics(PlotRenderTheme.LABEL_FONT);
    int w = fm.stringWidth(label) + 14;
    g.setColor(new Color(47, 58, 74));
    g.fillRoundRect(x, y, w, 22, 8, 8);
    g.setColor(new Color(72, 84, 102));
    g.drawRoundRect(x, y, w, 22, 8, 8);
    g.setFont(PlotRenderTheme.LABEL_FONT);
    g.setColor(PlotRenderTheme.TEXT_PRIMARY);
    g.drawString(label, x + 7, y + 15);
  }

  /**
   * Renders the imported recording workbench screenshot.
   *
   * <p>Shows a two-panel layout: on the left, a feature table listing extracted wingbeat feature
   * values for a synthetic 380 Hz mosquito-like recording; on the right, a bar chart of the
   * harmonic amplitude profile.
   *
   * @return a 760 × 480 image
   */
  public static BufferedImage renderImportedRecordingWorkbench() {
    BufferedImage img = createWbImage();
    Graphics2D g = img.createGraphics();
    try {
      applyHints(g);
      g.setColor(PlotRenderTheme.PANEL_BACKGROUND);
      g.fillRect(0, 0, W, WB_H);

      // Title bar
      g.setColor(new Color(31, 38, 48));
      g.fillRect(0, 0, W, 44);
      g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 14));
      g.setColor(PlotRenderTheme.TEXT_PRIMARY);
      g.drawString("Imported Recording Workbench", 16, 28);
      g.setFont(PlotRenderTheme.LABEL_FONT);
      g.setColor(PlotRenderTheme.TEXT_MUTED);
      g.drawString(
          "Dataset: HumBugDB  |  Recording: rec_0042  |  Label: Culex quinquefasciatus", 16, 42);

      // Feature table (left panel)
      int tableX = 12;
      int tableY = 52;
      int tableW = 390;
      int tableH = WB_H - 80;
      g.setColor(PlotRenderTheme.PLOT_BACKGROUND);
      g.fillRect(tableX, tableY, tableW, tableH);
      g.setColor(new Color(72, 84, 102));
      g.drawRect(tableX, tableY, tableW, tableH);

      // Table header
      g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 12));
      g.setColor(new Color(35, 43, 55));
      g.fillRect(tableX, tableY, tableW, 22);
      g.setColor(PlotRenderTheme.TEXT_PRIMARY);
      g.drawString("Feature", tableX + 8, tableY + 15);
      g.drawString("Value", tableX + 230, tableY + 15);
      g.drawString("Unit", tableX + 330, tableY + 15);

      // Feature rows
      String[][] features = {
        {"Fundamental frequency", "382.4", "Hz"},
        {"Spectral centroid", "401.2", "Hz"},
        {"Spectral bandwidth", "14.6", "Hz"},
        {"Harmonic count", "4", "—"},
        {"Harmonic ratio H2/H1", "0.48", "—"},
        {"Harmonic ratio H3/H1", "0.24", "—"},
        {"Amplitude modulation", "0.12", "[0..1]"},
        {"Frequency drift", "+0.5", "Hz/s"},
        {"Frequency jitter", "0.9", "Hz"},
        {"Signal-to-noise ratio", "18.3", "—"},
        {"Track duration", "1.42", "s"},
        {"Feature confidence", "0.91", "[0..1]"},
      };
      g.setFont(PlotRenderTheme.LABEL_FONT);
      int rowH = (tableH - 22) / features.length;
      for (int i = 0; i < features.length; i++) {
        int rowY = tableY + 22 + i * rowH;
        if (i % 2 == 0) {
          g.setColor(new Color(22, 26, 33));
          g.fillRect(tableX + 1, rowY, tableW - 2, rowH);
        }
        g.setColor(PlotRenderTheme.TEXT_PRIMARY);
        drawClippedString(g, features[i][0], tableX + 8, rowY + rowH - 5, 210);
        g.setColor(new Color(104, 188, 255));
        drawClippedString(g, features[i][1], tableX + 230, rowY + rowH - 5, 90);
        g.setColor(PlotRenderTheme.TEXT_MUTED);
        drawClippedString(g, features[i][2], tableX + 330, rowY + rowH - 5, 55);
      }

      // Harmonic profile bar chart (right panel)
      int chartX = tableX + tableW + 12;
      int chartW = W - chartX - 12;
      int chartY = 52;
      int chartH = (WB_H - 80) / 2 - 4;
      Rectangle chartBounds = new Rectangle(chartX + 42, chartY + 24, chartW - 52, chartH - 44);
      PlotRenderTheme.drawPlotBackground(g, W, WB_H, chartBounds);
      PlotRenderTheme.drawGrid(g, chartBounds, 4, 5);
      g.setFont(PlotRenderTheme.TITLE_FONT);
      g.setColor(PlotRenderTheme.TEXT_PRIMARY);
      g.drawString("Harmonic profile", chartX + 42, chartY + 16);
      PlotRenderTheme.drawYAxisLabel(g, chartBounds, "Amplitude");
      PlotRenderTheme.drawYTicks(
          g, chartBounds, new double[] {0.0d, 0.5d, 1.0d}, new String[] {"1.0", "0.5", "0.0"});
      PlotRenderTheme.drawXTicks(
          g,
          chartBounds,
          new double[] {0.125, 0.375, 0.625, 0.875},
          new String[] {"H1", "H2", "H3", "H4"});

      double[] harmonicAmps = {0.80, 0.48 * 0.80, 0.24 * 0.80, 0.09 * 0.80};
      int barCount = harmonicAmps.length;
      int barW = chartBounds.width / (barCount * 2);
      for (int i = 0; i < barCount; i++) {
        int bx = chartBounds.x + i * chartBounds.width / barCount + barW / 2;
        int barHeight = (int) (harmonicAmps[i] * (chartBounds.height - 8));
        int by = chartBounds.y + chartBounds.height - barHeight;
        g.setColor(new Color(104, 188, 255, 180));
        g.fillRect(bx, by, barW, barHeight);
        g.setColor(PlotRenderTheme.SPECTRUM_LINE);
        g.drawRect(bx, by, barW, barHeight);
      }

      // Calibration status panel (bottom right)
      int calY = chartY + chartH + 8;
      int calH = WB_H - calY - 28;
      Rectangle calBounds = new Rectangle(chartX, calY, chartW, calH);
      g.setColor(PlotRenderTheme.PLOT_BACKGROUND);
      g.fillRect(calBounds.x, calBounds.y, calBounds.width, calBounds.height);
      g.setColor(new Color(72, 84, 102));
      g.drawRect(calBounds.x, calBounds.y, calBounds.width, calBounds.height);
      g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 12));
      g.setColor(PlotRenderTheme.TEXT_PRIMARY);
      g.drawString("Calibration status", calBounds.x + 8, calBounds.y + 16);
      g.setFont(PlotRenderTheme.LABEL_FONT);
      g.setColor(new Color(102, 235, 187));
      g.drawString("✓ Generator calibrated from this dataset", calBounds.x + 8, calBounds.y + 34);
      g.setColor(PlotRenderTheme.TEXT_MUTED);
      g.drawString("Baseline vs real: 28 % mean rel. diff.", calBounds.x + 8, calBounds.y + 50);
      g.drawString("Calibrated vs real: 9 % mean rel. diff.", calBounds.x + 8, calBounds.y + 66);

      // Status bar
      g.setColor(new Color(35, 43, 55));
      g.fillRect(0, WB_H - 28, W, 28);
      g.setFont(PlotRenderTheme.LABEL_FONT);
      g.setColor(PlotRenderTheme.TEXT_MUTED);
      g.drawString(
          "12 recordings  |  Feature extraction: complete  |  Provenance: HumBugDB v1.0",
          12,
          WB_H - 11);
    } finally {
      g.dispose();
    }
    return img;
  }

  /**
   * Renders the generator calibration screenshot.
   *
   * <p>Displays grouped before/after bars for key wingbeat feature differences, illustrating how
   * generator calibration reduces the gap between synthetic and real recordings.
   *
   * @return a 760 × 480 image
   */
  public static BufferedImage renderGeneratorCalibration() {
    // Hardcoded representative before/after calibration data (380 Hz mosquito-like baseline)
    String[] featureNames = {
      "freq", "centroid", "bandwidth", "AM", "drift", "jitter", "SNR", "duration"
    };
    double[] beforeDiffs = {0.62, 0.48, 0.35, 0.71, 0.55, 0.41, 0.28, 0.19};
    double[] afterDiffs = {0.12, 0.09, 0.14, 0.18, 0.22, 0.17, 0.10, 0.08};
    double improvementPct = 72.0;
    double meanBefore = 0.449;
    double meanAfter = 0.138;
    int corpusSize = 10;

    BufferedImage img = createWbImage();
    Graphics2D g = img.createGraphics();
    try {
      applyHints(g);
      g.setColor(PlotRenderTheme.PANEL_BACKGROUND);
      g.fillRect(0, 0, W, WB_H);

      // Title bar
      g.setColor(new Color(31, 38, 48));
      g.fillRect(0, 0, W, 44);
      g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 14));
      g.setColor(PlotRenderTheme.TEXT_PRIMARY);
      g.drawString("Generator Calibration Result", 16, 28);
      g.setFont(PlotRenderTheme.LABEL_FONT);
      g.setColor(PlotRenderTheme.TEXT_MUTED);
      g.drawString(
          String.format(
              Locale.ROOT,
              "Baseline: 380 Hz mosquito-like  |  Improvement: %.0f %%",
              improvementPct),
          16,
          42);

      // Bar chart area
      int chartMarginLeft = 148;
      Rectangle chart = new Rectangle(chartMarginLeft, 60, W - chartMarginLeft - 16, WB_H - 120);
      PlotRenderTheme.drawPlotBackground(g, W, WB_H, chart);
      PlotRenderTheme.drawGrid(g, chart, 5, 8);
      PlotRenderTheme.drawYAxisLabel(g, chart, "Relative difference");
      PlotRenderTheme.drawYTicks(
          g,
          chart,
          new double[] {0.0d, 0.25d, 0.5d, 0.75d, 1.0d},
          new String[] {"0 %", "25 %", "50 %", "75 %", "≥100 %"});

      int n = featureNames.length;
      int groupW = chart.width / Math.max(1, n);
      int barW = groupW / 3;

      for (int i = 0; i < n; i++) {
        int gx = chart.x + i * groupW + barW / 2;

        // Before bar
        int bHt = (int) (Math.min(1.0, beforeDiffs[i]) * (chart.height - 4));
        int bY = chart.y + chart.height - bHt;
        g.setColor(new Color(255, 100, 80, 180));
        g.fillRect(gx, bY, barW, bHt);
        g.setColor(new Color(255, 100, 80));
        g.drawRect(gx, bY, barW, bHt);

        // After bar
        int aHt = (int) (Math.min(1.0, afterDiffs[i]) * (chart.height - 4));
        int aY = chart.y + chart.height - aHt;
        g.setColor(new Color(102, 235, 187, 180));
        g.fillRect(gx + barW + 2, aY, barW, aHt);
        g.setColor(new Color(102, 235, 187));
        g.drawRect(gx + barW + 2, aY, barW, aHt);

        // Feature name on left
        g.setFont(PlotRenderTheme.LABEL_FONT);
        g.setColor(PlotRenderTheme.TEXT_MUTED);
        g.drawString(featureNames[i], 4, chart.y + (i + 1) * chart.height / n - 4);
      }

      // X axis: feature names
      double[] xPositions = new double[n];
      for (int i = 0; i < n; i++) {
        xPositions[i] = (i + 0.5) / n;
      }
      PlotRenderTheme.drawXTicks(g, chart, xPositions, featureNames);

      // Legend
      int legendY = WB_H - 44;
      g.setColor(new Color(35, 43, 55));
      g.fillRect(0, legendY, W, 44);
      g.setColor(new Color(255, 100, 80, 180));
      g.fillRect(16, legendY + 12, 14, 14);
      g.setColor(new Color(255, 100, 80));
      g.drawRect(16, legendY + 12, 14, 14);
      g.setFont(PlotRenderTheme.LABEL_FONT);
      g.setColor(PlotRenderTheme.TEXT_MUTED);
      g.drawString("Before calibration", 34, legendY + 24);

      g.setColor(new Color(102, 235, 187, 180));
      g.fillRect(160, legendY + 12, 14, 14);
      g.setColor(new Color(102, 235, 187));
      g.drawRect(160, legendY + 12, 14, 14);
      g.setColor(PlotRenderTheme.TEXT_MUTED);
      g.drawString("After calibration", 178, legendY + 24);

      g.setColor(PlotRenderTheme.TEXT_MUTED);
      g.drawString(
          String.format(
              Locale.ROOT,
              "Corpus: %d recordings  |  Mean Δ before: %.1f %%  →  after: %.1f %%",
              corpusSize,
              meanBefore * 100.0,
              meanAfter * 100.0),
          320,
          legendY + 24);
    } finally {
      g.dispose();
    }
    return img;
  }

  /**
   * Returns the string to draw, truncated with {@code …} when its pixel width exceeds {@code
   * maxWidth}. The returned string always has a measured width {@code ≤ maxWidth}.
   *
   * @param fm font metrics to use for measurement
   * @param text the text to clip
   * @param maxWidth maximum allowed pixel width
   * @return the original string when it fits, otherwise the longest prefix followed by {@code …}
   *     that fits within {@code maxWidth}
   */
  static String clipString(FontMetrics fm, String text, int maxWidth) {
    if (fm.stringWidth(text) <= maxWidth) {
      return text;
    }
    String ellipsis = "…";
    int ellipsisW = fm.stringWidth(ellipsis);
    int available = maxWidth - ellipsisW;
    if (available <= 0) {
      return ellipsis;
    }
    // Binary-search for the longest prefix that fits
    int lo = 0;
    int hi = text.length();
    while (lo < hi - 1) {
      int mid = (lo + hi) / 2;
      if (fm.stringWidth(text.substring(0, mid)) <= available) lo = mid;
      else hi = mid;
    }
    return text.substring(0, lo) + ellipsis;
  }

  /**
   * Draws a string, truncating with {@code …} if it exceeds {@code maxWidth} pixels.
   *
   * @param g graphics context
   * @param text the text to draw
   * @param x left edge of the text baseline
   * @param y baseline y position
   * @param maxWidth maximum allowed pixel width; text is clipped with an ellipsis if exceeded
   */
  static void drawClippedString(Graphics2D g, String text, int x, int y, int maxWidth) {
    g.drawString(clipString(g.getFontMetrics(), text, maxWidth), x, y);
  }

  private static void applyHints(Graphics2D g) {
    g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
    g.setRenderingHint(
        RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
    g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
  }

  private static void writePng(Path out, BufferedImage img) throws IOException {
    if (!ImageIO.write(img, "png", out.toFile())) {
      throw new IOException("PNG writer not available for " + out);
    }
  }
}