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:
1
changelog/3961.changed.md
Normal file
1
changelog/3961.changed.md
Normal file
@@ -0,0 +1 @@
|
||||
- Re-added `enhancement_level` support to `AICFilter` with runtime `FilterEnableFrame` control, applying `ProcessorParameter.Bypass` and `ProcessorParameter.EnhancementLevel` together.
|
||||
@@ -23,6 +23,7 @@ from typing import List, Optional, Tuple
|
||||
import numpy as np
|
||||
from aic_sdk import (
|
||||
Model,
|
||||
ParameterOutOfRangeError,
|
||||
ProcessorAsync,
|
||||
ProcessorConfig,
|
||||
ProcessorParameter,
|
||||
@@ -220,6 +221,7 @@ class AICFilter(BaseAudioFilter):
|
||||
model_id: Optional[str] = None,
|
||||
model_path: Optional[Path] = None,
|
||||
model_download_dir: Optional[Path] = None,
|
||||
enhancement_level: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Initialize the AIC filter.
|
||||
|
||||
@@ -231,9 +233,12 @@ class AICFilter(BaseAudioFilter):
|
||||
model_id is ignored and no download occurs.
|
||||
model_download_dir: Directory for downloading models as a Path object.
|
||||
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:
|
||||
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(6)
|
||||
@@ -244,14 +249,18 @@ class AICFilter(BaseAudioFilter):
|
||||
"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._model_id = model_id
|
||||
self._model_path = model_path
|
||||
self._model_download_dir = model_download_dir or (
|
||||
Path.home() / ".cache" / "pipecat" / "aic-models"
|
||||
)
|
||||
|
||||
self._enhancement_level = enhancement_level
|
||||
self._bypass = False
|
||||
|
||||
self._sample_rate = 0
|
||||
self._aic_ready = False
|
||||
self._frames_per_block = 0
|
||||
@@ -325,6 +334,26 @@ class AICFilter(BaseAudioFilter):
|
||||
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):
|
||||
"""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._vad_ctx = self._processor.get_vad_context()
|
||||
|
||||
# Apply initial parameters
|
||||
self._processor_ctx.set_parameter(ProcessorParameter.Bypass, 1.0 if self._bypass else 0.0)
|
||||
# Apply initial control parameters
|
||||
self._apply_bypass()
|
||||
self._apply_enhancement_level()
|
||||
|
||||
# Log processor information
|
||||
logger.debug(f"ai-coustics filter started:")
|
||||
logger.debug(f" Model ID: {self._model.get_id()}")
|
||||
logger.debug(f" Sample rate: {self._sample_rate} Hz")
|
||||
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 number of frames for {self._sample_rate} Hz: "
|
||||
@@ -425,9 +459,8 @@ class AICFilter(BaseAudioFilter):
|
||||
self._bypass = not frame.enable
|
||||
if self._processor_ctx is not None:
|
||||
try:
|
||||
self._processor_ctx.set_parameter(
|
||||
ProcessorParameter.Bypass, 1.0 if self._bypass else 0.0
|
||||
)
|
||||
self._apply_bypass()
|
||||
self._apply_enhancement_level()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(f"AIC set_parameter failed: {e}")
|
||||
|
||||
|
||||
@@ -8,16 +8,19 @@ import asyncio
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Check if aic_sdk is available
|
||||
aic_sdk: Any
|
||||
try:
|
||||
import aic_sdk
|
||||
|
||||
HAS_AIC_SDK = True
|
||||
except ImportError:
|
||||
aic_sdk = None
|
||||
HAS_AIC_SDK = False
|
||||
|
||||
# Module path for patching
|
||||
@@ -67,6 +70,22 @@ class MockProcessorContext:
|
||||
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:
|
||||
"""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._model_id, "test-model")
|
||||
self.assertIsNone(filter_instance._model_path)
|
||||
self.assertIsNone(filter_instance._enhancement_level)
|
||||
self.assertFalse(filter_instance._bypass)
|
||||
|
||||
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)
|
||||
|
||||
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):
|
||||
"""Test starting filter with a local model path."""
|
||||
model_path = Path("/tmp/test.aicmodel")
|
||||
@@ -246,7 +289,6 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase):
|
||||
filter_instance = self._create_filter_with_mocks()
|
||||
await self._start_filter_with_mocks(filter_instance)
|
||||
|
||||
# Check that bypass was set to 0.0 (enabled)
|
||||
bypass_params = [
|
||||
(p, v)
|
||||
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.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):
|
||||
"""Test that stop properly cleans up resources and releases model reference."""
|
||||
filter_instance = self._create_filter_with_mocks()
|
||||
@@ -420,57 +506,64 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIsNone(filter_instance._processor)
|
||||
self.assertFalse(filter_instance._aic_ready)
|
||||
|
||||
async def test_start_parameter_fixed_error_logged(self):
|
||||
"""Test start() when set_parameter raises ParameterFixedError: logged, no raise."""
|
||||
filter_instance = self._create_filter_with_mocks()
|
||||
self.mock_processor.processor_ctx.set_parameter = MagicMock(
|
||||
side_effect=aic_sdk.ParameterFixedError("fixed")
|
||||
)
|
||||
async def test_start_skips_unsupported_enhancement_level_after_first_attempt(self):
|
||||
"""Test unsupported enhancement_level is attempted once and then skipped."""
|
||||
filter_instance = self._create_filter_with_mocks(enhancement_level=0.9)
|
||||
|
||||
with (
|
||||
patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls,
|
||||
patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls,
|
||||
patch(f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor),
|
||||
):
|
||||
mock_manager_cls.acquire = AsyncMock(return_value=(self.mock_model, "test-key"))
|
||||
mock_config_cls.optimal.return_value = MagicMock()
|
||||
with patch(f"{AIC_FILTER_MODULE}.ParameterOutOfRangeError", ValueError):
|
||||
unsupported_ctx = UnsupportedEnhancementProcessorContext(
|
||||
aic_sdk.ProcessorParameter.EnhancementLevel, ValueError
|
||||
)
|
||||
self.mock_processor.processor_ctx = unsupported_ctx
|
||||
|
||||
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):
|
||||
"""Test process_frame when set_parameter raises: exception logged, no raise."""
|
||||
filter_instance = self._create_filter_with_mocks()
|
||||
async def test_process_frame_disable_sets_bypass(self):
|
||||
"""Test disable frame toggles bypass."""
|
||||
filter_instance = self._create_filter_with_mocks(enhancement_level=0.7)
|
||||
await self._start_filter_with_mocks(filter_instance)
|
||||
filter_instance._processor_ctx.set_parameter = MagicMock(
|
||||
side_effect=ValueError("param error")
|
||||
)
|
||||
await filter_instance.process_frame(self.FilterEnableFrame(enable=False))
|
||||
|
||||
await filter_instance.process_frame(self.FilterEnableFrame(enable=True))
|
||||
|
||||
self.assertFalse(filter_instance._bypass)
|
||||
|
||||
async def test_process_frame_enable(self):
|
||||
"""Test processing FilterEnableFrame to enable filtering."""
|
||||
filter_instance = self._create_filter_with_mocks()
|
||||
await self._start_filter_with_mocks(filter_instance)
|
||||
filter_instance._bypass = True
|
||||
|
||||
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)
|
||||
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.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):
|
||||
"""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
|
||||
|
||||
This is the exact error path reported in production (line 414).
|
||||
The fix removes the memoryview by snapshotting data into immutable
|
||||
bytes before any await.
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user