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
|
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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user