Files
Barış Keser db6dcf7225 v1.3.0: crossfade + boslukssuz (gapless) calma ve Discord Rich Presence
- Motor: siradaki parcayi onden coz, sinirda es-guclu crossfade (0-12 sn) ya da
  boslukssuz terfi; kilit-guvenli callback, elle geciste onyukleme iptal
- Bridge: setCrossfade/setGapless/setDiscordRpc/setDiscordClientId slotlari;
  sirali calmada otomatik onyukleme; motorun kendiliginden ilerlemesinde arayuzu
  yeniden yukleme yapmadan (boslukssuz) guncelle
- richpresence.py: opsiyonel pypresence sarmalayici (Discord acik + Client ID ister),
  hataya dayanikli + throttle'li; calan parca/oynat-duraklat Discord'a yansir
- Web: Efektler'de 'Gecis & Discord' karti (crossfade slider, gapless toggle,
  Discord toggle + Client ID alani); TR/EN i18n
- requirements/build: pypresence eklendi, --collect-all pypresence
2026-09-20 17:01:18 +03:00

766 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Gerçek zamanlı DSP ses motoru.
- Çözümleme: gömülü ffmpeg ile herhangi bir biçim -> 44.1kHz stereo float PCM.
- Çıkış: sounddevice (PortAudio) geri çağırma akışı.
- İşleme zinciri (blok bazlı, numpy/scipy): hız (yeniden örnekleme) -> preamp ->
10 bant EQ -> bass low-shelf -> echo -> reverb (çok-taplı) -> 8D spatial -> ses.
- Görselleştirici için gerçek FFT spektrumu üretir.
Qt'ye bağımlı değildir; arayüz durumu QTimer ile yoklar (poll eder).
"""
from __future__ import annotations
import math
import subprocess
import threading
import numpy as np
from scipy.signal import lfilter, sosfilt
from . import config, eqpresets
SR = 44100
BLOCK = 1024
N_BARS = 56
_NO_WINDOW = 0x08000000 # CREATE_NO_WINDOW (windowed exe'de konsol açılmasın)
# ---------------------------------------------------------------------------
# Biquad katsayıları (RBJ cookbook)
# ---------------------------------------------------------------------------
def _peaking(f0: float, gain_db: float, q: float, sr: int) -> list[float]:
a = 10 ** (gain_db / 40.0)
w0 = 2 * math.pi * f0 / sr
cw, sw = math.cos(w0), math.sin(w0)
alpha = sw / (2 * q)
b0 = 1 + alpha * a
b1 = -2 * cw
b2 = 1 - alpha * a
a0 = 1 + alpha / a
a1 = -2 * cw
a2 = 1 - alpha / a
return [b0 / a0, b1 / a0, b2 / a0, 1.0, a1 / a0, a2 / a0]
def _low_shelf(f0: float, gain_db: float, sr: int) -> list[float]:
a = 10 ** (gain_db / 40.0)
w0 = 2 * math.pi * f0 / sr
cw, sw = math.cos(w0), math.sin(w0)
alpha = sw / 2 * math.sqrt((a + 1 / a) * (1 / 0.9 - 1) + 2)
two_sqrt_a_alpha = 2 * math.sqrt(a) * alpha
b0 = a * ((a + 1) - (a - 1) * cw + two_sqrt_a_alpha)
b1 = 2 * a * ((a - 1) - (a + 1) * cw)
b2 = a * ((a + 1) - (a - 1) * cw - two_sqrt_a_alpha)
a0 = (a + 1) + (a - 1) * cw + two_sqrt_a_alpha
a1 = -2 * ((a - 1) + (a + 1) * cw)
a2 = (a + 1) + (a - 1) * cw - two_sqrt_a_alpha
return [b0 / a0, b1 / a0, b2 / a0, 1.0, a1 / a0, a2 / a0]
def build_eq_sos(gains_db: list[float], sr: int = SR) -> np.ndarray:
rows = [_peaking(f, g, 1.1, sr) for f, g in zip(eqpresets.BANDS, gains_db)]
return np.array(rows, dtype=np.float64)
def decode_audio(path: str, sr: int = SR) -> np.ndarray:
"""ffmpeg ile dosyayı (N, 2) float32 diziye çözümler."""
cmd = [
config.FFMPEG, "-v", "quiet", "-nostdin",
"-i", path, "-f", "f32le", "-acodec", "pcm_f32le",
"-ac", "2", "-ar", str(sr), "-",
]
proc = subprocess.run(
cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
creationflags=_NO_WINDOW,
)
if proc.returncode != 0 or not proc.stdout:
raise RuntimeError("Çözümleme başarısız")
data = np.frombuffer(proc.stdout, dtype=np.float32)
if data.size < 2:
raise RuntimeError("Boş ses")
return data.reshape(-1, 2).copy()
def auto_eq_gains(audio: np.ndarray, sr: int = SR) -> list[float]:
"""Parçanın ortalama spektrumunu analiz edip düzleştirici + hafif gülümseme
EQ kazançları üretir (Oto mod)."""
if audio is None or audio.size < sr:
return [0.0] * eqpresets.N_BANDS
mono = audio.mean(axis=1)
if mono.size > sr * 60:
mono = mono[: sr * 60]
win = 8192
if mono.size < win:
return [0.0] * eqpresets.N_BANDS
window = np.hanning(win)
mags = []
for start in range(0, mono.size - win, win):
seg = mono[start:start + win]
mags.append(np.abs(np.fft.rfft(seg * window)))
if not mags:
return [0.0] * eqpresets.N_BANDS
avg = np.mean(mags, axis=0)
freqs = np.fft.rfftfreq(win, 1 / sr)
band_db = []
for f in eqpresets.BANDS:
lo, hi = f / 1.414, f * 1.414
sel = (freqs >= lo) & (freqs < hi)
val = avg[sel].mean() if sel.any() else 1e-6
band_db.append(20 * np.log10(val + 1e-9))
band_db = np.array(band_db)
gains = np.clip((band_db.mean() - band_db) * 0.5, -8, 8)
smile = np.array([2, 1.5, 0.5, 0, 0, 0, 0, 0.5, 1.5, 2])
gains = np.clip(gains + smile, -12, 12)
return [round(float(g), 1) for g in gains]
def waveform_peaks(audio: np.ndarray, n: int = 480) -> list[float]:
"""Şarkının tamamından N adet tepe değeri (dalga formu önizlemesi) üretir."""
if audio is None or audio.size == 0:
return []
mono = np.abs(audio).max(axis=1)
binsz = max(1, mono.size // n)
usable = mono[: binsz * (mono.size // binsz)]
if usable.size == 0:
return []
peaks = usable.reshape(-1, binsz).max(axis=1)
mx = float(peaks.max()) or 1.0
return [round(float(p / mx), 3) for p in peaks[:n]]
def analyze_mood(audio: np.ndarray, sr: int = SR) -> tuple[str, str]:
"""Sesin enerji + parlaklığından bir 'ruh hali' etiketi ve rengi çıkarır."""
if audio is None or audio.size < sr:
return ("🎵 Dengeli", "#1db954")
mono = audio.mean(axis=1)
if mono.size > sr * 50:
mono = mono[sr * 5: sr * 50]
rms = float(np.sqrt(np.mean(mono ** 2)))
win = 8192
if mono.size < win:
return ("🎵 Dengeli", "#1db954")
window = np.hanning(win)
mags = []
for start in range(0, mono.size - win, win):
mags.append(np.abs(np.fft.rfft(mono[start:start + win] * window)))
if not mags:
return ("🎵 Dengeli", "#1db954")
avg = np.mean(mags, axis=0)
freqs = np.fft.rfftfreq(win, 1 / sr)
centroid = float((freqs * avg).sum() / (avg.sum() + 1e-9))
energy = min(1.0, rms * 6.5)
bright = min(1.0, centroid / 4500.0)
if energy > 0.5 and bright > 0.5:
return ("⚡ Enerjik", "#f97316")
if energy > 0.5 and bright <= 0.5:
return ("🔥 Güçlü", "#ef4444")
if energy <= 0.32 and bright > 0.5:
return ("✨ Aydınlık", "#38bdf8")
if energy <= 0.32 and bright <= 0.42:
return ("🌙 Sakin", "#8b5cf6")
return ("🎧 Dengeli", "#1db954")
def estimate_bpm(audio: np.ndarray, sr: int = SR) -> int:
"""Onset zarfı otokorelasyonuyla kaba BPM tahmini."""
if audio is None or audio.size < sr * 5:
return 0
mono = audio.mean(axis=1)
if mono.size > sr * 60:
mono = mono[sr * 5: sr * 60]
hop, win = 512, 1024
rms = np.array([np.sqrt(np.mean(mono[i:i + win] ** 2))
for i in range(0, mono.size - win, hop)])
if rms.size < 20:
return 0
env = np.diff(rms)
env[env < 0] = 0
env = env - env.mean()
ac = np.correlate(env, env, "full")[env.size - 1:]
fps = sr / hop
lo, hi = int(fps * 60 / 190), int(fps * 60 / 60)
hi = min(hi, ac.size - 1)
if hi <= lo:
return 0
lag = lo + int(np.argmax(ac[lo:hi]))
bpm = 60 * fps / max(1, lag)
while bpm < 70:
bpm *= 2
while bpm > 190:
bpm /= 2
return int(round(bpm))
def integrated_loudness(audio: np.ndarray) -> float:
"""Parçanın ortalama gürültülük (RMS dB) değeri — ses eşitleme için."""
if audio is None or audio.size == 0:
return -20.0
rms = float(np.sqrt(np.mean(audio ** 2)) + 1e-9)
return 20.0 * np.log10(rms)
def loudness_gain(audio: np.ndarray, target_db: float = -14.0) -> float:
"""Hedef gürültülüğe getiren çarpan (0.3..3.0 arası)."""
ld = integrated_loudness(audio)
gain = 10 ** ((target_db - ld) / 20.0)
return float(max(0.3, min(3.0, gain)))
def spectrogram(audio: np.ndarray, sr: int = SR, cols: int = 130, rows: int = 48) -> list:
"""Küçük spektrogram (cols x rows, 0..1) — görsel analiz için."""
if audio is None or audio.size < sr:
return []
mono = audio.mean(axis=1)
if mono.size > sr * 240:
mono = mono[: sr * 240]
win = 2048
hop = max(1, (mono.size - win) // cols)
if hop < 1:
return []
window = np.hanning(win)
out = []
freqs = np.fft.rfftfreq(win, 1 / sr)
edges = np.logspace(np.log10(40), np.log10(sr / 2), rows + 1)
idx = [np.searchsorted(freqs, e) for e in edges]
for c in range(cols):
start = c * hop
seg = mono[start:start + win]
if seg.size < win:
break
mag = np.abs(np.fft.rfft(seg * window))
col = []
for r in range(rows):
a, b = idx[r], max(idx[r] + 1, idx[r + 1])
col.append(float(mag[a:b].mean()))
out.append(col)
if not out:
return []
arr = np.array(out)
arr = np.log1p(arr)
mx = arr.max() or 1.0
arr = (arr / mx)
return [[round(float(v), 2) for v in col] for col in arr.tolist()]
def _interp_block(audio: np.ndarray, pos: float, frames: int, speed: float):
"""pos'tan başlayıp frames örnek (speed hızıyla) doğrusal-ara-değerli blok üretir."""
n = audio.shape[0]
idx = pos + np.arange(frames) * speed
i0 = np.floor(idx).astype(np.int64)
frac = (idx - i0).astype(np.float32)
i0 = np.clip(i0, 0, n - 2)
block = (audio[i0] * (1.0 - frac)[:, None]
+ audio[i0 + 1] * frac[:, None]).astype(np.float32)
return block, idx
class DspEngine:
def __init__(self) -> None:
self.sr = SR
self.block = BLOCK
self._lock = threading.Lock()
self.audio: np.ndarray | None = None
self.current_path = None # çözümlemesi biten dosya (analiz için)
self.pos = 0.0 # kesirli örnek indeksi
self.playing = False
self._at_end = False
self.load_error = ""
self._gen = 0 # yükleme kuşağı (eski çözümlemeleri yok say)
# parametreler
self.speed = 1.0
self.volume = 0.8 # 0..1.5
self.eq_enabled = True
self._sos = build_eq_sos(eqpresets.PRESETS["🎚 Flat"])
self._zi = np.zeros((self._sos.shape[0], 2, 2))
self.preamp = 1.0
self.effects = dict(eqpresets.DEFAULT_EFFECTS)
# A-B döngü + ses manzarası (soundscape)
self.loop_a = None
self.loop_b = None
self.soundscape = "off"
self.soundscape_level = 0.0
self._brown_zi = np.zeros((1, 2))
self._sc_rng = np.random.default_rng(7)
# efekt durumları
self._bass_sos: np.ndarray | None = None
self._bass_zi = np.zeros((1, 2, 2))
maxd = 2 * SR
self._echo_ring = np.zeros((maxd, 2), dtype=np.float32)
self._echo_wp = 0
self._echo_max = maxd
self._rv_ring = np.zeros((SR, 2), dtype=np.float32)
self._rv_wp = 0
self._rv_max = SR
self._rv_taps = [int(SR * t) for t in (0.030, 0.058, 0.089, 0.121, 0.157)]
self._rv_gains = [0.62, 0.48, 0.36, 0.26, 0.18]
self._spatial_phase = 0.0
# geçiş: crossfade / gapless (sıradaki parçayı önden çöz, sınırda harmanla)
self.crossfade_sec = 0.0 # 0 = crossfade yok
self.gapless = False # sınırda kesintisiz (boşluksuz) terfi
self._next_audio: np.ndarray | None = None
self._next_path = None
self._next_pos = 0.0
self._next_gen = 0
self._next_loading = None
self._advanced = None # terfi eden yeni parçanın yolu (poller tüketir)
# görselleştirici spektrumu
self._spectrum = np.zeros(N_BARS, dtype=np.float32)
self._win = np.hanning(BLOCK).astype(np.float32)
self._stream = None
# ------------------------------------------------------------------ akış
def start(self) -> bool:
if self._stream is not None:
return True
try:
import sounddevice as sd
self._stream = sd.OutputStream(
samplerate=self.sr, channels=2, blocksize=self.block,
dtype="float32", callback=self._callback,
)
self._stream.start()
return True
except Exception as exc:
self.load_error = f"Ses aygıtı açılamadı: {exc}"
self._stream = None
return False
def close(self) -> None:
if self._stream is not None:
try:
self._stream.stop()
self._stream.close()
except Exception:
pass
self._stream = None
# --------------------------------------------------------------- yükleme
def load(self, path: str, autoplay: bool = True) -> None:
self._gen += 1
gen = self._gen
self.load_error = ""
self.clear_next() # elle parça değişimi: bekleyen crossfade önyüklemesini at
self._advanced = None
with self._lock:
self.audio = None
self.current_path = None
self.pos = 0.0
self.playing = False
self._at_end = False
self.loop_a = self.loop_b = None
threading.Thread(target=self._decode_worker, args=(path, gen, autoplay), daemon=True).start()
def _decode_worker(self, path: str, gen: int, autoplay: bool) -> None:
try:
audio = decode_audio(path)
except Exception as exc:
if gen == self._gen:
self.load_error = str(exc)
return
if gen != self._gen:
return # bu arada başka parça yüklendi
with self._lock:
self.audio = audio
self.current_path = path
self.pos = 0.0
self._at_end = False
self.playing = autoplay
self._reset_filter_state()
def swap_source(self, path: str) -> None:
"""Aynı parçanın alternatif kaynağına geç (ör. htdemucs enstrümantal/vokal
stem) — KONUMU ve çalma durumunu KORU. Karaoke/akapella için kesintisiz geçiş.
(load() konumu sıfırlar; bu onu korur.)"""
self._gen += 1
gen = self._gen
self.load_error = ""
with self._lock:
keep_pos = self.pos
was_playing = self.playing
threading.Thread(target=self._swap_worker,
args=(path, gen, keep_pos, was_playing), daemon=True).start()
def _swap_worker(self, path: str, gen: int, keep_pos: float, was_playing: bool) -> None:
try:
audio = decode_audio(path)
except Exception as exc:
if gen == self._gen:
self.load_error = str(exc)
return
if gen != self._gen:
return # bu arada başka parça yüklendi
with self._lock:
self.audio = audio
self.current_path = path
n = audio.shape[0]
self.pos = float(np.clip(keep_pos, 0, max(0, n - 1)))
self._at_end = False
self.playing = was_playing
self._reset_filter_state()
def _reset_filter_state(self) -> None:
self._zi = np.zeros((self._sos.shape[0], 2, 2))
self._bass_zi = np.zeros((1, 2, 2))
self._echo_ring[:] = 0
self._rv_ring[:] = 0
# ---------------------------------------------------- crossfade / gapless
def set_crossfade(self, sec: float) -> None:
self.crossfade_sec = max(0.0, min(12.0, float(sec)))
def set_gapless(self, on: bool) -> None:
self.gapless = bool(on)
def preload_next(self, path: str) -> None:
"""Sıradaki parçayı arka planda çöz (kesintisiz/crossfade geçiş için)."""
with self._lock:
if self._next_path == path and self._next_audio is not None:
return
if self._next_loading == path:
return
self._next_gen += 1
gen = self._next_gen
self._next_loading = path
threading.Thread(target=self._preload_worker, args=(path, gen), daemon=True).start()
def _preload_worker(self, path: str, gen: int) -> None:
try:
audio = decode_audio(path)
except Exception:
if gen == self._next_gen:
self._next_loading = None
return
if gen != self._next_gen:
return # bu arada iptal edildi / değişti
with self._lock:
self._next_audio = audio
self._next_path = path
self._next_pos = 0.0
self._next_loading = None
def clear_next(self) -> None:
self._next_gen += 1
self._next_loading = None
with self._lock:
self._next_audio = None
self._next_path = None
self._next_pos = 0.0
def has_next_loaded(self) -> bool:
return self._next_audio is not None
def consume_advanced(self):
"""Crossfade/gapless ile terfi eden yeni parçanın yolunu döndürür (bir kez)."""
p = self._advanced
if p is not None:
self._advanced = None
return p
def _promote_next(self) -> None:
"""Sıradaki parçayı geçerli parça yap (kilit tutulurken çağrılır)."""
self.audio = self._next_audio
self.current_path = self._next_path
self.pos = self._next_pos
self._advanced = self._next_path
self._at_end = False
self.playing = True
self._next_audio = None
self._next_path = None
self._next_pos = 0.0
self._next_gen += 1
# ------------------------------------------------------------- transport
def play(self) -> None:
with self._lock:
if self.audio is not None:
if self._at_end:
self.pos = 0.0
self._at_end = False
self.playing = True
def pause(self) -> None:
with self._lock:
self.playing = False
def toggle(self) -> None:
with self._lock:
if self.audio is not None:
self.playing = not self.playing
def stop(self) -> None:
with self._lock:
self.playing = False
self.pos = 0.0
def clear(self) -> None:
"""Yüklü parçayı boşaltır (is_loaded -> False)."""
self.clear_next()
self._advanced = None
with self._lock:
self.audio = None
self.current_path = None
self.pos = 0.0
self.playing = False
self._at_end = False
self.loop_a = self.loop_b = None
def seek(self, seconds: float) -> None:
with self._lock:
if self.audio is not None:
n = self.audio.shape[0]
self.pos = float(np.clip(seconds * self.sr, 0, max(0, n - 1)))
self._at_end = False
def is_playing(self) -> bool:
return self.playing
def is_loaded(self) -> bool:
return self.audio is not None
def position(self) -> float:
return self.pos / self.sr
def duration(self) -> float:
with self._lock:
return (self.audio.shape[0] / self.sr) if self.audio is not None else 0.0
def consume_end(self) -> bool:
"""Parça bittiyse True döndürür ve bayrağı temizler (poller kullanır)."""
if self._at_end:
self._at_end = False
return True
return False
def spectrum(self) -> np.ndarray:
return self._spectrum
# ------------------------------------------------------------ parametre
def set_volume(self, pct: float) -> None:
self.volume = max(0.0, min(1.5, pct / 100.0))
def set_speed(self, rate: float) -> None:
self.speed = max(0.25, min(3.0, float(rate)))
def set_loop(self, a: float, b: float) -> None:
with self._lock:
if b > a >= 0:
self.loop_a, self.loop_b = float(a), float(b)
def clear_loop(self) -> None:
with self._lock:
self.loop_a = self.loop_b = None
def set_soundscape(self, kind: str, level: float) -> None:
self.soundscape = kind or "off"
self.soundscape_level = max(0.0, min(1.0, level / 100.0))
def waveform(self, n: int = 480):
with self._lock:
return waveform_peaks(self.audio, n) if self.audio is not None else []
def mood(self):
with self._lock:
return analyze_mood(self.audio) if self.audio is not None else ("", "")
def set_eq(self, gains_db: list[float]) -> None:
sos = build_eq_sos(gains_db)
with self._lock:
self._sos = sos
if self._zi.shape[0] != sos.shape[0]:
self._zi = np.zeros((sos.shape[0], 2, 2))
def set_eq_enabled(self, on: bool) -> None:
self.eq_enabled = on
def set_effect(self, name: str, value: float) -> None:
self.effects[name] = value
if name == "preamp":
self.preamp = 10 ** (((value - 50) / 50.0 * 12.0) / 20.0)
elif name == "bass":
if value <= 0:
self._bass_sos = None
else:
self._bass_sos = np.array([_low_shelf(110, value / 100.0 * 12.0, self.sr)])
self._bass_zi = np.zeros((1, 2, 2))
# -------------------------------------------------------------- callback
def _callback(self, outdata, frames, time_info, status) -> None: # noqa: ARG002
try:
with self._lock:
audio = self.audio
sc, sc_lvl = self.soundscape, self.soundscape_level
if audio is None or not self.playing:
outdata[:] = self._gen_soundscape(frames) if (sc != "off" and sc_lvl > 0) else 0
return
n = audio.shape[0]
speed = self.speed
pos = self.pos
nxt = self._next_audio
xf = self.crossfade_sec
loop_active = (self.loop_a is not None and self.loop_b is not None)
can_trans = (nxt is not None) and (not loop_active)
if pos >= n - 1:
# geçerli parça bitti: sıradaki hazırsa kesintisiz terfi et
if can_trans and (xf > 0 or self.gapless):
self._promote_next()
audio = self.audio
n = audio.shape[0]
pos = self.pos
nxt = None
can_trans = False
else:
self.playing = False
self._at_end = True
outdata[:] = self._gen_soundscape(frames) if (sc != "off" and sc_lvl > 0) else 0
return
block, idx = _interp_block(audio, pos, frames, speed)
new_pos = pos + frames * speed
self.pos = new_pos
if loop_active and self.pos >= self.loop_b * self.sr:
self.pos = self.loop_a * self.sr
elif can_trans and xf > 0:
# crossfade: kalan süre <= xf iken sıradaki parçayı eş-güçle harmanla
sec_left = (n - 1 - idx) / self.sr
if float(sec_left.min()) <= xf:
npos = self._next_pos
nblock, _ = _interp_block(nxt, npos, frames, speed)
self._next_pos = npos + frames * speed
t = np.clip(1.0 - sec_left / xf, 0.0, 1.0).astype(np.float32)
gin = np.sin(t * (math.pi / 2))[:, None]
gout = np.cos(t * (math.pi / 2))[:, None]
block = (block * gout.astype(np.float32)
+ nblock * gin.astype(np.float32)).astype(np.float32)
if new_pos >= n - 1:
self._promote_next() # geçerli bitti -> sıradakine terfi
elif self.pos >= n - 1:
if can_trans and self.gapless:
self._promote_next() # boşluksuz: sınırda hemen terfi
else:
self._at_end = True
self.playing = False
sos = self._sos
eq_on = self.eq_enabled
preamp = self.preamp
volume = self.volume
bass_sos = self._bass_sos
fx = self.effects
karaoke = fx.get("karaoke", 0)
# ---- ağır DSP (kilit dışında; filtre durumlarına yalnız callback dokunur) ----
block *= preamp
if eq_on:
block, self._zi = sosfilt(sos, block, axis=0, zi=self._zi)
block = block.astype(np.float32)
if bass_sos is not None:
block, self._bass_zi = sosfilt(bass_sos, block, axis=0, zi=self._bass_zi)
block = block.astype(np.float32)
# karaoke / vokal azaltma (merkez kanal iptali)
if karaoke > 0:
amt = karaoke / 100.0
mid = (block[:, 0] + block[:, 1]) * 0.5
block[:, 0] -= amt * mid
block[:, 1] -= amt * mid
self._update_spectrum(block)
if fx["echo"] > 0:
block = self._apply_echo(block, frames, fx)
if fx["reverb"] > 0:
block = self._apply_reverb(block, frames, fx)
if fx["spatial"] > 0:
block = self._apply_spatial(block, frames, fx)
block *= volume
if sc != "off" and sc_lvl > 0:
block = block + self._gen_soundscape(frames)
np.clip(block, -1.0, 1.0, out=block)
outdata[:] = block
except Exception:
outdata[:] = 0
def _gen_soundscape(self, frames: int) -> np.ndarray:
lvl = self.soundscape_level
kind = self.soundscape
if kind == "off" or lvl <= 0:
return np.zeros((frames, 2), dtype=np.float32)
white = self._sc_rng.standard_normal((frames, 2))
if kind == "white":
out = white * 0.16
else: # brown / rain — sürekli (tıksız) leaky integrator
filt, self._brown_zi = lfilter(np.array([0.03]), np.array([1.0, -0.985]),
white, axis=0, zi=self._brown_zi)
out = filt * 9.0
if kind == "rain":
out = out * 0.7 + white * 0.09
out = out * 0.55
return (out * lvl).astype(np.float32)
# ---- efekt uygulayıcıları --------------------------------------------
def _apply_echo(self, block, frames, fx):
mix = fx["echo"] / 100.0
delay = 0.05 + fx["echo_time"] / 100.0 * 0.75
fb = fx["echo_feedback"] / 100.0 * 0.85
L = int(delay * self.sr)
L = max(frames, min(L, self._echo_max - frames))
wp = self._echo_wp
ridx = (wp - L + np.arange(frames)) % self._echo_max
delayed = self._echo_ring[ridx]
wet = block + fb * delayed
widx = (wp + np.arange(frames)) % self._echo_max
self._echo_ring[widx] = wet
self._echo_wp = (wp + frames) % self._echo_max
return ((1 - mix) * block + mix * wet).astype(np.float32)
def _apply_reverb(self, block, frames, fx):
amount = fx["reverb"] / 100.0
wp = self._rv_wp
widx = (wp + np.arange(frames)) % self._rv_max
self._rv_ring[widx] = block
wet = np.zeros_like(block)
for tap, g in zip(self._rv_taps, self._rv_gains):
ridx = (wp - tap + np.arange(frames)) % self._rv_max
wet += self._rv_ring[ridx] * g
self._rv_wp = (wp + frames) % self._rv_max
return (block + amount * 0.7 * wet).astype(np.float32)
def _apply_spatial(self, block, frames, fx):
depth = fx["spatial"] / 100.0
rate = 0.18 # Hz
step = 2 * math.pi * rate / self.sr
phase = self._spatial_phase + np.arange(frames) * step
self._spatial_phase = float((phase[-1] + step) % (2 * math.pi))
pan = depth * np.sin(phase)
lg = np.cos((pan + 1) * math.pi / 4).astype(np.float32)
rg = np.sin((pan + 1) * math.pi / 4).astype(np.float32)
out = block.copy()
out[:, 0] *= lg
out[:, 1] *= rg
return out
def _update_spectrum(self, block) -> None:
if block.shape[0] < self.block:
return
mono = block[: self.block, 0] + block[: self.block, 1]
spec = np.abs(np.fft.rfft(mono * self._win))
# logaritmik grup -> N_BARS
edges = np.logspace(np.log10(2), np.log10(len(spec) - 1), N_BARS + 1).astype(int)
bars = np.zeros(N_BARS, dtype=np.float32)
for i in range(N_BARS):
a, b = edges[i], max(edges[i] + 1, edges[i + 1])
bars[i] = spec[a:b].mean()
bars = np.log1p(bars) / 6.0
np.clip(bars, 0, 1, out=bars)
# yumuşat
self._spectrum = (0.6 * self._spectrum + 0.4 * bars).astype(np.float32)