fix: NAM engine stability and hum — post-NAM DC blocker + HPF, warm-before-kill subprocess swap, non-blocking pipe I/O, sample rate sync, arch detection

- Add first-order DC blocker (R=0.999) after NAM processing to kill subsonic offset
- Add 80Hz Butterworth HPF after NAM to catch residual 60/120Hz hum
- Recompute HPF coefficients on sample rate change in set_audio_profile()
- Warm-before-kill: spawn new C++ subprocess before stopping old one (no gap)
- Add background reader thread for non-blocking stdout consumption
- Reuse last known output frame if engine is slow (keeps stream aligned)
- Pass sample_rate to NAMEngineProcess and FastNAMHost constructors
- Forward sample_rate in server.py profile change and main.py init
- Read actual architecture from .nam files instead of hardcoding 'LSTM'
- Add threading.Lock to FastNAMHost for safe engine ref swaps
This commit is contained in:
2026-06-19 14:13:44 -04:00
parent acdff5eb9b
commit 04931fd738
6 changed files with 349 additions and 75 deletions
+3 -2
View File
@@ -201,7 +201,8 @@ class PedalApp:
# ── 2. DSP pipeline (NAM + IR + FX chain) ──────────── # ── 2. DSP pipeline (NAM + IR + FX chain) ────────────
block_size = self.audio_config.latency_profile["period"] block_size = self.audio_config.latency_profile["period"]
self.nam_host = NAMEngineRouter(block_size=block_size) sample_rate = self.audio_config.latency_profile.get("rate", 48000)
self.nam_host = NAMEngineRouter(block_size=block_size, sample_rate=sample_rate)
self.ir_loader = IRLoader() self.ir_loader = IRLoader()
self.pipeline = AudioPipeline(nam_host=self.nam_host, ir_loader=self.ir_loader) self.pipeline = AudioPipeline(nam_host=self.nam_host, ir_loader=self.ir_loader)
self.nam_host.warm_up() self.nam_host.warm_up()
@@ -248,7 +249,7 @@ class PedalApp:
self.bass_nam_host: NAMEngineRouter | None = None self.bass_nam_host: NAMEngineRouter | None = None
self.bass_ir_loader: IRLoader | None = None self.bass_ir_loader: IRLoader | None = None
if multi_ch_enabled: if multi_ch_enabled:
self.bass_nam_host = NAMEngineRouter(block_size=block_size) self.bass_nam_host = NAMEngineRouter(block_size=block_size, sample_rate=sample_rate)
self.bass_ir_loader = IRLoader() self.bass_ir_loader = IRLoader()
self.bass_pipeline = AudioPipeline( self.bass_pipeline = AudioPipeline(
nam_host=self.bass_nam_host, nam_host=self.bass_nam_host,
+97 -13
View File
@@ -10,7 +10,9 @@ from __future__ import annotations
import json import json
import logging import logging
import os import os
import select
import subprocess import subprocess
import threading
import time import time
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -21,20 +23,38 @@ logger = logging.getLogger(__name__)
ENGINE_PATH = Path(__file__).parent / 'nam_engine' ENGINE_PATH = Path(__file__).parent / 'nam_engine'
DEFAULT_BLOCK_SIZE = 256 DEFAULT_BLOCK_SIZE = 256
DEFAULT_SAMPLE_RATE = 48000
# How long to wait for a block to be processed before returning passthrough.
# Set to 2x the expected JACK period at 256/48k (5.33ms) to avoid false
# timeouts under load. If the engine doesn't respond in this window, we
# reuse the previous output buffer to keep the stream aligned.
READ_TIMEOUT_MS = 10.0 # 10ms hard timeout for RT thread safety
class NAMEngineProcess: class NAMEngineProcess:
"""Manages the C++ nam_engine subprocess for a single model.""" """Manages the C++ nam_engine subprocess for a single model."""
def __init__(self, model_path: str | Path, block_size: int = DEFAULT_BLOCK_SIZE): def __init__(self, model_path: str | Path, block_size: int = DEFAULT_BLOCK_SIZE,
sample_rate: int = DEFAULT_SAMPLE_RATE):
self._model_path = Path(model_path) self._model_path = Path(model_path)
self._block_size = block_size self._block_size = block_size
self._sample_rate = sample_rate
self._proc: Optional[subprocess.Popen] = None self._proc: Optional[subprocess.Popen] = None
self._static: bool = False self._static: bool = False
self._sample_rate: float = 48000.0
self._timing_samples: list[float] = [] self._timing_samples: list[float] = []
self._loaded: bool = False self._loaded: bool = False
# Background reader thread for non-blocking stdout consumption
self._reader_thread: Optional[threading.Thread] = None
self._reader_running: bool = False
self._read_buf: bytes = b""
self._read_lock = threading.Lock()
# Last successfully processed output — reused if engine is slow
self._last_output: Optional[np.ndarray] = None
self._last_output_shape: Optional[tuple] = None
def start(self) -> bool: def start(self) -> bool:
"""Launch the engine subprocess.""" """Launch the engine subprocess."""
if not self._model_path.exists(): if not self._model_path.exists():
@@ -79,12 +99,54 @@ class NAMEngineProcess:
if ready_line2: if ready_line2:
logger.info('NAM engine ready2: %s', ready_line2.strip()) logger.info('NAM engine ready2: %s', ready_line2.strip())
# Start background reader thread to consume stdout non-blocking
self._reader_running = True
self._reader_thread = threading.Thread(target=self._reader_loop, daemon=True)
self._reader_thread.start()
self._loaded = True self._loaded = True
return True return True
def _reader_loop(self) -> None:
"""Background thread: continuously read engine stdout into a buffer.
This ensures the stdout pipe never fills up (which would block
the engine) and keeps the RT thread's process() call non-blocking.
"""
proc = self._proc
if proc is None or proc.stdout is None:
return
# Set stdout to non-blocking for safe reading
fd = proc.stdout.fileno()
import fcntl
fl = fcntl.fcntl(fd, fcntl.F_GETFL)
fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
while self._reader_running and proc.poll() is None:
try:
chunk = os.read(fd, 65536)
if not chunk:
# EOF — engine has closed stdout
break
with self._read_lock:
self._read_buf += chunk
except BlockingIOError:
# No data available yet — sleep briefly before retrying
time.sleep(0.0001) # 100µs
except OSError:
# Broken pipe or other I/O error
break
logger.debug("NAM engine reader thread exiting")
def process(self, audio_block: np.ndarray) -> np.ndarray: def process(self, audio_block: np.ndarray) -> np.ndarray:
"""Process a block of audio through the NAM engine. """Process a block of audio through the NAM engine.
Non-blocking: writes to stdin and reads from a background buffer.
If the engine hasn't produced output yet, reuses the previous
block's output to maintain stream alignment.
Args: Args:
audio_block: float32 numpy array of shape (N,) or (1, N) audio_block: float32 numpy array of shape (N,) or (1, N)
@@ -105,15 +167,34 @@ class NAMEngineProcess:
start = time.perf_counter() start = time.perf_counter()
# Write block to engine # Write block to engine (fast — 1KB into 64KB pipe buffer)
try:
self._proc.stdin.write(audio_block.tobytes()) self._proc.stdin.write(audio_block.tobytes())
self._proc.stdin.flush() self._proc.stdin.flush()
except BrokenPipeError:
logger.warning("NAM engine stdin broken pipe — engine may have crashed")
return audio_block # passthrough
# Read processed block # Read processed block from background buffer (non-blocking)
raw = self._proc.stdout.read(audio_block.nbytes) nbytes = audio_block.nbytes
if len(raw) != audio_block.nbytes: with self._read_lock:
if len(self._read_buf) >= nbytes:
raw = self._read_buf[:nbytes]
self._read_buf = self._read_buf[nbytes:]
else:
# Engine hasn't produced output yet — reuse previous frame
elapsed_ms = (time.perf_counter() - start) * 1000
self._timing_samples.append(elapsed_ms)
if len(self._timing_samples) > 200:
self._timing_samples = self._timing_samples[-100:]
if self._last_output is not None:
return self._last_output.copy()
return audio_block # first frame before engine responds
# Check for short read (engine crash mid-block)
if len(raw) != nbytes:
logger.warning('NAM engine short read: got %d bytes, expected %d', logger.warning('NAM engine short read: got %d bytes, expected %d',
len(raw), audio_block.nbytes) len(raw), nbytes)
return audio_block # passthrough on error return audio_block # passthrough on error
out = np.frombuffer(raw, dtype=np.float32).copy() out = np.frombuffer(raw, dtype=np.float32).copy()
@@ -128,10 +209,16 @@ class NAMEngineProcess:
if len(self._timing_samples) > 200: if len(self._timing_samples) > 200:
self._timing_samples = self._timing_samples[-100:] self._timing_samples = self._timing_samples[-100:]
# Cache last output for reuse on slow frames
self._last_output = out.copy()
return out return out
def stop(self): def stop(self):
"""Terminate the engine subprocess.""" """Terminate the engine subprocess."""
self._reader_running = False
if self._reader_thread is not None:
self._reader_thread.join(timeout=2)
if self._proc is not None: if self._proc is not None:
try: try:
self._proc.terminate() self._proc.terminate()
@@ -147,10 +234,6 @@ class NAMEngineProcess:
if self._proc is None or self._proc.stderr is None: if self._proc is None or self._proc.stderr is None:
return None return None
# Poll until data available or timeout # Poll until data available or timeout
import select
import sys
# Use polling loop
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
while time.monotonic() < deadline: while time.monotonic() < deadline:
# Check if process is still alive # Check if process is still alive
@@ -160,7 +243,7 @@ class NAMEngineProcess:
logger.error('Engine exited with code %d: %s', self._proc.returncode, remaining) logger.error('Engine exited with code %d: %s', self._proc.returncode, remaining)
return 'FAILED: process exited' return 'FAILED: process exited'
# Try non-blocking read # Try non-blocking read from stderr
line = self._proc.stderr.readline() line = self._proc.stderr.readline()
if line: if line:
return line.decode('utf-8', errors='replace') return line.decode('utf-8', errors='replace')
@@ -195,8 +278,9 @@ if __name__ == '__main__':
model = sys.argv[1] if len(sys.argv) > 1 else 'models/nam/clean.nam' model = sys.argv[1] if len(sys.argv) > 1 else 'models/nam/clean.nam'
block_size = int(sys.argv[2]) if len(sys.argv) > 2 else 256 block_size = int(sys.argv[2]) if len(sys.argv) > 2 else 256
sample_rate = int(sys.argv[3]) if len(sys.argv) > 3 else 48000
engine = NAMEngineProcess(model, block_size) engine = NAMEngineProcess(model, block_size, sample_rate)
if not engine.start(): if not engine.start():
print('FAILED to start engine') print('FAILED to start engine')
sys.exit(1) sys.exit(1)
+118 -27
View File
@@ -6,8 +6,10 @@ spawns the C++ NeuralAudio engine for ~34x faster inference.
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import os import os
import threading
import time import time
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -50,6 +52,20 @@ class NAMFastModel:
return "0.05-0.2 ms (C++ NeuralAudio engine)" return "0.05-0.2 ms (C++ NeuralAudio engine)"
def _read_nam_architecture(model_path: str) -> str:
"""Read the architecture field from a .nam file without loading the full model.
Returns the architecture string (e.g. 'WaveNet', 'Linear', 'LSTM', 'ConvNet')
or 'unknown' if the file can't be read.
"""
try:
with open(model_path) as f:
data = json.load(f)
return data.get("architecture", "unknown")
except (json.JSONDecodeError, OSError, FileNotFoundError):
return "unknown"
class FastNAMHost: class FastNAMHost:
"""NAM model host using the C++ nam_engine subprocess. """NAM model host using the C++ nam_engine subprocess.
@@ -62,18 +78,23 @@ class FastNAMHost:
Directory scanned for available .nam models. Directory scanned for available .nam models.
block_size : int block_size : int
Audio block size (must match the pipeline's JACK buffer). Audio block size (must match the pipeline's JACK buffer).
sample_rate : int
Audio sample rate in Hz (sent to the C++ engine).
""" """
def __init__( def __init__(
self, self,
models_dir: str | Path = MODELS_DIR, models_dir: str | Path = MODELS_DIR,
block_size: int = 256, block_size: int = 256,
sample_rate: int = 48000,
): ):
self._models_dir = Path(models_dir) self._models_dir = Path(models_dir)
self._block_size = block_size self._block_size = block_size
self._sample_rate = sample_rate
self._engine: Optional[NAMModel] = None # Using current naming matching nam_host self._engine: Optional[NAMModel] = None # Using current naming matching nam_host
self._loaded_path: Optional[str] = None self._loaded_path: Optional[str] = None
self._loaded_model: Optional[NAMFastModel] = None self._loaded_model: Optional[NAMFastModel] = None
self._lock = threading.Lock()
self._models_dir.mkdir(parents=True, exist_ok=True) self._models_dir.mkdir(parents=True, exist_ok=True)
@@ -81,14 +102,17 @@ class FastNAMHost:
@property @property
def is_loaded(self) -> bool: def is_loaded(self) -> bool:
with self._lock:
return self._engine is not None and self._engine.is_loaded return self._engine is not None and self._engine.is_loaded
@property @property
def current_model(self) -> Optional[NAMFastModel]: def current_model(self) -> Optional[NAMFastModel]:
with self._lock:
return self._loaded_model return self._loaded_model
@property @property
def avg_inference_ms(self) -> float: def avg_inference_ms(self) -> float:
with self._lock:
if self._engine is None: if self._engine is None:
return 0.0 return 0.0
return self._engine.avg_inference_ms return self._engine.avg_inference_ms
@@ -97,14 +121,68 @@ class FastNAMHost:
def block_size(self) -> int: def block_size(self) -> int:
return self._block_size return self._block_size
@property
def sample_rate(self) -> int:
return self._sample_rate
@property
def last_error(self) -> str:
"""Last model-load error message (empty string if last load succeeded)."""
with self._lock:
if hasattr(self, '_last_error_val'):
return self._last_error_val
return ""
def set_block_size(self, block_size: int) -> None: def set_block_size(self, block_size: int) -> None:
"""Update block size. Reloads current model if loaded.""" """Update block size. Reloads current model if loaded.
Uses warm-before-kill: spawns the new subprocess before stopping
the old one, so there's no gap in NAM processing.
"""
if block_size == self._block_size: if block_size == self._block_size:
return return
self._block_size = block_size self._block_size = block_size
if self._loaded_path: if self._loaded_path:
logger.info("Block size changed to %d — reloading model %s", block_size, self._loaded_path) # Warm-before-kill: spin up new engine while old one still serves
self.load_model(self._loaded_path) new_engine = NAMEngineProcess(
self._loaded_path, self._block_size, self._sample_rate,
)
if not new_engine.start():
logger.error("Failed to start new engine for block size %d — keeping old engine", block_size)
new_engine.stop()
return
logger.info("Warm-before-kill: spawned new engine, swapping...")
with self._lock:
old_engine = self._engine
self._engine = new_engine
# Old engine can be stopped now — no one is reading from it
if old_engine is not None:
old_engine.stop()
logger.debug("Old NAM engine stopped")
def set_sample_rate(self, sample_rate: int) -> None:
"""Update sample rate. Reloads current model if loaded.
Uses warm-before-kill like set_block_size.
"""
if sample_rate == self._sample_rate:
return
self._sample_rate = sample_rate
if self._loaded_path:
new_engine = NAMEngineProcess(
self._loaded_path, self._block_size, self._sample_rate,
)
if not new_engine.start():
logger.error("Failed to restart engine for sample rate %d", sample_rate)
new_engine.stop()
return
with self._lock:
old_engine = self._engine
self._engine = new_engine
if old_engine is not None:
old_engine.stop()
# ── Model loading ────────────────────────────────────────────── # ── Model loading ──────────────────────────────────────────────
@@ -112,58 +190,72 @@ class FastNAMHost:
"""Load a .nam model into the C++ engine. """Load a .nam model into the C++ engine.
Returns True on success, False on error. Returns True on success, False on error.
Uses warm-before-kill: spawns new process before stopping old one.
""" """
path = Path(model_path) path = Path(model_path)
if not path.exists() or path.suffix.lower() not in (".nam",): if not path.exists() or path.suffix.lower() not in (".nam",):
logger.error("Model not found or invalid: %s", model_path) logger.error("Model not found or invalid: %s", model_path)
self._last_error_val = f"Model not found: {model_path}"
return False return False
# Stop any existing engine
self.unload()
size_mb = path.stat().st_size / (1024 * 1024) size_mb = path.stat().st_size / (1024 * 1024)
arch = _read_nam_architecture(model_path)
# Create and start the engine # Create and start the new engine BEFORE stopping the old one
engine = NAMEngineProcess(str(path), self._block_size) engine = NAMEngineProcess(str(path), self._block_size, self._sample_rate)
if not engine.start(): if not engine.start():
logger.error("Failed to start NAM engine for: %s", model_path) msg = f"Failed to start NAM engine for: {model_path}"
logger.error(msg)
self._last_error_val = msg
return False return False
# Swap: new engine takes over, old one is cleaned up
with self._lock:
old_engine = self._engine
self._engine = engine self._engine = engine
self._loaded_path = str(path) self._loaded_path = str(path)
self._loaded_model = NAMFastModel( self._loaded_model = NAMFastModel(
name=path.stem, name=path.stem,
path=str(path), path=str(path),
size_mb=size_mb, size_mb=size_mb,
architecture="LSTM", architecture=arch,
) )
if old_engine is not None:
old_engine.stop()
logger.info( logger.info(
"Loaded NAM model via C++ engine: %s (%.1f KB, static=%s, engine=NeuralAudio)", "Loaded NAM model via C++ engine: %s (%.1f KB, static=%s, arch=%s, engine=NeuralAudio)",
path.stem, path.stem,
size_mb * 1024, size_mb * 1024,
engine.is_static, engine.is_static,
arch,
) )
self._last_error_val = ""
return True return True
def unload(self) -> None: def unload(self) -> None:
"""Unload the current model and stop the engine.""" """Unload the current model and stop the engine."""
if self._engine is not None: with self._lock:
self._engine.stop() engine = self._engine
self._engine = None self._engine = None
self._loaded_path = None self._loaded_path = None
self._loaded_model = None self._loaded_model = None
if engine is not None:
engine.stop()
logger.info("NAM model unloaded") logger.info("NAM model unloaded")
# ── Warm-up ──────────────────────────────────────────────────── # ── Warm-up ────────────────────────────────────────────────────
def warm_up(self, block_size: int = 256) -> None: def warm_up(self, block_size: int = 256) -> None:
"""Run a dry inference to warm caches.""" """Run a dry inference to warm caches."""
if self._engine is None or not self._engine.is_loaded: with self._lock:
engine = self._engine
if engine is None or not engine.is_loaded:
return return
dummy = np.zeros(block_size, dtype=np.float32) dummy = np.zeros(block_size, dtype=np.float32)
for _ in range(5): for _ in range(5):
self._engine.process(dummy) engine.process(dummy)
# ── Inference ────────────────────────────────────────────────── # ── Inference ──────────────────────────────────────────────────
@@ -176,32 +268,31 @@ class FastNAMHost:
Returns: Returns:
Processed audio, same shape, float32. Processed audio, same shape, float32.
""" """
if self._engine is None or not self._engine.is_loaded: with self._lock:
engine = self._engine
if engine is None or not engine.is_loaded:
return audio_block # passthrough return audio_block # passthrough
return self._engine.process(audio_block) return engine.process(audio_block)
# ── Model switching (crossfade compatible) ─────────────────────
_crossfade_buf = None # For pipeline crossfade compatibility
def apply_crossfade(self, buf: np.ndarray) -> np.ndarray:
"""Passthrough — crossfade not needed with fast C++ switching."""
return buf
# ── Model discovery ──────────────────────────────────────────── # ── Model discovery ────────────────────────────────────────────
def list_available_models(self) -> list[NAMFastModel]: def list_available_models(self) -> list[NAMFastModel]:
"""Scan models_dir for .nam files and return metadata.""" """Scan models_dir for .nam files and return metadata.
Reads the actual architecture from each .nam file instead of
hardcoding a default.
"""
models: list[NAMFastModel] = [] models: list[NAMFastModel] = []
for f in sorted(self._models_dir.glob("*.nam")): for f in sorted(self._models_dir.glob("*.nam")):
size_mb = f.stat().st_size / (1024 * 1024) size_mb = f.stat().st_size / (1024 * 1024)
arch = _read_nam_architecture(str(f))
models.append( models.append(
NAMFastModel( NAMFastModel(
name=f.stem, name=f.stem,
path=str(f), path=str(f),
size_mb=size_mb, size_mb=size_mb,
architecture="LSTM", architecture=arch,
) )
) )
return models return models
+38 -4
View File
@@ -12,6 +12,7 @@ Usage:
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import threading import threading
from pathlib import Path from pathlib import Path
@@ -34,6 +35,8 @@ class NAMEngineRouter:
Directory scanned for available .nam models. Directory scanned for available .nam models.
block_size : int block_size : int
Audio block size in samples. Audio block size in samples.
sample_rate : int
Audio sample rate in Hz.
""" """
ENGINE_MODES = ("cpp", "pytorch") ENGINE_MODES = ("cpp", "pytorch")
@@ -43,6 +46,7 @@ class NAMEngineRouter:
engine_mode: str = "cpp", engine_mode: str = "cpp",
models_dir: str | Path | None = None, models_dir: str | Path | None = None,
block_size: int = 256, block_size: int = 256,
sample_rate: int = 48000,
): ):
if engine_mode not in self.ENGINE_MODES: if engine_mode not in self.ENGINE_MODES:
raise ValueError(f"engine_mode must be one of {self.ENGINE_MODES}, got {engine_mode!r}") raise ValueError(f"engine_mode must be one of {self.ENGINE_MODES}, got {engine_mode!r}")
@@ -51,6 +55,7 @@ class NAMEngineRouter:
Path(__file__).parent.parent / "models" / "nam" Path(__file__).parent.parent / "models" / "nam"
) )
self._block_size = block_size self._block_size = block_size
self._sample_rate = sample_rate
self._engine_mode = engine_mode self._engine_mode = engine_mode
self._engine: object = None # FastNAMHost or NAMHost instance self._engine: object = None # FastNAMHost or NAMHost instance
self._loaded_path: Optional[str] = None self._loaded_path: Optional[str] = None
@@ -70,6 +75,7 @@ class NAMEngineRouter:
self._engine = FastNAMHost( self._engine = FastNAMHost(
models_dir=str(self._models_dir), models_dir=str(self._models_dir),
block_size=self._block_size, block_size=self._block_size,
sample_rate=self._sample_rate,
) )
logger.info("NAM engine: C++ subprocess (FastNAMHost)") logger.info("NAM engine: C++ subprocess (FastNAMHost)")
else: else:
@@ -139,12 +145,17 @@ class NAMEngineRouter:
def block_size(self) -> int: def block_size(self) -> int:
return self._block_size return self._block_size
@property
def sample_rate(self) -> int:
return self._sample_rate
# ── Crossfade (compatible with both engines) ──────────────────── # ── Crossfade (compatible with both engines) ────────────────────
@property @property
def _crossfade_buf(self): def _crossfade_buf(self):
"""For pipeline crossfade compatibility. """For pipeline crossfade compatibility.
PyTorch NAMHost has this natively; FastNAMHost has None.""" PyTorch NAMHost has this natively; FastNAMHost has None.
"""
with self._lock: with self._lock:
if hasattr(self._engine, '_crossfade_buf'): if hasattr(self._engine, '_crossfade_buf'):
return self._engine._crossfade_buf return self._engine._crossfade_buf
@@ -174,10 +185,26 @@ class NAMEngineRouter:
self._engine.unload() self._engine.unload()
self._loaded_path = None self._loaded_path = None
# ── Audio profile sync ──────────────────────────────────────────
def set_block_size(self, block_size: int) -> None: def set_block_size(self, block_size: int) -> None:
"""Update block size. Delegates to the active engine."""
self._block_size = block_size self._block_size = block_size
if self._engine is not None and hasattr(self._engine, 'set_block_size'): with self._lock:
self._engine.set_block_size(block_size) engine = self._engine
if engine is not None and hasattr(engine, 'set_block_size'):
engine.set_block_size(block_size)
def set_sample_rate(self, sample_rate: int) -> None:
"""Update sample rate. Delegates to the active engine."""
self._sample_rate = sample_rate
with self._lock:
engine = self._engine
if engine is not None and hasattr(engine, 'set_sample_rate'):
engine.set_sample_rate(sample_rate)
elif engine is not None:
# PyTorch backend doesn't need SR — skip
pass
@property @property
def last_error(self) -> str: def last_error(self) -> str:
@@ -227,10 +254,17 @@ class NAMEngineRouter:
for f in sorted(extra.glob("*.nam")): for f in sorted(extra.glob("*.nam")):
if str(f) not in seen: if str(f) not in seen:
size_mb = f.stat().st_size / (1024 * 1024) size_mb = f.stat().st_size / (1024 * 1024)
# Read actual architecture from the .nam file
try:
with open(f) as fp:
data = json.load(fp)
arch = data.get("architecture", "unknown")
except Exception:
arch = "unknown"
models.append(NAMFastModel( models.append(NAMFastModel(
name=f.stem, name=f.stem,
path=str(f), path=str(f),
size_mb=size_mb, size_mb=size_mb,
architecture="LSTM", architecture=arch,
)) ))
return models return models
+61
View File
@@ -370,6 +370,27 @@ class AudioPipeline:
self._notch_b0, self._notch_b1, self._notch_b2 = _b0, _b1, _b2 self._notch_b0, self._notch_b1, self._notch_b2 = _b0, _b1, _b2
self._notch_a1, self._notch_a2 = _a1, _a2 self._notch_a1, self._notch_a2 = _a1, _a2
# ── Post-NAM high-pass filter (80Hz) to remove residual hum ─────────
# Applied after NAM processing to catch DC offset and low-frequency
# artifacts introduced by the NAM engine / subprocess pipe.
self._post_nam_x1: float = 0.0
self._post_nam_x2: float = 0.0
self._post_nam_y1: float = 0.0
self._post_nam_y2: float = 0.0
self._post_nam_b0: float = 1.0
self._post_nam_b1: float = 0.0
self._post_nam_b2: float = 0.0
self._post_nam_a1: float = 0.0
self._post_nam_a2: float = 0.0
# Compute initial coefficients for 80Hz high-pass with Q=0.707 (Butterworth)
_b0, _b1, _b2, _a1, _a2 = _compute_hpf_coeffs(80.0, 0.707, 48000.0)
self._post_nam_b0, self._post_nam_b1, self._post_nam_b2 = _b0, _b1, _b2
self._post_nam_a1, self._post_nam_a2 = _a1, _a2
# ── DC blocker state (first-order, applied after NAM) ───────────────
self._dc_x_prev: float = 0.0
self._dc_y_prev: float = 0.0
logger.info("Audio pipeline initialized (block=%d, sr=%d)", logger.info("Audio pipeline initialized (block=%d, sr=%d)",
self._block_size, self._sample_rate) self._block_size, self._sample_rate)
@@ -878,6 +899,36 @@ class AudioPipeline:
if self.nam._crossfade_buf is not None: if self.nam._crossfade_buf is not None:
processed = self.nam.apply_crossfade(processed) processed = self.nam.apply_crossfade(processed)
# ── Post-NAM DC blocker ─────────────────────────────
# First-order high-pass: y[n] = x[n] - x[n-1] + R * y[n-1]
# R = 0.999 (~10Hz cutoff at 48kHz, blocks subsonic DC offset)
R = 0.999
x = processed
y = np.empty_like(x)
y[0] = x[0] - self._dc_x_prev + R * self._dc_y_prev
y[1:] = x[1:] - x[:-1] + R * y[:-1]
self._dc_x_prev = x[-1]
self._dc_y_prev = y[-1]
processed = y
# ── Post-NAM HPF at 80Hz (catches residual 60/120Hz hum) ──
b0, b1, b2 = self._post_nam_b0, self._post_nam_b1, self._post_nam_b2
a1, a2 = self._post_nam_a1, self._post_nam_a2
pn_x1, pn_x2 = self._post_nam_x1, self._post_nam_x2
pn_y1, pn_y2 = self._post_nam_y1, self._post_nam_y2
for i in range(len(processed)):
pn_x = processed[i]
pn_y = b0*pn_x + b1*pn_x1 + b2*pn_x2 - a1*pn_y1 - a2*pn_y2
pn_x2 = pn_x1
pn_x1 = pn_x
pn_y2 = pn_y1
pn_y1 = pn_y
processed[i] = pn_y
self._post_nam_x1 = pn_x1
self._post_nam_x2 = pn_x2
self._post_nam_y1 = pn_y1
self._post_nam_y2 = pn_y2
# Clip output to prevent digital distortion # Clip output to prevent digital distortion
return np.clip(processed, -1.0, 1.0) return np.clip(processed, -1.0, 1.0)
@@ -3175,6 +3226,16 @@ class AudioPipeline:
# Reset notch filter state to avoid pop on rate change # Reset notch filter state to avoid pop on rate change
self._notch_x1 = self._notch_x2 = 0.0 self._notch_x1 = self._notch_x2 = 0.0
self._notch_y1 = self._notch_y2 = 0.0 self._notch_y1 = self._notch_y2 = 0.0
# Recompute post-NAM 80Hz HPF coefficients for new sample rate
_b0, _b1, _b2, _a1, _a2 = _compute_hpf_coeffs(80.0, 0.707, sample_rate)
self._post_nam_b0, self._post_nam_b1, self._post_nam_b2 = _b0, _b1, _b2
self._post_nam_a1, self._post_nam_a2 = _a1, _a2
# Reset post-NAM filter state
self._post_nam_x1 = self._post_nam_x2 = 0.0
self._post_nam_y1 = self._post_nam_y2 = 0.0
# Reset DC blocker state
self._dc_x_prev = 0.0
self._dc_y_prev = 0.0
# Clear DSP state — effects will reinit with new block/sample rate # Clear DSP state — effects will reinit with new block/sample rate
self._state.clear() self._state.clear()
self._coeffs.clear() self._coeffs.clear()
+3
View File
@@ -1765,6 +1765,9 @@ class WebServer:
nam_host = self.deps.nam_host nam_host = self.deps.nam_host
if nam_host and hasattr(nam_host, 'set_block_size'): if nam_host and hasattr(nam_host, 'set_block_size'):
nam_host.set_block_size(target_profile["period"]) nam_host.set_block_size(target_profile["period"])
# Sync NAM engine sample rate too
if nam_host and hasattr(nam_host, 'set_sample_rate'):
nam_host.set_sample_rate(target_profile["rate"])
# Sync AudioPipeline block size and sample rate for correct DSP timing # Sync AudioPipeline block size and sample rate for correct DSP timing
pipeline = self.deps.pipeline pipeline = self.deps.pipeline