|
12 | 12 |
|
13 | 13 | Classes: |
14 | 14 | AICFilter: For aic-sdk (uses 'aic_sdk' module) |
| 15 | + AICModelManager: Singleton manager for read-only AIC Model instances. |
15 | 16 | """ |
16 | 17 |
|
| 18 | +import asyncio |
17 | 19 | from pathlib import Path |
18 | | -from typing import List, Optional |
| 20 | +from threading import Lock |
| 21 | +from typing import List, Optional, Tuple |
19 | 22 |
|
20 | 23 | import numpy as np |
21 | 24 | from aic_sdk import ( |
|
33 | 36 | from pipecat.frames.frames import FilterControlFrame, FilterEnableFrame |
34 | 37 |
|
35 | 38 |
|
| 39 | +class AICModelManager: |
| 40 | + """Singleton manager for read-only AIC Model instances with reference counting. |
| 41 | +
|
| 42 | + Caches Model instances by path or (model_id + download_dir). Multiple |
| 43 | + AICFilter instances using the same model share one Model; the manager |
| 44 | + acquires on first use and releases when the last reference is dropped. |
| 45 | + """ |
| 46 | + |
| 47 | + _cache: dict[str, Tuple[Model, int]] = {} # key -> (model, ref_count) |
| 48 | + _lock = Lock() |
| 49 | + _loading: dict[ |
| 50 | + str, asyncio.Task[Model] |
| 51 | + ] = {} # key -> load task (deduplicates concurrent loads) |
| 52 | + |
| 53 | + @classmethod |
| 54 | + def _increment_reference(cls, cache_key: str, entry: Tuple[Model, int]) -> Tuple[Model, str]: |
| 55 | + """Increment reference count for cached entry. Caller must hold _lock.""" |
| 56 | + cached_model, ref_count = entry |
| 57 | + cls._cache[cache_key] = (cached_model, ref_count + 1) |
| 58 | + logger.debug(f"AIC model cache key={cache_key!r} ref_count={ref_count + 1}") |
| 59 | + return cached_model, cache_key |
| 60 | + |
| 61 | + @classmethod |
| 62 | + def _store_new_reference(cls, cache_key: str, model: Model) -> Tuple[Model, str]: |
| 63 | + """Store new model in cache with ref count 1. Caller must hold _lock.""" |
| 64 | + cls._cache[cache_key] = (model, 1) |
| 65 | + logger.debug(f"AIC model cached key={cache_key!r} ref_count=1") |
| 66 | + return model, cache_key |
| 67 | + |
| 68 | + @classmethod |
| 69 | + async def _load_model_from_file( |
| 70 | + cls, |
| 71 | + cache_key: str, |
| 72 | + *, |
| 73 | + model_path: Optional[Path] = None, |
| 74 | + model_id: Optional[str] = None, |
| 75 | + model_download_dir: Optional[Path] = None, |
| 76 | + ) -> Model: |
| 77 | + """Run the actual load (file or download). Separate to allow create_task and deduplication.""" |
| 78 | + if model_path is not None: |
| 79 | + logger.debug(f"Loading AIC model from file: {model_path}") |
| 80 | + model_path_str = str(model_path) |
| 81 | + |
| 82 | + elif model_id is not None and model_download_dir is not None: |
| 83 | + logger.debug(f"Downloading AIC model: {model_id}") |
| 84 | + model_download_dir.mkdir(parents=True, exist_ok=True) |
| 85 | + model_path_str = await Model.download_async(model_id, str(model_download_dir)) |
| 86 | + logger.debug(f"Model downloaded to: {model_path_str}") |
| 87 | + |
| 88 | + else: |
| 89 | + raise ValueError("Unexpected model_path or (model_id and model_download_dir) state.") |
| 90 | + |
| 91 | + loop = asyncio.get_running_loop() |
| 92 | + return await loop.run_in_executor(None, lambda: Model.from_file(model_path_str)) |
| 93 | + |
| 94 | + @staticmethod |
| 95 | + def _get_cache_key( |
| 96 | + *, |
| 97 | + model_path: Optional[Path] = None, |
| 98 | + model_id: Optional[str] = None, |
| 99 | + model_download_dir: Optional[Path] = None, |
| 100 | + ) -> str: |
| 101 | + """Build a stable cache key for the model. |
| 102 | +
|
| 103 | + Args: |
| 104 | + model_path: Path to a local .aicmodel file. |
| 105 | + model_id: Model identifier (See https://artifacts.ai-coustics.io/ for available models). |
| 106 | + model_download_dir: Directory used for downloading models. |
| 107 | +
|
| 108 | + Returns: |
| 109 | + A string key unique per (path) or (model_id + download_dir). |
| 110 | + """ |
| 111 | + if model_path is not None: |
| 112 | + return f"path:{model_path.resolve()}" |
| 113 | + |
| 114 | + if model_id is not None and model_download_dir is not None: |
| 115 | + return f"id:{model_id}:{model_download_dir.resolve()}" |
| 116 | + |
| 117 | + raise ValueError("Either model_path or (model_id and model_download_dir) must be set.") |
| 118 | + |
| 119 | + @classmethod |
| 120 | + async def acquire( |
| 121 | + cls, |
| 122 | + *, |
| 123 | + model_path: Optional[Path] = None, |
| 124 | + model_id: Optional[str] = None, |
| 125 | + model_download_dir: Optional[Path] = None, |
| 126 | + ) -> Tuple[Model, str]: |
| 127 | + """Get or load a Model and increment its reference count. |
| 128 | +
|
| 129 | + Call this when starting a filter. Store the returned key and pass it |
| 130 | + to release() when stopping the filter. |
| 131 | +
|
| 132 | + Args: |
| 133 | + model_path: Path to a local .aicmodel file. If set, model_id is ignored. |
| 134 | + model_id: Model identifier to download from CDN. |
| 135 | + model_download_dir: Directory for downloading models. Required if |
| 136 | + model_id is used. |
| 137 | +
|
| 138 | + Returns: |
| 139 | + Tuple of (shared Model instance, cache key for release). |
| 140 | +
|
| 141 | + Raises: |
| 142 | + ValueError: If neither model_path nor (model_id + model_download_dir) |
| 143 | + is provided, or if model_id is set without model_download_dir. |
| 144 | + """ |
| 145 | + cache_key = cls._get_cache_key( |
| 146 | + model_path=model_path, |
| 147 | + model_id=model_id, |
| 148 | + model_download_dir=model_download_dir, |
| 149 | + ) |
| 150 | + |
| 151 | + with cls._lock: |
| 152 | + entry = cls._cache.get(cache_key) |
| 153 | + if entry is not None: |
| 154 | + return cls._increment_reference(cache_key, entry) |
| 155 | + |
| 156 | + # Deduplicate concurrent loads for the same key |
| 157 | + load_task = cls._loading.get(cache_key) |
| 158 | + if load_task is None: |
| 159 | + load_task = asyncio.create_task( |
| 160 | + cls._load_model_from_file( |
| 161 | + cache_key, |
| 162 | + model_path=model_path, |
| 163 | + model_id=model_id, |
| 164 | + model_download_dir=model_download_dir, |
| 165 | + ) |
| 166 | + ) |
| 167 | + cls._loading[cache_key] = load_task |
| 168 | + |
| 169 | + try: |
| 170 | + model = await load_task |
| 171 | + finally: |
| 172 | + with cls._lock: |
| 173 | + cls._loading.pop(cache_key, None) |
| 174 | + |
| 175 | + with cls._lock: |
| 176 | + entry = cls._cache.get(cache_key) |
| 177 | + if entry is not None: |
| 178 | + return cls._increment_reference(cache_key, entry) |
| 179 | + return cls._store_new_reference(cache_key, model) |
| 180 | + |
| 181 | + @classmethod |
| 182 | + def release(cls, key: str) -> None: |
| 183 | + """Release a reference to a cached model. |
| 184 | +
|
| 185 | + Call this when stopping a filter, with the key returned from |
| 186 | + get_model(). When the last reference is released, the model |
| 187 | + is removed from the cache. |
| 188 | +
|
| 189 | + Args: |
| 190 | + key: Cache key returned by get_model(). |
| 191 | + """ |
| 192 | + with cls._lock: |
| 193 | + entry = cls._cache.get(key) |
| 194 | + |
| 195 | + if entry is None: |
| 196 | + logger.warning(f"AIC model release unknown key={key!r}") |
| 197 | + return |
| 198 | + |
| 199 | + model, ref_count = entry |
| 200 | + ref_count -= 1 |
| 201 | + |
| 202 | + if ref_count <= 0: |
| 203 | + del cls._cache[key] |
| 204 | + logger.debug(f"AIC model evicted key={key!r}") |
| 205 | + else: |
| 206 | + cls._cache[key] = (model, ref_count) |
| 207 | + logger.debug(f"AIC model key={key!r} ref_count={ref_count}") |
| 208 | + |
| 209 | + |
36 | 210 | class AICFilter(BaseAudioFilter): |
37 | 211 | """Audio filter using ai-coustics' AIC SDK for real-time enhancement. |
38 | 212 |
|
@@ -91,7 +265,8 @@ def __init__( |
91 | 265 | 32768.0 # 2^15, for normalizing int16 (-32768 to 32767) to float32 (-1.0 to 1.0) |
92 | 266 | ) |
93 | 267 |
|
94 | | - # AIC SDK objects |
| 268 | + # AIC SDK objects; model is shared via AICModelManager |
| 269 | + self._model_cache_key: Optional[str] = None |
95 | 270 | self._model = None |
96 | 271 | self._processor = None |
97 | 272 | self._processor_ctx = None |
@@ -162,16 +337,12 @@ async def start(self, sample_rate: int): |
162 | 337 | """ |
163 | 338 | self._sample_rate = sample_rate |
164 | 339 |
|
165 | | - # Load or download model |
166 | | - if self._model_path: |
167 | | - logger.debug(f"Loading AIC model from: {self._model_path}") |
168 | | - self._model = Model.from_file(str(self._model_path)) |
169 | | - else: |
170 | | - logger.debug(f"Downloading AIC model: {self._model_id}") |
171 | | - self._model_download_dir.mkdir(parents=True, exist_ok=True) |
172 | | - model_path = await Model.download_async(self._model_id, str(self._model_download_dir)) |
173 | | - logger.debug(f"Model downloaded to: {model_path}") |
174 | | - self._model = Model.from_file(model_path) |
| 340 | + # Acquire shared read-only model from singleton manager |
| 341 | + self._model, self._model_cache_key = await AICModelManager.acquire( |
| 342 | + model_path=self._model_path, |
| 343 | + model_id=self._model_id, |
| 344 | + model_download_dir=self._model_download_dir, |
| 345 | + ) |
175 | 346 |
|
176 | 347 | # Get optimal frames for this sample rate |
177 | 348 | self._frames_per_block = self._model.get_optimal_num_frames(self._sample_rate) |
@@ -242,6 +413,10 @@ async def stop(self): |
242 | 413 | self._aic_ready = False |
243 | 414 | self._audio_buffer.clear() |
244 | 415 |
|
| 416 | + if self._model_cache_key is not None: |
| 417 | + AICModelManager.release(self._model_cache_key) |
| 418 | + self._model_cache_key = None |
| 419 | + |
245 | 420 | async def process_frame(self, frame: FilterControlFrame): |
246 | 421 | """Process control frames to enable/disable filtering. |
247 | 422 |
|
|
0 commit comments