218 KiB
218 KiB
In [ ]:
# ============================================================================
# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트
# ============================================================================
!pip install -q numpy pandas matplotlib scipy scikit-learn
import os
import sys
import time
import math
import json
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')
# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)
plt.rcParams['font.sans-serif'] = 'DejaVu Sans'
plt.rcParams['axes.unicode_minus'] = False
print(f"실행 환경 준비 완료 (Python {sys.version.split()[0]})")
In [ ]:
"""전술 무전 음성 통신 환경과 생성형 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):
# [TODO : 학생 실습 1] 전술 무전 음성 통신 환경 분석 및 딥페이크/스푸핑 음성 데이터셋 규격 설계 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def validate(rec):
# [TODO : 학생 실습 1] 전술 무전 음성 통신 환경 분석 및 딥페이크/스푸핑 음성 데이터셋 규격 설계 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
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())
In [ ]:
"""무전기 대역 제한(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):
# [TODO : 학생 실습 2] 무전 잡음(전술 배경소음, 주파수 감쇄) 모델링 및 음향 특징(MFCC/스펙트로그램) 전처리 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
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), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍."""
# [TODO : 학생 실습 2] 무전 잡음(전술 배경소음, 주파수 감쇄) 모델링 및 음향 특징(MFCC/스펙트로그램) 전처리 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
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()
In [ ]:
"""음향 스펙트럼 분석으로 구한 위조 단서(고주파 에너지 비율, 프레임 경계 흔적, 배음 대 잡음비, 스펙트럼 평탄도, 저역 에너지, 등록 화자 유사도)를 진짜 음성 기준 분포와 비교하는 규칙 기반 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):
# [TODO : 학생 실습 3] 음향 스펙트럼 분석 기반 1차 무전 음성 변조 탐지 Baseline 모델 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
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), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍."""
# [TODO : 학생 실습 3] 음향 스펙트럼 분석 기반 1차 무전 음성 변조 탐지 Baseline 모델 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
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()
In [ ]:
"""음향 특징 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):
# [TODO : 학생 실습 4] 음성 합성 인공지능(TTS/VC) 아티팩트 및 위조 음성 판별 딥러닝 탐지 엔진 고도화 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
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), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍."""
# [TODO : 학생 실습 4] 음성 합성 인공지능(TTS/VC) 아티팩트 및 위조 음성 판별 딥러닝 탐지 엔진 고도화 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
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()
In [ ]:
"""채널 품질 특징(추정 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):
# [TODO : 학생 실습 5] 전장 소음 환경 오탐 방지 및 무전 화자 진위 신뢰도 점수 산출 모듈 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
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), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍."""
# [TODO : 학생 실습 5] 전장 소음 환경 오탐 방지 및 무전 화자 진위 신뢰도 점수 산출 모듈 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
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()
In [ ]:
"""모의 무전 송신 스트림을 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):
# [TODO : 학생 실습 6] 실시간 무전 음성 위조 경보 및 스펙트로그램 시각화 C2 보안 인터페이스 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
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), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍."""
# [TODO : 학생 실습 6] 실시간 무전 음성 위조 경보 및 스펙트로그램 시각화 C2 보안 인터페이스 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
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()
In [ ]:
"""지휘관 명의 긴급 음성 명령이 수신되면 탐지 점수와 무관하게 통신보안관 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자리 응답을 만든다(실제 암호 체계를 흉내 낸 학습용 모형)."""
# [TODO : 학생 실습 7] 지휘관 긴급 음성 명령 수신 시 통신관 HITL 2차 육안/청음 검증 절차 설계 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
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책임 분계: 탐지기=점수 제시, 통신보안관=인증·청음·결정, 지휘관=최종 명령 책임. 자동 판정으로 명령을 전파하거나 차단하지 않는다.")
In [ ]:
"""전자전 재밍·잡음 세기를 단계적으로 높여 위조 탐지 성능의 한계점을 찾고, 무전 트래픽을 평시의 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):
# [TODO : 학생 실습 8] 전자전 재밍 및 잡음 혼입 환경 모의 딥페이크 탐지 한계치 스트레스 테스트 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
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), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍."""
# [TODO : 학생 실습 8] 전자전 재밍 및 잡음 혼입 환경 모의 딥페이크 탐지 한계치 스트레스 테스트 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
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()
In [ ]:
"""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):
# [TODO : 학생 실습 9] AAR 기반 무전 위조 오판정 음성 분석 및 음향 특징 추출 파라미터 재보정 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
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), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍."""
# [TODO : 학생 실습 9] AAR 기반 무전 위조 오판정 음성 분석 및 음향 특징 추출 파라미터 재보정 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
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()
In [ ]:
"""수신 메타데이터 검증, 무전 채널 전처리·특징 추출, 재보정 파라미터를 적용한 위조 판별·신뢰도 등급, 통신보안관 검증 대기열·감사 로그까지 한 번에 실행하는 통합 파이프라인을 만들고, 자체 점검(함수 커버리지 포함)과 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):
# [TODO : 학생 실습 10] 무전 음성 스푸핑·딥페이크 탐지 시스템 통합 및 C2 음성 보안 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
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), 충격성 잡음, 신호 끊김, 포화(클리핑), 단일톤 재밍."""
# [TODO : 학생 실습 10] 무전 음성 스푸핑·딥페이크 탐지 시스템 통합 및 C2 음성 보안 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
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()