#!/usr/bin/env python3
"""
kzk_gui.py - interactive viewer for the simple 2D KZK model.

Two signals, chosen at the top right of the window:

  Pure tone (water)   1 MHz sine, 0 to 0.90 m: the on-axis waveform steepening
                      into a shock, and its harmonics.
  AM speaker (air)    a 60 kHz carrier modulated by a 1 kHz tone, 0 to 3 m (carrier,
                      tone and distance editable): the modulated beam and its spectrum,
                      plus the AUDIBLE sound the air demodulates out of it, its
                      spectrum and its harmonic distortion (THD).

Drag the vertical slider to change the propagation distance z. The march is
computed once, in the background, when a signal is chosen; you can watch the
result while it is still running, and after that every slider position is instant.

Model and numerics come from kzk_sim.py (it must sit in the same folder):

  d2p/(dz dtau) = (c0/2) d2p/dx2 + (delta/(2 c0^3)) d3p/dtau3 + (beta/(2 rho0 c0^3)) d2(p^2)/dtau2
                  [diffraction]     [absorption]                  [nonlinearity]

  Strang splitting in z: diffraction + absorption solved exactly per harmonic
  (FFTs in x and tau), nonlinearity by its exact implicit solution in tau.

Run:  python3 kzk_gui.py          (starts with the pure tone)
      python3 kzk_gui.py --am     (starts with the AM speaker)
"""

import sys
import threading
import tkinter as tk
from pathlib import Path
from tkinter import ttk, filedialog, messagebox

import numpy as np
from scipy import fft as sfft

import matplotlib
matplotlib.use("TkAgg")
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure

from kzk_sim import (AUDIBLE_MAX, Medium, Source, _nonlinear_step, absorption, am_nt, audible,
                     derived_scales, source_waveform, spl, thd, window_frequency)

# pure tone in water
Z_MAX = 0.90        # slider range [m]
DZ = 0.01           # slider step [m]
NT = 1024           # time samples per period
N_BARS = 10         # harmonics shown in the spectrum

# AM speaker in air: carrier, tone and distance are the only inputs; the rest is fixed
AM_DEFAULTS = dict(fc_khz=60.0, fm_khz=1.0, z_max=3.0)
AM_FIXED = dict(m=1.0, p0=100.0, a_cm=10.0, temp=20.0, rh=50.0)
AM_DZ = 0.05        # slider step [m]
N_AUDIO_BARS = 8    # audible harmonics shown


# ----------------------------------------------------------------------------
# Physics (the same model and helpers as kzk_sim.py)
# ----------------------------------------------------------------------------

def march(z_end, dz_out, med, src, nt=NT, cfl=0.5, stop=None):
    """Yield (z, on-axis waveform) at z = 0, dz_out, 2*dz_out, ..., z_end.

    Same numerics as kzk_sim.simulate(), but the waveform is handed back at
    every dz_out so the window can show it while the march is still running.
    The time window is one period of window_frequency(src): 1/f0 for a pure
    tone, 1/fm for AM.
    """
    s = derived_scales(med, src)
    f_win = window_frequency(src)
    k_win = 2 * np.pi * f_win / med.c0
    dtau = 1 / (f_win * nt)
    tau = np.arange(nt) * dtau
    b = med.beta / (med.rho0 * med.c0 ** 3)

    # transverse grid sized for the farthest distance (periodic, absorbing sponge at the edges);
    # in AM mode it must also hold the audio, which spreads far faster than the carrier
    w_end = src.a * np.sqrt(1 + (z_end / s["z_rayleigh"]) ** 2)
    w_end_win = src.a * np.sqrt(1 + (z_end / (k_win * src.a ** 2 / 2)) ** 2)
    x_useful = max(4 * w_end, 6 * src.a, 0.75 * w_end_win)
    x_sponge = 0.35 * x_useful
    dx = src.a / (12 if src.fm else 24)
    nx = sfft.next_fast_len(int(np.ceil(2 * (x_useful + x_sponge) / dx)))
    nx += nx % 2
    x = (np.arange(nx) - nx // 2) * dx
    depth = np.clip((np.abs(x) - x_useful) / x_sponge, 0, None)
    sponge_rate = (40 / (k_win * src.a ** 2 / 2)) * depth ** 2      # [Np/m]
    ix0 = nx // 2                                                   # x = 0 (axis)

    # linear operator per harmonic: diffraction phase and absorption
    n = np.arange(nt // 2 + 1)
    absorb = absorption(med, n * f_win)
    kx = 2 * np.pi * sfft.fftfreq(nx, dx)
    diff = np.zeros((nx, n.size))
    diff[:, 1:] = kx[:, None] ** 2 / (2 * k_win * n[None, 1:])

    def linear_step(p, h):
        P = sfft.fft(sfft.rfft(p, axis=1, workers=-1), axis=0, workers=-1)
        P *= np.exp((1j * diff - absorb[None, :]) * h)
        P[:, 0] = 0.0                                               # no DC in KZK
        p = sfft.irfft(sfft.ifft(P, axis=0, workers=-1), n=nt, axis=1, workers=-1)
        return p * np.exp(-sponge_rate * h)[:, None]

    def nonlinear_step(p, h):
        rows = np.flatnonzero(np.abs(p).max(axis=1) > 1e-6 * src.p0)
        if rows.size:
            p[rows] = _nonlinear_step(p[rows], h, b, dtau, cubic=bool(src.fm))
        return p

    p = src.p0 * np.exp(-(x / src.a) ** 2)[:, None] * source_waveform(src, tau)[None, :]
    h_max = min(s["x_shock"], s["z_rayleigh"]) / 100
    yield 0.0, p[ix0].copy()

    z = 0.0
    for i in range(1, int(round(z_end / dz_out)) + 1):
        target = i * dz_out
        while target - z > 1e-12:
            if stop is not None and stop.is_set():
                return
            slope = np.abs(np.diff(p, axis=1, append=p[:, :1])).max() / dtau
            h = min(h_max, cfl / (b * slope), target - z)
            p = nonlinear_step(p, h / 2)
            p = linear_step(p, h)
            p = nonlinear_step(p, h / 2)
            z += h
        z = target
        yield target, p[ix0].copy()


# ----------------------------------------------------------------------------
# GUI
# ----------------------------------------------------------------------------

INK = "#0b0b0b"
INK2 = "#52514e"
MUTED = "#898781"
GRID = "#e1e0d9"
AXIS = "#c3c2b7"
SURFACE = "#fcfcfb"
BLUE = "#2a78d6"
RED = "#b0182f"     # the audible part in AM mode
MPa = 1e-6


def _style(ax):
    ax.set_facecolor(SURFACE)
    for side in ("top", "right"):
        ax.spines[side].set_visible(False)
    for side in ("left", "bottom"):
        ax.spines[side].set_color(AXIS)
    ax.tick_params(colors=INK2, labelsize=9)
    ax.xaxis.label.set_color(INK2)
    ax.yaxis.label.set_color(INK2)
    ax.grid(color=GRID, linewidth=0.6)
    ax.set_axisbelow(True)


def _tile(w):
    """Two periods of a periodic waveform, closed at the right edge."""
    return np.concatenate([w, w, w[:1]])


class KZKViewer:
    def __init__(self, root, am=False):
        self.root = root
        self.mode = tk.StringVar(value="am" if am else "tone")    # the radio buttons
        self.active = None                                        # the signal actually shown
        self.waves = []             # on-axis waveform at z = i*dz, appended by the worker
        self.error = None
        self.shown = None           # index of the waveform currently drawn
        self.playing = False
        self.stop = threading.Event()
        self._poll_id = self._play_id = None

        self.fig = Figure(figsize=(11.5, 7.4), facecolor=SURFACE)
        self.canvas = FigureCanvasTkAgg(self.fig, master=root)
        self.canvas.get_tk_widget().grid(row=0, column=0, sticky="nsew")
        self.canvas.mpl_connect("resize_event", lambda event: self._relayout())
        self._build_toolbar()
        root.grid_columnconfigure(0, weight=1)
        root.grid_rowconfigure(0, weight=1)
        root.protocol("WM_DELETE_WINDOW", self._on_close)
        self._start()

    # ---------------- run setup ----------------

    def _settings(self):
        """Medium, source, z range and time samples for the chosen signal (None if a field is bad)."""
        if self.mode.get() == "tone":
            return dict(med=Medium(), src=Source(), z_max=Z_MAX, dz=DZ, nt=NT)
        v = dict(AM_FIXED)
        try:
            v.update({k: float(var.get()) for k, var in self.am_vars.items()})
        except ValueError:
            messagebox.showerror("Invalid input", "Carrier, tone and distance must be numbers.")
            return None
        problems = []
        if v["fm_khz"] <= 0 or v["fc_khz"] <= v["fm_khz"]:
            problems.append("The carrier must be higher than the tone, and both positive.")
        if v["z_max"] < AM_DZ:
            problems.append(f"The distance must be at least {AM_DZ} m.")
        src = Source(f0=v["fc_khz"] * 1e3, fm=v["fm_khz"] * 1e3, m=v["m"], p0=v["p0"],
                     a=v["a_cm"] / 100)
        if not problems:
            try:
                nt = am_nt(src)
            except ValueError as exc:
                problems.append(str(exc)[:1].upper() + str(exc)[1:] + ".")
        if problems:
            messagebox.showerror("Invalid AM settings", "\n".join(problems))
            return None
        z_max = round(v["z_max"] / AM_DZ) * AM_DZ
        return dict(med=Medium.air(v["temp"], v["rh"]), src=src, z_max=z_max, dz=AM_DZ, nt=nt)

    def _start(self):
        """(Re)start the background march for the current signal and settings."""
        cfg = self._settings()
        if cfg is None:
            if self.active is not None:
                self.mode.set(self.active)       # keep the radio buttons in step with the plots
            return
        self.stop.set()                                  # stop any previous march
        self.stop = threading.Event()
        self.cfg = cfg
        self.med, self.src = cfg["med"], cfg["src"]
        self.scales = derived_scales(self.med, self.src)
        self.f_win = window_frequency(self.src)
        self.n_total = int(round(cfg["z_max"] / cfg["dz"])) + 1
        self.waves = []                                  # a fresh list: an old worker keeps its own
        self.error = None
        self.shown = None
        if self.playing:
            self._toggle_play()

        am = self.mode.get() == "am"
        self.active = self.mode.get()
        for child in self.am_frame.winfo_children():
            if isinstance(child, (ttk.Entry, ttk.Button)):
                child.configure(state="normal" if am else "disabled")
        self.z_var.set(0.0)
        self.slider.configure(to=cfg["z_max"], resolution=cfg["dz"],
                              tickinterval=0.5 if am else 0.1, digits=3)
        self.progress.configure(maximum=self.n_total, value=0)
        self.root.title("KZK 2D - AM speaker in air vs distance" if am
                        else "KZK 2D - pure tone vs distance")

        self.fig.clear()
        (self._build_am_axes if am else self._build_tone_axes)()
        self._relayout()
        self.canvas.draw_idle()

        threading.Thread(target=self._compute, args=(self.waves, self.stop, cfg), daemon=True).start()
        if self._poll_id is None:
            self._poll()

    # ---------------- layout ----------------

    def _build_tone_axes(self):
        src = self.src
        tau = np.arange(NT) / (src.f0 * NT)
        source_wave = src.p0 * np.sin(2 * np.pi * src.f0 * tau)
        self.t_us = np.arange(2 * NT + 1) / (src.f0 * NT) * 1e6
        self.f_mhz = np.arange(1, N_BARS + 1) * src.f0 / 1e6

        fig = self.fig
        self.ax_t, self.ax_f = fig.subplots(1, 2)
        self.header = fig.suptitle("", color=INK, fontsize=11.5, x=0.01, ha="left")

        # time domain
        ax = self.ax_t
        _style(ax)
        self.title_t = ax.set_title("", loc="left", color=INK, fontsize=11, pad=8)
        ax.plot(self.t_us, _tile(source_wave) * MPa, color=MUTED, lw=1.4, ls="--",
                label="source sine (z = 0)")
        (self.wave_line,) = ax.plot(self.t_us, _tile(source_wave) * MPa, color=BLUE, lw=2,
                                    label="at z")
        amp = src.p0 * MPa
        ax.set_xlim(0, self.t_us[-1])
        ax.set_ylim(-1.1 * amp, 1.45 * amp)
        ax.set_xlabel("time τ [µs]   (retarded time τ = t − z/c0)")
        ax.set_ylabel("pressure p [MPa]")
        self.legend = ax.legend(frameon=False, fontsize=9, labelcolor=INK2, ncol=2,
                                loc="upper center")

        # frequency domain
        ax = self.ax_f
        _style(ax)
        self.title_f = ax.set_title("", loc="left", color=INK, fontsize=11, pad=8)
        self.bars = ax.bar(self.f_mhz, np.zeros(N_BARS), width=0.6 * src.f0 / 1e6,
                           color=BLUE, zorder=3)
        ax.axhline(src.p0 * MPa, color=MUTED, lw=1.4, ls="--")
        ax.text(self.f_mhz[-1] + 0.4 * src.f0 / 1e6, src.p0 * MPa, "source: p0 at f0 only",
                color=MUTED, fontsize=9, ha="right", va="bottom")
        self.bar_labels = [
            ax.text(f, 0, "", color=INK2, fontsize=9, ha="center", va="bottom", zorder=4,
                    bbox=dict(facecolor=SURFACE, edgecolor="none", pad=1))
            for f in self.f_mhz[:3]]
        ax.set_xticks(self.f_mhz)
        ax.set_xlabel("frequency [MHz]   (harmonics n·f0)")
        ax.set_ylabel("amplitude [MPa]")
        ax.set_ylim(0, src.p0 * MPa * 1.12)
        ax.grid(axis="x", visible=False)

    def _build_am_axes(self):
        src, nt, f_win = self.src, self.cfg["nt"], self.f_win
        fig = self.fig
        (self.ax_t, self.ax_f), (self.ax_a, self.ax_s) = fig.subplots(2, 2)
        self.header = fig.suptitle("", color=INK, fontsize=11.5, x=0.01, ha="left")
        self.t_ms = np.arange(nt + 1) / (nt * f_win) * 1e3          # one modulation period
        self.t2_ms = np.arange(2 * nt + 1) / (nt * f_win) * 1e3     # two periods

        # the modulated carrier on axis
        ax = self.ax_t
        _style(ax)
        self.title_t = ax.set_title("", loc="left", color=INK, fontsize=11, pad=8)
        envelope = src.p0 * (1 + src.m * np.cos(2 * np.pi * src.fm * self.t_ms / 1e3))
        ax.plot(self.t_ms, envelope, color=MUTED, lw=1.2, ls="--", label="source envelope (z = 0)")
        ax.plot(self.t_ms, -envelope, color=MUTED, lw=1.2, ls="--")
        (self.wave_line,) = ax.plot(self.t_ms, np.zeros(nt + 1), color=BLUE, lw=0.5, label="at z")
        ax.set_xlim(0, self.t_ms[-1])
        ax.set_ylim(-1.15 * envelope.max(), 1.4 * envelope.max())
        ax.set_xlabel("time τ [ms]   (one period of the modulation)")
        ax.set_ylabel("pressure p [Pa]")
        self.legend = ax.legend(frameon=False, fontsize=9, labelcolor=INK2, ncol=2, loc="upper center")

        # full spectrum in dB SPL: audible lines red, ultrasonic blue
        ax = self.ax_f
        _style(ax)
        self.title_f = ax.set_title("", loc="left", color=INK, fontsize=11, pad=8)
        self.n_spec = min(nt // 2, int(3.2 * src.f0 / f_win))
        self.f_khz = np.arange(1, self.n_spec + 1) * f_win / 1e3
        self.spec_top = spl(src.p0 * (1 + src.m / 2)) + 12
        self.spec_floor = max(0.0, self.spec_top - 122)
        is_audio = self.f_khz * 1e3 <= AUDIBLE_MAX
        ax.axvspan(0, AUDIBLE_MAX / 1e3, color=RED, alpha=0.07, lw=0)
        self.spec_lines = ax.vlines(self.f_khz, self.spec_floor, self.spec_floor,
                                    colors=np.where(is_audio, RED, BLUE),
                                    linewidths=np.where(is_audio, 1.8, 1.2))
        self.carrier_note = ax.text(0, 0, "", color=INK2, fontsize=9, va="center")
        self.audio_note = ax.text(0, 0, "", color=RED, fontsize=9)
        ax.set_xlim(0, self.f_khz[-1])
        ax.set_ylim(self.spec_floor, self.spec_top)
        ax.set_xlabel("frequency [kHz]   (audible band shaded)")
        ax.set_ylabel("level [dB SPL]")

        # the audible waveform against an undistorted tone
        ax = self.ax_a
        _style(ax)
        self.title_a = ax.set_title("", loc="left", color=INK, fontsize=11, pad=8)
        (self.tone_line,) = ax.plot(self.t2_ms, np.zeros(2 * nt + 1), color=MUTED, lw=1.4, ls="--",
                                    label=f"its {src.fm / 1e3:g} kHz part alone (undistorted)")
        (self.audio_line,) = ax.plot(self.t2_ms, np.zeros(2 * nt + 1), color=RED, lw=2,
                                     label="what a listener on axis hears")
        ax.set_xlim(0, self.t2_ms[-1])
        ax.set_xlabel("time τ [ms]")
        ax.set_ylabel("pressure [mPa]")
        ax.legend(frameon=False, fontsize=9, labelcolor=INK2, loc="upper right")
        self.silent_note = ax.text(0.5, 0.5, "", transform=ax.transAxes, ha="center",
                                   va="center", color=INK2, fontsize=11)

        # the audible spectrum: the tone and its distortion
        ax = self.ax_s
        _style(ax)
        ax.set_title("Audible spectrum: the tone and its distortion", loc="left", color=INK,
                     fontsize=11, pad=8)
        self.fa_khz = np.arange(1, N_AUDIO_BARS + 1) * f_win / 1e3
        self.audio_bars = ax.bar(self.fa_khz, np.zeros(N_AUDIO_BARS), width=0.6 * f_win / 1e3,
                                 color=RED, zorder=3)
        self.audio_labels = [ax.text(f, 0, "", color=INK2, fontsize=9, ha="center", va="bottom")
                             for f in self.fa_khz[:3]]
        ax.set_xticks(self.fa_khz)
        ax.set_xlabel(f"frequency [kHz]   (multiples of {src.fm / 1e3:g} kHz)")
        ax.set_ylabel("level [dB SPL]")
        ax.grid(axis="x", visible=False)

    def _relayout(self):
        self.fig.tight_layout(rect=(0, 0, 1, 0.94))

    def _build_toolbar(self):
        bar = ttk.Frame(self.root, padding=10)
        bar.grid(row=0, column=1, sticky="ns")
        bar.grid_rowconfigure(3, weight=1)

        ttk.Label(bar, text="Signal", font=("", 11, "bold")).grid(row=0, column=0, sticky="w")
        modes = ttk.Frame(bar)
        modes.grid(row=1, column=0, sticky="w", pady=(2, 6))
        ttk.Radiobutton(modes, text="Pure tone (water)", value="tone", variable=self.mode,
                        command=self._start).grid(row=0, column=0, sticky="w")
        ttk.Radiobutton(modes, text="AM speaker (air)", value="am", variable=self.mode,
                        command=self._start).grid(row=1, column=0, sticky="w")

        # AM settings; Run restarts the march with them
        self.am_frame = ttk.Frame(bar)
        self.am_frame.grid(row=2, column=0, sticky="ew", pady=(0, 8))
        fields = [("fc_khz", "Carrier kHz"), ("fm_khz", "Tone kHz"), ("z_max", "z max (m)")]
        self.am_vars = {}
        for i, (key, label) in enumerate(fields):
            row, col = divmod(i, 2)
            ttk.Label(self.am_frame, text=label).grid(row=row, column=2 * col, sticky="w", pady=1,
                                                      padx=(10 if col else 0, 0))
            var = tk.StringVar(value=f"{AM_DEFAULTS[key]:g}")
            ttk.Entry(self.am_frame, textvariable=var, width=6).grid(row=row, column=2 * col + 1,
                                                                    padx=(4, 0))
            self.am_vars[key] = var
        f = AM_FIXED
        ttk.Label(self.am_frame, foreground=INK2, wraplength=250, justify="left",
                  text=f"Fixed: p0 = {f['p0']:g} Pa, m = {f['m']:g}, a = {f['a_cm']:g} cm, "
                       f"{f['temp']:g} °C, {f['rh']:g} % RH").grid(
            row=2, column=0, columnspan=4, sticky="w", pady=(3, 0))
        ttk.Button(self.am_frame, text="Run with these settings", command=self._start).grid(
            row=3, column=0, columnspan=4, sticky="ew", pady=(4, 0))

        slider_box = ttk.Frame(bar)
        slider_box.grid(row=3, column=0, sticky="ns")
        slider_box.grid_rowconfigure(1, weight=1)
        ttk.Label(slider_box, text="Distance z (m)", font=("", 11, "bold")).grid(
            row=0, column=0, pady=(0, 4))
        self.z_var = tk.DoubleVar(value=0.0)
        self.slider = tk.Scale(
            slider_box, from_=0.0, to=Z_MAX, resolution=DZ, digits=3, tickinterval=0.1,
            orient="vertical", variable=self.z_var, command=self._on_slide,
            length=220, width=18, sliderlength=26, showvalue=True)
        self.slider.grid(row=1, column=0, sticky="ns")
        self.slider.focus_set()     # Up/Down arrow keys step z

        self.play_btn = ttk.Button(bar, text="Play ▶", command=self._toggle_play)
        self.play_btn.grid(row=4, column=0, sticky="ew", pady=(10, 4))
        ttk.Button(bar, text="Save PNG...", command=self._save).grid(
            row=5, column=0, sticky="ew", pady=4)

        self.progress = ttk.Progressbar(bar, maximum=1, length=170)
        self.progress.grid(row=6, column=0, sticky="ew", pady=(10, 2))
        self.status = tk.StringVar(value="Computing...")
        ttk.Label(bar, textvariable=self.status, wraplength=180, foreground=INK2).grid(
            row=7, column=0, sticky="w")

        ttk.Label(bar, text="Results:", font=("", 10, "bold")).grid(
            row=8, column=0, sticky="w", pady=(12, 2))
        self.info_text = tk.Text(bar, width=26, height=8, font=("Menlo", 11),
                                 relief="sunken", borderwidth=1)
        self.info_text.grid(row=9, column=0, sticky="ew")
        self.info_text.configure(state="disabled")

    # ---------------- computation ----------------

    def _compute(self, waves, stop, cfg):
        try:
            for _, wave in march(cfg["z_max"], cfg["dz"], cfg["med"], cfg["src"],
                                 nt=cfg["nt"], stop=stop):
                waves.append(wave)
        except Exception as exc:        # reported to the user by _poll
            if not stop.is_set():
                self.error = exc

    def _poll(self):
        """Runs on the Tk thread: update progress and draw newly computed distances."""
        self._poll_id = None
        done = len(self.waves)
        self.progress["value"] = done
        if self.error is not None:
            self.status.set("Computation failed")
            messagebox.showerror("KZK computation failed", str(self.error))
            return
        if done < self.n_total:
            ready = f"ready up to z = {(done - 1) * self.cfg['dz']:.2f} m" if done else "starting"
            self.status.set(f"Computing... {ready}")
        else:
            self.status.set(f"Ready: z = 0 to {self.cfg['z_max']:.2f} m")
        if done and self.shown != self._index():
            self._draw(self._index())
        self._poll_id = self.root.after(100 if done < self.n_total else 400, self._poll)

    # ---------------- interaction ----------------

    def _index(self):
        return int(np.clip(round(self.z_var.get() / self.cfg["dz"]), 0, self.n_total - 1))

    def _on_slide(self, _value):
        self._draw(self._index())

    def _draw(self, wanted):
        if not self.waves:
            return
        idx = min(wanted, len(self.waves) - 1)
        if idx == self.shown:
            return
        self.shown = idx
        z = idx * self.cfg["dz"]
        pending = "   (still computing this z)" if idx < wanted else ""
        if self.active == "am":
            info = self._draw_am(self.waves[idx], z, pending)
        else:
            info = self._draw_tone(self.waves[idx], z, pending)
        self.canvas.draw_idle()
        self.info_text.configure(state="normal")
        self.info_text.delete("1.0", "end")
        self.info_text.insert("1.0", info)
        self.info_text.configure(state="disabled")

    def _draw_tone(self, wave, z, pending):
        src, s = self.src, self.scales
        spec = 2 * np.abs(np.fft.rfft(wave))[1:N_BARS + 1] / NT

        self.wave_line.set_ydata(_tile(wave) * MPa)
        self.legend.get_texts()[1].set_text(f"after z = {z:.2f} m")
        for rect, h in zip(self.bars, spec):
            rect.set_height(h * MPa)
        for i, label in enumerate(self.bar_labels):
            label.set_position((self.f_mhz[i], (spec[i] + 0.012 * src.p0) * MPa))
            label.set_text(f"{spec[i] * MPa:.3f}")
        self.header.set_text(
            f"KZK 2D, pure tone f0 = {src.f0 / 1e6:g} MHz, p0 = {src.p0 * MPa:g} MPa, "
            f"a = {src.a * 100:g} cm, water  |  distance z = {z:.2f} m  "
            f"(z / x_shock = {z / s['x_shock']:.2f}){pending}")
        self.title_t.set_text(f"Time: pressure on the beam axis at z = {z:.2f} m")
        self.title_f.set_text(f"Frequency: amplitude spectrum at z = {z:.2f} m")

        return (
            f"z          {z:6.2f} m\n"
            f"z/x_shock  {z / s['x_shock']:6.2f}\n"
            f"peak +     {wave.max() * MPa:+6.3f} MPa\n"
            f"peak -     {wave.min() * MPa:+6.3f} MPa\n\n"
            + "\n".join(f"{(i + 1) * src.f0 / 1e6:g} MHz      {spec[i] * MPa:6.3f} MPa"
                        for i in range(4))
            + f"\n\nx_shock = {s['x_shock']:.3f} m"
        )

    def _draw_am(self, wave, z, pending):
        src, med, nt, f_win = self.src, self.med, self.cfg["nt"], self.f_win
        audio, amps = audible(wave, f_win)
        silent = amps[0] < 1e-9 * src.p0           # z = 0: nothing demodulated yet

        # modulated carrier and full spectrum
        self.wave_line.set_ydata(np.append(wave, wave[:1]))
        self.legend.get_texts()[1].set_text(f"after z = {z:.2f} m")
        W = 2 * np.abs(np.fft.rfft(wave)) / nt
        lev = np.maximum(spl(W[1:self.n_spec + 1]), self.spec_floor)
        self.spec_lines.set_segments([[(f, self.spec_floor), (f, l)] for f, l in zip(self.f_khz, lev)])
        i_c = int(round(src.f0 / f_win)) - 1
        carrier = W[i_c + 1]
        self.carrier_note.set_position((self.f_khz[i_c] + 2, lev[i_c]))
        self.carrier_note.set_text(f"carrier {src.f0 / 1e3:g} kHz: {spl(carrier):.0f} dB")

        # audible part
        if silent:
            self.audio_note.set_text("")
            self.title_a.set_text("Audible sound (below 20 kHz)")
            self.silent_note.set_text("No audible sound yet: at the source\nthe air has not demodulated anything")
            self.audio_line.set_visible(False)
            self.tone_line.set_visible(False)
            self.ax_a.set_ylim(-1, 1)
            for rect in self.audio_bars:
                rect.set_height(0)
            for label in self.audio_labels:
                label.set_text("")
            self.ax_s.set_ylim(0, 100)
        else:
            distortion = thd(amps)
            self.audio_note.set_position((self.f_khz[0] + 2, spl(amps[0]) + 2))
            self.audio_note.set_text(f"audible {src.fm / 1e3:g} kHz: {spl(amps[0]):.0f} dB")
            self.title_a.set_text(f"Audible sound (below 20 kHz): THD = {100 * distortion:.1f} %")
            self.silent_note.set_text("")
            F = np.fft.rfft(audio)
            F[2:] = 0
            tone = np.fft.irfft(F, n=nt)
            self.audio_line.set_visible(True)
            self.tone_line.set_visible(True)
            self.audio_line.set_ydata(_tile(audio) * 1e3)
            self.tone_line.set_ydata(_tile(tone) * 1e3)
            peak = np.abs(audio).max() * 1e3
            self.ax_a.set_ylim(-1.2 * peak, 1.6 * peak)
            la = spl(amps[:N_AUDIO_BARS])
            base = min(la.min(), la[0] - 60)
            for rect, l in zip(self.audio_bars, la):
                rect.set_y(base)
                rect.set_height(l - base)
            for i, label in enumerate(self.audio_labels):
                label.set_position((self.fa_khz[i], la[i] + 1))
                label.set_text(f"{la[i]:.1f} dB")
            self.ax_s.set_ylim(base, la[0] + 10)

        self.header.set_text(
            f"KZK 2D, AM in air: {src.f0 / 1e3:g} kHz carrier modulated by {src.fm / 1e3:g} kHz "
            f"(m = {src.m:g}), p0 = {src.p0:g} Pa, a = {src.a * 100:g} cm, {med.temp_c:g} °C, "
            f"{med.rh:g} % RH  |  distance z = {z:.2f} m{pending}")
        self.title_t.set_text(f"Time: pressure on the beam axis at z = {z:.2f} m")
        self.title_f.set_text(f"Frequency: spectrum at z = {z:.2f} m")

        lines = [f"z            {z:6.2f} m",
                 f"carrier  {spl(carrier):6.1f} dB SPL", ""]
        if silent:
            lines.append("no audible sound yet")
        else:
            lines += [f"{(i + 1) * src.fm / 1e3:g} kHz    {spl(amps[i]):6.1f} dB SPL" for i in range(3)]
            lines += ["", f"THD          {100 * thd(amps):5.1f} %"]
        return "\n".join(lines)

    def _toggle_play(self):
        self.playing = not self.playing
        self.play_btn.configure(text="Pause ❚❚" if self.playing else "Play ▶")
        if self.playing:
            if self._index() >= self.n_total - 1:
                self.z_var.set(0.0)
                self._draw(0)
            self._play_step()

    def _play_step(self):
        if not self.playing:
            return
        i = self._index()
        if i >= self.n_total - 1:
            self._toggle_play()
            return
        if i + 1 < len(self.waves):          # otherwise wait for the computation
            self.z_var.set(round((i + 1) * self.cfg["dz"], 2))
            self._draw(i + 1)
        self._play_id = self.root.after(80, self._play_step)

    def _save(self):
        z = (self.shown or 0) * self.cfg["dz"]
        outdir = Path(__file__).resolve().parent / "output"
        outdir.mkdir(exist_ok=True)
        name = f"kzk_am_z{z:.2f}m.png" if self.active == "am" else f"kzk_z{z:.2f}m.png"
        path = filedialog.asksaveasfilename(
            initialdir=outdir, initialfile=name, defaultextension=".png",
            filetypes=[("PNG image", "*.png")], title="Save figure as PNG")
        if not path:
            return
        try:
            self.fig.savefig(path, dpi=130, facecolor=SURFACE)
        except Exception as exc:
            messagebox.showerror("Save failed", str(exc))

    def _on_close(self):
        self.stop.set()
        self.playing = False
        for after_id in (self._poll_id, self._play_id):
            if after_id is not None:
                try:
                    self.root.after_cancel(after_id)
                except tk.TclError:
                    pass
        self.root.destroy()


def main():
    root = tk.Tk()
    KZKViewer(root, am="--am" in sys.argv[1:])
    # bring the window to the front (it can open behind the editor on macOS)
    root.lift()
    root.attributes("-topmost", True)
    root.after(300, lambda: root.attributes("-topmost", False))
    root.mainloop()


if __name__ == "__main__":
    main()
