Skip to content

Commit dba4de7

Browse files
authored
Merge pull request pipecat-ai#3684 from ai-coustics/goedev/aic-model-caching
AIC model caching
2 parents c682a44 + 2036757 commit dba4de7

3 files changed

Lines changed: 398 additions & 30 deletions

File tree

changelog/3684.changed.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
- `AICFilter` now shares read-only AIC models via a singleton `AICModelManager` in `aic_filter.py`.
2+
- Multiple filters using the same model path or `(model_id, model_download_dir)` share one loaded model, with reference counting and concurrent load deduplication.
3+
- Model file I/O runs off the event loop so the filter does not block.

src/pipecat/audio/filters/aic_filter.py

Lines changed: 187 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@
1212
1313
Classes:
1414
AICFilter: For aic-sdk (uses 'aic_sdk' module)
15+
AICModelManager: Singleton manager for read-only AIC Model instances.
1516
"""
1617

18+
import asyncio
1719
from pathlib import Path
18-
from typing import List, Optional
20+
from threading import Lock
21+
from typing import List, Optional, Tuple
1922

2023
import numpy as np
2124
from aic_sdk import (
@@ -33,6 +36,177 @@
3336
from pipecat.frames.frames import FilterControlFrame, FilterEnableFrame
3437

3538

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+
36210
class AICFilter(BaseAudioFilter):
37211
"""Audio filter using ai-coustics' AIC SDK for real-time enhancement.
38212
@@ -91,7 +265,8 @@ def __init__(
91265
32768.0 # 2^15, for normalizing int16 (-32768 to 32767) to float32 (-1.0 to 1.0)
92266
)
93267

94-
# AIC SDK objects
268+
# AIC SDK objects; model is shared via AICModelManager
269+
self._model_cache_key: Optional[str] = None
95270
self._model = None
96271
self._processor = None
97272
self._processor_ctx = None
@@ -162,16 +337,12 @@ async def start(self, sample_rate: int):
162337
"""
163338
self._sample_rate = sample_rate
164339

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+
)
175346

176347
# Get optimal frames for this sample rate
177348
self._frames_per_block = self._model.get_optimal_num_frames(self._sample_rate)
@@ -242,6 +413,10 @@ async def stop(self):
242413
self._aic_ready = False
243414
self._audio_buffer.clear()
244415

416+
if self._model_cache_key is not None:
417+
AICModelManager.release(self._model_cache_key)
418+
self._model_cache_key = None
419+
245420
async def process_frame(self, frame: FilterControlFrame):
246421
"""Process control frames to enable/disable filtering.
247422

0 commit comments

Comments
 (0)