게시판SGF 기보로 학습하는 바둑 LLM 인공지능예제 소스

예제 소스

SGF 기보로 학습하는 바둑 LLM 인공지능 · 파일 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()