"""Analüüsi SDR++ baseband (IQ) salvestust: spekter, FM-demod heli, RDS.

Kasutus:
  python iq.py fail.wav                 -> skaneeri jaamad + RDS igal tugevamal jaamal
  python iq.py fail.wav --scan          -> ainult spekter/jaamade nimekiri
  python iq.py fail.wav --freq 101.6    -> demoduleeri see jaam: RDS + heli (salvestab *_audio.wav)
  python iq.py fail.wav --freq 101.6 --no-audio

Failinimest loetakse keskmine sagedus (baseband_101600000Hz_...).
"""
import argparse, os, re, sys, wave
import numpy as np

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

# Ainult numpy: Windows Smart App Control blokeerib scipy DLL-e, seega
# kõik filtreerimine/resämplimine käib FFT-ga sagedusruumis.


def fft_bandpass_analytic(x, fs, lo, hi):
    """Reaalse signaali analüütiline (kompleksne) riba lo..hi Hz, FFT-ga."""
    n = len(x)
    X = np.fft.fft(x)
    f = np.fft.fftfreq(n, 1 / fs)
    m = (f >= lo) & (f <= hi)
    X[~m] = 0
    return np.fft.ifft(X) * 2


def fft_decimate_complex(x, fs, center, out_fs):
    """Lõika kompleksest signaalist riba 'center' ümber ja väljasta madalamal fs-il.
    Sagedusruumi dekimeerimine: täpne, ilma FIR-ita."""
    n = len(x)
    X = np.fft.fft(x)
    del x
    f = np.fft.fftfreq(n, 1 / fs)
    m = int(round(n * out_fs / fs))
    half = m // 2
    # bin-indeksid: keskkoha bin +- half
    c = int(round(center / fs * n))
    idx = (np.arange(-half, m - half) + c) % n
    Y = X[idx]
    del X
    Y = np.fft.ifftshift(Y)                        # -half..+half -> standard fft järjestus
    y = np.fft.ifft(Y) * (m / n)
    return y.astype(np.complex64), fs * m / n


def fft_lowpass_real_decimate(x, fs, cutoff, out_fs):
    n = len(x)
    X = np.fft.rfft(x)
    f = np.fft.rfftfreq(n, 1 / fs)
    X[f > cutoff] = 0
    m = int(round(n * out_fs / fs))
    keep = m // 2 + 1
    y = np.fft.irfft(X[:keep], m) * (m / n)
    return y, fs * m / n

# ---------------------------------------------------------------- IQ laadimine

def load_iq(path):
    with wave.open(path, "rb") as w:
        fs, ch, sw, n = w.getframerate(), w.getnchannels(), w.getsampwidth(), w.getnframes()
        raw = w.readframes(n)
    if sw == 2:
        d = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
    elif sw == 4:
        d = np.frombuffer(raw, dtype=np.float32)
    else:
        raise ValueError("ootamatu sample width %d" % sw)
    if ch != 2:
        raise ValueError("baseband fail peab olema 2 kanalit (I/Q)")
    d = d.reshape(-1, 2)
    return fs, (d[:, 0] + 1j * d[:, 1]).astype(np.complex64)


def center_freq_from_name(path):
    m = re.search(r"_(\d+)Hz_", os.path.basename(path))
    return int(m.group(1)) if m else None

# ---------------------------------------------------------------- spekter

def scan(iq, fs, fc, nfft=8192, chan_bw=150e3, min_snr=8.0):
    """Keskmistatud PSD (Welch) + FM-kanalite (100 kHz raster) SNR."""
    nseg = len(iq) // nfft
    segs = iq[: nseg * nfft].reshape(nseg, nfft) * np.hanning(nfft).astype(np.float32)
    p = np.mean(np.abs(np.fft.fft(segs, axis=1)) ** 2, axis=0)
    f = np.fft.fftshift(np.fft.fftfreq(nfft, 1 / fs)); p = np.fft.fftshift(p)
    pdb = 10 * np.log10(p + 1e-20)
    floor = np.percentile(pdb, 20)                      # mürapõhi
    usable = fs * 0.45                                  # jäta filtri serv välja
    first = int(np.ceil((fc - usable) / 100e3)); last = int(np.floor((fc + usable) / 100e3))
    rows = []
    for k in range(first, last + 1):
        ch = k * 100e3
        off = ch - fc
        m = (f > off - chan_bw / 2) & (f < off + chan_bw / 2)
        if m.sum() < 8:
            continue
        rows.append((ch, float(np.percentile(pdb[m], 90) - floor), float(pdb[m].max())))
    # kohalikud maksimumid rastril
    out = []
    for i, (ch, snr, pk) in enumerate(rows):
        left = rows[i - 1][1] if i > 0 else -1
        right = rows[i + 1][1] if i < len(rows) - 1 else -1
        if snr >= min_snr and snr >= left and snr >= right:
            out.append((ch, snr))
    return out, floor

# ---------------------------------------------------------------- FM demod

def extract_channel(iq, fs, offset, out_fs=240e3, chunk_s=10.0):
    """Lõika kanal (offset Hz keskmest) välja ja langeta sämplimissagedus (2.4M -> 240k).
    Pikad failid tükkhaaval (FFT 288M punkti ei mahu mällu); tükkide piiril väike katkestus, mis ei loe."""
    n = len(iq)
    step = int(fs * chunk_s)
    if n <= step * 1.5:
        return fft_decimate_complex(iq.copy(), fs, offset, out_fs)
    parts, fs2 = [], None
    for s in range(0, n, step):
        seg = iq[s:s + step]
        if len(seg) < fs * 0.5:
            break
        y, fs2 = fft_decimate_complex(seg.copy(), fs, offset, out_fs)
        parts.append(y)
    return np.concatenate(parts), fs2


def fm_demod(x, fs):
    """Diskriminaator -> MPX (Hz hälve)."""
    d = np.angle(x[1:] * np.conj(x[:-1]))
    d = np.concatenate([d[:1], d])               # pikkus = n (n-1 võib olla FFT-le halb pikkus -> 10x aeglasem)
    return d * fs / (2 * np.pi)


def mpx_to_audio(mpx, fs, audio_fs=48000):
    a, afs = fft_lowpass_real_decimate(mpx, fs, 15e3, audio_fs)
    # de-emphasis 50 us sagedusruumis: H(f) = 1 / (1 + j 2 pi f tau)
    A = np.fft.rfft(a)
    f = np.fft.rfftfreq(len(a), 1 / afs)
    A /= (1 + 2j * np.pi * f * 50e-6)
    a = np.fft.irfft(A, len(a)) / 75e3                   # ±75 kHz -> ±1
    return np.clip(a, -1, 1).astype(np.float32), afs


def save_wav(path, a, fs):
    with wave.open(path, "wb") as w:
        w.setnchannels(1); w.setsampwidth(2); w.setframerate(fs)
        w.writeframes((a * 32767).astype(np.int16).tobytes())

# ---------------------------------------------------------------- AM / lennuside

def airscan(iq, fs, fc, nfft=4096, block_s=0.05, thresh_db=8.0, min_active=0.01, min_burst_s=0.4):
    """Purskeliste kitsaribaliste (AM) kanalite otsing aja-sageduse pildilt.
    Tagastab [(sagedus_Hz, aktiivne_osakaal, max_SNR_dB, purskeid)]."""
    hop = nfft
    nblk = int(round(block_s * fs / hop))            # STFT raame ühes ajaplokis
    nfr = len(iq) // hop
    frames = iq[: nfr * hop].reshape(nfr, hop) * np.hanning(hop).astype(np.float32)
    # töötle tükkidena, et mälu ei paisuks
    spec = np.empty((nfr, hop), dtype=np.float32)
    for s in range(0, nfr, 2048):
        spec[s:s + 2048] = np.abs(np.fft.fft(frames[s:s + 2048], axis=1)) ** 2
    del frames
    nb = nfr // nblk
    p = spec[: nb * nblk].reshape(nb, nblk, hop).mean(axis=1)      # (ajaplokid, binid)
    p = np.fft.fftshift(p, axes=1)
    f = np.fft.fftshift(np.fft.fftfreq(hop, 1 / fs)) + fc
    pdb = 10 * np.log10(p + 1e-20)
    floor = np.median(pdb, axis=0)                                  # iga bini müra (ajas mediaan)
    snr = pdb - floor
    active = snr > thresh_db
    frac = active.mean(axis=0)
    usable = (np.abs(f - fc) < fs * 0.45) & (np.abs(f - fc) > 10e3)     # serv ja DC-piik välja
    cand = np.where((frac >= min_active) & usable)[0]
    # klasterda kõrvuti binid kanaliteks
    chans = []
    i = 0
    while i < len(cand):
        j = i
        while j + 1 < len(cand) and cand[j + 1] - cand[j] <= 2:
            j += 1
        idx = cand[i:j + 1]
        peak = idx[np.argmax(frac[idx])]
        # sagedus lähimale 8.33 kHz rastrile (lennuside) – näita ka täpne
        fr = f[peak]
        raster = round((fr - 118e6) / (25e3 / 3)) * (25e3 / 3) + 118e6
        act = active[:, idx].any(axis=1)
        edges = np.diff(np.concatenate([[0], act.astype(int), [0]]))
        starts, ends = np.where(edges == 1)[0], np.where(edges == -1)[0]
        bursts = len(starts)
        longest = float((ends - starts).max() * block_s) if bursts else 0.0
        if longest >= min_burst_s:                       # lühikesed klõpsud (segajad) välja
            chans.append((float(raster), float(act.mean()), float(snr[:, idx].max()), bursts, float(fr), longest))
        i = j + 1
    # pidevad kitsaribalised kandjad (ATIS, majakad): bini mediaan üle terve akna mürapõhja
    gfloor = np.median(floor[usable])
    cont = (floor - gfloor > thresh_db) & usable
    ci = np.where(cont)[0]
    i = 0
    while i < len(ci):
        j = i
        while j + 1 < len(ci) and ci[j + 1] - ci[j] <= 2:
            j += 1
        idx = ci[i:j + 1]
        if len(idx) <= 12:                               # <= ~7 kHz lai = kitsariba, mitte FM
            peak = idx[np.argmax(floor[idx])]
            fr = f[peak]
            raster = round((fr - 118e6) / (25e3 / 3)) * (25e3 / 3) + 118e6
            chans.append((float(raster), 1.0, float(floor[peak] - gfloor), 0, float(fr), nb * block_s))
        i = j + 1
    chans.sort(key=lambda c: -c[1])
    return chans, nb * block_s


def am_demod_coherent(x, fs, audio_fs=16000, carrier_search=800, carrier_bw=40):
    """Sünkroonne AM-detektor: leia kandja (FFT tipp ±carrier_search Hz), filtreeri ta kitsalt (±carrier_bw)
    puhtaks faasireferentsiks ja võta Re(x · conj(ref)). Nõrkade külgribade puhul selgelt parem kui mähisjoon."""
    n = len(x)
    X = np.fft.fft(x * np.hanning(n).astype(np.complex64))
    f = np.fft.fftfreq(n, 1 / fs)
    m = np.abs(f) <= carrier_search
    fcar = f[m][np.argmax(np.abs(X[m]))]
    Xr = np.fft.fft(x)
    Xr[np.abs(f - fcar) > carrier_bw] = 0
    ref = np.fft.ifft(Xr)
    ref = ref / (np.abs(ref) + 1e-9)
    base = np.real(x * np.conj(ref))                  # koherentne detektsioon
    a, afs = fft_lowpass_real_decimate(base, fs, 3400, audio_fs)
    A = np.fft.rfft(a)
    fr = np.fft.rfftfreq(len(a), 1 / afs)
    A[(fr < 300) | (fr > 2700)] = 0
    return np.fft.irfft(A, len(a)), afs, fcar


def am_demod(x, fs, audio_fs=16000, agc=True):
    """AM (lennuside): mähisjoon, kandja maha, ribafilter 300-3000 Hz, aeglane AGC (1 s)."""
    env = np.abs(x).astype(np.float64)
    a, afs = fft_lowpass_real_decimate(env, fs, 3400, audio_fs)
    A = np.fft.rfft(a)
    fr = np.fft.rfftfreq(len(a), 1 / afs)
    A[(fr < 300) | (fr > 3000)] = 0              # kandja/DC maha, lennuside kõneriba
    a = np.fft.irfft(A, len(a))
    if not agc:
        return a.astype(np.float32), afs
    # aeglane AGC: 1 s aknad, piiratud võimendus, et mürapausid ei paisuks kõnest valjemaks
    w = int(afs * 1.0)
    n = len(a) // w * w
    blocks = a[:n].reshape(-1, w)
    g = 0.25 / (np.sqrt(np.mean(blocks ** 2, axis=1)) + 1e-4)
    g = np.minimum(g, 30)
    g = np.repeat(np.convolve(g, np.ones(3) / 3, mode="same"), w)
    out = np.zeros(len(a)); out[:n] = a[:n] * g
    return np.clip(out, -1, 1).astype(np.float32), afs


# ---------------------------------------------------------------- RDS

RDS_POLY = 0x5B9                       # x^10+x^8+x^7+x^5+x^4+x^3+1
OFFSETS = {0x0FC: "A", 0x198: "B", 0x168: "C", 0x350: "C'", 0x1B4: "D"}
PTY_EU = ["None", "News", "Current Affairs", "Information", "Sport", "Education", "Drama",
          "Culture", "Science", "Varied", "Pop Music", "Rock Music", "Easy Listening",
          "Light Classical", "Serious Classical", "Other Music", "Weather", "Finance",
          "Children's", "Social Affairs", "Religion", "Phone In", "Travel", "Leisure",
          "Jazz Music", "Country Music", "National Music", "Oldies Music", "Folk Music",
          "Documentary", "Alarm Test", "Alarm"]


def _syndrome_table():
    """Sündroom iga 26-biti positsiooni jaoks (lineaarne kood -> XOR tabel)."""
    tab = np.zeros(26, dtype=np.int64)
    for bit in range(26):
        v = 1 << bit
        # polünoomjääk: (v) mod g, kus v esindab 26-bitist sõna (info<<10 | check)
        r = v
        for i in range(25, 9, -1):
            if r & (1 << i):
                r ^= RDS_POLY << (i - 10)
        tab[bit] = r & 0x3FF
    return tab

SYN = _syndrome_table()
OFFSET_WORD = {v: k for k, v in OFFSETS.items()}


def _syndrome_of(word):
    s = 0
    for bit in range(26):
        if word >> bit & 1:
            s ^= int(SYN[bit])
    return s


def _burst_table(maxlen=5):
    """sündroom -> veamuster kõigi <=maxlen bitiste pursete jaoks (RDS kood parandab need üheselt)."""
    tab = {}
    for start in range(26):
        for length in range(1, maxlen + 1):
            if start + length > 26:
                continue
            inner = length - 2
            for mid in range(1 << max(inner, 0)):
                if length == 1:
                    e = 1 << start
                else:
                    e = (1 << start) | (1 << (start + length - 1)) | (mid << (start + 1))
                s = _syndrome_of(e)
                if s not in tab:
                    tab[s] = e
    return tab

BURST = _burst_table()


def block_offsets(bits):
    """Iga alguspositsiooni jaoks: 26-biti sõna, sündroom ja offset-nimi (None kui vigane)."""
    n = len(bits) - 26
    words = np.zeros(n, dtype=np.int64)
    syn = np.zeros(n, dtype=np.int64)
    for j in range(26):
        b = bits[j:j + n].astype(np.int64)
        words |= b << (25 - j)
        syn ^= b * SYN[25 - j]
    names = [OFFSETS.get(int(s)) for s in syn]
    return words, names, syn


def try_correct(word, syn, want):
    """Proovi parandada plokk eeldatava offseti(te) jaoks. Tagastab (info16, offset_nimi) või None."""
    for nm in want:
        e = BURST.get(int(syn) ^ OFFSET_WORD[nm])
        if e is not None:
            return int((word ^ e) >> 10), nm
    return None


def rds_bits_from_mpx(mpx, fs):
    """MPX -> RDS bitid (diferentsiaal-dekodeeritud). Tagastab (bits, kvaliteet)."""
    n = len(mpx)
    # pilot 19 kHz -> faas; RDS kandja = 3 x pilot
    pil = fft_bandpass_analytic(mpx, fs, 18950, 19050)
    pil_pow = np.mean(np.abs(pil) ** 2)
    rds = fft_bandpass_analytic(mpx, fs, 57000 - 2400, 57000 + 2400)
    carrier = (pil / (np.abs(pil) + 1e-9)) ** 3
    bb = rds * np.conj(carrier)
    # BPSK telg: pööra nii, et andmed on reaalteljel
    rot = np.exp(-1j * np.angle(np.mean(bb ** 2)) / 2)
    r = np.real(bb * rot)

    # sobitatud filter bifaasi impulsile (FFT-konvolutsioon, 'same' joondus)
    T = fs / 1187.5
    half = int(round(T / 2))
    h = np.concatenate([np.ones(half), -np.ones(half)])
    H = np.fft.rfft(np.roll(np.pad(h[::-1], (0, n - len(h))), -half))
    y = np.fft.irfft(np.fft.rfft(r) * H, n)

    # sümbolikella faas: maksimaalne energia sämplipunktides
    k = np.arange(int((len(y) - T) / T))
    best = None
    for t0 in np.arange(0, T, 1.0):
        idx = np.round(t0 + k * T).astype(int)
        e = np.mean(np.abs(y[idx]))
        if best is None or e > best[0]:
            best = (e, t0)
    cands = [best[1], (best[1] + T / 2) % T]
    results = []
    for t0 in cands:
        idx = np.round(t0 + k * T).astype(int)
        s = y[idx]
        t = (s > 0).astype(np.uint8)
        d = t[1:] ^ t[:-1]
        # kvaliteet: silma avatus
        eye = np.mean(np.abs(s)) / (np.std(np.abs(s)) + 1e-9)
        results.append((d, eye))
    return results, pil_pow


def decode_groups(bits, correct=True):
    words, names, syn = block_offsets(bits)
    n = len(names)
    # sünkrofaas: positsioonid, kus A järel tuleb 26 biti pärast B
    votes = {}
    for i in range(n - 26):
        if names[i] == "A" and names[i + 26] == "B":
            votes[i % 104] = votes.get(i % 104, 0) + 1
    if not votes:
        return None
    phase = max(votes, key=votes.get)
    groups, valid, corrected, total = [], 0, 0, 0
    for p in range(phase, n - 78, 104):
        blk = []
        for j, want in enumerate((("A",), ("B",), ("C", "C'"), ("D",))):
            q = p + 26 * j
            nm = names[q]
            total += 1
            if nm in want:
                valid += 1
                blk.append((int(words[q] >> 10), nm, 1.0))
                continue
            fix = try_correct(words[q], syn[q], want) if correct else None
            if fix:
                corrected += 1
                blk.append((fix[0], fix[1], 0.35))      # parandatud plokk hääletab väiksema kaaluga
            else:
                blk.append((int(words[q] >> 10), None, 0.0))
        groups.append(blk)
    return groups, valid, total, corrected


def rds_decode(mpx, fs):
    cands, pil_pow = rds_bits_from_mpx(mpx, fs)
    best = None
    for bits, eye in cands:
        g = decode_groups(bits)
        if g and (best is None or g[1] > best[0][1]):
            best = (g, eye)
    if best is None:
        return {"ok": False, "pilot": pil_pow}
    (groups, valid, total, corrected), eye = best

    pi_votes, pty_votes = {}, {}
    ps = [dict() for _ in range(8)]
    rt = [dict() for _ in range(64)]
    ab_flag = None
    for A, B, C, D in groups:
        if A[1]:
            pi_votes[A[0]] = pi_votes.get(A[0], 0) + A[2]
        if not B[1]:
            continue
        b = B[0]
        wb = B[2]
        gtype, ver, pty = b >> 12, (b >> 11) & 1, (b >> 5) & 0x1F
        pty_votes[pty] = pty_votes.get(pty, 0) + wb
        if gtype == 0 and D[1]:
            seg = b & 0x3
            w = min(wb, D[2])
            for k, ch in enumerate(((D[0] >> 8) & 0xFF, D[0] & 0xFF)):
                slot = ps[seg * 2 + k]; slot[ch] = slot.get(ch, 0) + w
        elif gtype == 2:
            seg = b & 0xF
            flag = (b >> 4) & 1
            if ab_flag is not None and flag != ab_flag and wb == 1.0:
                rt = [dict() for _ in range(64)]
            if wb == 1.0:
                ab_flag = flag
            chars = []
            if ver == 0:
                if C[1]: chars += [(seg * 4 + 0, (C[0] >> 8) & 0xFF, min(wb, C[2])), (seg * 4 + 1, C[0] & 0xFF, min(wb, C[2]))]
                if D[1]: chars += [(seg * 4 + 2, (D[0] >> 8) & 0xFF, min(wb, D[2])), (seg * 4 + 3, D[0] & 0xFF, min(wb, D[2]))]
            else:
                if D[1]: chars += [(seg * 2 + 0, (D[0] >> 8) & 0xFF, min(wb, D[2])), (seg * 2 + 1, D[0] & 0xFF, min(wb, D[2]))]
            for pos, ch, w in chars:
                rt[pos][ch] = rt[pos].get(ch, 0) + w

    def vote(slots, end_at_cr=False):
        out = ""
        for s in slots:
            if not s:
                out += "?"; continue
            ch = max(s, key=s.get)
            if s[ch] < 0.7:                 # ainult nõrgad (parandatud) hääled -> ebakindel
                out += "?"; continue
            if end_at_cr and ch == 0x0D:
                break
            out += chr(ch) if 32 <= ch < 127 else "?"
        return out

    pi = max(pi_votes, key=pi_votes.get) if pi_votes else None
    pty = max(pty_votes, key=pty_votes.get) if pty_votes else None
    return {
        "ok": True, "pilot": pil_pow, "eye": eye,
        "blocks_valid": valid, "blocks_total": total, "blocks_corrected": corrected,
        "pi": pi, "pi_conf": (pi_votes[pi] / max(sum(pi_votes.values()), 1)) if pi else 0,
        "pty": PTY_EU[pty] if pty is not None else None,
        "ps": vote(ps), "rt": vote(rt, end_at_cr=True).rstrip("?").rstrip(),
    }

# ---------------------------------------------------------------- main

def analyse_station(iq, fs, fc, freq_hz, do_audio, out_base):
    x, fs2 = extract_channel(iq, fs, freq_hz - fc)
    mpx = fm_demod(x, fs2)
    r = rds_decode(mpx, fs2)
    print(f"--- {freq_hz/1e6:.1f} MHz ---")
    if not r["ok"]:
        print(f"  RDS: sünkrot ei leitud (pilot {10*np.log10(r['pilot']+1e-12):.0f} dB)")
    else:
        q = 100 * r["blocks_valid"] / max(r["blocks_total"], 1)
        qc = 100 * (r["blocks_valid"] + r["blocks_corrected"]) / max(r["blocks_total"], 1)
        print(f"  RDS: {q:.0f}% plokke kehtivad ({r['blocks_valid']}/{r['blocks_total']}), +{r['blocks_corrected']} parandatud -> {qc:.0f}%, silm {r['eye']:.2f}")
        print(f"  PI {r['pi']:04X} (kindlus {100*r['pi_conf']:.0f}%)  PTY {r['pty']}")
        print(f"  PS  '{r['ps']}'")
        if r["rt"]:
            print(f"  RT  '{r['rt']}'")
    if do_audio:
        a, afs = mpx_to_audio(mpx, fs2)
        path = f"{out_base}_{freq_hz/1e6:.1f}MHz_audio.wav"
        save_wav(path, a, afs)
        try:
            import listen
            print("  heli:", end=" "); sys.stdout.flush()
            listen.analyse(path)
        except Exception as e:
            print(f"  heli salvestatud {path} ({e})")
    return r


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("wav")
    ap.add_argument("--freq", type=float, help="jaama sagedus MHz")
    ap.add_argument("--scan", action="store_true")
    ap.add_argument("--no-audio", action="store_true")
    ap.add_argument("--min-snr", type=float, default=15.0, help="RDS-i proovitakse jaamadel üle selle SNR (dB)")
    ap.add_argument("--airscan", action="store_true", help="lennuside: leia purskelised AM-kanalid")
    ap.add_argument("--am", type=float, help="AM-demoduleeri see sagedus (MHz) -> *_am.wav")
    ap.add_argument("--clip-active", action="store_true", help="--am: salvesta ainult aktiivsed lõigud")
    a = ap.parse_args()

    fs, iq = load_iq(a.wav)
    fc = center_freq_from_name(a.wav)
    if fc is None:
        sys.exit("failinimest ei leia keskmist sagedust (_123456789Hz_)")
    print(f"{os.path.basename(a.wav)}: {len(iq)/fs:.1f} s, fs {fs/1e6:.2f} MHz, kese {fc/1e6:.3f} MHz")
    out_base = os.path.splitext(a.wav)[0]

    if a.airscan:
        chans, dur = airscan(iq, fs, fc)
        print(f"AM-kanalid (aktiivne osa {dur:.0f} s jooksul):")
        for fr, frac, snr, bursts, exact, longest in chans:
            print(f"  {fr/1e6:8.3f} MHz  aktiivne {100*frac:5.1f}%  max SNR {snr:5.1f} dB  purskeid {bursts:2d}  pikim {longest:4.1f} s  (tipp {exact/1e6:.4f})")
        if not chans:
            print("  (midagi ei leitud)")
        return

    if a.am:
        f_hz = int(round(a.am * 1e6))
        x, fs2 = extract_channel(iq, fs, f_hz - fc, out_fs=12e3)
        audio, afs = am_demod(x, fs2, agc=not a.clip_active)
        if a.clip_active:
            # jäta alles ainult lõigud, kus AM-kandja on sees: 0.1 s plokkides kandja (±200 Hz)
            # võimsus vs ülejäänud kanali mediaan (squelch kandja järgi, mitte mähisjoone järgi)
            w = int(afs * 0.1)
            bl = int(fs2 * 0.1)
            nb = len(x) // bl
            blocks = x[: nb * bl].reshape(nb, bl) * np.hanning(bl).astype(np.complex64)
            S = np.abs(np.fft.fft(blocks, axis=1)) ** 2
            fb = np.fft.fftfreq(bl, 1 / fs2)
            cm = np.abs(fb) <= 800                                               # kandja võib olla mõnisada Hz nihkes
            car = S[:, cm].sum(axis=1)
            noise = np.median(S[:, np.abs(fb) > 4000]) * cm.sum()               # müra väljaspool kõneriba, kogu fail
            on = car > noise * 6                                                 # kandja ~8 dB üle müra
            on = np.convolve(on.astype(float), np.ones(5), mode="same") > 0      # +-0.2 s varu
            keep = np.zeros(len(audio), dtype=bool)
            k = np.repeat(on, w)[: len(audio)]
            keep[: len(k)] = k
            # iga aktiivne lõik eraldi: üks võimendus (95. protsentiil -> 0.7) + 50 ms sisse/välja fade,
            # lõikude vahele 0.3 s vaikust
            edges = np.diff(np.concatenate([[0], keep.astype(int), [0]]))
            starts, ends = np.where(edges == 1)[0], np.where(edges == -1)[0]
            pieces, gap, fade = [], np.zeros(int(afs * 0.3), dtype=np.float32), int(afs * 0.05)
            for s0, e0 in zip(starts, ends):
                # koherentne detektsioon otse IQ-lõigust (kandja faas referentsiks)
                i0, i1 = int(s0 / afs * fs2), int(e0 / afs * fs2)
                if i1 - i0 < fs2 * 0.3:
                    continue
                seg, _, fcar = am_demod_coherent(x[i0:i1], fs2, afs)
                seg = seg.astype(np.float64)
                if len(seg) < fade * 2:
                    continue
                seg *= 0.7 / (np.percentile(np.abs(seg), 95) + 1e-6)
                seg[:fade] *= np.linspace(0, 1, fade); seg[-fade:] *= np.linspace(1, 0, fade)
                pieces += [np.clip(seg, -1, 1).astype(np.float32), gap]
            audio = np.concatenate(pieces) if pieces else audio[:0]
            print(f"  aktiivseid lõike: {len(starts)} tk, {on.sum()*0.1:.1f} s / {len(on)*0.1:.1f} s")
        path = f"{out_base}_{a.am:.3f}MHz_am.wav"
        save_wav(path, audio, afs)
        print(f"  AM heli: {path} ({len(audio)/afs:.1f} s)")
        return

    if a.freq:
        analyse_station(iq, fs, fc, int(round(a.freq * 1e6)), not a.no_audio, out_base)
        return

    stations, floor = scan(iq, fs, fc)
    print(f"mürapõhi {floor:.1f} dB; jaamad (SNR):")
    for ch, snr in stations:
        print(f"  {ch/1e6:6.1f} MHz  {snr:5.1f} dB")
    if a.scan:
        return
    for ch, snr in stations:
        if snr >= a.min_snr:
            analyse_station(iq, fs, fc, int(ch), False, out_base)


if __name__ == "__main__":
    main()
