File size: 3,360 Bytes
472bb49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
"""In-process audio cache — keys large numpy arrays out of Gradio gr.State.

Gradio deep-copies State values, so passing raw audio arrays between callbacks
would double ~1GB+ memory per transition. Callers store the array once and pass
the returned key through State instead.

Count-bounded LRU. The bound (``config.AUDIO_CACHE_MAX_ENTRIES``, default 32)
sits well above the app's request concurrency (queue limit 20) so an in-flight
request's audio is never evicted mid-pipeline. Tradeoff: a browser tab left
idle past 32 newer uploads loses its entry — follow-up actions then raise a
cache miss ("run Extract Segments first"); saved sessions rehydrate from disk
via ``register_audio``. Every load refreshes recency.
"""
import logging
import threading
import uuid as _uuid
from collections import OrderedDict

import numpy as np

from config import AUDIO_CACHE_MAX_ENTRIES

log = logging.getLogger(__name__)

_AUDIO_STORE: "OrderedDict[str, tuple]" = OrderedDict()   # key → (audio_array, sample_rate)
_LOCK = threading.Lock()


def _get_entry(key: str):
    """Fetch an entry and refresh its recency. Returns None on miss."""
    with _LOCK:
        entry = _AUDIO_STORE.get(key)
        if entry is not None:
            _AUDIO_STORE.move_to_end(key)
        return entry


def _store_audio(audio: np.ndarray, sample_rate: int) -> str:
    """Cache audio in-process, return a lightweight reference key."""
    key = _uuid.uuid4().hex
    with _LOCK:
        _AUDIO_STORE[key] = (audio, sample_rate)
        while len(_AUDIO_STORE) > AUDIO_CACHE_MAX_ENTRIES:
            evicted_key, (evicted_audio, _) = _AUDIO_STORE.popitem(last=False)
            log.debug("audio cache evicted %s (%.0fs of audio, bound=%d)",
                      evicted_key, len(evicted_audio) / 16000,
                      AUDIO_CACHE_MAX_ENTRIES)
    return key


def _load_audio(ref) -> tuple:
    """Retrieve (audio, sample_rate) from a cache key or pass-through arrays."""
    if isinstance(ref, str):
        entry = _get_entry(ref)
        if entry is not None:
            return entry
        raise ValueError(f"Audio cache miss: {ref}")
    # Backward compat: raw numpy array (shouldn't happen in normal flow)
    return (ref, None)


def _audio_duration_from_ref(ref, fallback_sr=16000) -> float | None:
    """Get audio duration in seconds from a cache key."""
    if isinstance(ref, str):
        entry = _get_entry(ref)
        if entry:
            audio, sr = entry
            return len(audio) / (sr or fallback_sr)
    elif ref is not None and hasattr(ref, '__len__'):
        return len(ref) / fallback_sr
    return None


def register_audio(audio_path: str, *, target_sr: int = 16000) -> tuple[str, int]:
    """Load a WAV/MP3 from disk into the cache and return ``(key, sr)``.

    Used by the saved-sessions Load handler to re-stage audio without rerunning
    the pipeline so ``c.cached_audio`` (a State carrying a UUID key) resolves
    successfully when downstream actions look it up via ``_load_audio``.

    Mirrors the in-memory caching done by the live extract path; we intentionally
    avoid persisting the float32 audio array to the bucket because ``audio.<ext>``
    on disk is enough to rehydrate it cheaply.
    """
    import librosa
    audio, sr = librosa.load(audio_path, sr=target_sr, mono=True)
    return _store_audio(audio, sr), sr