예제 소스
바둑 on-policy 학습: 대국 로그를 KataGo 라벨로 바꾸기 · 파일 169개
guanyu_voice.py
#!/usr/bin/env python3
"""녹음한 "단수~" 목소리를 관우풍(굵고 낮고 울리는)으로 가공해 단수 효과음을 만든다.
python3 scripts/guanyu_voice.py ~/Desktop/괴전동.m4a # → public/sounds/danshu.wav (바리톤 ≈ 105Hz)
python3 scripts/guanyu_voice.py in.m4a --target-hz 90 --no-hit # 더 낮게, 타격음 없이
처리: 앞뒤 무음 제거 → 입력 기본음을 재서 목표(바리톤)까지 하강 — 일부는 TD-PSOLA(음높이만),
일부는 포먼트까지 함께(체구가 큰 목소리) →
한 옥타브 아래 겹목소리(울림) → 저역 강조·고역 정리 → 부드러운 포화 → 압축 → 전각 잔향.
마지막에 칼바람·타격음과 섞는다. ffmpeg 와 numpy 만 쓴다.
"""
from __future__ import annotations
import argparse
import subprocess
import tempfile
import wave
from pathlib import Path
import numpy as np
SR = 44100
WEB = Path(__file__).resolve().parents[1]
def ffmpeg(args: list[str]) -> None:
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", *args], check=True)
def read_wav(p: Path) -> np.ndarray:
with wave.open(str(p)) as w:
x = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(np.float32) / 32768
if w.getnchannels() == 2:
x = x.reshape(-1, 2).mean(axis=1)
return x
def write_wav(p: Path, x: np.ndarray, sr: int = SR) -> None:
with wave.open(str(p), "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(sr)
w.writeframes((np.clip(x, -1, 1) * 32767).astype(np.int16).tobytes())
def _period_track(x: np.ndarray, hop: int, fmin: float = 70, fmax: float = 450) -> np.ndarray:
"""프레임별 기본 주기(샘플). 무성음은 0."""
win = 2048
lo, hi = int(SR / fmax), int(SR / fmin)
out = np.zeros(len(x) // hop + 1)
gate = 0.04 * (np.abs(x).max() + 1e-9)
for k, i in enumerate(range(0, len(x), hop)):
fr = x[i : i + win]
if len(fr) < hi * 2 or np.abs(fr).max() < gate:
continue
fr = fr - fr.mean()
ac = np.correlate(fr, fr, "full")[len(fr) - 1 :]
j = lo + int(np.argmax(ac[lo:hi]))
if ac[j] > 0.45 * ac[0]:
out[k] = j
return out
def psola_pitch(x: np.ndarray, semitones: float) -> np.ndarray:
"""TD-PSOLA: 길이·포먼트는 두고 음높이만 바꾼다(유성음 구간)."""
factor = 2 ** (semitones / 12) # <1 이면 낮아짐
hop = 256
per = _period_track(x, hop)
# 분석 피치마크: 유성 구간은 주기마다, 무성 구간은 5ms 간격
marks: list[int] = []
t = 0
while t < len(x):
p = per[min(len(per) - 1, t // hop)]
if p > 0:
seg = x[t : t + int(p)]
if len(seg) == 0:
break
t0 = t + int(np.argmax(np.abs(seg)))
marks.append(t0)
t = t0 + int(p)
else:
marks.append(t)
t += int(SR * 0.005)
marks_a = np.array(marks)
y = np.zeros(len(x) + SR // 2)
ts = float(marks_a[0]) if len(marks_a) else 0.0
while ts < len(x):
k = int(np.argmin(np.abs(marks_a - ts)))
ta = marks_a[k]
p = per[min(len(per) - 1, ta // hop)]
if p > 0:
L = int(p)
step = p / factor
else:
L = int(SR * 0.005)
step = L
a0, a1 = max(0, ta - L), min(len(x), ta + L)
seg = x[a0:a1]
w = np.hanning(len(seg)) if len(seg) > 2 else np.ones(len(seg))
s0 = int(ts) - (ta - a0)
if s0 >= 0 and s0 + len(seg) <= len(y):
y[s0 : s0 + len(seg)] += seg * w
ts += step
return y[: len(x)]
def pitch_chain(semitones: float) -> str:
"""asetrate 로 피치·포먼트를 내리고 atempo 로 길이를 원래대로 되돌린다."""
ratio = 2 ** (semitones / 12)
tempo = 1 / ratio
tempos = []
while tempo > 2.0:
tempos.append(2.0)
tempo /= 2.0
tempos.append(tempo)
return f"asetrate={SR * ratio:.0f},aresample={SR}," + ",".join(f"atempo={t:.4f}" for t in tempos)
def process_voice(src: Path, out: Path, semitones: float, sub: float, reverb: float) -> None:
trim = (
"silenceremove=start_periods=1:start_threshold=-42dB:start_silence=0.05,"
"areverse,silenceremove=start_periods=1:start_threshold=-42dB:start_silence=0.12,areverse"
)
main = pitch_chain(semitones)
low = pitch_chain(semitones - 12)
tone = (
"highpass=f=55,"
"equalizer=f=110:t=q:w=1.0:g=7,"
"equalizer=f=240:t=q:w=1.2:g=3,"
"equalizer=f=900:t=q:w=1.5:g=-2,"
"equalizer=f=2200:t=q:w=1.2:g=2.5,"
"equalizer=f=5500:t=q:w=1.0:g=-4,"
"lowpass=f=9000"
)
hall = (
f"aecho=0.85:{0.55 + 0.25 * reverb:.2f}:45|95|160|240:"
f"{0.35 * reverb:.2f}|{0.25 * reverb:.2f}|{0.16 * reverb:.2f}|{0.09 * reverb:.2f}"
)
graph = (
f"[0:a]aformat=channel_layouts=mono,aresample={SR},{trim},asplit=2[a][b];"
f"[a]{main}[m];"
f"[b]{low},lowpass=f=700,volume={sub:.2f}[s];"
f"[m][s]amix=inputs=2:normalize=0,{tone},"
"asoftclip=type=tanh:threshold=0.6,"
"acompressor=threshold=-18dB:ratio=4:attack=5:release=120:makeup=3,"
f"{hall},apad=pad_dur=0.6,"
"loudnorm=I=-14:TP=-1.5:LRA=7"
)
ffmpeg(["-i", str(src), "-filter_complex", graph, "-ac", "1", "-ar", str(SR), str(out)])
def impact(n: int, rng: np.random.Generator) -> np.ndarray:
t = np.arange(n) / SR
f = 36 + 84 * np.exp(-t * 9)
boom = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-t * 4.5)
noise = rng.standard_normal(n) * np.exp(-t * 28)
noise = np.convolve(noise, np.ones(8) / 8, "same")
return boom * 0.95 + noise * 0.5
def whoosh(n: int, rng: np.random.Generator) -> np.ndarray:
t = np.arange(n) / SR
s = rng.standard_normal(n)
s = s - np.convolve(s, np.ones(5) / 5, "same")
return s * np.sin(np.pi * t / t[-1]) ** 2 * 0.3
def build(args: argparse.Namespace) -> None:
src = Path(args.input).expanduser()
out = Path(args.out) if args.out else WEB / "public/sounds/danshu.wav"
with tempfile.TemporaryDirectory() as td:
raw_wav = Path(td) / "raw.wav"
ffmpeg(["-i", str(src), "-ac", "1", "-ar", str(SR), str(raw_wav)])
raw = read_wav(raw_wav)
per = _period_track(raw, 256)
voiced = per[per > 0]
f0 = float(SR / np.median(voiced)) if len(voiced) else 0.0
if args.semitones is None:
total = 12 * np.log2(args.target_hz / f0) if f0 > 0 else -7.0
total = float(np.clip(total, -16.0, 0.0))
# 포먼트(체구)는 일부만 내리고 나머지는 음높이만 내린다 — 전부 내리면 괴물 목소리가 된다.
formant = float(np.clip(total * 0.4, -5.0, 0.0))
psola = total - formant
else:
formant, psola = args.semitones, args.psola
src_for_chain = raw_wav
if psola < -0.1:
shifted = Path(td) / "psola.wav"
write_wav(shifted, psola_pitch(raw, psola))
src_for_chain = shifted
voice_wav = Path(td) / "voice.wav"
process_voice(src_for_chain, voice_wav, formant, args.sub, args.reverb)
v = read_wav(voice_wav)
per_out = _period_track(v, 256)
vo = per_out[per_out > 0]
f0_out = float(SR / np.median(vo)) if len(vo) else 0.0
print(f"f0 {f0:.0f}Hz → {f0_out:.0f}Hz (psola {psola:+.1f}, formant {formant:+.1f} 반음)")
v = v / (np.abs(v).max() + 1e-9) * 0.95
rng = np.random.default_rng(7)
if args.no_hit:
total = v
else:
pre = int(SR * 0.30)
hit = impact(int(SR * 0.9), rng)
sw = whoosh(int(SR * 0.22), rng)
total = np.zeros(max(pre + len(v), int(SR * 1.2)))
total[: len(sw)] += sw
o = int(SR * 0.16)
total[o : o + len(hit)] += hit[: len(total) - o] * 0.85
total[pre : pre + len(v)] += v
total = np.tanh(total * 1.1) / np.tanh(1.1)
total = total / (np.abs(total).max() + 1e-9) * 0.92
fade = int(SR * 0.12)
total[-fade:] *= np.linspace(1, 0, fade)
out.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(out), "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(SR)
w.writeframes((total * 32767).astype(np.int16).tobytes())
print(f"wrote {out} ({len(total) / SR:.2f}s, sub={args.sub}, reverb={args.reverb})")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("input", help="녹음 파일 (m4a/wav/mp3 등)")
ap.add_argument("--out", default="", help="기본 public/sounds/danshu.wav")
ap.add_argument("--target-hz", type=float, default=95.0, help="목표 기본음(Hz). 겹목소리 때문에 결과는 약 +10Hz. 바리톤 ≈ 95, 베이스 ≈ 80")
ap.add_argument("--semitones", type=float, default=None, help="직접 지정: 포먼트+음높이 하강(반음). 주면 --target-hz 무시")
ap.add_argument("--psola", type=float, default=0.0, help="직접 지정: 음높이만 하강(반음), --semitones 와 함께")
ap.add_argument("--sub", type=float, default=0.35, help="한 옥타브 아래 겹목소리 크기 (0~0.6)")
ap.add_argument("--reverb", type=float, default=0.8, help="전각 잔향 세기 (0~1)")
ap.add_argument("--no-hit", action="store_true", help="칼바람·타격음 없이 목소리만")
build(ap.parse_args())
if __name__ == "__main__":
main()