Merge pull request #3961 from ai-coustics/goekmengoergen/sys-663-re-enable-enhancement-level-feature-on-pipecat

Add enhancement_level support to `AICFilter`.
This commit is contained in:
Mark Backman
2026-03-10 14:24:15 -04:00
committed by GitHub
3 changed files with 178 additions and 52 deletions

View File

@@ -0,0 +1 @@
- Re-added `enhancement_level` support to `AICFilter` with runtime `FilterEnableFrame` control, applying `ProcessorParameter.Bypass` and `ProcessorParameter.EnhancementLevel` together.

View File

@@ -23,6 +23,7 @@ from typing import List, Optional, Tuple
import numpy as np import numpy as np
from aic_sdk import ( from aic_sdk import (
Model, Model,
ParameterOutOfRangeError,
ProcessorAsync, ProcessorAsync,
ProcessorConfig, ProcessorConfig,
ProcessorParameter, ProcessorParameter,
@@ -220,6 +221,7 @@ class AICFilter(BaseAudioFilter):
model_id: Optional[str] = None, model_id: Optional[str] = None,
model_path: Optional[Path] = None, model_path: Optional[Path] = None,
model_download_dir: Optional[Path] = None, model_download_dir: Optional[Path] = None,
enhancement_level: Optional[float] = None,
) -> None: ) -> None:
"""Initialize the AIC filter. """Initialize the AIC filter.
@@ -231,9 +233,12 @@ class AICFilter(BaseAudioFilter):
model_id is ignored and no download occurs. model_id is ignored and no download occurs.
model_download_dir: Directory for downloading models as a Path object. model_download_dir: Directory for downloading models as a Path object.
Defaults to a cache directory in user's home folder. Defaults to a cache directory in user's home folder.
enhancement_level: Optional overall enhancement strength (0.0..1.0).
If None, the model default is used.
Raises: Raises:
ValueError: If neither model_id nor model_path is provided. ValueError: If neither model_id nor model_path is provided, or if
enhancement_level is out of range.
""" """
# Set SDK ID for telemetry identification (6 = pipecat) # Set SDK ID for telemetry identification (6 = pipecat)
set_sdk_id(6) set_sdk_id(6)
@@ -244,14 +249,18 @@ class AICFilter(BaseAudioFilter):
"See https://artifacts.ai-coustics.io/ for available models." "See https://artifacts.ai-coustics.io/ for available models."
) )
if enhancement_level is not None and not 0.0 <= enhancement_level <= 1.0:
raise ValueError("'enhancement_level' must be between 0.0 and 1.0.")
self._license_key = license_key self._license_key = license_key
self._model_id = model_id self._model_id = model_id
self._model_path = model_path self._model_path = model_path
self._model_download_dir = model_download_dir or ( self._model_download_dir = model_download_dir or (
Path.home() / ".cache" / "pipecat" / "aic-models" Path.home() / ".cache" / "pipecat" / "aic-models"
) )
self._enhancement_level = enhancement_level
self._bypass = False self._bypass = False
self._sample_rate = 0 self._sample_rate = 0
self._aic_ready = False self._aic_ready = False
self._frames_per_block = 0 self._frames_per_block = 0
@@ -325,6 +334,26 @@ class AICFilter(BaseAudioFilter):
sensitivity=sensitivity, sensitivity=sensitivity,
) )
def _apply_enhancement_level(self):
"""Apply enhancement_level if configured and supported by the active model."""
if self._processor_ctx is None or self._enhancement_level is None:
return
try:
self._processor_ctx.set_parameter(
ProcessorParameter.EnhancementLevel, self._enhancement_level
)
except ParameterOutOfRangeError as e:
logger.warning(f"AIC EnhancementLevel set_parameter out-of-range: {e}")
self._enhancement_level = None
def _apply_bypass(self):
"""Apply bypass parameter to the active processor."""
if self._processor_ctx is None:
return
self._processor_ctx.set_parameter(ProcessorParameter.Bypass, 1.0 if self._bypass else 0.0)
async def start(self, sample_rate: int): async def start(self, sample_rate: int):
"""Initialize the filter with the transport's sample rate. """Initialize the filter with the transport's sample rate.
@@ -373,14 +402,19 @@ class AICFilter(BaseAudioFilter):
self._processor_ctx = self._processor.get_processor_context() self._processor_ctx = self._processor.get_processor_context()
self._vad_ctx = self._processor.get_vad_context() self._vad_ctx = self._processor.get_vad_context()
# Apply initial parameters # Apply initial control parameters
self._processor_ctx.set_parameter(ProcessorParameter.Bypass, 1.0 if self._bypass else 0.0) self._apply_bypass()
self._apply_enhancement_level()
# Log processor information # Log processor information
logger.debug(f"ai-coustics filter started:") logger.debug(f"ai-coustics filter started:")
logger.debug(f" Model ID: {self._model.get_id()}") logger.debug(f" Model ID: {self._model.get_id()}")
logger.debug(f" Sample rate: {self._sample_rate} Hz") logger.debug(f" Sample rate: {self._sample_rate} Hz")
logger.debug(f" Frames per chunk: {self._frames_per_block}") logger.debug(f" Frames per chunk: {self._frames_per_block}")
if self._enhancement_level is not None:
logger.debug(f" Enhancement level: {self._enhancement_level}")
else:
logger.debug(" Enhancement level not configured; using the model's default behavior.")
logger.debug(f" Optimal sample rate: {self._model.get_optimal_sample_rate()} Hz") logger.debug(f" Optimal sample rate: {self._model.get_optimal_sample_rate()} Hz")
logger.debug( logger.debug(
f" Optimal number of frames for {self._sample_rate} Hz: " f" Optimal number of frames for {self._sample_rate} Hz: "
@@ -425,9 +459,8 @@ class AICFilter(BaseAudioFilter):
self._bypass = not frame.enable self._bypass = not frame.enable
if self._processor_ctx is not None: if self._processor_ctx is not None:
try: try:
self._processor_ctx.set_parameter( self._apply_bypass()
ProcessorParameter.Bypass, 1.0 if self._bypass else 0.0 self._apply_enhancement_level()
)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.error(f"AIC set_parameter failed: {e}") logger.error(f"AIC set_parameter failed: {e}")

View File

@@ -8,16 +8,19 @@ import asyncio
import time import time
import unittest import unittest
from pathlib import Path from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import numpy as np import numpy as np
# Check if aic_sdk is available # Check if aic_sdk is available
aic_sdk: Any
try: try:
import aic_sdk import aic_sdk
HAS_AIC_SDK = True HAS_AIC_SDK = True
except ImportError: except ImportError:
aic_sdk = None
HAS_AIC_SDK = False HAS_AIC_SDK = False
# Module path for patching # Module path for patching
@@ -67,6 +70,22 @@ class MockProcessorContext:
self.reset_called = True self.reset_called = True
class UnsupportedEnhancementProcessorContext(MockProcessorContext):
"""Processor context mock that rejects EnhancementLevel updates."""
def __init__(self, enhancement_parameter, error_type):
super().__init__()
self._enhancement_parameter = enhancement_parameter
self._error_type = error_type
self.enhancement_attempts = 0
def set_parameter(self, param, value):
if param == self._enhancement_parameter:
self.enhancement_attempts += 1
raise self._error_type("EnhancementLevel out of range")
super().set_parameter(param, value)
class MockVadContext: class MockVadContext:
"""A lightweight mock for AIC VadContext.""" """A lightweight mock for AIC VadContext."""
@@ -157,6 +176,7 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase):
self.assertEqual(filter_instance._license_key, "test-key") self.assertEqual(filter_instance._license_key, "test-key")
self.assertEqual(filter_instance._model_id, "test-model") self.assertEqual(filter_instance._model_id, "test-model")
self.assertIsNone(filter_instance._model_path) self.assertIsNone(filter_instance._model_path)
self.assertIsNone(filter_instance._enhancement_level)
self.assertFalse(filter_instance._bypass) self.assertFalse(filter_instance._bypass)
async def test_initialization_with_model_path(self): async def test_initialization_with_model_path(self):
@@ -174,6 +194,29 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase):
self.assertEqual(filter_instance._model_download_dir, download_dir) self.assertEqual(filter_instance._model_download_dir, download_dir)
async def test_initialization_with_valid_enhancement_level(self):
"""Test filter initialization with a valid enhancement_level."""
filter_instance = self._create_filter_with_mocks(enhancement_level=0.75)
self.assertEqual(filter_instance._enhancement_level, 0.75)
async def test_initialization_with_none_enhancement_level(self):
"""Test filter initialization with enhancement_level set to None."""
filter_instance = self._create_filter_with_mocks(enhancement_level=None)
self.assertIsNone(filter_instance._enhancement_level)
async def test_initialization_invalid_enhancement_level_raises(self):
"""Test initialization rejects enhancement_level outside 0.0..1.0."""
with patch(f"{AIC_FILTER_MODULE}.set_sdk_id"):
with self.assertRaises(ValueError) as context:
self.AICFilter(
license_key="test-key",
model_id="test-model",
enhancement_level=1.5,
)
self.assertIn("enhancement_level", str(context.exception))
async def test_start_with_model_path(self): async def test_start_with_model_path(self):
"""Test starting filter with a local model path.""" """Test starting filter with a local model path."""
model_path = Path("/tmp/test.aicmodel") model_path = Path("/tmp/test.aicmodel")
@@ -246,7 +289,6 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase):
filter_instance = self._create_filter_with_mocks() filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance) await self._start_filter_with_mocks(filter_instance)
# Check that bypass was set to 0.0 (enabled)
bypass_params = [ bypass_params = [
(p, v) (p, v)
for p, v in self.mock_processor.processor_ctx.parameters_set for p, v in self.mock_processor.processor_ctx.parameters_set
@@ -255,6 +297,50 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase):
self.assertTrue(len(bypass_params) > 0) self.assertTrue(len(bypass_params) > 0)
self.assertEqual(bypass_params[-1][1], 0.0) self.assertEqual(bypass_params[-1][1], 0.0)
async def test_start_applies_enhancement_level_when_supported(self):
"""Test that start applies enhancement_level when supported by model."""
filter_instance = self._create_filter_with_mocks(enhancement_level=0.65)
await self._start_filter_with_mocks(filter_instance)
enhancement_params = [
(p, v)
for p, v in self.mock_processor.processor_ctx.parameters_set
if p == aic_sdk.ProcessorParameter.EnhancementLevel
]
self.assertTrue(len(enhancement_params) > 0)
self.assertEqual(enhancement_params[-1][1], 0.65)
async def test_start_ignores_enhancement_level_when_unsupported(self):
"""Test unsupported enhancement_level logs warning and keeps filter ready."""
filter_instance = self._create_filter_with_mocks(enhancement_level=0.65)
with patch(f"{AIC_FILTER_MODULE}.ParameterOutOfRangeError", ValueError):
unsupported_ctx = UnsupportedEnhancementProcessorContext(
aic_sdk.ProcessorParameter.EnhancementLevel, ValueError
)
self.mock_processor.processor_ctx = unsupported_ctx
await self._start_filter_with_mocks(filter_instance)
self.assertTrue(filter_instance._aic_ready)
self.assertIsNone(filter_instance._enhancement_level)
self.assertEqual(unsupported_ctx.enhancement_attempts, 1)
async def test_start_does_not_set_enhancement_level_when_none(self):
"""Test start does not attempt enhancement_level when not configured."""
filter_instance = self._create_filter_with_mocks(enhancement_level=None)
with patch(f"{AIC_FILTER_MODULE}.logger.debug") as mock_debug:
await self._start_filter_with_mocks(filter_instance)
enhancement_params = [
(p, v)
for p, v in self.mock_processor.processor_ctx.parameters_set
if p == aic_sdk.ProcessorParameter.EnhancementLevel
]
self.assertEqual(enhancement_params, [])
self.assertTrue(
any("default behavior" in str(call.args[0]) for call in mock_debug.call_args_list)
)
async def test_stop_cleans_up_resources(self): async def test_stop_cleans_up_resources(self):
"""Test that stop properly cleans up resources and releases model reference.""" """Test that stop properly cleans up resources and releases model reference."""
filter_instance = self._create_filter_with_mocks() filter_instance = self._create_filter_with_mocks()
@@ -420,57 +506,64 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(filter_instance._processor) self.assertIsNone(filter_instance._processor)
self.assertFalse(filter_instance._aic_ready) self.assertFalse(filter_instance._aic_ready)
async def test_start_parameter_fixed_error_logged(self): async def test_start_skips_unsupported_enhancement_level_after_first_attempt(self):
"""Test start() when set_parameter raises ParameterFixedError: logged, no raise.""" """Test unsupported enhancement_level is attempted once and then skipped."""
filter_instance = self._create_filter_with_mocks() filter_instance = self._create_filter_with_mocks(enhancement_level=0.9)
self.mock_processor.processor_ctx.set_parameter = MagicMock(
side_effect=aic_sdk.ParameterFixedError("fixed")
)
with ( with patch(f"{AIC_FILTER_MODULE}.ParameterOutOfRangeError", ValueError):
patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls, unsupported_ctx = UnsupportedEnhancementProcessorContext(
patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls, aic_sdk.ProcessorParameter.EnhancementLevel, ValueError
patch(f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor), )
): self.mock_processor.processor_ctx = unsupported_ctx
mock_manager_cls.acquire = AsyncMock(return_value=(self.mock_model, "test-key"))
mock_config_cls.optimal.return_value = MagicMock()
await filter_instance.start(16000) await self._start_filter_with_mocks(filter_instance)
await filter_instance.stop()
await self._start_filter_with_mocks(filter_instance)
self.assertTrue(filter_instance._aic_ready) self.assertEqual(unsupported_ctx.enhancement_attempts, 1)
async def test_process_frame_set_parameter_exception_logged(self): async def test_process_frame_disable_sets_bypass(self):
"""Test process_frame when set_parameter raises: exception logged, no raise.""" """Test disable frame toggles bypass."""
filter_instance = self._create_filter_with_mocks() filter_instance = self._create_filter_with_mocks(enhancement_level=0.7)
await self._start_filter_with_mocks(filter_instance) await self._start_filter_with_mocks(filter_instance)
filter_instance._processor_ctx.set_parameter = MagicMock( await filter_instance.process_frame(self.FilterEnableFrame(enable=False))
side_effect=ValueError("param error")
)
await filter_instance.process_frame(self.FilterEnableFrame(enable=True)) enhancement_params = [
(p, v)
self.assertFalse(filter_instance._bypass) for p, v in self.mock_processor.processor_ctx.parameters_set
if p == aic_sdk.ProcessorParameter.EnhancementLevel
async def test_process_frame_enable(self): ]
"""Test processing FilterEnableFrame to enable filtering.""" bypass_params = [
filter_instance = self._create_filter_with_mocks() (p, v)
await self._start_filter_with_mocks(filter_instance) for p, v in self.mock_processor.processor_ctx.parameters_set
filter_instance._bypass = True if p == aic_sdk.ProcessorParameter.Bypass
]
enable_frame = self.FilterEnableFrame(enable=True)
await filter_instance.process_frame(enable_frame)
self.assertFalse(filter_instance._bypass)
async def test_process_frame_disable(self):
"""Test processing FilterEnableFrame to disable filtering."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
disable_frame = self.FilterEnableFrame(enable=False)
await filter_instance.process_frame(disable_frame)
self.assertTrue(filter_instance._bypass) self.assertTrue(filter_instance._bypass)
self.assertEqual(enhancement_params[-1][1], 0.7)
self.assertEqual(bypass_params[-1][1], 1.0)
async def test_process_frame_enable_restores_configured_enhancement(self):
"""Test enable frame restores configured enhancement level."""
filter_instance = self._create_filter_with_mocks(enhancement_level=0.7)
await self._start_filter_with_mocks(filter_instance)
await filter_instance.process_frame(self.FilterEnableFrame(enable=False))
await filter_instance.process_frame(self.FilterEnableFrame(enable=True))
enhancement_params = [
(p, v)
for p, v in self.mock_processor.processor_ctx.parameters_set
if p == aic_sdk.ProcessorParameter.EnhancementLevel
]
bypass_params = [
(p, v)
for p, v in self.mock_processor.processor_ctx.parameters_set
if p == aic_sdk.ProcessorParameter.Bypass
]
self.assertFalse(filter_instance._bypass)
self.assertEqual(enhancement_params[-1][1], 0.7)
self.assertEqual(bypass_params[-1][1], 0.0)
async def test_filter_when_not_ready(self): async def test_filter_when_not_ready(self):
"""Test that filter returns audio unchanged when not ready.""" """Test that filter returns audio unchanged when not ready."""
@@ -678,7 +771,6 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase):
BufferError: Existing exports of data: object cannot be re-sized BufferError: Existing exports of data: object cannot be re-sized
This is the exact error path reported in production (line 414).
The fix removes the memoryview by snapshotting data into immutable The fix removes the memoryview by snapshotting data into immutable
bytes before any await. bytes before any await.
""" """