Files
2026-10-08 16:43:48 +09:00

165 KiB

C204 정답 코드 - 무전 음성 스푸핑·딥페이크 탐지 실험

[교육용가상자료] 모든 데이터는 코드 안에서 난수로 만든 가상 자료이며 실제 부대·장비·인원·작전 정보와 무관합니다.

실행 환경

  • Python 3.10 이상, numpy, pandas, scipy, scikit-learn, matplotlib
  • 미션별 코드는 각각 한 파일로 독립 실행됩니다. 외부 파일 없이 코드 안에서 가상 데이터를 생성합니다.
  • 그래프의 글자는 한글 글꼴이 없는 실행 환경에서도 깨지지 않도록 영문으로 표기했습니다. 표·출력 결과는 한글입니다.
  • 난수 시드를 고정해 두어 같은 코드를 실행하면 같은 결과가 나옵니다.

미션 1. 전술 무전 음성 통신 환경 분석 및 딥페이크/스푸핑 음성 데이터셋 규격 설계

  • 난이도: 초급 / 예상 소요시간: 1.5시간
  • 코드 설명: 전술 무전 음성 통신 환경과 생성형 AI 음성 위조(TTS/VC)·재생(replay) 위협을 '탐지 단서' 관점에서 정리하고, 무전 음성 클립 메타데이터·채널 조건·전술 파라미터 스키마를 정의해 가상 샘플 데이터를 검증한다.
"""전술 무전 음성 통신 환경과 생성형 AI 음성 위조(TTS/VC)·재생(replay) 위협을 '탐지 단서' 관점에서 정리하고, 무전 음성 클립 메타데이터·채널 조건·전술 파라미터 스키마를 정의해 가상 샘플 데이터를 검증한다."""
import json
import re
import numpy as np
import pandas as pd

# 1) 위협 유형별 탐지 단서(탐지 측 관점 요약, 생성 방법은 다루지 않음)
threats = pd.DataFrame([
    ("bonafide", "실제 송신자 음성", "자연스러운 피치·진폭 흔들림, 등록 화자와 일치"),
    ("tts_art", "문자->음성 합성 의심", "흔들림 부족, 고주파 에너지 부족, 프레임 경계 흔적"),
    ("vc_art", "음성 변환 의심", "배음 위상 불연속(HNR 저하), 스펙트럼 평탄화, 화자 특징 근사"),
    ("replay", "녹음 재생 의심", "스피커 대역 제한(저역 부족), 실내 잔향, 화자 특징은 일치"),
], columns=["라벨", "의미", "탐지 단서"])
print("[위협 유형별 탐지 단서]")
print(threats.to_string(index=False))
print("-> 무전 채널(300~3400Hz 대역 제한, 잡음, 끊김)이 단서를 가리므로 채널 조건을 함께 기록해야 판정 신뢰도를 해석할 수 있다.")

SCHEMA = {
    "clip": {
        "clip_id": {"type": str, "pattern": r"^C\d{6}$"},
        "net_id": {"type": str, "pattern": r"^NET-[A-Z]$"},                     # 가상 망 이름
        "sample_rate": {"type": int, "enum": [8000, 16000]},
        "duration_s": {"type": (int, float), "min": 0.2, "max": 60},
        "claimed_speaker": {"type": str, "pattern": r"^SPK_[A-Z]$"},
        "urgency": {"type": str, "enum": ["ROUTINE", "URGENT"]},
        "label": {"type": str, "enum": ["bonafide", "tts_art", "vc_art", "replay", "unknown"]},
    },
    "channel": {
        "band": {"type": str, "enum": ["HF", "VHF", "UHF"]},
        "codec": {"type": str, "enum": ["analog_fm", "digital_vocoder"]},
        "snr_db": {"type": (int, float), "min": -20, "max": 60},
        "dropout_ratio": {"type": (int, float), "min": 0, "max": 1},
    },
    "tactical": {
        "weather": {"type": str, "enum": ["clear", "rain", "snow", "fog", "night"]},
        "position_error_m": {"type": (int, float), "min": 0, "max": 500},
        "comm_delay_ms": {"type": (int, float), "min": 0, "max": 5000},
        "ew_jamming": {"type": bool},
    },
}


def check(name, value, rule):
    if not isinstance(value, rule["type"]) or (isinstance(value, bool) and rule["type"] is not bool):
        return [f"{name}: 자료형 오류({type(value).__name__})"]
    errs = []
    if "enum" in rule and value not in rule["enum"]:
        errs.append(f"{name}: 허용되지 않은 값 '{value}'")
    if "min" in rule and value < rule["min"]:
        errs.append(f"{name}: 최소값 {rule['min']} 미만")
    if "max" in rule and value > rule["max"]:
        errs.append(f"{name}: 최대값 {rule['max']} 초과")
    if "pattern" in rule and not re.match(rule["pattern"], value):
        errs.append(f"{name}: 형식 불일치 '{value}'")
    return errs


def validate(rec):
    errs = []
    for g in SCHEMA:
        for k, rule in SCHEMA[g].items():
            errs += [f"{g}.{k}: 필수 필드 누락"] if k not in rec.get(g, {}) else check(f"{g}.{k}", rec[g][k], rule)
    if not errs and rec["clip"]["urgency"] == "URGENT" and rec["clip"]["label"] == "unknown" and rec["channel"]["snr_db"] < 0:
        errs.append("업무 규칙: SNR 0dB 미만 긴급 클립은 '판정 불가'로 표시 후 수동 확인 필요")
    return errs


# 2) [교육용가상자료] 샘플 클립 메타데이터 20건
rng = np.random.default_rng(1)
records = []
for i in range(20):
    records.append({"clip": {"clip_id": f"C01{i:04d}", "net_id": str(rng.choice(["NET-A", "NET-B"])), "sample_rate": 8000, "duration_s": 0.5,
                             "claimed_speaker": str(rng.choice(["SPK_A", "SPK_B", "SPK_C"])), "urgency": str(rng.choice(["ROUTINE", "URGENT"], p=[0.7, 0.3])),
                             "label": str(rng.choice(["bonafide", "tts_art", "vc_art", "replay"], p=[0.55, 0.15, 0.15, 0.15]))},
                    "channel": {"band": str(rng.choice(["VHF", "UHF"])), "codec": str(rng.choice(["analog_fm", "digital_vocoder"])),
                                "snr_db": float(round(rng.uniform(0, 25), 1)), "dropout_ratio": float(round(rng.uniform(0, 0.1), 2))},
                    "tactical": {"weather": str(rng.choice(["clear", "rain", "night"])), "position_error_m": float(round(rng.uniform(5, 50), 1)),
                                 "comm_delay_ms": int(rng.integers(50, 800)), "ew_jamming": bool(rng.random() < 0.2)}})
ok = sum(1 for r in records if not validate(r))
print(f"\n정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건")

bad = json.loads(json.dumps(records[0]))
bad["clip"]["clip_id"] = "CLIP-7"
bad["clip"]["sample_rate"] = 44100
bad["channel"]["snr_db"] = 99
del bad["tactical"]["comm_delay_ms"]
bad["tactical"]["ew_jamming"] = "yes"
print("[오류 주입 결과]")
for e in validate(bad):
    print(" -", e)
rule = json.loads(json.dumps(records[0]))
rule["clip"].update(urgency="URGENT", label="unknown"); rule["channel"]["snr_db"] = -3.0
print(" -", validate(rule)[0])

rows = []
for g in SCHEMA:
    for k, r in SCHEMA[g].items():
        t = r["type"].__name__ if isinstance(r["type"], type) else "number"
        rows.append((g, k, t, str({x: y for x, y in r.items() if x != "type"})[:52]))
print("\n[데이터 명세 요약]")
print(pd.DataFrame(rows, columns=["그룹", "필드", "자료형", "규칙"]).to_string(index=False))
print("\n라벨 분포:", pd.Series([r["clip"]["label"] for r in records]).value_counts().to_dict())

실행 결과 예시

[위협 유형별 탐지 단서]
      라벨           의미                                 탐지 단서
bonafide    실제 송신자 음성            자연스러운 피치·진폭 흔들림, 등록 화자와 일치
 tts_art 문자->음성 합성 의심         흔들림 부족, 고주파 에너지 부족, 프레임 경계 흔적
  vc_art     음성 변환 의심 배음 위상 불연속(HNR 저하), 스펙트럼 평탄화, 화자 특징 근사
  replay     녹음 재생 의심    스피커 대역 제한(저역 부족), 실내 잔향, 화자 특징은 일치
-> 무전 채널(300~3400Hz 대역 제한, 잡음, 끊김)이 단서를 가리므로 채널 조건을 함께 기록해야 판정 신뢰도를 해석할 수 있다.

정상 샘플 20건 중 검증 통과 20건, 오류 0건
[오류 주입 결과]
 - clip.clip_id: 형식 불일치 'CLIP-7'
 - clip.sample_rate: 허용되지 않은 값 '44100'
 - channel.snr_db: 최대값 60 초과
 - tactical.comm_delay_ms: 필수 필드 누락
 - tactical.ew_jamming: 자료형 오류(str)
 - 업무 규칙: SNR 0dB 미만 긴급 클립은 '판정 불가'로 표시 후 수동 확인 필요

[데이터 명세 요약]
      그룹               필드    자료형                                                   규칙
    clip          clip_id    str                             {'pattern': '^C\\d{6}$'}
    clip           net_id    str                           {'pattern': '^NET-[A-Z]$'}
    clip      sample_rate    int                              {'enum': [8000, 16000]}
    clip       duration_s number                              {'min': 0.2, 'max': 60}
    clip  claimed_speaker    str                           {'pattern': '^SPK_[A-Z]$'}
    clip          urgency    str                      {'enum': ['ROUTINE', 'URGENT']}
    clip            label    str {'enum': ['bonafide', 'tts_art', 'vc_art', 'replay',
 channel             band    str                       {'enum': ['HF', 'VHF', 'UHF']}
 channel            codec    str           {'enum': ['analog_fm', 'digital_vocoder']}
 channel           snr_db number                              {'min': -20, 'max': 60}
 channel    dropout_ratio number                                 {'min': 0, 'max': 1}
tactical          weather    str  {'enum': ['clear', 'rain', 'snow', 'fog', 'night']}
tactical position_error_m number                               {'min': 0, 'max': 500}
tactical    comm_delay_ms number                              {'min': 0, 'max': 5000}
tactical       ew_jamming   bool                                                   {}

라벨 분포: {'bonafide': 13, 'vc_art': 3, 'replay': 3, 'tts_art': 1}

미션 2. 무전 잡음(전술 배경소음, 주파수 감쇄) 모델링 및 음향 특징(MFCC/스펙트로그램) 전처리 구현

  • 난이도: 초급~중급 / 예상 소요시간: 2.0시간
  • 코드 설명: 무전기 대역 제한(300~3400Hz)과 전장 소음(배경 잡음·충격성 잡음·신호 끊김·포화)을 모형화하고, 끊김 구간 검출·충격 잡음 제한·조건별 스펙트럼 차감·정규화 후 MFCC/스펙트로그램 특징을 뽑는 전처리 모듈을 구현해 처리 속도, 특징 왜곡 감소, 단위 테스트 통과 여부를 확인한다.
"""무전기 대역 제한(300~3400Hz)과 전장 소음(배경 잡음·충격성 잡음·신호 끊김·포화)을 모형화하고, 끊김 구간 검출·충격 잡음 제한·조건별 스펙트럼 차감·정규화 후 MFCC/스펙트로그램 특징을 뽑는 전처리 모듈을 구현해 처리 속도, 특징 왜곡 감소, 단위 테스트 통과 여부를 확인한다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import signal
from scipy.fft import dct
from scipy.ndimage import maximum_filter1d, uniform_filter1d

# ---------------------------------------------------------------------------
# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)
# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과
# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.
#   bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음
#   tts_art  : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호
#   vc_art   : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호
#   replay   : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호
# ---------------------------------------------------------------------------
FS, DUR = 8000, 0.5
N = int(FS * DUR)
LABELS = ["bonafide", "tts_art", "vc_art", "replay"]
SPEAKERS = {f"SPK_{c}": (f0, fm) for c, f0, fm in [("A", 115, (650, 1150, 2450)), ("B", 135, (700, 1220, 2600)), ("C", 98, (600, 1050, 2350)),
                                                      ("D", 160, (720, 1300, 2700)), ("E", 125, (680, 1180, 2500)), ("F", 105, (630, 1100, 2400))]}


def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):
    t = np.arange(N) / FS
    contour = f0 * (1 + 0.06 * np.sin(2 * np.pi * rng.uniform(1.5, 3.5) * t + rng.uniform(0, 6)))
    f = contour * (1 + jitter * rng.normal(0, 1, N // 80 + 1).repeat(80)[:N])            # 10ms 단위 피치 흔들림(jitter)
    phase = 2 * np.pi * np.cumsum(f) / FS
    K = int(3800 / f0)
    fk = np.arange(1, K + 1) * f0
    amp = sum(np.exp(-((fk - F) / 120.0) ** 2) / (i + 1) for i, F in enumerate(fm)) + 0.02 / np.arange(1, K + 1)
    if hf_cut:
        amp = np.where(fk > hf_cut, amp * 0.2, amp)
    ph = np.arange(1, K + 1)[:, None] * phase[None, :]
    if phase_break:                                                   # 20ms 프레임마다 배음별 위상 불연속
        ph = ph + rng.uniform(0, 2 * np.pi, (K, N // 160 + 1)).repeat(160, axis=1)[:, :N]
    sig = (amp[:, None] * np.sin(ph)).sum(0)
    env = 0.55 + 0.45 * np.sin(2 * np.pi * rng.uniform(3, 5) * t + rng.uniform(0, 6)) ** 2      # 음절형 진폭 변화
    env *= 1 + shimmer * np.interp(t, np.linspace(0, DUR, 60), rng.normal(0, 1, 60))           # 완만한 진폭 흔들림(shimmer)
    if frame_mod:
        env *= 1 + frame_mod * np.sign(np.sin(2 * np.pi * 50 * t))     # 20ms 프레임 경계 흔적(50Hz)
    return sig * env + (0.03 if jitter > 0.003 else 0.005) * rng.normal(0, 1, N) * env


def make_clip(label, speaker, rng):
    f0, fm = SPEAKERS[speaker]
    if label in ("tts_art", "vc_art"):                               # 위조는 화자 특성을 근사만 함
        f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)
    if label == "bonafide" or label == "replay":
        x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))
    elif label == "tts_art":
        x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))
    else:
        x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)
    if label == "replay":                                              # 스피커 대역 + 실내 잔향
        x = signal.sosfilt(signal.butter(4, [450, 2900], "bandpass", fs=FS, output="sos"), x)
        ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0
        x = signal.fftconvolve(x, ir)[:N]
    return x / (np.abs(x).max() + 1e-9)


RADIO_BP = signal.butter(4, [300, 3400], "bandpass", fs=FS, output="sos")


def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):
    """[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍."""
    y = signal.sosfilt(RADIO_BP, x)
    p = np.mean(y ** 2)
    noise = rng.normal(0, 1, N) * np.sqrt(p / 10 ** (snr_db / 10))
    noise += np.cumsum(rng.normal(0, 0.02, N)) * np.sqrt(p) * 0.05                   # 저주파 차량·엔진형 소음
    y = y + noise
    if impulse:
        k = rng.random(N) < impulse
        y[k] += rng.normal(0, 6 * np.sqrt(p), k.sum())
    if jam_tone:
        y += jam_tone * np.sqrt(p) * np.sin(2 * np.pi * rng.uniform(800, 2000) * np.arange(N) / FS)
    if dropout:
        for _ in range(int(dropout * 10)):
            a = int(rng.integers(0, N - 400)); y[a:a + int(rng.integers(100, 400))] = 0.0
    return np.clip(y / (np.abs(y).max() + 1e-9), -clip, clip)


def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim="SPK_A", **chan):
    """클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame)."""
    rng = np.random.default_rng(seed)
    X, meta = np.zeros((n, N)), []
    if p is None and len(labels) > 1 and labels[0] == "bonafide":        # 기본: 진짜 50%, 나머지 위조 유형 균등
        p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)
    for i in range(n):
        lab = str(rng.choice(labels, p=p))
        spk = str(rng.choice(list(SPEAKERS))) if lab == "bonafide" else claim
        if lab == "replay":
            spk = claim
        s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)
        X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)
        meta.append({"clip": f"C{seed:02d}{i:04d}", "label": lab, "spoof": lab != "bonafide", "claimed": spk if lab == "bonafide" else claim,
                     "true_spk": spk if lab == "bonafide" else "unknown", "snr_db": round(s, 1)})
    return X, pd.DataFrame(meta)
# ---------------------------------------------------------------------------

# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----
NFFT, HOP = 256, 80


def mel_bank(n_mels=26, fmin=300, fmax=3400):
    mel = lambda f: 2595 * np.log10(1 + f / 700)
    imel = lambda m: 700 * (10 ** (m / 2595) - 1)
    pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))
    freqs = np.fft.rfftfreq(NFFT, 1 / FS)
    fb = np.zeros((n_mels, len(freqs)))
    for m in range(n_mels):
        l, c, r = pts[m], pts[m + 1], pts[m + 2]
        fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)
    return fb


MEL = mel_bank()
FREQS = np.fft.rfftfreq(NFFT, 1 / FS)


def spectrogram(X, denoise=0.0):
    """배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감."""
    X = np.atleast_2d(X)
    Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]]                                       # 프리엠퍼시스
    frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)
    P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2
    P = np.transpose(P, (0, 2, 1))
    if denoise:
        floor = np.percentile(P, 10, axis=2, keepdims=True)
        P = np.maximum(P - denoise * floor, 0.05 * P)
    return P


def mfcc(P, n=13):
    logmel = np.log(np.einsum("mf,cft->cmt", MEL, P) + 1e-8)
    return dct(logmel, type=2, axis=1, norm="ortho")[:, :n, :]


def band(P, lo, hi):
    return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))


def jitter_hnr(X):
    """40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다."""
    W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)
    fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]
    fr = fr - fr.mean(-1, keepdims=True)
    spec = np.fft.rfft(fr, 2 * W, axis=-1)
    ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]
    ac = ac / (ac[..., :1] + 1e-12)
    seg = ac[..., lag0:lag1]
    k = seg.argmax(-1)
    peak = seg.max(-1)
    km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]
    lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12)                      # 포물선 보간으로 주기 정밀화
    f0 = FS / lag
    jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)
    return jit, np.median(peak, axis=1)


def frame_mod(X):
    """진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서)."""
    env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)
    sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2
    f = np.fft.rfftfreq(env.shape[1], 20 / FS)
    return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)


FEAT_NAMES = [f"mfcc{i}_m" for i in range(13)] + [f"mfcc{i}_s" for i in range(13)] + \
             ["delta_s", "hf_ratio", "lf_ratio", "flatness", "flux", "jitter", "hnr", "frame_mod"]


def extract(X, denoise=0.0):
    """클립 배치 -> 특징 행렬 (클립 x 34) DataFrame."""
    P = spectrogram(X, denoise)
    C = mfcc(P)
    tot = band(P, 300, 3400) + 1e-12
    flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)
    logP = np.log(P + 1e-8)
    jit, hnr = jitter_hnr(X)
    F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,
              flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]
    return pd.DataFrame(F, columns=FEAT_NAMES)


def enroll(seed=99):
    """지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균)."""
    rng = np.random.default_rng(seed)
    tpl = {}
    for spk in SPEAKERS:
        Xe = np.stack([radio_channel(make_clip("bonafide", spk, rng), rng, snr_db=25) for _ in range(5)])
        tpl[spk] = extract(Xe)[[f"mfcc{i}_m" for i in range(1, 13)]].mean().values
    return tpl


def speaker_sim(F, claimed, tpl):
    """주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수)."""
    v = F[[f"mfcc{i}_m" for i in range(1, 13)]].values
    t = np.stack([tpl[c] for c in claimed])
    return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)

DENOISE = {}                                                           # 상황별 스펙트럼 차감 배수(아래에서 왜곡이 가장 작은 값으로 자동 선택)
CHANNEL = {"평시": dict(snr_db=18), "야간(차량 소음)": dict(snr_db=6), "악천후(충격 잡음)": dict(snr_db=8, impulse=0.004, dropout=0.3)}


def find_dropouts(X, min_len=40):
    """연속 0(신호 끊김)이 min_len 샘플 이상인 구간 마스크(배치 처리)."""
    Z = (np.abs(np.atleast_2d(X)) < 1e-9).astype(float)
    full = uniform_filter1d(Z, min_len, axis=1) > 1 - 1e-9                 # 창 전체가 0인 위치
    return maximum_filter1d(full, min_len + 2, axis=1) & (Z > 0)


def preprocess(X, cond=None):
    """끊김 구간 검출 -> 평균 제거 -> 충격 잡음 제한(중앙값 기준 6배) -> 끊김 구간을 잡음 바닥 수준으로 채움 -> 정규화. 배치 단위로 처리."""
    X = np.atleast_2d(X)
    m = find_dropouts(X)
    A = np.where(m, np.nan, X)
    X = X - np.nan_to_num(np.nanmean(A, axis=1, keepdims=True))
    lim = 6 * np.nanmedian(np.abs(np.where(m, np.nan, X)), axis=1, keepdims=True) / 0.6745
    X = np.clip(X, -lim, lim)
    floor = lim / 40                                                     # 잡음 바닥 근사(충격 제한값의 1/40)
    X = np.where(m, np.random.default_rng(0).normal(0, 1, X.shape) * floor, X)
    return X / (np.abs(X).max(axis=1, keepdims=True) + 1e-9), int(m.any(axis=1).sum())


# [교육용가상자료] 같은 클립을 깨끗한 채널과 상황별 잡음 채널로 각각 통과시켜 특징 왜곡 비교
rng = np.random.default_rng(3)
clean_src = [make_clip(str(rng.choice(LABELS)), "SPK_A", rng) for _ in range(150)]
X_clean = np.stack([radio_channel(s, np.random.default_rng(k), snr_db=40) for k, s in enumerate(clean_src)])
F_clean = extract(X_clean)
scale = F_clean.std() + 1e-9
rows, speed = [], []
for cond, ch in CHANNEL.items():
    Xn = np.stack([radio_channel(s, np.random.default_rng(1000 + k), **ch) for k, s in enumerate(clean_src)])
    raw = extract(Xn)
    t0 = time.perf_counter()
    Xp, n_drop = preprocess(Xn, cond)
    proc = extract(Xp)
    dt = time.perf_counter() - t0
    speed.append(len(Xn) / dt)
    Xc = preprocess(X_clean)[0]
    best = (9e9, 0.0)
    for k in (0.0, 0.5, 1.0, 1.5, 2.0):                                  # 상황별 차감 배수 탐색(같은 전처리를 거친 깨끗한 신호와 비교)
        ref = extract(Xc, denoise=k)
        d = ((extract(Xp, denoise=k) - ref).abs() / (ref.std() + 1e-9)).mean().mean()
        best = min(best, (d, k))
    d1, DENOISE[cond] = best
    d0 = ((raw - F_clean).abs() / scale).mean().mean()
    rows.append({"상황": cond, "끊김 검출 클립": n_drop, "특징 왜곡 전": round(d0, 3), "특징 왜곡 후": round(d1, 3), "개선율": f"{1 - d1 / d0:.0%}",
                 "NaN 수": int(proc.isna().sum().sum())})
fps = np.mean(speed) * (N // HOP)
print(f"전처리(끊김·충격 잡음 정제)+MFCC/스펙트로그램 처리 속도: 0.5초 클립 {np.mean(speed):,.0f}개/초 = 10ms 프레임 {fps:,.0f}건/초 "
      f"(목표 1,000건/초 이상: {'달성' if fps >= 1000 else '미달'})")
print("처리 중 오류 0건(오류율 0%), 특징 NaN 0개" if all(r["NaN 수"] == 0 for r in rows) else "NaN 발생")
print("\n[상황별 전처리 효과: 깨끗한 신호 대비 특징 왜곡(표준화 거리)]")
print(pd.DataFrame(rows).to_string(index=False))
print("상황별로 선택된 스펙트럼 차감 배수:", DENOISE, "-> 잡음이 정상적(stationary)이면 차감이 특징을 오히려 왜곡할 수 있어 상황별로 따로 정한다")

# 단위 테스트 5종
t = np.arange(N) / FS
hum = np.sin(2 * np.pi * 100 * t)
x_gap = radio_channel(clean_src[0], np.random.default_rng(1), snr_db=20).copy(); x_gap[1000:1300] = 0
x_imp = x_gap.copy(); x_imp[2000] = 50.0
P0 = spectrogram(radio_channel(clean_src[1], np.random.default_rng(2), snr_db=5))
P1 = spectrogram(radio_channel(clean_src[1], np.random.default_rng(2), snr_db=5), denoise=1.5)
tests = {
    "무전 대역 필터가 100Hz 험 제거(에너지 1% 미만)": np.mean(signal.sosfilt(RADIO_BP, hum)[2000:] ** 2) < 0.01 * np.mean(hum ** 2),
    "끊김 구간 300샘플 검출": find_dropouts(x_gap)[0, 1000:1300].all(),
    "충격 잡음 제한(최대/중앙 비 감소)": (lambda y: np.abs(y).max() / np.median(np.abs(y)))(preprocess(x_imp[None], "평시")[0][0])
                                    < np.abs(x_imp).max() / np.median(np.abs(x_imp)),
    "스펙트럼 차감 후 잡음 바닥 하락": np.percentile(P1, 10) < np.percentile(P0, 10),
    "MFCC 형태(13 x 프레임)·NaN 없음": mfcc(P0).shape[1] == 13 and not np.isnan(mfcc(P0)).any(),
}
for k, v in tests.items():
    print(f"[{'PASS' if v else 'FAIL'}] {k}")
print(f"단위 테스트 {sum(tests.values())}/{len(tests)} 통과")

fig, ax = plt.subplots(1, 3, figsize=(13, 3.5))
xn = radio_channel(clean_src[0], np.random.default_rng(5), **CHANNEL["악천후(충격 잡음)"])
for a, P, title in [(ax[0], spectrogram(X_clean[0])[0], "clean channel"), (ax[1], spectrogram(xn)[0], "noisy (impulse + dropout)"),
                    (ax[2], spectrogram(preprocess(xn[None], "악천후(충격 잡음)")[0][0], denoise=2.0)[0], "preprocessed")]:
    a.imshow(np.log(P + 1e-8), origin="lower", aspect="auto", extent=[0, DUR, 0, FS / 2]); a.set_title(title); a.set_xlabel("time (s)")
ax[0].set_ylabel("frequency (Hz)")
plt.suptitle("Synthetic radio signal spectrograms (training data only)"); plt.tight_layout(); plt.show()

실행 결과 예시

전처리(끊김·충격 잡음 정제)+MFCC/스펙트로그램 처리 속도: 0.5초 클립 863개/초 = 10ms 프레임 43,155건/초 (목표 1,000건/초 이상: 달성)
처리 중 오류 0건(오류율 0%), 특징 NaN 0개

[상황별 전처리 효과: 깨끗한 신호 대비 특징 왜곡(표준화 거리)]
        상황  끊김 검출 클립  특징 왜곡 전  특징 왜곡 후 개선율  NaN 수
        평시         0    0.986    1.003 -2%      0
 야간(차량 소음)         0    1.952    1.955 -0%      0
악천후(충격 잡음)       150    3.511    1.896 46%      0
상황별로 선택된 스펙트럼 차감 배수: {'평시': 0.0, '야간(차량 소음)': 0.5, '악천후(충격 잡음)': 0.0} -> 잡음이 정상적(stationary)이면 차감이 특징을 오히려 왜곡할 수 있어 상황별로 따로 정한다
[PASS] 무전 대역 필터가 100Hz 험 제거(에너지 1% 미만)
[PASS] 끊김 구간 300샘플 검출
[PASS] 충격 잡음 제한(최대/중앙 비 감소)
[PASS] 스펙트럼 차감 후 잡음 바닥 하락
[PASS] MFCC 형태(13 x 프레임)·NaN 없음
단위 테스트 5/5 통과

미션 3. 음향 스펙트럼 분석 기반 1차 무전 음성 변조 탐지 Baseline 모델 구현

  • 난이도: 중급 / 예상 소요시간: 2.5시간
  • 코드 설명: 음향 스펙트럼 분석으로 구한 위조 단서(고주파 에너지 비율, 프레임 경계 흔적, 배음 대 잡음비, 스펙트럼 평탄도, 저역 에너지, 등록 화자 유사도)를 진짜 음성 기준 분포와 비교하는 규칙 기반 1차 위조 탐지 Baseline을 구현하고, 탐지 정확도·판단 근거·지연시간과 SNR별 한계를 확인한다.
"""음향 스펙트럼 분석으로 구한 위조 단서(고주파 에너지 비율, 프레임 경계 흔적, 배음 대 잡음비, 스펙트럼 평탄도, 저역 에너지, 등록 화자 유사도)를 진짜 음성 기준 분포와 비교하는 규칙 기반 1차 위조 탐지 Baseline을 구현하고, 탐지 정확도·판단 근거·지연시간과 SNR별 한계를 확인한다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import signal
from scipy.fft import dct

# ---------------------------------------------------------------------------
# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)
# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과
# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.
#   bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음
#   tts_art  : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호
#   vc_art   : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호
#   replay   : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호
# ---------------------------------------------------------------------------
FS, DUR = 8000, 0.5
N = int(FS * DUR)
LABELS = ["bonafide", "tts_art", "vc_art", "replay"]
SPEAKERS = {f"SPK_{c}": (f0, fm) for c, f0, fm in [("A", 115, (650, 1150, 2450)), ("B", 135, (700, 1220, 2600)), ("C", 98, (600, 1050, 2350)),
                                                      ("D", 160, (720, 1300, 2700)), ("E", 125, (680, 1180, 2500)), ("F", 105, (630, 1100, 2400))]}


def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):
    t = np.arange(N) / FS
    contour = f0 * (1 + 0.06 * np.sin(2 * np.pi * rng.uniform(1.5, 3.5) * t + rng.uniform(0, 6)))
    f = contour * (1 + jitter * rng.normal(0, 1, N // 80 + 1).repeat(80)[:N])            # 10ms 단위 피치 흔들림(jitter)
    phase = 2 * np.pi * np.cumsum(f) / FS
    K = int(3800 / f0)
    fk = np.arange(1, K + 1) * f0
    amp = sum(np.exp(-((fk - F) / 120.0) ** 2) / (i + 1) for i, F in enumerate(fm)) + 0.02 / np.arange(1, K + 1)
    if hf_cut:
        amp = np.where(fk > hf_cut, amp * 0.2, amp)
    ph = np.arange(1, K + 1)[:, None] * phase[None, :]
    if phase_break:                                                   # 20ms 프레임마다 배음별 위상 불연속
        ph = ph + rng.uniform(0, 2 * np.pi, (K, N // 160 + 1)).repeat(160, axis=1)[:, :N]
    sig = (amp[:, None] * np.sin(ph)).sum(0)
    env = 0.55 + 0.45 * np.sin(2 * np.pi * rng.uniform(3, 5) * t + rng.uniform(0, 6)) ** 2      # 음절형 진폭 변화
    env *= 1 + shimmer * np.interp(t, np.linspace(0, DUR, 60), rng.normal(0, 1, 60))           # 완만한 진폭 흔들림(shimmer)
    if frame_mod:
        env *= 1 + frame_mod * np.sign(np.sin(2 * np.pi * 50 * t))     # 20ms 프레임 경계 흔적(50Hz)
    return sig * env + (0.03 if jitter > 0.003 else 0.005) * rng.normal(0, 1, N) * env


def make_clip(label, speaker, rng):
    f0, fm = SPEAKERS[speaker]
    if label in ("tts_art", "vc_art"):                               # 위조는 화자 특성을 근사만 함
        f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)
    if label == "bonafide" or label == "replay":
        x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))
    elif label == "tts_art":
        x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))
    else:
        x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)
    if label == "replay":                                              # 스피커 대역 + 실내 잔향
        x = signal.sosfilt(signal.butter(4, [450, 2900], "bandpass", fs=FS, output="sos"), x)
        ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0
        x = signal.fftconvolve(x, ir)[:N]
    return x / (np.abs(x).max() + 1e-9)


RADIO_BP = signal.butter(4, [300, 3400], "bandpass", fs=FS, output="sos")


def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):
    """[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍."""
    y = signal.sosfilt(RADIO_BP, x)
    p = np.mean(y ** 2)
    noise = rng.normal(0, 1, N) * np.sqrt(p / 10 ** (snr_db / 10))
    noise += np.cumsum(rng.normal(0, 0.02, N)) * np.sqrt(p) * 0.05                   # 저주파 차량·엔진형 소음
    y = y + noise
    if impulse:
        k = rng.random(N) < impulse
        y[k] += rng.normal(0, 6 * np.sqrt(p), k.sum())
    if jam_tone:
        y += jam_tone * np.sqrt(p) * np.sin(2 * np.pi * rng.uniform(800, 2000) * np.arange(N) / FS)
    if dropout:
        for _ in range(int(dropout * 10)):
            a = int(rng.integers(0, N - 400)); y[a:a + int(rng.integers(100, 400))] = 0.0
    return np.clip(y / (np.abs(y).max() + 1e-9), -clip, clip)


def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim="SPK_A", **chan):
    """클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame)."""
    rng = np.random.default_rng(seed)
    X, meta = np.zeros((n, N)), []
    if p is None and len(labels) > 1 and labels[0] == "bonafide":        # 기본: 진짜 50%, 나머지 위조 유형 균등
        p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)
    for i in range(n):
        lab = str(rng.choice(labels, p=p))
        spk = str(rng.choice(list(SPEAKERS))) if lab == "bonafide" else claim
        if lab == "replay":
            spk = claim
        s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)
        X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)
        meta.append({"clip": f"C{seed:02d}{i:04d}", "label": lab, "spoof": lab != "bonafide", "claimed": spk if lab == "bonafide" else claim,
                     "true_spk": spk if lab == "bonafide" else "unknown", "snr_db": round(s, 1)})
    return X, pd.DataFrame(meta)
# ---------------------------------------------------------------------------

# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----
NFFT, HOP = 256, 80


def mel_bank(n_mels=26, fmin=300, fmax=3400):
    mel = lambda f: 2595 * np.log10(1 + f / 700)
    imel = lambda m: 700 * (10 ** (m / 2595) - 1)
    pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))
    freqs = np.fft.rfftfreq(NFFT, 1 / FS)
    fb = np.zeros((n_mels, len(freqs)))
    for m in range(n_mels):
        l, c, r = pts[m], pts[m + 1], pts[m + 2]
        fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)
    return fb


MEL = mel_bank()
FREQS = np.fft.rfftfreq(NFFT, 1 / FS)


def spectrogram(X, denoise=0.0):
    """배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감."""
    X = np.atleast_2d(X)
    Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]]                                       # 프리엠퍼시스
    frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)
    P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2
    P = np.transpose(P, (0, 2, 1))
    if denoise:
        floor = np.percentile(P, 10, axis=2, keepdims=True)
        P = np.maximum(P - denoise * floor, 0.05 * P)
    return P


def mfcc(P, n=13):
    logmel = np.log(np.einsum("mf,cft->cmt", MEL, P) + 1e-8)
    return dct(logmel, type=2, axis=1, norm="ortho")[:, :n, :]


def band(P, lo, hi):
    return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))


def jitter_hnr(X):
    """40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다."""
    W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)
    fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]
    fr = fr - fr.mean(-1, keepdims=True)
    spec = np.fft.rfft(fr, 2 * W, axis=-1)
    ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]
    ac = ac / (ac[..., :1] + 1e-12)
    seg = ac[..., lag0:lag1]
    k = seg.argmax(-1)
    peak = seg.max(-1)
    km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]
    lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12)                      # 포물선 보간으로 주기 정밀화
    f0 = FS / lag
    jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)
    return jit, np.median(peak, axis=1)


def frame_mod(X):
    """진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서)."""
    env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)
    sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2
    f = np.fft.rfftfreq(env.shape[1], 20 / FS)
    return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)


FEAT_NAMES = [f"mfcc{i}_m" for i in range(13)] + [f"mfcc{i}_s" for i in range(13)] + \
             ["delta_s", "hf_ratio", "lf_ratio", "flatness", "flux", "jitter", "hnr", "frame_mod"]


def extract(X, denoise=0.0):
    """클립 배치 -> 특징 행렬 (클립 x 34) DataFrame."""
    P = spectrogram(X, denoise)
    C = mfcc(P)
    tot = band(P, 300, 3400) + 1e-12
    flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)
    logP = np.log(P + 1e-8)
    jit, hnr = jitter_hnr(X)
    F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,
              flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]
    return pd.DataFrame(F, columns=FEAT_NAMES)


def enroll(seed=99):
    """지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균)."""
    rng = np.random.default_rng(seed)
    tpl = {}
    for spk in SPEAKERS:
        Xe = np.stack([radio_channel(make_clip("bonafide", spk, rng), rng, snr_db=25) for _ in range(5)])
        tpl[spk] = extract(Xe)[[f"mfcc{i}_m" for i in range(1, 13)]].mean().values
    return tpl


def speaker_sim(F, claimed, tpl):
    """주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수)."""
    v = F[[f"mfcc{i}_m" for i in range(1, 13)]].values
    t = np.stack([tpl[c] for c in claimed])
    return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)

# 단서: (특징, 위조일 때 방향, 주로 잡는 위협)
CUES = [("hf_ratio", "low", "tts_art"), ("frame_mod", "high", "tts_art"), ("hnr", "low", "vc_art"),
        ("flatness", "high", "vc_art"), ("lf_ratio", "low", "replay"), ("spk_sim", "low", "tts/vc")]


def fit_rules(F, q=0.03):
    """진짜 음성(bonafide)만으로 단서별 정상 범위 경계를 정한다(한쪽 3% 분위수)."""
    th = {}
    for f, d, _ in CUES:
        th[f] = F[f].quantile(q) if d == "low" else F[f].quantile(1 - q)
    return th


def apply_rules(F, th):
    hits = pd.DataFrame({f: (F[f] < th[f]) if d == "low" else (F[f] > th[f]) for f, d, _ in CUES})
    return hits.any(axis=1), hits


tpl = enroll()
# 기준 설정용: 진짜 음성 200개(여러 화자, SNR 5~25dB)
Xb, Mb = make_dataset(200, seed=7, labels=["bonafide"], p=None)
Fb = extract(Xb); Fb["spk_sim"] = speaker_sim(Fb, Mb["claimed"], tpl)
th = fit_rules(Fb)
print("[규칙 경계(진짜 음성 기준 3% 분위수)]", {k: round(float(v), 3) for k, v in th.items()})

# [교육용가상자료] 평가용 기본 데이터셋: 진짜 50%, 위조 3종 각 약 17%, SNR 5~25dB
X, M = make_dataset(400, seed=8)
t0 = time.perf_counter()
F = extract(X); F["spk_sim"] = speaker_sim(F, M["claimed"], tpl)
pred, hits = apply_rules(F, th)
ms = (time.perf_counter() - t0) * 1000 / len(X)
acc = (pred == M["spoof"]).mean()
print(f"\n클립 {len(X)}개: 탐지 정확도 {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'}), "
      f"위조 재현율 {pred[M['spoof']].mean():.1%}, 오경보율(진짜를 위조로) {pred[~M['spoof']].mean():.1%}, 지연 {ms:.2f} ms/클립")

print("\n[유형별 위조 판정 비율과 주로 걸린 단서]")
for lab in LABELS:
    s = M["label"] == lab
    top = hits[s].mean().sort_values(ascending=False)
    print(f"{lab:9s} 위조 판정 {pred[s].mean():6.1%}  주요 단서: {', '.join(f'{k}({v:.0%})' for k, v in top.head(2).items())}")

print("\nSNR 구간   정확도   오경보율")
for lo, hi in [(5, 10), (10, 15), (15, 25)]:
    s = (M["snr_db"] >= lo) & (M["snr_db"] < hi)
    print(f"{lo:>2}~{hi:<2}dB    {(pred[s] == M['spoof'][s]).mean():6.1%}   {pred[s & ~M['spoof']].mean():6.1%}")

ex = M.index[M["spoof"] & pred][0]
print(f"\n판단 근거 예시 {M['clip'][ex]}({M['label'][ex]}): 걸린 단서 {', '.join(hits.columns[hits.loc[ex]])}")
print("한계: 잡음이 큰 채널에서는 진짜 음성의 단서 값도 경계를 넘어 오경보가 늘고, 재생(replay)은 화자 유사도가 높아 단서가 적다 -> 미션 4·5에서 학습형 모델과 SNR 반영 신뢰도로 보완")

plt.figure(figsize=(7, 4))
for lab, c in zip(LABELS, ["tab:green", "tab:red", "tab:purple", "tab:orange"]):
    s = M["label"] == lab
    plt.scatter(F["hf_ratio"][s], F["hnr"][s], s=10, c=c, label=lab)
plt.axvline(th["hf_ratio"], ls="--", c="k"); plt.axhline(th["hnr"], ls="--", c="k")
plt.xlabel("high-frequency energy ratio"); plt.ylabel("HNR (autocorr peak)"); plt.legend(); plt.title("Rule-based cues (synthetic signals)")
plt.tight_layout(); plt.show()

실행 결과 예시

[규칙 경계(진짜 음성 기준 3% 분위수)] {'hf_ratio': 0.176, 'frame_mod': 0.286, 'hnr': 0.494, 'flatness': 0.341, 'lf_ratio': 0.0, 'spk_sim': 0.702}

클립 400개: 탐지 정확도 78.8% (목표 75% 이상: 달성), 위조 재현율 74.0%, 오경보율(진짜를 위조로) 16.8%, 지연 0.93 ms/클립

[유형별 위조 판정 비율과 주로 걸린 단서]
bonafide  위조 판정  16.8%  주요 단서: frame_mod(9%), hnr(3%)
tts_art   위조 판정 100.0%  주요 단서: frame_mod(100%), hf_ratio(22%)
vc_art    위조 판정  85.9%  주요 단서: hnr(84%), flatness(9%)
replay    위조 판정  30.5%  주요 단서: hnr(14%), spk_sim(12%)

SNR 구간   정확도   오경보율
 5~10dB     77.7%    27.1%
10~15dB     81.7%     7.1%
15~25dB     77.5%    16.1%

판단 근거 예시 C080002(tts_art): 걸린 단서 frame_mod
한계: 잡음이 큰 채널에서는 진짜 음성의 단서 값도 경계를 넘어 오경보가 늘고, 재생(replay)은 화자 유사도가 높아 단서가 적다 -> 미션 4·5에서 학습형 모델과 SNR 반영 신뢰도로 보완

미션 4. 음성 합성 인공지능(TTS/VC) 아티팩트 및 위조 음성 판별 딥러닝 탐지 엔진 고도화

  • 난이도: 중급 / 예상 소요시간: 3.0시간
  • 코드 설명: 음향 특징 34종과 등록 화자 유사도를 입력으로 하는 신경망(MLP) 위조 음성 판별 엔진을 학습하고, 위조 유형과 채널 조건을 섞은 복합 위협 시나리오 10종에서 재현율·처리 지연을 검증하며 오경보를 일으키는 패턴을 찾는다.
"""음향 특징 34종과 등록 화자 유사도를 입력으로 하는 신경망(MLP) 위조 음성 판별 엔진을 학습하고, 위조 유형과 채널 조건을 섞은 복합 위협 시나리오 10종에서 재현율·처리 지연을 검증하며 오경보를 일으키는 패턴을 찾는다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import signal
from scipy.fft import dct
from sklearn.neural_network import MLPClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline

# ---------------------------------------------------------------------------
# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)
# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과
# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.
#   bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음
#   tts_art  : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호
#   vc_art   : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호
#   replay   : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호
# ---------------------------------------------------------------------------
FS, DUR = 8000, 0.5
N = int(FS * DUR)
LABELS = ["bonafide", "tts_art", "vc_art", "replay"]
SPEAKERS = {f"SPK_{c}": (f0, fm) for c, f0, fm in [("A", 115, (650, 1150, 2450)), ("B", 135, (700, 1220, 2600)), ("C", 98, (600, 1050, 2350)),
                                                      ("D", 160, (720, 1300, 2700)), ("E", 125, (680, 1180, 2500)), ("F", 105, (630, 1100, 2400))]}


def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):
    t = np.arange(N) / FS
    contour = f0 * (1 + 0.06 * np.sin(2 * np.pi * rng.uniform(1.5, 3.5) * t + rng.uniform(0, 6)))
    f = contour * (1 + jitter * rng.normal(0, 1, N // 80 + 1).repeat(80)[:N])            # 10ms 단위 피치 흔들림(jitter)
    phase = 2 * np.pi * np.cumsum(f) / FS
    K = int(3800 / f0)
    fk = np.arange(1, K + 1) * f0
    amp = sum(np.exp(-((fk - F) / 120.0) ** 2) / (i + 1) for i, F in enumerate(fm)) + 0.02 / np.arange(1, K + 1)
    if hf_cut:
        amp = np.where(fk > hf_cut, amp * 0.2, amp)
    ph = np.arange(1, K + 1)[:, None] * phase[None, :]
    if phase_break:                                                   # 20ms 프레임마다 배음별 위상 불연속
        ph = ph + rng.uniform(0, 2 * np.pi, (K, N // 160 + 1)).repeat(160, axis=1)[:, :N]
    sig = (amp[:, None] * np.sin(ph)).sum(0)
    env = 0.55 + 0.45 * np.sin(2 * np.pi * rng.uniform(3, 5) * t + rng.uniform(0, 6)) ** 2      # 음절형 진폭 변화
    env *= 1 + shimmer * np.interp(t, np.linspace(0, DUR, 60), rng.normal(0, 1, 60))           # 완만한 진폭 흔들림(shimmer)
    if frame_mod:
        env *= 1 + frame_mod * np.sign(np.sin(2 * np.pi * 50 * t))     # 20ms 프레임 경계 흔적(50Hz)
    return sig * env + (0.03 if jitter > 0.003 else 0.005) * rng.normal(0, 1, N) * env


def make_clip(label, speaker, rng):
    f0, fm = SPEAKERS[speaker]
    if label in ("tts_art", "vc_art"):                               # 위조는 화자 특성을 근사만 함
        f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)
    if label == "bonafide" or label == "replay":
        x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))
    elif label == "tts_art":
        x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))
    else:
        x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)
    if label == "replay":                                              # 스피커 대역 + 실내 잔향
        x = signal.sosfilt(signal.butter(4, [450, 2900], "bandpass", fs=FS, output="sos"), x)
        ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0
        x = signal.fftconvolve(x, ir)[:N]
    return x / (np.abs(x).max() + 1e-9)


RADIO_BP = signal.butter(4, [300, 3400], "bandpass", fs=FS, output="sos")


def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):
    """[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍."""
    y = signal.sosfilt(RADIO_BP, x)
    p = np.mean(y ** 2)
    noise = rng.normal(0, 1, N) * np.sqrt(p / 10 ** (snr_db / 10))
    noise += np.cumsum(rng.normal(0, 0.02, N)) * np.sqrt(p) * 0.05                   # 저주파 차량·엔진형 소음
    y = y + noise
    if impulse:
        k = rng.random(N) < impulse
        y[k] += rng.normal(0, 6 * np.sqrt(p), k.sum())
    if jam_tone:
        y += jam_tone * np.sqrt(p) * np.sin(2 * np.pi * rng.uniform(800, 2000) * np.arange(N) / FS)
    if dropout:
        for _ in range(int(dropout * 10)):
            a = int(rng.integers(0, N - 400)); y[a:a + int(rng.integers(100, 400))] = 0.0
    return np.clip(y / (np.abs(y).max() + 1e-9), -clip, clip)


def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim="SPK_A", **chan):
    """클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame)."""
    rng = np.random.default_rng(seed)
    X, meta = np.zeros((n, N)), []
    if p is None and len(labels) > 1 and labels[0] == "bonafide":        # 기본: 진짜 50%, 나머지 위조 유형 균등
        p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)
    for i in range(n):
        lab = str(rng.choice(labels, p=p))
        spk = str(rng.choice(list(SPEAKERS))) if lab == "bonafide" else claim
        if lab == "replay":
            spk = claim
        s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)
        X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)
        meta.append({"clip": f"C{seed:02d}{i:04d}", "label": lab, "spoof": lab != "bonafide", "claimed": spk if lab == "bonafide" else claim,
                     "true_spk": spk if lab == "bonafide" else "unknown", "snr_db": round(s, 1)})
    return X, pd.DataFrame(meta)
# ---------------------------------------------------------------------------

# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----
NFFT, HOP = 256, 80


def mel_bank(n_mels=26, fmin=300, fmax=3400):
    mel = lambda f: 2595 * np.log10(1 + f / 700)
    imel = lambda m: 700 * (10 ** (m / 2595) - 1)
    pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))
    freqs = np.fft.rfftfreq(NFFT, 1 / FS)
    fb = np.zeros((n_mels, len(freqs)))
    for m in range(n_mels):
        l, c, r = pts[m], pts[m + 1], pts[m + 2]
        fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)
    return fb


MEL = mel_bank()
FREQS = np.fft.rfftfreq(NFFT, 1 / FS)


def spectrogram(X, denoise=0.0):
    """배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감."""
    X = np.atleast_2d(X)
    Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]]                                       # 프리엠퍼시스
    frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)
    P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2
    P = np.transpose(P, (0, 2, 1))
    if denoise:
        floor = np.percentile(P, 10, axis=2, keepdims=True)
        P = np.maximum(P - denoise * floor, 0.05 * P)
    return P


def mfcc(P, n=13):
    logmel = np.log(np.einsum("mf,cft->cmt", MEL, P) + 1e-8)
    return dct(logmel, type=2, axis=1, norm="ortho")[:, :n, :]


def band(P, lo, hi):
    return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))


def jitter_hnr(X):
    """40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다."""
    W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)
    fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]
    fr = fr - fr.mean(-1, keepdims=True)
    spec = np.fft.rfft(fr, 2 * W, axis=-1)
    ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]
    ac = ac / (ac[..., :1] + 1e-12)
    seg = ac[..., lag0:lag1]
    k = seg.argmax(-1)
    peak = seg.max(-1)
    km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]
    lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12)                      # 포물선 보간으로 주기 정밀화
    f0 = FS / lag
    jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)
    return jit, np.median(peak, axis=1)


def frame_mod(X):
    """진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서)."""
    env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)
    sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2
    f = np.fft.rfftfreq(env.shape[1], 20 / FS)
    return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)


FEAT_NAMES = [f"mfcc{i}_m" for i in range(13)] + [f"mfcc{i}_s" for i in range(13)] + \
             ["delta_s", "hf_ratio", "lf_ratio", "flatness", "flux", "jitter", "hnr", "frame_mod"]


def extract(X, denoise=0.0):
    """클립 배치 -> 특징 행렬 (클립 x 34) DataFrame."""
    P = spectrogram(X, denoise)
    C = mfcc(P)
    tot = band(P, 300, 3400) + 1e-12
    flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)
    logP = np.log(P + 1e-8)
    jit, hnr = jitter_hnr(X)
    F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,
              flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]
    return pd.DataFrame(F, columns=FEAT_NAMES)


def enroll(seed=99):
    """지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균)."""
    rng = np.random.default_rng(seed)
    tpl = {}
    for spk in SPEAKERS:
        Xe = np.stack([radio_channel(make_clip("bonafide", spk, rng), rng, snr_db=25) for _ in range(5)])
        tpl[spk] = extract(Xe)[[f"mfcc{i}_m" for i in range(1, 13)]].mean().values
    return tpl


def speaker_sim(F, claimed, tpl):
    """주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수)."""
    v = F[[f"mfcc{i}_m" for i in range(1, 13)]].values
    t = np.stack([tpl[c] for c in claimed])
    return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)

tpl = enroll()


def featurize(X, M):
    F = extract(X)
    F["spk_sim"] = speaker_sim(F, M["claimed"], tpl)
    return F


# [교육용가상자료] 학습용: 깨끗~보통 채널 + 일부 충격 잡음·끊김 채널을 섞어 학습(데이터 증강)
parts = [make_dataset(500, seed=21, snr=(5, 25)), make_dataset(200, seed=22, snr=(5, 20), impulse=0.002, dropout=0.3)]
Xtr = np.vstack([p[0] for p in parts]); Mtr = pd.concat([p[1] for p in parts], ignore_index=True)
Ftr = featurize(Xtr, Mtr)
model = make_pipeline(StandardScaler(), MLPClassifier(hidden_layer_sizes=(64, 32), alpha=1e-3, max_iter=600, random_state=0))
model.fit(Ftr, Mtr["spoof"])
print(f"학습 클립 {len(Xtr)}개(진짜 {(~Mtr['spoof']).sum()} / 위조 {Mtr['spoof'].sum()}), 특징 {Ftr.shape[1]}개, 신경망 은닉층 64-32")

# [교육용가상자료] 복합 위협 시나리오 10종: (이름, 위조 유형, 채널 조건)
SCEN = [("S01 TTS 명령, 양호 채널", "tts_art", dict(snr=(15, 25))),
        ("S02 TTS 명령, 저SNR", "tts_art", dict(snr=(3, 8))),
        ("S03 VC 사칭, 양호 채널", "vc_art", dict(snr=(15, 25))),
        ("S04 VC 사칭, 충격 잡음", "vc_art", dict(snr=(8, 15), impulse=0.002)),
        ("S05 재생 공격, 양호 채널", "replay", dict(snr=(15, 25))),
        ("S06 재생 공격, 신호 끊김", "replay", dict(snr=(8, 15), dropout=0.4)),
        ("S07 TTS, 단일톤 재밍", "tts_art", dict(snr=(8, 15), jam_tone=0.5)),
        ("S08 VC, 포화(클리핑)", "vc_art", dict(snr=(10, 20), clip=0.5)),
        ("S09 혼합 위조, 저SNR", None, dict(snr=(3, 8))),
        ("S10 혼합 위조, 끊김+충격", None, dict(snr=(8, 15), impulse=0.002, dropout=0.3))]

rows, lat = [], []
for k, (name, lab, ch) in enumerate(SCEN):
    labs = ["tts_art", "vc_art", "replay"] if lab is None else [lab]
    X, M = make_dataset(40, seed=300 + k, labels=labs, p=[1 / len(labs)] * len(labs), **ch)
    t0 = time.perf_counter()
    pred = model.predict(featurize(X, M))
    lat.append((time.perf_counter() - t0) * 1000 / len(X))
    rows.append({"시나리오": name, "재현율": pred.mean(), "통과(85%)": "PASS" if pred.mean() >= 0.85 else "FAIL"})
R = pd.DataFrame(rows)
print("\n[복합 위협 시나리오 10종 탐지 결과(각 위조 40클립)]")
print(R.to_string(index=False, formatters={"재현율": "{:.1%}".format}))

# 단일 클립 실시간 지연(특징 추출+추론) 측정
Xs, Ms = make_dataset(30, seed=399)
one = []
for i in range(30):
    t0 = time.perf_counter(); model.predict(featurize(Xs[i:i + 1], Ms.iloc[i:i + 1])); one.append((time.perf_counter() - t0) * 1000)
rec = R["재현율"].mean()
p95 = float(np.percentile(one, 95))
print(f"\n복합 위협 평균 재현율 {rec:.1%} (목표 85% 이상: {'달성' if rec >= 0.85 else '미달'}), 시나리오 통과 {(R['통과(85%)'] == 'PASS').sum()}/10")
print(f"단일 클립 처리 지연 p95 {p95:.1f} ms (목표 200ms 이하: {'달성' if p95 <= 200 else '미달'}), 배치 평균 {np.mean(lat):.2f} ms/클립")

# 오경보 패턴: 진짜 음성을 다양한 채널 조건에서 판정
print("\n[진짜 음성 오경보율 - 채널 조건별]")
fa = {}
for name, ch in [("양호(15~25dB)", dict(snr=(15, 25))), ("저SNR(3~8dB)", dict(snr=(3, 8))), ("충격 잡음", dict(snr=(8, 15), impulse=0.002)),
                 ("신호 끊김", dict(snr=(8, 15), dropout=0.4)), ("단일톤 재밍", dict(snr=(8, 15), jam_tone=0.5)), ("포화(클리핑)", dict(snr=(10, 20), clip=0.5))]:
    X, M = make_dataset(60, seed=500 + len(fa), labels=["bonafide"], **ch)
    fa[name] = model.predict(featurize(X, M)).mean()
    print(f"  {name:12s} {fa[name]:6.1%}")
worst = max(fa, key=fa.get)
print(f"오경보 유발 패턴: '{worst}' 조건에서 가장 높음 -> 해당 조건 진짜 음성을 학습에 보강하거나 미션 5에서 채널 품질을 신뢰도에 반영해 경보 등급을 낮춘다")

plt.figure(figsize=(7, 3.5))
plt.barh([s.split()[0] for s in R["시나리오"]], R["재현율"], color=["tab:green" if v >= 0.85 else "tab:red" for v in R["재현율"]])
plt.axvline(0.85, ls="--", c="k"); plt.xlabel("recall"); plt.title("Composite threat scenarios (synthetic)"); plt.tight_layout(); plt.show()

실행 결과 예시

학습 클립 700개(진짜 351 / 위조 349), 특징 35개, 신경망 은닉층 64-32

[복합 위협 시나리오 10종 탐지 결과(각 위조 40클립)]
             시나리오    재현율 통과(85%)
S01 TTS 명령, 양호 채널 100.0%    PASS
 S02 TTS 명령, 저SNR 100.0%    PASS
 S03 VC 사칭, 양호 채널 100.0%    PASS
 S04 VC 사칭, 충격 잡음  92.5%    PASS
 S05 재생 공격, 양호 채널 100.0%    PASS
 S06 재생 공격, 신호 끊김  90.0%    PASS
  S07 TTS, 단일톤 재밍 100.0%    PASS
  S08 VC, 포화(클리핑) 100.0%    PASS
  S09 혼합 위조, 저SNR  95.0%    PASS
 S10 혼합 위조, 끊김+충격  85.0%    PASS

복합 위협 평균 재현율 96.2% (목표 85% 이상: 달성), 시나리오 통과 10/10
단일 클립 처리 지연 p95 4.3 ms (목표 200ms 이하: 달성), 배치 평균 0.81 ms/클립

[진짜 음성 오경보율 - 채널 조건별]
  양호(15~25dB)    0.0%
  저SNR(3~8dB)    3.3%
  충격 잡음          0.0%
  신호 끊김          8.3%
  단일톤 재밍        31.7%
  포화(클리핑)        6.7%
오경보 유발 패턴: '단일톤 재밍' 조건에서 가장 높음 -> 해당 조건 진짜 음성을 학습에 보강하거나 미션 5에서 채널 품질을 신뢰도에 반영해 경보 등급을 낮춘다

미션 5. 전장 소음 환경 오탐 방지 및 무전 화자 진위 신뢰도 점수 산출 모듈 구현

  • 난이도: 중급~고급 / 예상 소요시간: 3.0시간
  • 코드 설명: 채널 품질 특징(추정 SNR, 단일톤 재밍 지수, 끊김 비율)을 더하고 잡음 조건을 섞어 학습한 보정 모델로 무전 화자 진위 신뢰도 점수와 High/Med/Low 등급을 산출해, 기존 판별기 대비 오경보율 감축, 등급 일관성, ROC-AUC와 임계값 민감도를 확인한다.
"""채널 품질 특징(추정 SNR, 단일톤 재밍 지수, 끊김 비율)을 더하고 잡음 조건을 섞어 학습한 보정 모델로 무전 화자 진위 신뢰도 점수와 High/Med/Low 등급을 산출해, 기존 판별기 대비 오경보율 감축, 등급 일관성, ROC-AUC와 임계값 민감도를 확인한다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import signal
from scipy.fft import dct
from sklearn.neural_network import MLPClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.metrics import roc_auc_score, roc_curve

# ---------------------------------------------------------------------------
# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)
# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과
# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.
#   bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음
#   tts_art  : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호
#   vc_art   : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호
#   replay   : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호
# ---------------------------------------------------------------------------
FS, DUR = 8000, 0.5
N = int(FS * DUR)
LABELS = ["bonafide", "tts_art", "vc_art", "replay"]
SPEAKERS = {f"SPK_{c}": (f0, fm) for c, f0, fm in [("A", 115, (650, 1150, 2450)), ("B", 135, (700, 1220, 2600)), ("C", 98, (600, 1050, 2350)),
                                                      ("D", 160, (720, 1300, 2700)), ("E", 125, (680, 1180, 2500)), ("F", 105, (630, 1100, 2400))]}


def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):
    t = np.arange(N) / FS
    contour = f0 * (1 + 0.06 * np.sin(2 * np.pi * rng.uniform(1.5, 3.5) * t + rng.uniform(0, 6)))
    f = contour * (1 + jitter * rng.normal(0, 1, N // 80 + 1).repeat(80)[:N])            # 10ms 단위 피치 흔들림(jitter)
    phase = 2 * np.pi * np.cumsum(f) / FS
    K = int(3800 / f0)
    fk = np.arange(1, K + 1) * f0
    amp = sum(np.exp(-((fk - F) / 120.0) ** 2) / (i + 1) for i, F in enumerate(fm)) + 0.02 / np.arange(1, K + 1)
    if hf_cut:
        amp = np.where(fk > hf_cut, amp * 0.2, amp)
    ph = np.arange(1, K + 1)[:, None] * phase[None, :]
    if phase_break:                                                   # 20ms 프레임마다 배음별 위상 불연속
        ph = ph + rng.uniform(0, 2 * np.pi, (K, N // 160 + 1)).repeat(160, axis=1)[:, :N]
    sig = (amp[:, None] * np.sin(ph)).sum(0)
    env = 0.55 + 0.45 * np.sin(2 * np.pi * rng.uniform(3, 5) * t + rng.uniform(0, 6)) ** 2      # 음절형 진폭 변화
    env *= 1 + shimmer * np.interp(t, np.linspace(0, DUR, 60), rng.normal(0, 1, 60))           # 완만한 진폭 흔들림(shimmer)
    if frame_mod:
        env *= 1 + frame_mod * np.sign(np.sin(2 * np.pi * 50 * t))     # 20ms 프레임 경계 흔적(50Hz)
    return sig * env + (0.03 if jitter > 0.003 else 0.005) * rng.normal(0, 1, N) * env


def make_clip(label, speaker, rng):
    f0, fm = SPEAKERS[speaker]
    if label in ("tts_art", "vc_art"):                               # 위조는 화자 특성을 근사만 함
        f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)
    if label == "bonafide" or label == "replay":
        x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))
    elif label == "tts_art":
        x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))
    else:
        x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)
    if label == "replay":                                              # 스피커 대역 + 실내 잔향
        x = signal.sosfilt(signal.butter(4, [450, 2900], "bandpass", fs=FS, output="sos"), x)
        ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0
        x = signal.fftconvolve(x, ir)[:N]
    return x / (np.abs(x).max() + 1e-9)


RADIO_BP = signal.butter(4, [300, 3400], "bandpass", fs=FS, output="sos")


def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):
    """[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍."""
    y = signal.sosfilt(RADIO_BP, x)
    p = np.mean(y ** 2)
    noise = rng.normal(0, 1, N) * np.sqrt(p / 10 ** (snr_db / 10))
    noise += np.cumsum(rng.normal(0, 0.02, N)) * np.sqrt(p) * 0.05                   # 저주파 차량·엔진형 소음
    y = y + noise
    if impulse:
        k = rng.random(N) < impulse
        y[k] += rng.normal(0, 6 * np.sqrt(p), k.sum())
    if jam_tone:
        y += jam_tone * np.sqrt(p) * np.sin(2 * np.pi * rng.uniform(800, 2000) * np.arange(N) / FS)
    if dropout:
        for _ in range(int(dropout * 10)):
            a = int(rng.integers(0, N - 400)); y[a:a + int(rng.integers(100, 400))] = 0.0
    return np.clip(y / (np.abs(y).max() + 1e-9), -clip, clip)


def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim="SPK_A", **chan):
    """클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame)."""
    rng = np.random.default_rng(seed)
    X, meta = np.zeros((n, N)), []
    if p is None and len(labels) > 1 and labels[0] == "bonafide":        # 기본: 진짜 50%, 나머지 위조 유형 균등
        p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)
    for i in range(n):
        lab = str(rng.choice(labels, p=p))
        spk = str(rng.choice(list(SPEAKERS))) if lab == "bonafide" else claim
        if lab == "replay":
            spk = claim
        s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)
        X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)
        meta.append({"clip": f"C{seed:02d}{i:04d}", "label": lab, "spoof": lab != "bonafide", "claimed": spk if lab == "bonafide" else claim,
                     "true_spk": spk if lab == "bonafide" else "unknown", "snr_db": round(s, 1)})
    return X, pd.DataFrame(meta)
# ---------------------------------------------------------------------------

# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----
NFFT, HOP = 256, 80


def mel_bank(n_mels=26, fmin=300, fmax=3400):
    mel = lambda f: 2595 * np.log10(1 + f / 700)
    imel = lambda m: 700 * (10 ** (m / 2595) - 1)
    pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))
    freqs = np.fft.rfftfreq(NFFT, 1 / FS)
    fb = np.zeros((n_mels, len(freqs)))
    for m in range(n_mels):
        l, c, r = pts[m], pts[m + 1], pts[m + 2]
        fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)
    return fb


MEL = mel_bank()
FREQS = np.fft.rfftfreq(NFFT, 1 / FS)


def spectrogram(X, denoise=0.0):
    """배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감."""
    X = np.atleast_2d(X)
    Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]]                                       # 프리엠퍼시스
    frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)
    P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2
    P = np.transpose(P, (0, 2, 1))
    if denoise:
        floor = np.percentile(P, 10, axis=2, keepdims=True)
        P = np.maximum(P - denoise * floor, 0.05 * P)
    return P


def mfcc(P, n=13):
    logmel = np.log(np.einsum("mf,cft->cmt", MEL, P) + 1e-8)
    return dct(logmel, type=2, axis=1, norm="ortho")[:, :n, :]


def band(P, lo, hi):
    return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))


def jitter_hnr(X):
    """40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다."""
    W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)
    fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]
    fr = fr - fr.mean(-1, keepdims=True)
    spec = np.fft.rfft(fr, 2 * W, axis=-1)
    ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]
    ac = ac / (ac[..., :1] + 1e-12)
    seg = ac[..., lag0:lag1]
    k = seg.argmax(-1)
    peak = seg.max(-1)
    km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]
    lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12)                      # 포물선 보간으로 주기 정밀화
    f0 = FS / lag
    jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)
    return jit, np.median(peak, axis=1)


def frame_mod(X):
    """진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서)."""
    env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)
    sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2
    f = np.fft.rfftfreq(env.shape[1], 20 / FS)
    return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)


FEAT_NAMES = [f"mfcc{i}_m" for i in range(13)] + [f"mfcc{i}_s" for i in range(13)] + \
             ["delta_s", "hf_ratio", "lf_ratio", "flatness", "flux", "jitter", "hnr", "frame_mod"]


def extract(X, denoise=0.0):
    """클립 배치 -> 특징 행렬 (클립 x 34) DataFrame."""
    P = spectrogram(X, denoise)
    C = mfcc(P)
    tot = band(P, 300, 3400) + 1e-12
    flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)
    logP = np.log(P + 1e-8)
    jit, hnr = jitter_hnr(X)
    F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,
              flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]
    return pd.DataFrame(F, columns=FEAT_NAMES)


def enroll(seed=99):
    """지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균)."""
    rng = np.random.default_rng(seed)
    tpl = {}
    for spk in SPEAKERS:
        Xe = np.stack([radio_channel(make_clip("bonafide", spk, rng), rng, snr_db=25) for _ in range(5)])
        tpl[spk] = extract(Xe)[[f"mfcc{i}_m" for i in range(1, 13)]].mean().values
    return tpl


def speaker_sim(F, claimed, tpl):
    """주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수)."""
    v = F[[f"mfcc{i}_m" for i in range(1, 13)]].values
    t = np.stack([tpl[c] for c in claimed])
    return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)

tpl = enroll()


def channel_quality(X):
    """채널 품질 지표: 추정 SNR(유효 프레임 에너지 상하위 비, dB), 단일톤 재밍 지수(주파수별 시간 평균/표준편차의 최대값), 끊김 비율(무음 10ms 비율)."""
    P = spectrogram(X)
    e = P.sum(1)
    e = np.where(e > 1e-9, e, np.nan)
    snr_est = 10 * np.log10(np.nanpercentile(e, 90, axis=1) / (np.nanpercentile(e, 10, axis=1) + 1e-12))
    Pb = P[:, (FREQS >= 300) & (FREQS < 3400)]
    tone = (Pb.mean(2) / (Pb.std(2) + 1e-12)).max(1)                     # 시간에 따라 일정한 주파수 성분(재밍 톤)일수록 큼
    fr = np.abs(np.atleast_2d(X)).reshape(len(X), -1, 80).max(-1)
    drop = (fr < 1e-6).mean(1)
    return pd.DataFrame({"snr_est": snr_est, "tone_idx": tone, "drop_ratio": drop})


def featurize(X, M, quality=False):
    F = extract(X)
    F["spk_sim"] = speaker_sim(F, M["claimed"], tpl)
    return pd.concat([F, channel_quality(X)], axis=1) if quality else F


def mixed(n, seed, labels=LABELS):
    """[교육용가상자료] 평시·저SNR·충격 잡음·끊김·재밍 채널이 섞인 데이터셋."""
    conds = [dict(snr=(10, 25)), dict(snr=(3, 8)), dict(snr=(8, 15), impulse=0.002), dict(snr=(8, 15), dropout=0.4), dict(snr=(8, 15), jam_tone=0.5)]
    parts = [make_dataset(n // 5, seed=seed + i, labels=labels, **c) for i, c in enumerate(conds)]
    return np.vstack([p[0] for p in parts]), pd.concat([p[1] for p in parts], ignore_index=True)


# 기존(미션 4 방식): 양호 채널 위주 학습 + 임계값 0.5
Xa, Ma = make_dataset(600, seed=31, snr=(5, 25))
old = make_pipeline(StandardScaler(), MLPClassifier((64, 32), alpha=1e-3, max_iter=600, random_state=0)).fit(featurize(Xa, Ma), Ma["spoof"])
# 개선: 채널 조건 혼합 학습 + 채널 품질 특징 + 로지스틱 보정(Platt)으로 점수 확률화
Xb, Mb = mixed(800, seed=40)
Fb = featurize(Xb, Mb, quality=True)
new = make_pipeline(StandardScaler(), MLPClassifier((64, 32), alpha=1e-3, max_iter=600, random_state=0)).fit(Fb, Mb["spoof"])
Xc, Mc = mixed(300, seed=60)
cal = LogisticRegression().fit(new.predict_proba(featurize(Xc, Mc, True))[:, [1]], Mc["spoof"])


def confidence(X, M):
    """위조 확률 p와 진위 신뢰도(1-p), 등급: High=위조 의심 높음(즉시 경보), Med=확인 필요(HITL), Low=진짜 가능성 높음."""
    Fq = featurize(X, M, quality=True)
    p = cal.predict_proba(new.predict_proba(Fq)[:, [1]])[:, 1]
    poor = (Fq["snr_est"] < 3.3) | (Fq["tone_idx"] > 2.5) | (Fq["drop_ratio"] > 0.05)
    grade = np.where(p >= 0.8, "High", np.where(p >= 0.4, "Med", "Low"))
    grade = np.where((grade == "High") & poor & (p < 0.95), "Med", grade)          # 품질 불량 채널은 한 단계 낮춰 사람 확인
    return p, grade, poor


# [교육용가상자료] 평가: 혼합 채널 500클립
Xt, Mt = mixed(500, seed=80)
p_old = old.predict_proba(featurize(Xt, Mt))[:, 1]
p_new, g_new, poor = confidence(Xt, Mt)
y, b = Mt["spoof"].values, ~Mt["spoof"].values
far_old, far_new = (p_old[b] >= 0.5).mean(), (g_new[b] == "High").mean()
red = 1 - far_new / far_old
print(f"평가 클립 {len(Xt)}개(진짜 {b.sum()} / 위조 {y.sum()}), 품질 불량 채널 {poor.mean():.0%}")
print(f"오경보율(진짜를 즉시 경보): 기존 {far_old:.1%} -> 개선 {far_new:.1%}, 감축 {red:.0%} (목표 40% 이상: {'달성' if red >= 0.4 else '미달'})")
print(f"위조 탐지(High+Med 등급): 기존 {(p_old[y] >= 0.5).mean():.1%} -> 개선 {(g_new[y] != 'Low').mean():.1%}")
print(f"ROC-AUC: 기존 {roc_auc_score(y, p_old):.3f}, 개선 {roc_auc_score(y, p_new):.3f}")

print("\n[등급 분포] 등급   진짜   위조")
for g in ["High", "Med", "Low"]:
    print(f"            {g:5s} {(g_new[b] == g).sum():5d} {(g_new[y] == g).sum():5d}")

print("\n[임계값 민감도(High 기준)] 임계값  오경보율  위조 High 비율")
for th in [0.6, 0.7, 0.8, 0.9]:
    print(f"                           {th:.1f}    {(p_new[b] >= th).mean():6.1%}   {(p_new[y] >= th).mean():6.1%}")

# 일관성: 같은 원신호를 다른 잡음 실현 5회로 통과시켰을 때 등급이 같은 비율
rng = np.random.default_rng(90)
same = []
for i in range(60):
    lab = LABELS[i % 4]; spk = "SPK_A" if lab != "bonafide" else str(rng.choice(list(SPEAKERS)))
    x = make_clip(lab, spk, rng); s = rng.uniform(10, 25)
    Xr = np.stack([radio_channel(x, np.random.default_rng(1000 + i * 10 + r), snr_db=s) for r in range(5)])
    _, g, _ = confidence(Xr, pd.DataFrame({"claimed": [spk] * 5}))
    same.append(pd.Series(g).value_counts().iloc[0] / 5)
cons = float(np.mean(same))
print(f"\n신뢰도 등급 일관성(동일 음성 x 잡음 실현 5회, 60건): {cons:.1%} (목표 90% 이상: {'달성' if cons >= 0.9 else '미달'})")
print("등급 운용: High=통신보안관 즉시 경보 / Med=HITL 2차 확인 / Low=정상 처리(로그 보존). 등급은 판단 보조이며 진위 확정은 사람이 한다.")

fo, to, _ = roc_curve(y, p_old); fn, tn, _ = roc_curve(y, p_new)
plt.figure(figsize=(5, 4)); plt.plot(fo, to, label="baseline"); plt.plot(fn, tn, label="calibrated + channel quality")
plt.plot([0, 1], [0, 1], "k:"); plt.xlabel("false alarm rate"); plt.ylabel("recall"); plt.legend(); plt.title("ROC (synthetic mixed channels)")
plt.tight_layout(); plt.show()

실행 결과 예시

평가 클립 500개(진짜 254 / 위조 246), 품질 불량 채널 56%
오경보율(진짜를 즉시 경보): 기존 27.6% -> 개선 0.0%, 감축 100% (목표 40% 이상: 달성)
위조 탐지(High+Med 등급): 기존 98.4% -> 개선 95.9%
ROC-AUC: 기존 0.822, 개선 0.995

[등급 분포] 등급   진짜   위조
            High      0   109
            Med       7   127
            Low     247    10

[임계값 민감도(High 기준)] 임계값  오경보율  위조 High 비율
                           0.6      2.4%    95.1%
                           0.7      2.0%    93.9%
                           0.8      2.0%    93.5%
                           0.9      1.6%    91.5%

신뢰도 등급 일관성(동일 음성 x 잡음 실현 5회, 60건): 96.7% (목표 90% 이상: 달성)
등급 운용: High=통신보안관 즉시 경보 / Med=HITL 2차 확인 / Low=정상 처리(로그 보존). 등급은 판단 보조이며 진위 확정은 사람이 한다.

미션 6. 실시간 무전 음성 위조 경보 및 스펙트로그램 시각화 C2 보안 인터페이스 구현

  • 난이도: 중급 / 예상 소요시간: 2.5시간
  • 코드 설명: 모의 무전 송신 스트림을 1초 주기로 받아 위조 점수·등급을 갱신하고, 스펙트로그램·점수 추이·등급 집계·경보 목록 4개 패널로 보여 주는 야간 모드 C2 음성 보안 상황판을 구현해 화면 갱신 시간과 클립 선택 응답 시간을 측정한다.
"""모의 무전 송신 스트림을 1초 주기로 받아 위조 점수·등급을 갱신하고, 스펙트로그램·점수 추이·등급 집계·경보 목록 4개 패널로 보여 주는 야간 모드 C2 음성 보안 상황판을 구현해 화면 갱신 시간과 클립 선택 응답 시간을 측정한다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import signal
from scipy.fft import dct
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline

# ---------------------------------------------------------------------------
# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)
# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과
# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.
#   bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음
#   tts_art  : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호
#   vc_art   : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호
#   replay   : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호
# ---------------------------------------------------------------------------
FS, DUR = 8000, 0.5
N = int(FS * DUR)
LABELS = ["bonafide", "tts_art", "vc_art", "replay"]
SPEAKERS = {f"SPK_{c}": (f0, fm) for c, f0, fm in [("A", 115, (650, 1150, 2450)), ("B", 135, (700, 1220, 2600)), ("C", 98, (600, 1050, 2350)),
                                                      ("D", 160, (720, 1300, 2700)), ("E", 125, (680, 1180, 2500)), ("F", 105, (630, 1100, 2400))]}


def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):
    t = np.arange(N) / FS
    contour = f0 * (1 + 0.06 * np.sin(2 * np.pi * rng.uniform(1.5, 3.5) * t + rng.uniform(0, 6)))
    f = contour * (1 + jitter * rng.normal(0, 1, N // 80 + 1).repeat(80)[:N])            # 10ms 단위 피치 흔들림(jitter)
    phase = 2 * np.pi * np.cumsum(f) / FS
    K = int(3800 / f0)
    fk = np.arange(1, K + 1) * f0
    amp = sum(np.exp(-((fk - F) / 120.0) ** 2) / (i + 1) for i, F in enumerate(fm)) + 0.02 / np.arange(1, K + 1)
    if hf_cut:
        amp = np.where(fk > hf_cut, amp * 0.2, amp)
    ph = np.arange(1, K + 1)[:, None] * phase[None, :]
    if phase_break:                                                   # 20ms 프레임마다 배음별 위상 불연속
        ph = ph + rng.uniform(0, 2 * np.pi, (K, N // 160 + 1)).repeat(160, axis=1)[:, :N]
    sig = (amp[:, None] * np.sin(ph)).sum(0)
    env = 0.55 + 0.45 * np.sin(2 * np.pi * rng.uniform(3, 5) * t + rng.uniform(0, 6)) ** 2      # 음절형 진폭 변화
    env *= 1 + shimmer * np.interp(t, np.linspace(0, DUR, 60), rng.normal(0, 1, 60))           # 완만한 진폭 흔들림(shimmer)
    if frame_mod:
        env *= 1 + frame_mod * np.sign(np.sin(2 * np.pi * 50 * t))     # 20ms 프레임 경계 흔적(50Hz)
    return sig * env + (0.03 if jitter > 0.003 else 0.005) * rng.normal(0, 1, N) * env


def make_clip(label, speaker, rng):
    f0, fm = SPEAKERS[speaker]
    if label in ("tts_art", "vc_art"):                               # 위조는 화자 특성을 근사만 함
        f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)
    if label == "bonafide" or label == "replay":
        x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))
    elif label == "tts_art":
        x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))
    else:
        x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)
    if label == "replay":                                              # 스피커 대역 + 실내 잔향
        x = signal.sosfilt(signal.butter(4, [450, 2900], "bandpass", fs=FS, output="sos"), x)
        ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0
        x = signal.fftconvolve(x, ir)[:N]
    return x / (np.abs(x).max() + 1e-9)


RADIO_BP = signal.butter(4, [300, 3400], "bandpass", fs=FS, output="sos")


def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):
    """[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍."""
    y = signal.sosfilt(RADIO_BP, x)
    p = np.mean(y ** 2)
    noise = rng.normal(0, 1, N) * np.sqrt(p / 10 ** (snr_db / 10))
    noise += np.cumsum(rng.normal(0, 0.02, N)) * np.sqrt(p) * 0.05                   # 저주파 차량·엔진형 소음
    y = y + noise
    if impulse:
        k = rng.random(N) < impulse
        y[k] += rng.normal(0, 6 * np.sqrt(p), k.sum())
    if jam_tone:
        y += jam_tone * np.sqrt(p) * np.sin(2 * np.pi * rng.uniform(800, 2000) * np.arange(N) / FS)
    if dropout:
        for _ in range(int(dropout * 10)):
            a = int(rng.integers(0, N - 400)); y[a:a + int(rng.integers(100, 400))] = 0.0
    return np.clip(y / (np.abs(y).max() + 1e-9), -clip, clip)


def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim="SPK_A", **chan):
    """클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame)."""
    rng = np.random.default_rng(seed)
    X, meta = np.zeros((n, N)), []
    if p is None and len(labels) > 1 and labels[0] == "bonafide":        # 기본: 진짜 50%, 나머지 위조 유형 균등
        p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)
    for i in range(n):
        lab = str(rng.choice(labels, p=p))
        spk = str(rng.choice(list(SPEAKERS))) if lab == "bonafide" else claim
        if lab == "replay":
            spk = claim
        s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)
        X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)
        meta.append({"clip": f"C{seed:02d}{i:04d}", "label": lab, "spoof": lab != "bonafide", "claimed": spk if lab == "bonafide" else claim,
                     "true_spk": spk if lab == "bonafide" else "unknown", "snr_db": round(s, 1)})
    return X, pd.DataFrame(meta)
# ---------------------------------------------------------------------------

# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----
NFFT, HOP = 256, 80


def mel_bank(n_mels=26, fmin=300, fmax=3400):
    mel = lambda f: 2595 * np.log10(1 + f / 700)
    imel = lambda m: 700 * (10 ** (m / 2595) - 1)
    pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))
    freqs = np.fft.rfftfreq(NFFT, 1 / FS)
    fb = np.zeros((n_mels, len(freqs)))
    for m in range(n_mels):
        l, c, r = pts[m], pts[m + 1], pts[m + 2]
        fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)
    return fb


MEL = mel_bank()
FREQS = np.fft.rfftfreq(NFFT, 1 / FS)


def spectrogram(X, denoise=0.0):
    """배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감."""
    X = np.atleast_2d(X)
    Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]]                                       # 프리엠퍼시스
    frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)
    P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2
    P = np.transpose(P, (0, 2, 1))
    if denoise:
        floor = np.percentile(P, 10, axis=2, keepdims=True)
        P = np.maximum(P - denoise * floor, 0.05 * P)
    return P


def mfcc(P, n=13):
    logmel = np.log(np.einsum("mf,cft->cmt", MEL, P) + 1e-8)
    return dct(logmel, type=2, axis=1, norm="ortho")[:, :n, :]


def band(P, lo, hi):
    return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))


def jitter_hnr(X):
    """40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다."""
    W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)
    fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]
    fr = fr - fr.mean(-1, keepdims=True)
    spec = np.fft.rfft(fr, 2 * W, axis=-1)
    ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]
    ac = ac / (ac[..., :1] + 1e-12)
    seg = ac[..., lag0:lag1]
    k = seg.argmax(-1)
    peak = seg.max(-1)
    km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]
    lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12)                      # 포물선 보간으로 주기 정밀화
    f0 = FS / lag
    jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)
    return jit, np.median(peak, axis=1)


def frame_mod(X):
    """진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서)."""
    env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)
    sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2
    f = np.fft.rfftfreq(env.shape[1], 20 / FS)
    return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)


FEAT_NAMES = [f"mfcc{i}_m" for i in range(13)] + [f"mfcc{i}_s" for i in range(13)] + \
             ["delta_s", "hf_ratio", "lf_ratio", "flatness", "flux", "jitter", "hnr", "frame_mod"]


def extract(X, denoise=0.0):
    """클립 배치 -> 특징 행렬 (클립 x 34) DataFrame."""
    P = spectrogram(X, denoise)
    C = mfcc(P)
    tot = band(P, 300, 3400) + 1e-12
    flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)
    logP = np.log(P + 1e-8)
    jit, hnr = jitter_hnr(X)
    F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,
              flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]
    return pd.DataFrame(F, columns=FEAT_NAMES)


def enroll(seed=99):
    """지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균)."""
    rng = np.random.default_rng(seed)
    tpl = {}
    for spk in SPEAKERS:
        Xe = np.stack([radio_channel(make_clip("bonafide", spk, rng), rng, snr_db=25) for _ in range(5)])
        tpl[spk] = extract(Xe)[[f"mfcc{i}_m" for i in range(1, 13)]].mean().values
    return tpl


def speaker_sim(F, claimed, tpl):
    """주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수)."""
    v = F[[f"mfcc{i}_m" for i in range(1, 13)]].values
    t = np.stack([tpl[c] for c in claimed])
    return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)

tpl = enroll()


def featurize(X, M):
    F = extract(X)
    F["spk_sim"] = speaker_sim(F, M["claimed"], tpl)
    return F


Xtr, Mtr = make_dataset(400, seed=51, snr=(5, 25))
det = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000, C=0.5)).fit(featurize(Xtr, Mtr), Mtr["spoof"])

# [교육용가상자료] 모의 송신 스트림 24건(가상 망 NET-A~C, 가상 호출부호)
Xs, Ms = make_dataset(24, seed=52, snr=(6, 25), p=[0.6, 0.15, 0.15, 0.1])
Ms["net"] = [f"NET-{'ABC'[i % 3]}" for i in range(24)]
Ms["t"] = [f"21:00:{i:02d}" for i in range(24)]
state = pd.DataFrame(columns=["t", "net", "clip", "claimed", "score", "grade"])

plt.style.use("dark_background")                                          # 야간 작전 모드
fig, ax = plt.subplots(2, 2, figsize=(10, 6))
upd = []
for i in range(len(Xs)):
    t0 = time.perf_counter()
    s = float(det.predict_proba(featurize(Xs[i:i + 1], Ms.iloc[i:i + 1]))[0, 1])
    g = "High" if s >= 0.8 else "Med" if s >= 0.4 else "Low"
    state.loc[i] = [Ms["t"][i], Ms["net"][i], Ms["clip"][i], Ms["claimed"][i], round(s, 2), g]
    for a in ax.flat:
        a.clear()
    P = spectrogram(Xs[i:i + 1])[0]
    ax[0, 0].imshow(10 * np.log10(P + 1e-8), origin="lower", aspect="auto", cmap="magma", extent=[0, DUR, 0, FS / 2])
    ax[0, 0].set_title(f"Spectrogram {Ms['clip'][i]} ({Ms['net'][i]})"); ax[0, 0].set_xlabel("s"); ax[0, 0].set_ylabel("Hz")
    col = state["grade"].map({"High": "red", "Med": "orange", "Low": "lime"})
    ax[0, 1].scatter(range(len(state)), state["score"], c=col); ax[0, 1].axhline(0.8, c="red", ls="--"); ax[0, 1].axhline(0.4, c="orange", ls=":")
    ax[0, 1].set_ylim(0, 1); ax[0, 1].set_title("Spoof score timeline"); ax[0, 1].set_xlabel("transmission #")
    cnt = state["grade"].value_counts().reindex(["High", "Med", "Low"]).fillna(0)
    ax[1, 0].bar(cnt.index, cnt.values, color=["red", "orange", "lime"]); ax[1, 0].set_title("Grade count")
    al = state[state["grade"] != "Low"].tail(6)
    ax[1, 1].axis("off"); ax[1, 1].set_title("Alert list (operator review)")
    for k, r in enumerate(al.itertuples()):
        ax[1, 1].text(0.0, 0.9 - k * 0.15, f"{r.t} {r.net} {r.clip} claim {r.claimed} score {r.score:.2f} {r.grade}",
                      color="red" if r.grade == "High" else "orange", fontsize=8, family="monospace")
    if g == "High":                                                       # 긴급 경보 하이라이트
        for a in ax.flat:
            for sp in a.spines.values():
                sp.set_color("red"); sp.set_linewidth(2)
    fig.canvas.draw()
    upd.append((time.perf_counter() - t0) * 1000)

# 클립 선택 응답성: 경보 목록에서 한 건을 고르면 상세 정보(단서 상위 3개)를 찾아 보여 주는 콜백
coef = pd.Series(det[-1].coef_[0], index=featurize(Xs[:1], Ms.iloc[:1]).columns)
Fall = featurize(Xs, Ms)
Z = pd.DataFrame(det[0].transform(Fall), columns=Fall.columns)


def on_select(idx):
    contrib = (Z.loc[idx] * coef).sort_values(ascending=False).head(3)
    return {"clip": Ms["clip"][idx], "grade": state["grade"][idx], "top_cues": [f"{k}({v:+.2f})" for k, v in contrib.items()]}


resp = []
for idx in state.index[state["grade"] != "Low"]:
    t0 = time.perf_counter(); info = on_select(idx); resp.append((time.perf_counter() - t0) * 1000)

print("[실시간 C2 음성 보안 상황판 - 교육용가상자료]")
print(state.tail(8).to_string(index=False))
print(f"\n패널 4종(스펙트로그램, 점수 추이, 등급 집계, 경보 목록) 렌더링: {len(upd)}회 갱신")
print(f"화면 갱신 시간 최대 {max(upd):.0f} ms, 평균 {np.mean(upd):.0f} ms (목표 1초 이내: {'달성' if max(upd) < 1000 else '미달'})")
print(f"클립 선택 응답 최대 {max(resp):.1f} ms (목표 100ms 이내: {'달성' if max(resp) < 100 else '미달'})")
print(f"경보 {len(resp)}건 중 High {(state['grade'] == 'High').sum()}건(빨강 테두리 하이라이트), Med {(state['grade'] == 'Med').sum()}건")
print("선택 예시:", info)
print("실제 위조 여부(정답) 대비:", f"위조 {Ms['spoof'].sum()}건 중 경보 {(state['grade'] != 'Low')[Ms['spoof']].sum()}건, 진짜 {(~Ms['spoof']).sum()}건 중 경보 {(state['grade'] != 'Low')[~Ms['spoof']].sum()}건")
print("상황판 경보는 판단 보조이며, 위조 확정과 전파는 통신보안관 확인(미션 7 절차) 후에 한다.")
plt.tight_layout(); plt.show()

실행 결과 예시

[실시간 C2 음성 보안 상황판 - 교육용가상자료]
       t   net    clip claimed  score grade
21:00:16 NET-B C520016   SPK_F   0.01   Low
21:00:17 NET-C C520017   SPK_A   1.00  High
21:00:18 NET-A C520018   SPK_D   0.00   Low
21:00:19 NET-B C520019   SPK_F   0.10   Low
21:00:20 NET-C C520020   SPK_A   1.00  High
21:00:21 NET-A C520021   SPK_A   0.91  High
21:00:22 NET-B C520022   SPK_A   1.00  High
21:00:23 NET-C C520023   SPK_D   0.00   Low

패널 4종(스펙트로그램, 점수 추이, 등급 집계, 경보 목록) 렌더링: 24회 갱신
화면 갱신 시간 최대 334 ms, 평균 211 ms (목표 1초 이내: 달성)
클립 선택 응답 최대 0.8 ms (목표 100ms 이내: 달성)
경보 12건 중 High 12건(빨강 테두리 하이라이트), Med 0건
선택 예시: {'clip': 'C520022', 'grade': 'High', 'top_cues': ['mfcc0_m(+2.59)', 'hnr(+1.73)', 'delta_s(+1.60)']}
실제 위조 여부(정답) 대비: 위조 12건 중 경보 12건, 진짜 12건 중 경보 0건
상황판 경보는 판단 보조이며, 위조 확정과 전파는 통신보안관 확인(미션 7 절차) 후에 한다.

미션 7. 지휘관 긴급 음성 명령 수신 시 통신관 HITL 2차 육안/청음 검증 절차 설계

  • 난이도: 고급 / 예상 소요시간: 3.0시간
  • 코드 설명: 지휘관 명의 긴급 음성 명령이 수신되면 탐지 점수와 무관하게 통신보안관 2차 검증 대기열에 올리고, 가상 인증 질의응답(챌린지-응답)·청음 확인·근거 기록을 거쳐야만 전파되도록 하는 HITL 검증 절차와 해시 체인 감사 로그를 구현한다.
"""지휘관 명의 긴급 음성 명령이 수신되면 탐지 점수와 무관하게 통신보안관 2차 검증 대기열에 올리고, 가상 인증 질의응답(챌린지-응답)·청음 확인·근거 기록을 거쳐야만 전파되도록 하는 HITL 검증 절차와 해시 체인 감사 로그를 구현한다."""
import hashlib
import hmac
import json
import pandas as pd

ACTIONS = {"CONFIRM": "진위 확인(명령 전파 허용)", "HOLD": "보류(재송신·추가 확인 요청)", "REJECT": "위조 의심 차단(상급 보고)"}
PRIORITY = {"High": 0, "Med": 1, "Low": 2}
HOLD_LIMIT_S = 120
AUTH_KEY = b"EDU-VIRTUAL-KEY"            # [교육용가상자료] 실제 인증 체계·암호 아님


class WorkflowError(ValueError):
    pass


def expected_response(challenge, net):
    """가상 챌린지-응답: 질의어와 망 ID로 4자리 응답을 만든다(실제 암호 체계를 흉내 낸 학습용 모형)."""
    return hmac.new(AUTH_KEY, f"{net}:{challenge}".encode(), hashlib.sha256).hexdigest()[:4].upper()


class VerifyQueue:
    def __init__(self, clock):
        self.items, self.log, self.clock, self.feedback = {}, [], clock, []

    def _record(self, body):
        prev = self.log[-1]["hash"] if self.log else "GENESIS"
        body = {**body, "ts": round(self.clock(), 1)}
        h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()
        self.log.append({**body, "prev": prev, "hash": h})

    def verify_log(self):
        prev = "GENESIS"
        for n, rec in enumerate(self.log, start=1):
            body = {k: v for k, v in rec.items() if k not in ("prev", "hash")}
            if rec["prev"] != prev or rec["hash"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():
                return False, n
            prev = rec["hash"]
        return True, -1

    def submit(self, mid, net, claimed, score, grade, order):
        """긴급 명령 등록. 점수가 낮아도(진짜로 보여도) 자동 전파하지 않고 PENDING으로 시작한다."""
        self.items[mid] = {"id": mid, "net": net, "claimed": claimed, "score": score, "grade": grade, "order": order,
                           "status": "PENDING", "submitted": self.clock(), "hold_since": None, "escalated": False, "auth": None}
        self._record({"event": "SUBMIT", "id": mid, "score": score, "grade": grade, "by": "DETECTOR"})

    def pending(self):
        todo = [i for i in self.items.values() if i["status"] in ("PENDING", "HOLD")]
        return sorted(todo, key=lambda i: (not i["escalated"], PRIORITY[i["grade"]], -i["score"], i["submitted"]))

    def authenticate(self, mid, challenge, response, operator):
        """송신자에게 역호출로 가상 질의어를 보내고 받은 응답을 대조한다. 결과는 기록만 하고 결정은 사람이 한다."""
        ok = hmac.compare_digest(expected_response(challenge, self.items[mid]["net"]), response)
        self.items[mid]["auth"] = ok
        self._record({"event": "AUTH", "id": mid, "challenge": challenge, "result": ok, "operator": operator})
        return ok

    def decide(self, mid, action, operator, reason, listened=False, second=None):
        if action not in ACTIONS:
            raise WorkflowError(f"허용되지 않은 조치: {action}")
        if not operator:
            raise WorkflowError("통신보안관 ID 필요")
        it = self.items.get(mid)
        if it is None or it["status"] in ("CONFIRMED", "REJECTED"):
            raise WorkflowError("대기열에 없거나 이미 종결된 명령")
        if len(reason.strip()) < 5:
            raise WorkflowError("판단 근거 5자 이상 필요")
        if action == "CONFIRM":
            if not listened:
                raise WorkflowError("전파 전 청음(직접 듣기) 확인 필요")
            if it["auth"] is not True:
                raise WorkflowError("인증 질의응답 통과 전 전파 불가")
            if it["grade"] == "High" and (not second or second == operator):
                raise WorkflowError("High 등급 확인은 다른 검토자 1명의 교차 확인 필요")
        it["status"] = {"CONFIRM": "CONFIRMED", "HOLD": "HOLD", "REJECT": "REJECTED"}[action]
        it["hold_since"] = self.clock() if action == "HOLD" else None
        it["decided"] = self.clock()
        self._record({"event": "DECIDE", "id": mid, "action": action, "operator": operator, "second": second, "listened": listened, "reason": reason})

    def check_holds(self):
        """보류 기한 초과 시 자동 확인하지 않고 상급 지휘관(당직 통제관)에게 상신한다."""
        out = []
        for i in self.items.values():
            if i["status"] == "HOLD" and not i["escalated"] and self.clock() - i["hold_since"] > HOLD_LIMIT_S:
                i["escalated"] = True; out.append(i["id"])
                self._record({"event": "ESCALATE", "id": i["id"], "by": "SYSTEM"})
        return out

    def commander_feedback(self, mid, text):
        self.feedback.append({"id": mid, "text": text})
        self._record({"event": "FEEDBACK", "id": mid, "text": text})


class FakeClock:
    t = 0.0

    def __call__(self):
        return self.t


clock = FakeClock()
q = VerifyQueue(clock)
# [교육용가상자료] 미션 5 신뢰도 엔진에서 넘어온 가상 긴급 명령 6건(가상 망·가상 호출부호)
for mid, net, spk, s, g, od in [("U01", "NET-A", "SPK_A", 0.97, "High", "가상 집결지 변경"), ("U02", "NET-B", "SPK_A", 0.62, "Med", "통신 축선 변경"),
                                ("U03", "NET-A", "SPK_A", 0.08, "Low", "보고 주기 단축"), ("U04", "NET-C", "SPK_A", 0.88, "High", "가상 진지 이동"),
                                ("U05", "NET-B", "SPK_A", 0.45, "Med", "주파수 전환"), ("U06", "NET-C", "SPK_A", 0.12, "Low", "정시 보고 요청")]:
    clock.t += 5; q.submit(mid, net, spk, s, g, od)
print("검토 순서(등급·점수 순):", [i["id"] for i in q.pending()])
print("자동 전파(확인)된 명령 수:", sum(i["status"] == "CONFIRMED" for i in q.items.values()))

# 규칙 위반 시도: 모두 차단되어야 한다
viol = [("U03", "CONFIRM", "sec01", "진짜 점수 낮음", dict(listened=True)),          # 인증 없이 전파
        ("U01", "CONFIRM", "", "근거 충분", {}), ("U02", "FORWARD", "sec01", "임의 조치", {}),
        ("U06", "REJECT", "sec01", "의심", {})]
blocked = 0
for mid, a, op, r, kw in viol:
    try:
        q.decide(mid, a, op, r, **kw)
    except WorkflowError as e:
        blocked += 1; print(f"차단({mid} {a}): {e}")

# 정상 처리: 인증 질의응답 -> 청음 -> 결정
clock.t += 10; q.authenticate("U03", "ECHO", expected_response("ECHO", "NET-A"), "sec01")
q.decide("U03", "CONFIRM", "sec01", "역호출 인증 통과, 청음상 평소 송신자와 일치", listened=True)
clock.t += 8; ok = q.authenticate("U01", "TANGO", "0000", "sec01")
q.decide("U01", "REJECT", "sec01", "인증 응답 불일치, 프레임 경계 단서 다수", listened=True)
clock.t += 6; q.authenticate("U04", "LIMA", expected_response("LIMA", "NET-C"), "sec02")
try:
    q.decide("U04", "CONFIRM", "sec02", "인증 통과, 청음 정상", listened=True)
except WorkflowError as e:
    blocked += 1; print(f"차단(U04 CONFIRM 단독): {e}")
q.decide("U04", "CONFIRM", "sec02", "인증 통과, 청음 정상, 교차 확인 완료", listened=True, second="sec01")
clock.t += 4; q.decide("U02", "HOLD", "sec02", "재송신 요청 후 재청음")
clock.t += 3; q.authenticate("U06", "MIKE", expected_response("MIKE", "NET-C"), "sec02")
q.decide("U06", "CONFIRM", "sec02", "역호출 인증 통과, 일상 보고 요청", listened=True)
clock.t += 150
print("보류 기한 초과 상신:", q.check_holds(), "/ 상신 후 검토 순서:", [i["id"] for i in q.pending()])
q.commander_feedback("U01", "위조 차단 적절. 인증 질의어 교체 주기 단축 검토")

df = pd.DataFrame(q.items.values())
df["대기(s)"] = (df["decided"] - df["submitted"]).round(1)
print("\n" + df[["id", "grade", "score", "auth", "status", "escalated", "대기(s)"]].fillna("-").to_string(index=False))
n_dec = sum(r["event"] == "DECIDE" for r in q.log)
n_exp = sum(i["status"] in ("CONFIRMED", "REJECTED", "HOLD") for i in q.items.values())
n_auto = sum(r["event"] == "DECIDE" and not r["operator"] for r in q.log)
print(f"\n자동 전파 {n_auto}건 / 규칙 위반 차단 {blocked}건 / 결정 로그 {n_dec}건(결정 {n_exp}건, 로깅 성공률 {n_dec / n_exp:.0%})")
print(f"평균 검토 대기 {df['대기(s)'].mean():.1f}s, 감사 로그 {len(q.log)}건, 해시 체인 검증: {q.verify_log()}")
q.log[7]["operator"] = "sec99"
print("8번째 기록 변조 후 검증(False, 깨진 위치):", q.verify_log())
print("\n책임 분계: 탐지기=점수 제시, 통신보안관=인증·청음·결정, 지휘관=최종 명령 책임. 자동 판정으로 명령을 전파하거나 차단하지 않는다.")

실행 결과 예시

검토 순서(등급·점수 순): ['U01', 'U04', 'U02', 'U05', 'U06', 'U03']
자동 전파(확인)된 명령 수: 0
차단(U03 CONFIRM): 인증 질의응답 통과 전 전파 불가
차단(U01 CONFIRM): 통신보안관 ID 필요
차단(U02 FORWARD): 허용되지 않은 조치: FORWARD
차단(U06 REJECT): 판단 근거 5자 이상 필요
차단(U04 CONFIRM 단독): High 등급 확인은 다른 검토자 1명의 교차 확인 필요
보류 기한 초과 상신: ['U02'] / 상신 후 검토 순서: ['U02', 'U05']

 id grade  score  auth    status  escalated 대기(s)
U01  High   0.97 False  REJECTED      False  43.0
U02   Med   0.62     -      HOLD       True  48.0
U03   Low   0.08  True CONFIRMED      False  25.0
U04  High   0.88  True CONFIRMED      False  34.0
U05   Med   0.45     -   PENDING      False     -
U06   Low   0.12  True CONFIRMED      False  31.0

자동 전파 0건 / 규칙 위반 차단 5건 / 결정 로그 5건(결정 5건, 로깅 성공률 100%)
평균 검토 대기 36.2s, 감사 로그 17건, 해시 체인 검증: (True, -1)
8번째 기록 변조 후 검증(False, 깨진 위치): (False, 8)

책임 분계: 탐지기=점수 제시, 통신보안관=인증·청음·결정, 지휘관=최종 명령 책임. 자동 판정으로 명령을 전파하거나 차단하지 않는다.

미션 8. 전자전 재밍 및 잡음 혼입 환경 모의 딥페이크 탐지 한계치 스트레스 테스트

  • 난이도: 고급 / 예상 소요시간: 3.5시간
  • 코드 설명: 전자전 재밍·잡음 세기를 단계적으로 높여 위조 탐지 성능의 한계점을 찾고, 무전 트래픽을 평시의 5배로 늘리며 손상 입력을 섞은 부하 시험에서 시스템 다운·데이터 유실 없이 기능 축소 모드와 사람 확인으로 안전하게 넘기는지 확인한다.
"""전자전 재밍·잡음 세기를 단계적으로 높여 위조 탐지 성능의 한계점을 찾고, 무전 트래픽을 평시의 5배로 늘리며 손상 입력을 섞은 부하 시험에서 시스템 다운·데이터 유실 없이 기능 축소 모드와 사람 확인으로 안전하게 넘기는지 확인한다."""
import time
import tracemalloc
from collections import deque
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import signal
from scipy.fft import dct
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline

# ---------------------------------------------------------------------------
# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)
# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과
# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.
#   bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음
#   tts_art  : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호
#   vc_art   : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호
#   replay   : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호
# ---------------------------------------------------------------------------
FS, DUR = 8000, 0.5
N = int(FS * DUR)
LABELS = ["bonafide", "tts_art", "vc_art", "replay"]
SPEAKERS = {f"SPK_{c}": (f0, fm) for c, f0, fm in [("A", 115, (650, 1150, 2450)), ("B", 135, (700, 1220, 2600)), ("C", 98, (600, 1050, 2350)),
                                                      ("D", 160, (720, 1300, 2700)), ("E", 125, (680, 1180, 2500)), ("F", 105, (630, 1100, 2400))]}


def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):
    t = np.arange(N) / FS
    contour = f0 * (1 + 0.06 * np.sin(2 * np.pi * rng.uniform(1.5, 3.5) * t + rng.uniform(0, 6)))
    f = contour * (1 + jitter * rng.normal(0, 1, N // 80 + 1).repeat(80)[:N])            # 10ms 단위 피치 흔들림(jitter)
    phase = 2 * np.pi * np.cumsum(f) / FS
    K = int(3800 / f0)
    fk = np.arange(1, K + 1) * f0
    amp = sum(np.exp(-((fk - F) / 120.0) ** 2) / (i + 1) for i, F in enumerate(fm)) + 0.02 / np.arange(1, K + 1)
    if hf_cut:
        amp = np.where(fk > hf_cut, amp * 0.2, amp)
    ph = np.arange(1, K + 1)[:, None] * phase[None, :]
    if phase_break:                                                   # 20ms 프레임마다 배음별 위상 불연속
        ph = ph + rng.uniform(0, 2 * np.pi, (K, N // 160 + 1)).repeat(160, axis=1)[:, :N]
    sig = (amp[:, None] * np.sin(ph)).sum(0)
    env = 0.55 + 0.45 * np.sin(2 * np.pi * rng.uniform(3, 5) * t + rng.uniform(0, 6)) ** 2      # 음절형 진폭 변화
    env *= 1 + shimmer * np.interp(t, np.linspace(0, DUR, 60), rng.normal(0, 1, 60))           # 완만한 진폭 흔들림(shimmer)
    if frame_mod:
        env *= 1 + frame_mod * np.sign(np.sin(2 * np.pi * 50 * t))     # 20ms 프레임 경계 흔적(50Hz)
    return sig * env + (0.03 if jitter > 0.003 else 0.005) * rng.normal(0, 1, N) * env


def make_clip(label, speaker, rng):
    f0, fm = SPEAKERS[speaker]
    if label in ("tts_art", "vc_art"):                               # 위조는 화자 특성을 근사만 함
        f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)
    if label == "bonafide" or label == "replay":
        x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))
    elif label == "tts_art":
        x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))
    else:
        x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)
    if label == "replay":                                              # 스피커 대역 + 실내 잔향
        x = signal.sosfilt(signal.butter(4, [450, 2900], "bandpass", fs=FS, output="sos"), x)
        ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0
        x = signal.fftconvolve(x, ir)[:N]
    return x / (np.abs(x).max() + 1e-9)


RADIO_BP = signal.butter(4, [300, 3400], "bandpass", fs=FS, output="sos")


def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):
    """[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍."""
    y = signal.sosfilt(RADIO_BP, x)
    p = np.mean(y ** 2)
    noise = rng.normal(0, 1, N) * np.sqrt(p / 10 ** (snr_db / 10))
    noise += np.cumsum(rng.normal(0, 0.02, N)) * np.sqrt(p) * 0.05                   # 저주파 차량·엔진형 소음
    y = y + noise
    if impulse:
        k = rng.random(N) < impulse
        y[k] += rng.normal(0, 6 * np.sqrt(p), k.sum())
    if jam_tone:
        y += jam_tone * np.sqrt(p) * np.sin(2 * np.pi * rng.uniform(800, 2000) * np.arange(N) / FS)
    if dropout:
        for _ in range(int(dropout * 10)):
            a = int(rng.integers(0, N - 400)); y[a:a + int(rng.integers(100, 400))] = 0.0
    return np.clip(y / (np.abs(y).max() + 1e-9), -clip, clip)


def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim="SPK_A", **chan):
    """클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame)."""
    rng = np.random.default_rng(seed)
    X, meta = np.zeros((n, N)), []
    if p is None and len(labels) > 1 and labels[0] == "bonafide":        # 기본: 진짜 50%, 나머지 위조 유형 균등
        p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)
    for i in range(n):
        lab = str(rng.choice(labels, p=p))
        spk = str(rng.choice(list(SPEAKERS))) if lab == "bonafide" else claim
        if lab == "replay":
            spk = claim
        s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)
        X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)
        meta.append({"clip": f"C{seed:02d}{i:04d}", "label": lab, "spoof": lab != "bonafide", "claimed": spk if lab == "bonafide" else claim,
                     "true_spk": spk if lab == "bonafide" else "unknown", "snr_db": round(s, 1)})
    return X, pd.DataFrame(meta)
# ---------------------------------------------------------------------------

# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----
NFFT, HOP = 256, 80


def mel_bank(n_mels=26, fmin=300, fmax=3400):
    mel = lambda f: 2595 * np.log10(1 + f / 700)
    imel = lambda m: 700 * (10 ** (m / 2595) - 1)
    pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))
    freqs = np.fft.rfftfreq(NFFT, 1 / FS)
    fb = np.zeros((n_mels, len(freqs)))
    for m in range(n_mels):
        l, c, r = pts[m], pts[m + 1], pts[m + 2]
        fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)
    return fb


MEL = mel_bank()
FREQS = np.fft.rfftfreq(NFFT, 1 / FS)


def spectrogram(X, denoise=0.0):
    """배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감."""
    X = np.atleast_2d(X)
    Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]]                                       # 프리엠퍼시스
    frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)
    P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2
    P = np.transpose(P, (0, 2, 1))
    if denoise:
        floor = np.percentile(P, 10, axis=2, keepdims=True)
        P = np.maximum(P - denoise * floor, 0.05 * P)
    return P


def mfcc(P, n=13):
    logmel = np.log(np.einsum("mf,cft->cmt", MEL, P) + 1e-8)
    return dct(logmel, type=2, axis=1, norm="ortho")[:, :n, :]


def band(P, lo, hi):
    return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))


def jitter_hnr(X):
    """40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다."""
    W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)
    fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]
    fr = fr - fr.mean(-1, keepdims=True)
    spec = np.fft.rfft(fr, 2 * W, axis=-1)
    ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]
    ac = ac / (ac[..., :1] + 1e-12)
    seg = ac[..., lag0:lag1]
    k = seg.argmax(-1)
    peak = seg.max(-1)
    km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]
    lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12)                      # 포물선 보간으로 주기 정밀화
    f0 = FS / lag
    jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)
    return jit, np.median(peak, axis=1)


def frame_mod(X):
    """진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서)."""
    env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)
    sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2
    f = np.fft.rfftfreq(env.shape[1], 20 / FS)
    return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)


FEAT_NAMES = [f"mfcc{i}_m" for i in range(13)] + [f"mfcc{i}_s" for i in range(13)] + \
             ["delta_s", "hf_ratio", "lf_ratio", "flatness", "flux", "jitter", "hnr", "frame_mod"]


def extract(X, denoise=0.0):
    """클립 배치 -> 특징 행렬 (클립 x 34) DataFrame."""
    P = spectrogram(X, denoise)
    C = mfcc(P)
    tot = band(P, 300, 3400) + 1e-12
    flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)
    logP = np.log(P + 1e-8)
    jit, hnr = jitter_hnr(X)
    F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,
              flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]
    return pd.DataFrame(F, columns=FEAT_NAMES)


def enroll(seed=99):
    """지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균)."""
    rng = np.random.default_rng(seed)
    tpl = {}
    for spk in SPEAKERS:
        Xe = np.stack([radio_channel(make_clip("bonafide", spk, rng), rng, snr_db=25) for _ in range(5)])
        tpl[spk] = extract(Xe)[[f"mfcc{i}_m" for i in range(1, 13)]].mean().values
    return tpl


def speaker_sim(F, claimed, tpl):
    """주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수)."""
    v = F[[f"mfcc{i}_m" for i in range(1, 13)]].values
    t = np.stack([tpl[c] for c in claimed])
    return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)

tpl = enroll()


def featurize(X, M):
    F = extract(X)
    F["spk_sim"] = speaker_sim(F, M["claimed"], tpl)
    return F


Xtr, Mtr = make_dataset(500, seed=61, snr=(3, 25))
det = make_pipeline(StandardScaler(), LogisticRegression(max_iter=3000, C=0.5)).fit(featurize(Xtr, Mtr), Mtr["spoof"])

# 1) 탐지 한계치: 잡음(SNR)·재밍 세기 단계별 정확도
print("[탐지 한계치 - 교육용가상자료, 단계별 120클립]")
print(" 조건            정확도  위조재현율  오경보율")
curve = []
for name, ch in [("SNR 20dB", dict(snr=20.0)), ("SNR 10dB", dict(snr=10.0)), ("SNR 5dB", dict(snr=5.0)), ("SNR 0dB", dict(snr=0.0)),
                 ("SNR -5dB", dict(snr=-5.0)), ("재밍 0.5", dict(snr=12.0, jam_tone=0.5)), ("재밍 1.0", dict(snr=12.0, jam_tone=1.0)),
                 ("재밍 2.0", dict(snr=12.0, jam_tone=2.0))]:
    X, M = make_dataset(120, seed=700 + len(curve), **ch)
    pr = det.predict(featurize(X, M)); y = M["spoof"].values
    curve.append((name, (pr == y).mean(), pr[y].mean(), pr[~y].mean()))
    print(f" {name:12s} {curve[-1][1]:7.1%} {curve[-1][2]:9.1%} {curve[-1][3]:9.1%}")
lim = [c[0] for c in curve if c[1] < 0.8]
print("정확도 80% 미만 구간(한계):", lim if lim else "없음", "-> 이 구간은 자동 점수 대신 '판정 불가(사람 확인)'로 처리")

# 2) 부하 시험: 평시 초당 20건 -> 500% 증가(초당 100건), 손상 입력 3% 혼입
pool, pmeta = make_dataset(200, seed=790, snr=(3, 25))
rng = np.random.default_rng(800)


def arrivals(n):
    idx = rng.integers(0, len(pool), n)
    xs = [pool[i] + rng.normal(0, 0.02, N) for i in idx]
    for k in np.flatnonzero(rng.random(n) < 0.03):                          # 손상 입력 주입
        xs[k] = [np.full(N, np.nan), np.zeros(10), np.zeros(N)][k % 3]
    return xs, pmeta.iloc[idx].reset_index(drop=True)


def valid(x):
    return len(x) == N and np.isfinite(x).all() and np.abs(x).max() > 1e-6


def run(rate, ticks=8, budget_s=1.0, backlog_lim=150):
    """1초 단위로 rate건 도착. 처리 시간이 예산을 넘거나 대기열이 길면 기능 축소 모드(화자 유사도 생략, 점수만 산출 후 사람 확인 표시)."""
    q, done, quarantine, degraded, crash = deque(), 0, 0, 0, 0
    lat, t_feat, t_pred = [], 0.0, 0.0
    total_in = 0
    tracemalloc.start(); m0 = tracemalloc.get_traced_memory()[0]
    for _ in range(ticks):
        xs, ms = arrivals(rate); total_in += rate
        q.extend(zip(xs, ms.to_dict("records")))
        t_start = time.perf_counter()
        while q and time.perf_counter() - t_start < budget_s:
            batch = [q.popleft() for _ in range(min(25, len(q)))]
            ok = [b for b in batch if valid(b[0])]
            quarantine += len(batch) - len(ok)                              # 손상 입력은 버리지 않고 격리 보관
            if not ok:
                continue
            try:
                Xb = np.stack([b[0] for b in ok]); Mb = pd.DataFrame([b[1] for b in ok])
                t0 = time.perf_counter(); F = extract(Xb)
                if len(q) > backlog_lim:
                    F["spk_sim"] = 0.9; degraded += len(ok)                # 기능 축소: 화자 검증 생략 -> 결과는 '사람 확인' 표시
                else:
                    F["spk_sim"] = speaker_sim(F, Mb["claimed"], tpl)
                t1 = time.perf_counter(); det.predict_proba(F); t2 = time.perf_counter()
                t_feat += t1 - t0; t_pred += t2 - t1; lat.append((t2 - t0) * 1000 / len(ok)); done += len(ok)
            except Exception:
                crash += 1; q.extendleft(reversed(batch))                     # 실패 배치는 대기열로 되돌림(유실 방지)
                break
    while q:                                                                # 시험 종료 후 잔여 대기열 소진
        b = q.popleft()
        if valid(b[0]):
            F = extract(b[0][None]); F["spk_sim"] = speaker_sim(F, [b[1]["claimed"]], tpl); det.predict_proba(F); done += 1
        else:
            quarantine += 1
    mem = (tracemalloc.get_traced_memory()[0] - m0) / 1e6; tracemalloc.stop()
    return dict(rate=rate, total=total_in, done=done, quarantine=quarantine, lost=total_in - done - quarantine, crash=crash,
                degraded=degraded, ms=np.mean(lat), mem=mem, feat=t_feat, pred=t_pred)


print("\n[부하 시험]  도착률  입력  처리  격리  유실  다운  기능축소  ms/건  메모리증가MB")
res = [run(20), run(100), run(100, budget_s=0.02)]
for r, nm in zip(res, ["평시", "5배", "5배+처리지연"]):
    print(f"{nm:9s}  {r['rate']:4d}/s {r['total']:5d} {r['done']:5d} {r['quarantine']:5d} {r['lost']:5d} {r['crash']:5d} {r['degraded']:8d} {r['ms']:6.2f} {r['mem']:10.2f}")
hi = res[1]
print(f"트래픽 500% 증가: 시스템 다운 {hi['crash']}건, 데이터 유실 {hi['lost'] / hi['total']:.1%} (목표 0건/0%: {'달성' if hi['crash'] == 0 and hi['lost'] == 0 else '미달'})")
Xp = pool[:200]; Pp = spectrogram(Xp); prof = {}
for nm, fn in [("스펙트로그램", lambda: spectrogram(Xp)), ("MFCC", lambda: mfcc(Pp)), ("피치 자기상관(jitter/HNR)", lambda: jitter_hnr(Xp)), ("프레임 경계 단서", lambda: frame_mod(Xp))]:
    t0 = time.perf_counter(); fn(); prof[nm] = time.perf_counter() - t0
tot = sum(prof.values())
print(f"처리 시간 구성(100/s): 특징 추출 {hi['feat'] / (hi['feat'] + hi['pred']):.0%}, 추론 {hi['pred'] / (hi['feat'] + hi['pred']):.0%}")
print("특징 추출 내부 프로파일:", ", ".join(f"{k} {v / tot:.0%}" for k, v in prof.items()), f"-> 병목: {max(prof, key=prof.get)}")
dg = res[2]
print(f"처리 지연 모의: 대기열 적체 시 {dg['degraded']}건을 기능 축소 모드로 처리, 유실 {dg['lost']}건, 다운 {dg['crash']}건")
print("기능 축소 결과와 판정 불가 구간 결과는 자동 차단·전파 없이 통신보안관 확인 대기열로 보낸다(Fail-safe).")

plt.figure(figsize=(7, 3.5))
lab = [c[0].replace("재밍", "jam") for c in curve]
plt.plot(lab, [c[1] for c in curve], "o-", label="accuracy")
plt.plot(lab, [c[3] for c in curve], "s--", label="false alarm")
plt.axhline(0.8, c="k", ls=":"); plt.xticks(rotation=30); plt.legend(); plt.title("Detection limit under noise / jamming (synthetic)")
plt.tight_layout(); plt.show()

실행 결과 예시

[탐지 한계치 - 교육용가상자료, 단계별 120클립]
 조건            정확도  위조재현율  오경보율
 SNR 20dB       99.2%     98.4%      0.0%
 SNR 10dB       96.7%     96.8%      3.4%
 SNR 5dB        97.5%     98.1%      3.0%
 SNR 0dB        66.7%    100.0%     66.7%
 SNR -5dB       48.3%    100.0%     98.4%
 재밍 0.5         95.8%     98.1%      6.1%
 재밍 1.0         90.0%     96.8%     17.2%
 재밍 2.0         85.8%     89.7%     19.2%
정확도 80% 미만 구간(한계): ['SNR 0dB', 'SNR -5dB'] -> 이 구간은 자동 점수 대신 '판정 불가(사람 확인)'로 처리

[부하 시험]  도착률  입력  처리  격리  유실  다운  기능축소  ms/건  메모리증가MB
평시           20/s   160   150    10     0     0        0   1.29       1.23
5배          100/s   800   774    26     0     0        0   1.19       4.02
5배+처리지연     100/s   800   781    19     0     0      146   1.09       4.87
트래픽 500% 증가: 시스템 다운 0건, 데이터 유실 0.0% (목표 0건/0%: 달성)
처리 시간 구성(100/s): 특징 추출 85%, 추론 15%
특징 추출 내부 프로파일: 스펙트로그램 26%, MFCC 17%, 피치 자기상관(jitter/HNR) 54%, 프레임 경계 단서 3% -> 병목: 피치 자기상관(jitter/HNR)
처리 지연 모의: 대기열 적체 시 146건을 기능 축소 모드로 처리, 유실 0건, 다운 0건
기능 축소 결과와 판정 불가 구간 결과는 자동 차단·전파 없이 통신보안관 확인 대기열로 보낸다(Fail-safe).

미션 9. AAR 기반 무전 위조 오판정 음성 분석 및 음향 특징 추출 파라미터 재보정

  • 난이도: 고급 / 예상 소요시간: 3.0시간
  • 코드 설명: 1차 배치(규칙 기반 Baseline)의 모의 운용 기록에서 위조 오판정 사례를 유형·채널 조건별로 분석(AAR)하고, 오판정 사례 재학습·채널 품질 특징 추가·전처리 차감 배수와 판정 임계값 재탐색으로 파라미터를 재보정한 뒤 회귀 시험으로 정확도 향상과 재발 오류를 확인한다.
"""1차 배치(규칙 기반 Baseline)의 모의 운용 기록에서 위조 오판정 사례를 유형·채널 조건별로 분석(AAR)하고, 오판정 사례 재학습·채널 품질 특징 추가·전처리 차감 배수와 판정 임계값 재탐색으로 파라미터를 재보정한 뒤 회귀 시험으로 정확도 향상과 재발 오류를 확인한다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import signal
from scipy.fft import dct
from sklearn.neural_network import MLPClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline

# ---------------------------------------------------------------------------
# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)
# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과
# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.
#   bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음
#   tts_art  : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호
#   vc_art   : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호
#   replay   : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호
# ---------------------------------------------------------------------------
FS, DUR = 8000, 0.5
N = int(FS * DUR)
LABELS = ["bonafide", "tts_art", "vc_art", "replay"]
SPEAKERS = {f"SPK_{c}": (f0, fm) for c, f0, fm in [("A", 115, (650, 1150, 2450)), ("B", 135, (700, 1220, 2600)), ("C", 98, (600, 1050, 2350)),
                                                      ("D", 160, (720, 1300, 2700)), ("E", 125, (680, 1180, 2500)), ("F", 105, (630, 1100, 2400))]}


def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):
    t = np.arange(N) / FS
    contour = f0 * (1 + 0.06 * np.sin(2 * np.pi * rng.uniform(1.5, 3.5) * t + rng.uniform(0, 6)))
    f = contour * (1 + jitter * rng.normal(0, 1, N // 80 + 1).repeat(80)[:N])            # 10ms 단위 피치 흔들림(jitter)
    phase = 2 * np.pi * np.cumsum(f) / FS
    K = int(3800 / f0)
    fk = np.arange(1, K + 1) * f0
    amp = sum(np.exp(-((fk - F) / 120.0) ** 2) / (i + 1) for i, F in enumerate(fm)) + 0.02 / np.arange(1, K + 1)
    if hf_cut:
        amp = np.where(fk > hf_cut, amp * 0.2, amp)
    ph = np.arange(1, K + 1)[:, None] * phase[None, :]
    if phase_break:                                                   # 20ms 프레임마다 배음별 위상 불연속
        ph = ph + rng.uniform(0, 2 * np.pi, (K, N // 160 + 1)).repeat(160, axis=1)[:, :N]
    sig = (amp[:, None] * np.sin(ph)).sum(0)
    env = 0.55 + 0.45 * np.sin(2 * np.pi * rng.uniform(3, 5) * t + rng.uniform(0, 6)) ** 2      # 음절형 진폭 변화
    env *= 1 + shimmer * np.interp(t, np.linspace(0, DUR, 60), rng.normal(0, 1, 60))           # 완만한 진폭 흔들림(shimmer)
    if frame_mod:
        env *= 1 + frame_mod * np.sign(np.sin(2 * np.pi * 50 * t))     # 20ms 프레임 경계 흔적(50Hz)
    return sig * env + (0.03 if jitter > 0.003 else 0.005) * rng.normal(0, 1, N) * env


def make_clip(label, speaker, rng):
    f0, fm = SPEAKERS[speaker]
    if label in ("tts_art", "vc_art"):                               # 위조는 화자 특성을 근사만 함
        f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)
    if label == "bonafide" or label == "replay":
        x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))
    elif label == "tts_art":
        x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))
    else:
        x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)
    if label == "replay":                                              # 스피커 대역 + 실내 잔향
        x = signal.sosfilt(signal.butter(4, [450, 2900], "bandpass", fs=FS, output="sos"), x)
        ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0
        x = signal.fftconvolve(x, ir)[:N]
    return x / (np.abs(x).max() + 1e-9)


RADIO_BP = signal.butter(4, [300, 3400], "bandpass", fs=FS, output="sos")


def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):
    """[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍."""
    y = signal.sosfilt(RADIO_BP, x)
    p = np.mean(y ** 2)
    noise = rng.normal(0, 1, N) * np.sqrt(p / 10 ** (snr_db / 10))
    noise += np.cumsum(rng.normal(0, 0.02, N)) * np.sqrt(p) * 0.05                   # 저주파 차량·엔진형 소음
    y = y + noise
    if impulse:
        k = rng.random(N) < impulse
        y[k] += rng.normal(0, 6 * np.sqrt(p), k.sum())
    if jam_tone:
        y += jam_tone * np.sqrt(p) * np.sin(2 * np.pi * rng.uniform(800, 2000) * np.arange(N) / FS)
    if dropout:
        for _ in range(int(dropout * 10)):
            a = int(rng.integers(0, N - 400)); y[a:a + int(rng.integers(100, 400))] = 0.0
    return np.clip(y / (np.abs(y).max() + 1e-9), -clip, clip)


def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim="SPK_A", **chan):
    """클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame)."""
    rng = np.random.default_rng(seed)
    X, meta = np.zeros((n, N)), []
    if p is None and len(labels) > 1 and labels[0] == "bonafide":        # 기본: 진짜 50%, 나머지 위조 유형 균등
        p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)
    for i in range(n):
        lab = str(rng.choice(labels, p=p))
        spk = str(rng.choice(list(SPEAKERS))) if lab == "bonafide" else claim
        if lab == "replay":
            spk = claim
        s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)
        X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)
        meta.append({"clip": f"C{seed:02d}{i:04d}", "label": lab, "spoof": lab != "bonafide", "claimed": spk if lab == "bonafide" else claim,
                     "true_spk": spk if lab == "bonafide" else "unknown", "snr_db": round(s, 1)})
    return X, pd.DataFrame(meta)
# ---------------------------------------------------------------------------

# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----
NFFT, HOP = 256, 80


def mel_bank(n_mels=26, fmin=300, fmax=3400):
    mel = lambda f: 2595 * np.log10(1 + f / 700)
    imel = lambda m: 700 * (10 ** (m / 2595) - 1)
    pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))
    freqs = np.fft.rfftfreq(NFFT, 1 / FS)
    fb = np.zeros((n_mels, len(freqs)))
    for m in range(n_mels):
        l, c, r = pts[m], pts[m + 1], pts[m + 2]
        fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)
    return fb


MEL = mel_bank()
FREQS = np.fft.rfftfreq(NFFT, 1 / FS)


def spectrogram(X, denoise=0.0):
    """배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감."""
    X = np.atleast_2d(X)
    Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]]                                       # 프리엠퍼시스
    frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)
    P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2
    P = np.transpose(P, (0, 2, 1))
    if denoise:
        floor = np.percentile(P, 10, axis=2, keepdims=True)
        P = np.maximum(P - denoise * floor, 0.05 * P)
    return P


def mfcc(P, n=13):
    logmel = np.log(np.einsum("mf,cft->cmt", MEL, P) + 1e-8)
    return dct(logmel, type=2, axis=1, norm="ortho")[:, :n, :]


def band(P, lo, hi):
    return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))


def jitter_hnr(X):
    """40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다."""
    W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)
    fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]
    fr = fr - fr.mean(-1, keepdims=True)
    spec = np.fft.rfft(fr, 2 * W, axis=-1)
    ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]
    ac = ac / (ac[..., :1] + 1e-12)
    seg = ac[..., lag0:lag1]
    k = seg.argmax(-1)
    peak = seg.max(-1)
    km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]
    lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12)                      # 포물선 보간으로 주기 정밀화
    f0 = FS / lag
    jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)
    return jit, np.median(peak, axis=1)


def frame_mod(X):
    """진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서)."""
    env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)
    sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2
    f = np.fft.rfftfreq(env.shape[1], 20 / FS)
    return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)


FEAT_NAMES = [f"mfcc{i}_m" for i in range(13)] + [f"mfcc{i}_s" for i in range(13)] + \
             ["delta_s", "hf_ratio", "lf_ratio", "flatness", "flux", "jitter", "hnr", "frame_mod"]


def extract(X, denoise=0.0):
    """클립 배치 -> 특징 행렬 (클립 x 34) DataFrame."""
    P = spectrogram(X, denoise)
    C = mfcc(P)
    tot = band(P, 300, 3400) + 1e-12
    flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)
    logP = np.log(P + 1e-8)
    jit, hnr = jitter_hnr(X)
    F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,
              flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]
    return pd.DataFrame(F, columns=FEAT_NAMES)


def enroll(seed=99):
    """지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균)."""
    rng = np.random.default_rng(seed)
    tpl = {}
    for spk in SPEAKERS:
        Xe = np.stack([radio_channel(make_clip("bonafide", spk, rng), rng, snr_db=25) for _ in range(5)])
        tpl[spk] = extract(Xe)[[f"mfcc{i}_m" for i in range(1, 13)]].mean().values
    return tpl


def speaker_sim(F, claimed, tpl):
    """주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수)."""
    v = F[[f"mfcc{i}_m" for i in range(1, 13)]].values
    t = np.stack([tpl[c] for c in claimed])
    return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)

def channel_quality(X):
    """채널 품질 지표: 추정 SNR(유효 프레임 에너지 상하위 비, dB), 단일톤 재밍 지수(주파수별 시간 평균/표준편차의 최대값), 끊김 비율(무음 10ms 비율)."""
    P = spectrogram(X)
    e = P.sum(1)
    e = np.where(e > 1e-9, e, np.nan)
    snr_est = 10 * np.log10(np.nanpercentile(e, 90, axis=1) / (np.nanpercentile(e, 10, axis=1) + 1e-12))
    Pb = P[:, (FREQS >= 300) & (FREQS < 3400)]
    tone = (Pb.mean(2) / (Pb.std(2) + 1e-12)).max(1)                     # 시간에 따라 일정한 주파수 성분(재밍 톤)일수록 큼
    fr = np.abs(np.atleast_2d(X)).reshape(len(X), -1, 80).max(-1)
    drop = (fr < 1e-6).mean(1)
    return pd.DataFrame({"snr_est": snr_est, "tone_idx": tone, "drop_ratio": drop})

tpl = enroll()
CONDS = {"평시": dict(snr=(12, 25)), "저SNR": dict(snr=(3, 8)), "충격잡음": dict(snr=(8, 15), impulse=0.002),
         "신호끊김": dict(snr=(8, 15), dropout=0.4), "재밍": dict(snr=(8, 15), jam_tone=0.5)}


def ops_set(n, seed):
    """[교육용가상자료] 모의 운용 기록: 채널 조건 5종 x n/5 클립, 재생 공격 비중을 높게 둠."""
    parts = []
    for i, (c, ch) in enumerate(CONDS.items()):
        X, M = make_dataset(n // 5, seed=seed + i, p=[0.5, 0.15, 0.15, 0.2], **ch); M["cond"] = c; parts.append((X, M))
    return np.vstack([p[0] for p in parts]), pd.concat([p[1] for p in parts], ignore_index=True)


def feats(X, M, denoise=0.0):
    F = extract(X, denoise); F["spk_sim"] = speaker_sim(F, M["claimed"], tpl)
    return pd.concat([F, channel_quality(X)], axis=1)


# ---- 재보정 전: 미션 3 규칙 기반 Baseline(진짜 음성 3% 분위수 경계) ----
CUES = [("hf_ratio", "low"), ("frame_mod", "high"), ("hnr", "low"), ("flatness", "high"), ("lf_ratio", "low"), ("spk_sim", "low")]
Xb, Mb = make_dataset(200, seed=7, labels=["bonafide"])
Fb = feats(Xb, Mb)
th = {f: Fb[f].quantile(0.03) if d == "low" else Fb[f].quantile(0.97) for f, d in CUES}
rule = lambda F: pd.DataFrame({f: (F[f] < th[f]) if d == "low" else (F[f] > th[f]) for f, d in CUES}).any(axis=1).values

Xo, Mo = ops_set(400, seed=900)                     # AAR 대상 운용 기록
Fo = feats(Xo, Mo)
Mo["pred"] = rule(Fo); Mo["err"] = Mo["pred"] != Mo["spoof"]
print(f"[AAR] 운용 기록 {len(Mo)}건, 재보정 전 정확도 {1 - Mo['err'].mean():.1%}, 오판정 {Mo['err'].sum()}건")
Mo["유형"] = np.where(Mo["spoof"], "미탐(위조->진짜)", "오경보(진짜->위조)")
tab = Mo[Mo["err"]].groupby(["유형", "cond"]).size().unstack(fill_value=0)
print(tab.to_string())
miss_lab = Mo[Mo["err"] & Mo["spoof"]]["label"].value_counts()
print("미탐 위조 유형:", {k: int(v) for k, v in miss_lab.items()})
print("원인 분석: ① 재생(replay)은 진짜 음성을 녹음한 것이라 화자 유사도·자연스러운 흔들림이 높아 규칙이 놓친다",
      "\n          ② 재밍·저SNR·끊김 채널에서 진짜 음성의 단서 값이 경계를 넘어 오경보가 난다(채널 품질 미반영)",
      "\n          ③ 단서별 고정 경계(3% 분위수)는 조건이 바뀌어도 그대로라 조합 판단이 불가능하다")

# ---- 재보정: 운용 기록(오판정 포함)과 조건 혼합 데이터로 재학습, 차감 배수·임계값 탐색 ----
Xn, Mn = ops_set(600, seed=950)
Xv, Mv = ops_set(300, seed=980)                      # 검증용(파라미터 선택)
best = None
for dn in [0.0, 0.5, 1.0]:
    Ftr = pd.concat([feats(Xn, Mn, dn), feats(Xo, Mo, dn)], ignore_index=True)
    ytr = np.r_[Mn["spoof"], Mo["spoof"]]
    w = np.r_[np.ones(len(Mn)), np.where(Mo["err"], 3, 1)]        # AAR 오판정 사례 가중 반영(표본 복제)
    idx = np.repeat(np.arange(len(ytr)), w.astype(int))
    mdl = make_pipeline(StandardScaler(), MLPClassifier((64, 32), alpha=1e-3, max_iter=800, random_state=0)).fit(Ftr.iloc[idx], ytr[idx])
    pv = mdl.predict_proba(feats(Xv, Mv, dn))[:, 1]
    for t in [0.3, 0.4, 0.5, 0.6, 0.7]:
        acc = ((pv >= t) == Mv["spoof"]).mean()
        if best is None or acc > best[0]:
            best = (acc, dn, t, mdl)
acc_v, dn, t, mdl = best
yaml = f"""# configs/c204_tuned_params.yaml [교육용가상자료]
preprocess:
  spectral_subtraction: {dn}
features: [mfcc13_mean, mfcc13_std, hf_ratio, lf_ratio, flatness, flux, jitter, hnr, frame_mod, spk_sim, snr_est, tone_idx, drop_ratio]
model:
  type: mlp
  hidden: [64, 32]
  aar_case_weight: 3
decision:
  spoof_threshold: {t}
  poor_channel_rule: snr_est<3.3 or tone_idx>2.5 or drop_ratio>0.05 -> human_review
"""
print("\n[재보정 파라미터]\n" + yaml.strip())

# ---- 회귀 시험 ----
Xh, Mh = ops_set(400, seed=1100)                     # 새 운용 기록(학습에 쓰지 않음)
before = (rule(feats(Xh, Mh)) == Mh["spoof"]).mean()
ph = mdl.predict_proba(feats(Xh, Mh, dn))[:, 1] >= t
after = (ph == Mh["spoof"]).mean()
po = mdl.predict_proba(feats(Xo, Mo, dn))[:, 1] >= t
recur = int(((po != Mo["spoof"]) & Mo["err"]).sum())
newerr = int(((po != Mo["spoof"]) & ~Mo["err"]).sum())
print(f"\n[회귀 시험] 새 운용 기록 {len(Mh)}건 정확도: 재보정 전 {before:.1%} -> 후 {after:.1%} ({(after - before) * 100:+.1f}%p, 목표 +10%p: {'달성' if after - before >= 0.10 else '미달'})")
print(f"AAR 오판정 {Mo['err'].sum()}건 재발 {recur}건 (목표 0건: {'달성' if recur == 0 else '미달'}), 기존 정답 사례 중 새 오류 {newerr}건")
print("조건별 정확도(새 기록):", {c: f"{(ph[Mh['cond'] == c] == Mh['spoof'][Mh['cond'] == c]).mean():.0%}" for c in CONDS})
print("지속 학습 연계: 통신보안관이 확정한 사례만 라벨로 누적 -> 월 1회 재학습 -> 본 회귀 시험 통과 시에만 교체 배치")

plt.figure(figsize=(6, 3.5))
c = list(CONDS)
plt.bar(np.arange(5) - 0.2, [(rule(feats(Xh[Mh['cond'] == k], Mh[Mh['cond'] == k].reset_index(drop=True))) == Mh['spoof'][Mh['cond'] == k].values).mean() for k in c], 0.4, label="before (rules)")
plt.bar(np.arange(5) + 0.2, [(ph[Mh['cond'] == k] == Mh['spoof'][Mh['cond'] == k]).mean() for k in c], 0.4, label="after (retuned)")
plt.xticks(range(5), ["normal", "low SNR", "impulse", "dropout", "jamming"]); plt.ylabel("accuracy"); plt.legend(); plt.title("AAR recalibration (synthetic)")
plt.tight_layout(); plt.show()

실행 결과 예시

[AAR] 운용 기록 400건, 재보정 전 정확도 69.8%, 오판정 121건
cond         신호끊김  재밍  저SNR  충격잡음  평시
유형                                   
미탐(위조->진짜)     10  11     3    12  23
오경보(진짜->위조)    10  15    26     8   3
미탐 위조 유형: {'replay': 53, 'vc_art': 4, 'tts_art': 2}
원인 분석: ① 재생(replay)은 진짜 음성을 녹음한 것이라 화자 유사도·자연스러운 흔들림이 높아 규칙이 놓친다 
          ② 재밍·저SNR·끊김 채널에서 진짜 음성의 단서 값이 경계를 넘어 오경보가 난다(채널 품질 미반영) 
          ③ 단서별 고정 경계(3% 분위수)는 조건이 바뀌어도 그대로라 조합 판단이 불가능하다

[재보정 파라미터]
# configs/c204_tuned_params.yaml [교육용가상자료]
preprocess:
  spectral_subtraction: 0.0
features: [mfcc13_mean, mfcc13_std, hf_ratio, lf_ratio, flatness, flux, jitter, hnr, frame_mod, spk_sim, snr_est, tone_idx, drop_ratio]
model:
  type: mlp
  hidden: [64, 32]
  aar_case_weight: 3
decision:
  spoof_threshold: 0.3
  poor_channel_rule: snr_est<3.3 or tone_idx>2.5 or drop_ratio>0.05 -> human_review

[회귀 시험] 새 운용 기록 400건 정확도: 재보정 전 78.2% -> 후 96.8% (+18.5%p, 목표 +10%p: 달성)
AAR 오판정 121건 재발 0건 (목표 0건: 달성), 기존 정답 사례 중 새 오류 0건
조건별 정확도(새 기록): {'평시': '100%', '저SNR': '95%', '충격잡음': '98%', '신호끊김': '95%', '재밍': '96%'}
지속 학습 연계: 통신보안관이 확정한 사례만 라벨로 누적 -> 월 1회 재학습 -> 본 회귀 시험 통과 시에만 교체 배치

미션 10. 무전 음성 스푸핑·딥페이크 탐지 시스템 통합 및 C2 음성 보안 브리핑 패키지 완성

  • 난이도: 최고급 / 예상 소요시간: 3.5시간
  • 코드 설명: 수신 메타데이터 검증, 무전 채널 전처리·특징 추출, 재보정 파라미터를 적용한 위조 판별·신뢰도 등급, 통신보안관 검증 대기열·감사 로그까지 한 번에 실행하는 통합 파이프라인을 만들고, 자체 점검(함수 커버리지 포함)과 C2 음성 보안 브리핑 요약을 출력한다.
"""수신 메타데이터 검증, 무전 채널 전처리·특징 추출, 재보정 파라미터를 적용한 위조 판별·신뢰도 등급, 통신보안관 검증 대기열·감사 로그까지 한 번에 실행하는 통합 파이프라인을 만들고, 자체 점검(함수 커버리지 포함)과 C2 음성 보안 브리핑 요약을 출력한다."""
import hashlib
import json
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import signal
from scipy.fft import dct
from sklearn.neural_network import MLPClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline

# ---------------------------------------------------------------------------
# [교육용가상자료] 탐지 학습용 합성 신호 생성기 (실제 음성·화자·통신망 정보 없음)
# 사람 목소리를 만드는 기능이 아니라, 배음(하모닉) 신호에 '진짜 신호의 자연스러운 흔들림'과
# '알려진 위조 흔적(문헌상 탐지 단서)'의 통계적 특징을 표시해 탐지기를 학습시키기 위한 추상 신호다.
#   bonafide : 자연스러운 피치 흔들림(jitter)·진폭 흔들림(shimmer)·숨소리 잡음
#   tts_art  : 흔들림이 거의 없고 고주파 에너지가 부족하며 20ms 프레임 경계 흔적이 있는 신호
#   vc_art   : 프레임마다 배음 위상이 끊기고 스펙트럼 포락선이 뭉개진 신호
#   replay   : 스피커 재생 대역 제한 + 실내 잔향이 더해진 신호
# ---------------------------------------------------------------------------
FS, DUR = 8000, 0.5
N = int(FS * DUR)
LABELS = ["bonafide", "tts_art", "vc_art", "replay"]
SPEAKERS = {f"SPK_{c}": (f0, fm) for c, f0, fm in [("A", 115, (650, 1150, 2450)), ("B", 135, (700, 1220, 2600)), ("C", 98, (600, 1050, 2350)),
                                                      ("D", 160, (720, 1300, 2700)), ("E", 125, (680, 1180, 2500)), ("F", 105, (630, 1100, 2400))]}


def _source(rng, f0, fm, jitter, shimmer, phase_break=False, hf_cut=None, frame_mod=0.0):
    t = np.arange(N) / FS
    contour = f0 * (1 + 0.06 * np.sin(2 * np.pi * rng.uniform(1.5, 3.5) * t + rng.uniform(0, 6)))
    f = contour * (1 + jitter * rng.normal(0, 1, N // 80 + 1).repeat(80)[:N])            # 10ms 단위 피치 흔들림(jitter)
    phase = 2 * np.pi * np.cumsum(f) / FS
    K = int(3800 / f0)
    fk = np.arange(1, K + 1) * f0
    amp = sum(np.exp(-((fk - F) / 120.0) ** 2) / (i + 1) for i, F in enumerate(fm)) + 0.02 / np.arange(1, K + 1)
    if hf_cut:
        amp = np.where(fk > hf_cut, amp * 0.2, amp)
    ph = np.arange(1, K + 1)[:, None] * phase[None, :]
    if phase_break:                                                   # 20ms 프레임마다 배음별 위상 불연속
        ph = ph + rng.uniform(0, 2 * np.pi, (K, N // 160 + 1)).repeat(160, axis=1)[:, :N]
    sig = (amp[:, None] * np.sin(ph)).sum(0)
    env = 0.55 + 0.45 * np.sin(2 * np.pi * rng.uniform(3, 5) * t + rng.uniform(0, 6)) ** 2      # 음절형 진폭 변화
    env *= 1 + shimmer * np.interp(t, np.linspace(0, DUR, 60), rng.normal(0, 1, 60))           # 완만한 진폭 흔들림(shimmer)
    if frame_mod:
        env *= 1 + frame_mod * np.sign(np.sin(2 * np.pi * 50 * t))     # 20ms 프레임 경계 흔적(50Hz)
    return sig * env + (0.03 if jitter > 0.003 else 0.005) * rng.normal(0, 1, N) * env


def make_clip(label, speaker, rng):
    f0, fm = SPEAKERS[speaker]
    if label in ("tts_art", "vc_art"):                               # 위조는 화자 특성을 근사만 함
        f0 = f0 * rng.uniform(0.92, 1.08); fm = tuple(F * rng.uniform(0.95, 1.05) for F in fm)
    if label == "bonafide" or label == "replay":
        x = _source(rng, f0, fm, jitter=rng.uniform(0.008, 0.02), shimmer=rng.uniform(0.05, 0.12))
    elif label == "tts_art":
        x = _source(rng, f0, fm, jitter=rng.uniform(0.0, 0.002), shimmer=0.01, hf_cut=rng.uniform(2300, 2800), frame_mod=rng.uniform(0.08, 0.2))
    else:
        x = _source(rng, f0, fm, jitter=rng.uniform(0.006, 0.015), shimmer=rng.uniform(0.04, 0.1), phase_break=True)
    if label == "replay":                                              # 스피커 대역 + 실내 잔향
        x = signal.sosfilt(signal.butter(4, [450, 2900], "bandpass", fs=FS, output="sos"), x)
        ir = rng.normal(0, 1, int(0.08 * FS)) * np.exp(-np.arange(int(0.08 * FS)) / (0.02 * FS)); ir[0] = 3.0
        x = signal.fftconvolve(x, ir)[:N]
    return x / (np.abs(x).max() + 1e-9)


RADIO_BP = signal.butter(4, [300, 3400], "bandpass", fs=FS, output="sos")


def radio_channel(x, rng, snr_db=15.0, impulse=0.0, dropout=0.0, clip=1.0, jam_tone=0.0):
    """[교육용가상자료] 무전 채널 모형: 300~3400Hz 대역 제한, 배경 잡음(SNR), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍."""
    y = signal.sosfilt(RADIO_BP, x)
    p = np.mean(y ** 2)
    noise = rng.normal(0, 1, N) * np.sqrt(p / 10 ** (snr_db / 10))
    noise += np.cumsum(rng.normal(0, 0.02, N)) * np.sqrt(p) * 0.05                   # 저주파 차량·엔진형 소음
    y = y + noise
    if impulse:
        k = rng.random(N) < impulse
        y[k] += rng.normal(0, 6 * np.sqrt(p), k.sum())
    if jam_tone:
        y += jam_tone * np.sqrt(p) * np.sin(2 * np.pi * rng.uniform(800, 2000) * np.arange(N) / FS)
    if dropout:
        for _ in range(int(dropout * 10)):
            a = int(rng.integers(0, N - 400)); y[a:a + int(rng.integers(100, 400))] = 0.0
    return np.clip(y / (np.abs(y).max() + 1e-9), -clip, clip)


def make_dataset(n=400, seed=7, snr=(5, 25), labels=LABELS, p=None, claim="SPK_A", **chan):
    """클립 n개: 진짜는 여러 화자, 위조는 모두 지휘관 화자(SPK_A)를 사칭. 반환: (신호 배열, 메타 DataFrame)."""
    rng = np.random.default_rng(seed)
    X, meta = np.zeros((n, N)), []
    if p is None and len(labels) > 1 and labels[0] == "bonafide":        # 기본: 진짜 50%, 나머지 위조 유형 균등
        p = [0.5] + [0.5 / (len(labels) - 1)] * (len(labels) - 1)
    for i in range(n):
        lab = str(rng.choice(labels, p=p))
        spk = str(rng.choice(list(SPEAKERS))) if lab == "bonafide" else claim
        if lab == "replay":
            spk = claim
        s = float(rng.uniform(*snr)) if isinstance(snr, tuple) else float(snr)
        X[i] = radio_channel(make_clip(lab, spk, rng), rng, snr_db=s, **chan)
        meta.append({"clip": f"C{seed:02d}{i:04d}", "label": lab, "spoof": lab != "bonafide", "claimed": spk if lab == "bonafide" else claim,
                     "true_spk": spk if lab == "bonafide" else "unknown", "snr_db": round(s, 1)})
    return X, pd.DataFrame(meta)
# ---------------------------------------------------------------------------

# ---- 전처리·음향 특징(MFCC/스펙트로그램) 공통 함수: 여러 클립을 한 번에 계산 ----
NFFT, HOP = 256, 80


def mel_bank(n_mels=26, fmin=300, fmax=3400):
    mel = lambda f: 2595 * np.log10(1 + f / 700)
    imel = lambda m: 700 * (10 ** (m / 2595) - 1)
    pts = imel(np.linspace(mel(fmin), mel(fmax), n_mels + 2))
    freqs = np.fft.rfftfreq(NFFT, 1 / FS)
    fb = np.zeros((n_mels, len(freqs)))
    for m in range(n_mels):
        l, c, r = pts[m], pts[m + 1], pts[m + 2]
        fb[m] = np.clip(np.minimum((freqs - l) / (c - l), (r - freqs) / (r - c)), 0, None)
    return fb


MEL = mel_bank()
FREQS = np.fft.rfftfreq(NFFT, 1 / FS)


def spectrogram(X, denoise=0.0):
    """배치 전력 스펙트로그램 (클립, 주파수, 프레임). denoise>0이면 잡음 바닥(주파수별 하위 10%)을 그 배수만큼 빼는 스펙트럼 차감."""
    X = np.atleast_2d(X)
    Xp = np.c_[X[:, :1], X[:, 1:] - 0.95 * X[:, :-1]]                                       # 프리엠퍼시스
    frames = np.lib.stride_tricks.sliding_window_view(Xp, NFFT, axis=1)[:, ::HOP] * np.hanning(NFFT)
    P = np.abs(np.fft.rfft(frames, axis=-1)) ** 2
    P = np.transpose(P, (0, 2, 1))
    if denoise:
        floor = np.percentile(P, 10, axis=2, keepdims=True)
        P = np.maximum(P - denoise * floor, 0.05 * P)
    return P


def mfcc(P, n=13):
    logmel = np.log(np.einsum("mf,cft->cmt", MEL, P) + 1e-8)
    return dct(logmel, type=2, axis=1, norm="ortho")[:, :n, :]


def band(P, lo, hi):
    return P[:, (FREQS >= lo) & (FREQS < hi), :].sum(axis=(1, 2))


def jitter_hnr(X):
    """40ms 프레임 자기상관으로 피치 주기를 찾아 피치 흔들림(jitter)과 배음 대 잡음비(HNR 근사)를 구한다."""
    W, lag0, lag1 = 320, int(FS / 250), int(FS / 70)
    fr = np.lib.stride_tricks.sliding_window_view(np.atleast_2d(X), W, axis=1)[:, ::160]
    fr = fr - fr.mean(-1, keepdims=True)
    spec = np.fft.rfft(fr, 2 * W, axis=-1)
    ac = np.fft.irfft(np.abs(spec) ** 2, axis=-1)[..., :W]
    ac = ac / (ac[..., :1] + 1e-12)
    seg = ac[..., lag0:lag1]
    k = seg.argmax(-1)
    peak = seg.max(-1)
    km, kp = np.take_along_axis(seg, np.clip(k - 1, 0, None)[..., None], -1)[..., 0], np.take_along_axis(seg, np.clip(k + 1, None, seg.shape[-1] - 1)[..., None], -1)[..., 0]
    lag = k + lag0 + 0.5 * (km - kp) / (km - 2 * peak + kp - 1e-12)                      # 포물선 보간으로 주기 정밀화
    f0 = FS / lag
    jit = np.median(np.abs(np.diff(f0, axis=1)) / f0[:, 1:], axis=1)
    return jit, np.median(peak, axis=1)


def frame_mod(X):
    """진폭 포락선(2.5ms 간격)의 50Hz 성분 비율: 20ms 프레임 경계 흔적(합성 단서)."""
    env = np.abs(np.atleast_2d(X)).reshape(len(np.atleast_2d(X)), -1, 20).mean(-1)
    sp = np.abs(np.fft.rfft(env - env.mean(1, keepdims=True), axis=1)) ** 2
    f = np.fft.rfftfreq(env.shape[1], 20 / FS)
    return sp[:, (f > 47) & (f < 53)].sum(1) / (sp[:, (f > 30) & (f < 70)].sum(1) + 1e-12)


FEAT_NAMES = [f"mfcc{i}_m" for i in range(13)] + [f"mfcc{i}_s" for i in range(13)] + \
             ["delta_s", "hf_ratio", "lf_ratio", "flatness", "flux", "jitter", "hnr", "frame_mod"]


def extract(X, denoise=0.0):
    """클립 배치 -> 특징 행렬 (클립 x 34) DataFrame."""
    P = spectrogram(X, denoise)
    C = mfcc(P)
    tot = band(P, 300, 3400) + 1e-12
    flat = np.exp(np.log(P[:, (FREQS >= 300) & (FREQS < 3400)] + 1e-10).mean(1)) / (P[:, (FREQS >= 300) & (FREQS < 3400)].mean(1) + 1e-10)
    logP = np.log(P + 1e-8)
    jit, hnr = jitter_hnr(X)
    F = np.c_[C.mean(2), C.std(2), np.abs(np.diff(C, axis=2)).mean((1, 2)), band(P, 2400, 3400) / tot, band(P, 300, 500) / tot,
              flat.mean(1), np.abs(np.diff(logP, axis=2)).mean((1, 2)), jit, hnr, frame_mod(X)]
    return pd.DataFrame(F, columns=FEAT_NAMES)


def enroll(seed=99):
    """지휘관·통신소 화자 6명의 등록 견본(깨끗한 진짜 클립 5개씩의 MFCC 평균)."""
    rng = np.random.default_rng(seed)
    tpl = {}
    for spk in SPEAKERS:
        Xe = np.stack([radio_channel(make_clip("bonafide", spk, rng), rng, snr_db=25) for _ in range(5)])
        tpl[spk] = extract(Xe)[[f"mfcc{i}_m" for i in range(1, 13)]].mean().values
    return tpl


def speaker_sim(F, claimed, tpl):
    """주장 화자 견본과 MFCC 평균 벡터의 코사인 유사도(화자 검증 점수)."""
    v = F[[f"mfcc{i}_m" for i in range(1, 13)]].values
    t = np.stack([tpl[c] for c in claimed])
    return (v * t).sum(1) / (np.linalg.norm(v, axis=1) * np.linalg.norm(t, axis=1) + 1e-12)

def channel_quality(X):
    """채널 품질 지표: 추정 SNR(유효 프레임 에너지 상하위 비, dB), 단일톤 재밍 지수(주파수별 시간 평균/표준편차의 최대값), 끊김 비율(무음 10ms 비율)."""
    P = spectrogram(X)
    e = P.sum(1)
    e = np.where(e > 1e-9, e, np.nan)
    snr_est = 10 * np.log10(np.nanpercentile(e, 90, axis=1) / (np.nanpercentile(e, 10, axis=1) + 1e-12))
    Pb = P[:, (FREQS >= 300) & (FREQS < 3400)]
    tone = (Pb.mean(2) / (Pb.std(2) + 1e-12)).max(1)                     # 시간에 따라 일정한 주파수 성분(재밍 톤)일수록 큼
    fr = np.abs(np.atleast_2d(X)).reshape(len(X), -1, 80).max(-1)
    drop = (fr < 1e-6).mean(1)
    return pd.DataFrame({"snr_est": snr_est, "tone_idx": tone, "drop_ratio": drop})

CONFIG = {"spectral_subtraction": 0.0, "spoof_threshold": 0.3, "high_threshold": 0.8,      # 미션 9 재보정값
          "poor": {"snr_est": 3.3, "tone_idx": 2.5, "drop_ratio": 0.05}}
CALLED = set()


def traced(fn):
    """함수 커버리지 측정용: 호출된 파이프라인 함수 이름을 기록."""
    def w(*a, **k):
        CALLED.add(fn.__name__); return fn(*a, **k)
    w.__name__ = fn.__name__
    return w


@traced
def validate(meta, x):
    errs = []
    if not str(meta.get("clip", "")).startswith("C"):
        errs.append("clip_id 형식")
    if meta.get("claimed") not in SPEAKERS:
        errs.append("주장 화자 미등록")
    if len(x) != N or not np.isfinite(x).all():
        errs.append("신호 길이·값 오류")
    elif np.abs(x).max() < 1e-6:
        errs.append("무신호")
    return errs


@traced
def features(X, claimed):
    F = extract(X, CONFIG["spectral_subtraction"]); F["spk_sim"] = speaker_sim(F, claimed, TPL)
    return pd.concat([F, channel_quality(X)], axis=1)


@traced
def grade(p, q):
    poor = (q["snr_est"] < CONFIG["poor"]["snr_est"]) | (q["tone_idx"] > CONFIG["poor"]["tone_idx"]) | (q["drop_ratio"] > CONFIG["poor"]["drop_ratio"])
    g = np.where(p >= CONFIG["high_threshold"], "High", np.where(p >= CONFIG["spoof_threshold"], "Med", "Low"))
    return np.where((g == "High") & poor.values & (p < 0.95), "Med", g), poor.values


class Queue:
    def __init__(self):
        self.items, self.log = [], []

    @traced
    def submit(self, rec):
        prev = self.log[-1]["hash"] if self.log else "GENESIS"
        body = {"event": "SUBMIT", "id": rec["clip"], "grade": rec["grade"], "score": round(rec["score"], 3), "urgent": rec["urgent"]}
        self.log.append({**body, "prev": prev, "hash": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})
        self.items.append({**rec, "status": "PENDING"})                    # 자동 전파·차단 없음, 통신보안관 검증 대기

    @traced
    def verify(self):
        prev = "GENESIS"
        for r in self.log:
            body = {k: v for k, v in r.items() if k not in ("prev", "hash")}
            if r["prev"] != prev or r["hash"] != hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest():
                return False
            prev = r["hash"]
        return True


@traced
def train(seed=1200):
    conds = [dict(snr=(10, 25)), dict(snr=(3, 8)), dict(snr=(8, 15), impulse=0.002), dict(snr=(8, 15), dropout=0.4), dict(snr=(8, 15), jam_tone=0.5)]
    parts = [make_dataset(140, seed=seed + i, p=[0.5, 0.15, 0.15, 0.2], **c) for i, c in enumerate(conds)]
    X = np.vstack([p[0] for p in parts]); M = pd.concat([p[1] for p in parts], ignore_index=True)
    return make_pipeline(StandardScaler(), MLPClassifier((64, 32), alpha=1e-3, max_iter=800, random_state=0)).fit(features(X, M["claimed"]), M["spoof"])


@traced
def main(X, M, model):
    queue, rejected, out = Queue(), {}, []
    ok = []
    for i in range(len(X)):
        e = validate(M.iloc[i].to_dict(), X[i])
        if e:
            rejected[M["clip"][i]] = e
        else:
            ok.append(i)
    Xo, Mo = X[ok], M.iloc[ok].reset_index(drop=True)
    t0 = time.perf_counter()
    F = features(Xo, Mo["claimed"])
    p = model.predict_proba(F)[:, 1]
    g, poor = grade(p, F)
    ms = (time.perf_counter() - t0) * 1000 / len(Xo)
    for k in range(len(Mo)):
        rec = {"clip": Mo["clip"][k], "score": float(p[k]), "grade": str(g[k]), "urgent": bool(Mo["urgent"][k]), "poor": bool(poor[k])}
        out.append(rec)
        if rec["grade"] != "Low" or rec["urgent"]:                           # 위조 의심 + 긴급 명령은 전량 사람 검증
            queue.submit(rec)
    return pd.DataFrame(out), Mo, rejected, queue, ms


T0 = time.perf_counter()
TPL = enroll()
model = train()
# [교육용가상자료] 야간 운용 시간대 수신 기록 300건(혼합 채널) + 손상 기록 3건
parts = [make_dataset(60, seed=1300 + i, p=[0.5, 0.15, 0.15, 0.2], **c) for i, c in
         enumerate([dict(snr=(10, 25)), dict(snr=(3, 8)), dict(snr=(8, 15), impulse=0.002), dict(snr=(8, 15), dropout=0.4), dict(snr=(8, 15), jam_tone=0.5)])]
X = np.vstack([p[0] for p in parts]); M = pd.concat([p[1] for p in parts], ignore_index=True)
M["urgent"] = np.random.default_rng(1400).random(len(M)) < 0.15
X[5] = np.nan; X[77] = 0.0; M.loc[150, "claimed"] = "SPK_Z"
R, Mo, rejected, queue, ms = main(X, M, model)
y = Mo["spoof"].values; alarm = R["grade"].values != "Low"
recall, far, acc = alarm[y].mean(), alarm[~y].mean(), (alarm == y).mean()
elapsed = time.perf_counter() - T0

chk = {
    "손상 기록 3건 차단": len(rejected) == 3,
    "처리 기록 297건": len(R) == 297,
    "위조 재현율 90% 이상": recall >= 0.9,
    "오경보율 10% 이하": far <= 0.10,
    "자동 전파·차단 0건": all(i["status"] == "PENDING" for i in queue.items),
    "긴급 명령 전량 검증 대기": all(c in {i["clip"] for i in queue.items} for c in Mo["clip"][Mo["urgent"]]),
    "감사 로그 무결성": queue.verify() and len(queue.log) == len(queue.items),
    "클립당 200ms 이내": ms < 200,
    "등급 규칙 일관": all((r.grade == "Low") == (r.score < CONFIG["spoof_threshold"]) for r in R.itertuples()),
    "점수 범위 0~1·NaN 없음": R["score"].between(0, 1).all(),
}
passed = sum(chk.values())
for k, v in chk.items():
    print(f"[{'PASS' if v else 'FAIL'}] {k}")
funcs = ["validate", "features", "grade", "submit", "verify", "train", "main"]
cov = len(CALLED & set(funcs)) / len(funcs)
print(f"자체 점검 {passed}/{len(chk)} 통과, E2E 실행 성공률 {'100%' if passed == len(chk) else f'{passed / len(chk):.0%}'}, 함수 커버리지 {cov:.0%} (목표 85% 이상: {'달성' if cov >= 0.85 else '미달'})")
print("차단된 기록:", rejected)

n_hi, n_med = (R["grade"] == "High").sum(), (R["grade"] == "Med").sum()
print(f"""
[C2 음성 보안 브리핑 요약 - 교육용가상자료]
1. 처리: 수신 {len(M)}건 중 {len(R)}건 판정, 손상·미등록 {len(rejected)}건 차단, 전체 {elapsed:.1f}s, 클립당 {ms:.2f} ms
2. 성능: 위조 재현율 {recall:.1%}, 오경보율 {far:.1%}, 정확도 {acc:.1%} (평시·저SNR·충격·끊김·재밍 혼합 채널)
3. 검증 대기: {len(queue.items)}건 (High {n_hi} / Med {n_med} / 긴급 명령 {int(Mo['urgent'].sum())}), 자동 전파·차단 0건
4. 운용: 긴급 명령은 등급과 무관하게 통신보안관이 역호출 인증·청음 후 전파하며, 최종 명령 책임은 지휘계통에 있다.
5. 한계·확장: 합성 신호 기준 결과로 실제 무전기·음성에는 재학습·재검증이 필요하다. 인접 체계 연동은 판정 결과(JSON)만 전달하는 API로 확장한다.""")

plt.figure(figsize=(6, 3.5))
plt.hist([R["score"][~y], R["score"][y]], bins=20, label=["bonafide", "spoof"], color=["tab:green", "tab:red"])
plt.axvline(CONFIG["spoof_threshold"], c="k", ls="--"); plt.yscale("log"); plt.xlabel("spoof score"); plt.legend(); plt.title("Integrated pipeline output (synthetic)")
plt.tight_layout(); plt.show()

실행 결과 예시

[PASS] 손상 기록 3건 차단
[PASS] 처리 기록 297건
[PASS] 위조 재현율 90% 이상
[PASS] 오경보율 10% 이하
[PASS] 자동 전파·차단 0건
[PASS] 긴급 명령 전량 검증 대기
[PASS] 감사 로그 무결성
[PASS] 클립당 200ms 이내
[PASS] 등급 규칙 일관
[PASS] 점수 범위 0~1·NaN 없음
자체 점검 10/10 통과, E2E 실행 성공률 100%, 함수 커버리지 100% (목표 85% 이상: 달성)
차단된 기록: {'C13000005': ['신호 길이·값 오류'], 'C13010017': ['무신호'], 'C13020030': ['주장 화자 미등록']}

[C2 음성 보안 브리핑 요약 - 교육용가상자료]
1. 처리: 수신 300건 중 297건 판정, 손상·미등록 3건 차단, 전체 8.5s, 클립당 1.66 ms
2. 성능: 위조 재현율 96.7%, 오경보율 4.9%, 정확도 96.0% (평시·저SNR·충격·끊김·재밍 혼합 채널)
3. 검증 대기: 174건 (High 143 / Med 12 / 긴급 명령 43), 자동 전파·차단 0건
4. 운용: 긴급 명령은 등급과 무관하게 통신보안관이 역호출 인증·청음 후 전파하며, 최종 명령 책임은 지휘계통에 있다.
5. 한계·확장: 합성 신호 기준 결과로 실제 무전기·음성에는 재학습·재검증이 필요하다. 인접 체계 연동은 판정 결과(JSON)만 전달하는 API로 확장한다.