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:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user