diff --git a/main.py b/main.py index cbb527b..b706c11 100644 --- a/main.py +++ b/main.py @@ -201,7 +201,8 @@ class PedalApp: # ── 2. DSP pipeline (NAM + IR + FX chain) ──────────── 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.pipeline = AudioPipeline(nam_host=self.nam_host, ir_loader=self.ir_loader) self.nam_host.warm_up() @@ -248,7 +249,7 @@ class PedalApp: self.bass_nam_host: NAMEngineRouter | None = None self.bass_ir_loader: IRLoader | None = None 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_pipeline = AudioPipeline( nam_host=self.bass_nam_host, diff --git a/src/dsp/nam_engine.py b/src/dsp/nam_engine.py index 86da8b4..a927aa1 100644 --- a/src/dsp/nam_engine.py +++ b/src/dsp/nam_engine.py @@ -10,7 +10,9 @@ from __future__ import annotations import json import logging import os +import select import subprocess +import threading import time from pathlib import Path from typing import Optional @@ -21,20 +23,38 @@ logger = logging.getLogger(__name__) ENGINE_PATH = Path(__file__).parent / 'nam_engine' 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: """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._block_size = block_size + self._sample_rate = sample_rate self._proc: Optional[subprocess.Popen] = None self._static: bool = False - self._sample_rate: float = 48000.0 self._timing_samples: list[float] = [] 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: """Launch the engine subprocess.""" if not self._model_path.exists(): @@ -73,18 +93,60 @@ class NAMEngineProcess: if 'static=1' in ready_line: self._static = True logger.info('NAM engine ready: %s', ready_line.strip()) - + # Read the block_size line too ready_line2 = self._read_stderr_line(timeout=2.0) if ready_line2: 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 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: """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: audio_block: float32 numpy array of shape (N,) or (1, N) @@ -105,15 +167,34 @@ class NAMEngineProcess: start = time.perf_counter() - # Write block to engine - self._proc.stdin.write(audio_block.tobytes()) - self._proc.stdin.flush() + # Write block to engine (fast — 1KB into 64KB pipe buffer) + try: + self._proc.stdin.write(audio_block.tobytes()) + self._proc.stdin.flush() + except BrokenPipeError: + logger.warning("NAM engine stdin broken pipe — engine may have crashed") + return audio_block # passthrough - # Read processed block - raw = self._proc.stdout.read(audio_block.nbytes) - if len(raw) != audio_block.nbytes: - logger.warning('NAM engine short read: got %d bytes, expected %d', - len(raw), audio_block.nbytes) + # Read processed block from background buffer (non-blocking) + nbytes = 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', + len(raw), nbytes) return audio_block # passthrough on error out = np.frombuffer(raw, dtype=np.float32).copy() @@ -128,10 +209,16 @@ class NAMEngineProcess: if len(self._timing_samples) > 200: self._timing_samples = self._timing_samples[-100:] + # Cache last output for reuse on slow frames + self._last_output = out.copy() + return out def stop(self): """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: try: self._proc.terminate() @@ -147,10 +234,6 @@ class NAMEngineProcess: if self._proc is None or self._proc.stderr is None: return None # Poll until data available or timeout - import select - import sys - - # Use polling loop deadline = time.monotonic() + timeout while time.monotonic() < deadline: # Check if process is still alive @@ -159,14 +242,14 @@ class NAMEngineProcess: remaining = self._proc.stderr.read().decode('utf-8', errors='replace') logger.error('Engine exited with code %d: %s', self._proc.returncode, remaining) return 'FAILED: process exited' - - # Try non-blocking read + + # Try non-blocking read from stderr line = self._proc.stderr.readline() if line: return line.decode('utf-8', errors='replace') - + time.sleep(0.05) # 50ms poll interval - + return None # timeout @property @@ -192,32 +275,33 @@ class NAMEngineProcess: if __name__ == '__main__': import sys logging.basicConfig(level=logging.INFO) - + 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 - - engine = NAMEngineProcess(model, block_size) + sample_rate = int(sys.argv[3]) if len(sys.argv) > 3 else 48000 + + engine = NAMEngineProcess(model, block_size, sample_rate) if not engine.start(): print('FAILED to start engine') sys.exit(1) - + print(f'Engine loaded: static={engine.is_static}') - + # Benchmark block = np.random.randn(block_size).astype(np.float32) * 0.1 - + # Warmup for _ in range(10): engine.process(block) - + # Timed times = [] for _ in range(500): t0 = time.perf_counter() engine.process(block) times.append((time.perf_counter() - t0) * 1000) - + print(f'Avg: {np.mean(times):.3f} ms Max: {np.max(times):.3f} ms Min: {np.min(times):.3f} ms') print(f'Engine reported avg: {engine.avg_inference_ms:.3f} ms') - + engine.stop() diff --git a/src/dsp/nam_engine_host.py b/src/dsp/nam_engine_host.py index 23d7d4e..f4ab840 100644 --- a/src/dsp/nam_engine_host.py +++ b/src/dsp/nam_engine_host.py @@ -6,8 +6,10 @@ spawns the C++ NeuralAudio engine for ~34x faster inference. from __future__ import annotations +import json import logging import os +import threading import time from pathlib import Path from typing import Optional @@ -50,6 +52,20 @@ class NAMFastModel: 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: """NAM model host using the C++ nam_engine subprocess. @@ -62,18 +78,23 @@ class FastNAMHost: Directory scanned for available .nam models. block_size : int 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__( self, models_dir: str | Path = MODELS_DIR, block_size: int = 256, + sample_rate: int = 48000, ): self._models_dir = Path(models_dir) self._block_size = block_size + self._sample_rate = sample_rate self._engine: Optional[NAMModel] = None # Using current naming matching nam_host self._loaded_path: Optional[str] = None self._loaded_model: Optional[NAMFastModel] = None + self._lock = threading.Lock() self._models_dir.mkdir(parents=True, exist_ok=True) @@ -81,30 +102,87 @@ class FastNAMHost: @property def is_loaded(self) -> bool: - return self._engine is not None and self._engine.is_loaded + with self._lock: + return self._engine is not None and self._engine.is_loaded @property def current_model(self) -> Optional[NAMFastModel]: - return self._loaded_model + with self._lock: + return self._loaded_model @property def avg_inference_ms(self) -> float: - if self._engine is None: - return 0.0 - return self._engine.avg_inference_ms + with self._lock: + if self._engine is None: + return 0.0 + return self._engine.avg_inference_ms @property def block_size(self) -> int: 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: - """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: return self._block_size = block_size if self._loaded_path: - logger.info("Block size changed to %d — reloading model %s", block_size, self._loaded_path) - self.load_model(self._loaded_path) + # Warm-before-kill: spin up new engine while old one still serves + 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 ────────────────────────────────────────────── @@ -112,58 +190,72 @@ class FastNAMHost: """Load a .nam model into the C++ engine. Returns True on success, False on error. + Uses warm-before-kill: spawns new process before stopping old one. """ path = Path(model_path) if not path.exists() or path.suffix.lower() not in (".nam",): logger.error("Model not found or invalid: %s", model_path) + self._last_error_val = f"Model not found: {model_path}" return False - # Stop any existing engine - self.unload() - size_mb = path.stat().st_size / (1024 * 1024) + arch = _read_nam_architecture(model_path) - # Create and start the engine - engine = NAMEngineProcess(str(path), self._block_size) + # Create and start the new engine BEFORE stopping the old one + engine = NAMEngineProcess(str(path), self._block_size, self._sample_rate) 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 - self._engine = engine - self._loaded_path = str(path) - self._loaded_model = NAMFastModel( - name=path.stem, - path=str(path), - size_mb=size_mb, - architecture="LSTM", - ) + # Swap: new engine takes over, old one is cleaned up + with self._lock: + old_engine = self._engine + self._engine = engine + self._loaded_path = str(path) + self._loaded_model = NAMFastModel( + name=path.stem, + path=str(path), + size_mb=size_mb, + architecture=arch, + ) + + if old_engine is not None: + old_engine.stop() 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, size_mb * 1024, engine.is_static, + arch, ) + self._last_error_val = "" return True def unload(self) -> None: """Unload the current model and stop the engine.""" - if self._engine is not None: - self._engine.stop() + with self._lock: + engine = self._engine self._engine = None - self._loaded_path = None - self._loaded_model = None + self._loaded_path = None + self._loaded_model = None + if engine is not None: + engine.stop() logger.info("NAM model unloaded") # ── Warm-up ──────────────────────────────────────────────────── def warm_up(self, block_size: int = 256) -> None: """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 dummy = np.zeros(block_size, dtype=np.float32) for _ in range(5): - self._engine.process(dummy) + engine.process(dummy) # ── Inference ────────────────────────────────────────────────── @@ -176,32 +268,31 @@ class FastNAMHost: Returns: 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 self._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 + return engine.process(audio_block) # ── Model discovery ──────────────────────────────────────────── 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] = [] for f in sorted(self._models_dir.glob("*.nam")): size_mb = f.stat().st_size / (1024 * 1024) + arch = _read_nam_architecture(str(f)) models.append( NAMFastModel( name=f.stem, path=str(f), size_mb=size_mb, - architecture="LSTM", + architecture=arch, ) ) return models diff --git a/src/dsp/nam_router.py b/src/dsp/nam_router.py index ff6d903..01211a8 100644 --- a/src/dsp/nam_router.py +++ b/src/dsp/nam_router.py @@ -12,6 +12,7 @@ Usage: from __future__ import annotations +import json import logging import threading from pathlib import Path @@ -34,6 +35,8 @@ class NAMEngineRouter: Directory scanned for available .nam models. block_size : int Audio block size in samples. + sample_rate : int + Audio sample rate in Hz. """ ENGINE_MODES = ("cpp", "pytorch") @@ -43,6 +46,7 @@ class NAMEngineRouter: engine_mode: str = "cpp", models_dir: str | Path | None = None, block_size: int = 256, + sample_rate: int = 48000, ): if engine_mode not in self.ENGINE_MODES: 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" ) self._block_size = block_size + self._sample_rate = sample_rate self._engine_mode = engine_mode self._engine: object = None # FastNAMHost or NAMHost instance self._loaded_path: Optional[str] = None @@ -70,6 +75,7 @@ class NAMEngineRouter: self._engine = FastNAMHost( models_dir=str(self._models_dir), block_size=self._block_size, + sample_rate=self._sample_rate, ) logger.info("NAM engine: C++ subprocess (FastNAMHost)") else: @@ -139,12 +145,17 @@ class NAMEngineRouter: def block_size(self) -> int: return self._block_size + @property + def sample_rate(self) -> int: + return self._sample_rate + # ── Crossfade (compatible with both engines) ──────────────────── @property def _crossfade_buf(self): """For pipeline crossfade compatibility. - PyTorch NAMHost has this natively; FastNAMHost has None.""" + PyTorch NAMHost has this natively; FastNAMHost has None. + """ with self._lock: if hasattr(self._engine, '_crossfade_buf'): return self._engine._crossfade_buf @@ -174,10 +185,26 @@ class NAMEngineRouter: self._engine.unload() self._loaded_path = None + # ── Audio profile sync ────────────────────────────────────────── + def set_block_size(self, block_size: int) -> None: + """Update block size. Delegates to the active engine.""" self._block_size = block_size - if self._engine is not None and hasattr(self._engine, 'set_block_size'): - self._engine.set_block_size(block_size) + with self._lock: + 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 def last_error(self) -> str: @@ -227,10 +254,17 @@ class NAMEngineRouter: for f in sorted(extra.glob("*.nam")): if str(f) not in seen: 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( name=f.stem, path=str(f), size_mb=size_mb, - architecture="LSTM", + architecture=arch, )) return models diff --git a/src/dsp/pipeline.py b/src/dsp/pipeline.py index b7443b4..4cbcd65 100644 --- a/src/dsp/pipeline.py +++ b/src/dsp/pipeline.py @@ -370,6 +370,27 @@ class AudioPipeline: self._notch_b0, self._notch_b1, self._notch_b2 = _b0, _b1, _b2 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)", self._block_size, self._sample_rate) @@ -878,6 +899,36 @@ class AudioPipeline: if self.nam._crossfade_buf is not None: 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 return np.clip(processed, -1.0, 1.0) @@ -3175,6 +3226,16 @@ class AudioPipeline: # Reset notch filter state to avoid pop on rate change self._notch_x1 = self._notch_x2 = 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 self._state.clear() self._coeffs.clear() diff --git a/src/web/server.py b/src/web/server.py index 0f4fd93..c70cc65 100644 --- a/src/web/server.py +++ b/src/web/server.py @@ -1765,6 +1765,9 @@ class WebServer: nam_host = self.deps.nam_host if nam_host and hasattr(nam_host, 'set_block_size'): 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 pipeline = self.deps.pipeline