| """ |
| Waveform Service |
| |
| Handles generation and caching of audio waveform data for the audio annotation feature. |
| Uses BBC's audiowaveform tool to generate pre-computed waveform data files. |
| |
| Features: |
| - LRU cache for waveform files |
| - Background look-ahead pre-computation for upcoming instances |
| - Support for both local files and URLs |
| - Graceful fallback if audiowaveform not installed |
| """ |
|
|
| import os |
| import logging |
| import hashlib |
| import subprocess |
| import shutil |
| import tempfile |
| import threading |
| import time |
| from typing import Optional, List, Dict |
| from collections import OrderedDict |
| from urllib.parse import urlparse |
| from pathlib import Path |
|
|
| try: |
| import requests |
| REQUESTS_AVAILABLE = True |
| except ImportError: |
| REQUESTS_AVAILABLE = False |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class WaveformService: |
| """ |
| Service for generating and caching audio waveform data. |
| |
| Uses BBC's audiowaveform tool to generate pre-computed waveform data |
| that can be efficiently rendered by Peaks.js on the frontend. |
| """ |
|
|
| |
| DEFAULT_LOOK_AHEAD = 5 |
| DEFAULT_CACHE_MAX_SIZE = 100 |
| DEFAULT_CLIENT_FALLBACK_MAX_DURATION = 1800 |
|
|
| |
| WAVEFORM_ZOOM_LEVEL = 256 |
| WAVEFORM_BITS = 8 |
|
|
| def __init__( |
| self, |
| cache_dir: str, |
| look_ahead: int = DEFAULT_LOOK_AHEAD, |
| cache_max_size: int = DEFAULT_CACHE_MAX_SIZE, |
| client_fallback_max_duration: int = DEFAULT_CLIENT_FALLBACK_MAX_DURATION |
| ): |
| """ |
| Initialize the WaveformService. |
| |
| Args: |
| cache_dir: Directory to store generated waveform files |
| look_ahead: Number of instances to pre-compute ahead |
| cache_max_size: Maximum number of cached waveform files |
| client_fallback_max_duration: Max duration (seconds) for client-side fallback |
| """ |
| self.cache_dir = cache_dir |
| self.look_ahead = look_ahead |
| self.cache_max_size = cache_max_size |
| self.client_fallback_max_duration = client_fallback_max_duration |
|
|
| |
| self._cache_order: OrderedDict = OrderedDict() |
| self._cache_lock = threading.Lock() |
|
|
| |
| self._precompute_thread: Optional[threading.Thread] = None |
| self._precompute_queue: List[str] = [] |
| self._precompute_lock = threading.Lock() |
| self._stop_precompute = threading.Event() |
|
|
| |
| self._audiowaveform_available = self._check_audiowaveform_installed() |
|
|
| |
| self._ensure_cache_dir() |
|
|
| logger.info(f"WaveformService initialized: cache_dir={cache_dir}, " |
| f"look_ahead={look_ahead}, audiowaveform_available={self._audiowaveform_available}") |
|
|
| def _ensure_cache_dir(self) -> None: |
| """Create the cache directory if it doesn't exist.""" |
| if not os.path.exists(self.cache_dir): |
| os.makedirs(self.cache_dir, exist_ok=True) |
| logger.info(f"Created waveform cache directory: {self.cache_dir}") |
|
|
| def _check_audiowaveform_installed(self) -> bool: |
| """ |
| Check if the audiowaveform tool is installed and available. |
| |
| Returns: |
| True if audiowaveform is available, False otherwise |
| """ |
| try: |
| result = subprocess.run( |
| ['audiowaveform', '--version'], |
| capture_output=True, |
| text=True, |
| timeout=5 |
| ) |
| if result.returncode == 0: |
| version = result.stdout.strip() or result.stderr.strip() |
| logger.info(f"audiowaveform found: {version}") |
| return True |
| except (subprocess.SubprocessError, FileNotFoundError, OSError) as e: |
| logger.warning(f"audiowaveform not available: {e}") |
|
|
| return False |
|
|
| @property |
| def is_available(self) -> bool: |
| """Check if waveform generation is available.""" |
| return self._audiowaveform_available |
|
|
| def _get_cache_key(self, audio_path: str) -> str: |
| """ |
| Generate a unique cache key for an audio file. |
| |
| Args: |
| audio_path: Path or URL to the audio file |
| |
| Returns: |
| MD5 hash of the path as cache key |
| """ |
| return hashlib.md5(audio_path.encode('utf-8')).hexdigest() |
|
|
| def _get_waveform_cache_path(self, audio_path: str) -> str: |
| """ |
| Get the cache file path for a waveform. |
| |
| Args: |
| audio_path: Path or URL to the audio file |
| |
| Returns: |
| Full path to the waveform cache file |
| """ |
| cache_key = self._get_cache_key(audio_path) |
| return os.path.join(self.cache_dir, f"{cache_key}.dat") |
|
|
| def _is_url(self, path: str) -> bool: |
| """ |
| Check if a path is a URL. |
| |
| Args: |
| path: The path to check |
| |
| Returns: |
| True if path is a URL, False otherwise |
| """ |
| return path.startswith(('http://', 'https://', '//')) |
|
|
| def _download_audio(self, url: str) -> Optional[str]: |
| """ |
| Download an audio file from URL to a temporary file. |
| |
| Args: |
| url: URL of the audio file |
| |
| Returns: |
| Path to temporary file, or None if download failed |
| """ |
| if not REQUESTS_AVAILABLE: |
| logger.error("requests library not available for downloading audio") |
| return None |
|
|
| try: |
| |
| parsed = urlparse(url) |
| path = parsed.path |
| ext = os.path.splitext(path)[1] or '.mp3' |
|
|
| |
| temp_fd, temp_path = tempfile.mkstemp(suffix=ext) |
| os.close(temp_fd) |
|
|
| logger.debug(f"Downloading audio from {url} to {temp_path}") |
|
|
| response = requests.get(url, stream=True, timeout=60) |
| response.raise_for_status() |
|
|
| with open(temp_path, 'wb') as f: |
| for chunk in response.iter_content(chunk_size=8192): |
| f.write(chunk) |
|
|
| logger.debug(f"Downloaded audio: {os.path.getsize(temp_path)} bytes") |
| return temp_path |
|
|
| except Exception as e: |
| logger.error(f"Failed to download audio from {url}: {e}") |
| return None |
|
|
| def _generate_waveform(self, audio_path: str, output_path: str) -> bool: |
| """ |
| Generate waveform data using audiowaveform tool. |
| |
| Args: |
| audio_path: Path to the audio file (local) |
| output_path: Path to write the waveform data file |
| |
| Returns: |
| True if generation succeeded, False otherwise |
| """ |
| if not self._audiowaveform_available: |
| logger.warning("audiowaveform not available, cannot generate waveform") |
| return False |
|
|
| try: |
| |
| cmd = [ |
| 'audiowaveform', |
| '-i', audio_path, |
| '-o', output_path, |
| '-z', str(self.WAVEFORM_ZOOM_LEVEL), |
| '-b', str(self.WAVEFORM_BITS), |
| ] |
|
|
| logger.debug(f"Running: {' '.join(cmd)}") |
|
|
| result = subprocess.run( |
| cmd, |
| capture_output=True, |
| text=True, |
| timeout=300 |
| ) |
|
|
| if result.returncode == 0: |
| logger.info(f"Generated waveform: {output_path}") |
| return True |
| else: |
| logger.error(f"audiowaveform failed: {result.stderr}") |
| return False |
|
|
| except subprocess.TimeoutExpired: |
| logger.error(f"audiowaveform timed out for {audio_path}") |
| return False |
| except Exception as e: |
| logger.error(f"Error generating waveform for {audio_path}: {e}") |
| return False |
|
|
| def _update_cache_order(self, cache_path: str) -> None: |
| """ |
| Update LRU cache order and evict if necessary. |
| |
| Args: |
| cache_path: Path to the cache file being accessed |
| """ |
| with self._cache_lock: |
| |
| if cache_path in self._cache_order: |
| self._cache_order.move_to_end(cache_path) |
| else: |
| self._cache_order[cache_path] = True |
|
|
| |
| while len(self._cache_order) > self.cache_max_size: |
| oldest_path, _ = self._cache_order.popitem(last=False) |
| if os.path.exists(oldest_path): |
| try: |
| os.remove(oldest_path) |
| logger.debug(f"Evicted from cache: {oldest_path}") |
| except OSError as e: |
| logger.warning(f"Failed to remove cache file {oldest_path}: {e}") |
|
|
| def get_waveform_path(self, audio_path: str, generate: bool = True) -> Optional[str]: |
| """ |
| Get the waveform data file path for an audio file. |
| |
| If the waveform doesn't exist and generate=True, it will be generated. |
| |
| Args: |
| audio_path: Path or URL to the audio file |
| generate: Whether to generate if not cached |
| |
| Returns: |
| Path to waveform data file, or None if not available |
| """ |
| cache_path = self._get_waveform_cache_path(audio_path) |
|
|
| |
| if os.path.exists(cache_path): |
| self._update_cache_order(cache_path) |
| logger.debug(f"Waveform cache hit: {cache_path}") |
| return cache_path |
|
|
| if not generate: |
| return None |
|
|
| |
| temp_audio = None |
| try: |
| |
| if self._is_url(audio_path): |
| temp_audio = self._download_audio(audio_path) |
| if not temp_audio: |
| return None |
| local_path = temp_audio |
| else: |
| local_path = audio_path |
| if not os.path.exists(local_path): |
| logger.warning(f"Audio file not found: {local_path}") |
| return None |
|
|
| |
| if self._generate_waveform(local_path, cache_path): |
| self._update_cache_order(cache_path) |
| return cache_path |
| else: |
| return None |
|
|
| finally: |
| |
| if temp_audio and os.path.exists(temp_audio): |
| try: |
| os.remove(temp_audio) |
| except OSError: |
| pass |
|
|
| def get_waveform_url(self, audio_path: str, base_url: str = '/api/waveform/') -> Optional[str]: |
| """ |
| Get the URL to fetch waveform data for an audio file. |
| |
| Args: |
| audio_path: Path or URL to the audio file |
| base_url: Base URL for the waveform API endpoint |
| |
| Returns: |
| URL to fetch waveform data |
| """ |
| cache_key = self._get_cache_key(audio_path) |
| return f"{base_url}{cache_key}" |
|
|
| def precompute_batch(self, audio_paths: List[str]) -> None: |
| """ |
| Pre-compute waveforms for a batch of audio files. |
| |
| This is called synchronously and blocks until all are complete. |
| Use start_background_precompute for non-blocking operation. |
| |
| Args: |
| audio_paths: List of audio file paths or URLs |
| """ |
| for audio_path in audio_paths: |
| if audio_path: |
| self.get_waveform_path(audio_path, generate=True) |
|
|
| def queue_precompute(self, audio_paths: List[str]) -> None: |
| """ |
| Add audio files to the background pre-computation queue. |
| |
| Args: |
| audio_paths: List of audio file paths or URLs to pre-compute |
| """ |
| with self._precompute_lock: |
| |
| for path in audio_paths: |
| if path and path not in self._precompute_queue: |
| cache_path = self._get_waveform_cache_path(path) |
| if not os.path.exists(cache_path): |
| self._precompute_queue.append(path) |
|
|
| |
| if self._precompute_thread is None or not self._precompute_thread.is_alive(): |
| self._start_background_precompute() |
|
|
| def _start_background_precompute(self) -> None: |
| """Start the background pre-computation thread.""" |
| self._stop_precompute.clear() |
| self._precompute_thread = threading.Thread( |
| target=self._background_precompute_worker, |
| daemon=True |
| ) |
| self._precompute_thread.start() |
| logger.debug("Started background waveform pre-computation thread") |
|
|
| def _background_precompute_worker(self) -> None: |
| """Background worker for pre-computing waveforms.""" |
| while not self._stop_precompute.is_set(): |
| |
| audio_path = None |
| with self._precompute_lock: |
| if self._precompute_queue: |
| audio_path = self._precompute_queue.pop(0) |
|
|
| if audio_path: |
| logger.debug(f"Background pre-computing waveform for: {audio_path}") |
| self.get_waveform_path(audio_path, generate=True) |
| else: |
| |
| break |
|
|
| |
| time.sleep(0.1) |
|
|
| logger.debug("Background waveform pre-computation thread finished") |
|
|
| def stop_background_precompute(self) -> None: |
| """Stop the background pre-computation thread.""" |
| self._stop_precompute.set() |
| if self._precompute_thread and self._precompute_thread.is_alive(): |
| self._precompute_thread.join(timeout=5) |
|
|
| def get_audio_duration(self, audio_path: str) -> Optional[float]: |
| """ |
| Get the duration of an audio file in seconds. |
| |
| Uses ffprobe if available, otherwise returns None. |
| |
| Args: |
| audio_path: Path to the audio file |
| |
| Returns: |
| Duration in seconds, or None if cannot determine |
| """ |
| try: |
| result = subprocess.run( |
| [ |
| 'ffprobe', |
| '-v', 'error', |
| '-show_entries', 'format=duration', |
| '-of', 'default=noprint_wrappers=1:nokey=1', |
| audio_path |
| ], |
| capture_output=True, |
| text=True, |
| timeout=10 |
| ) |
| if result.returncode == 0: |
| return float(result.stdout.strip()) |
| except (subprocess.SubprocessError, ValueError, FileNotFoundError): |
| pass |
|
|
| return None |
|
|
| def should_use_client_fallback(self, audio_path: str) -> bool: |
| """ |
| Determine if client-side waveform generation should be used. |
| |
| Client-side is preferred for short files when server-side is not available. |
| |
| Args: |
| audio_path: Path to the audio file |
| |
| Returns: |
| True if client-side fallback should be used |
| """ |
| if self._audiowaveform_available: |
| return False |
|
|
| duration = self.get_audio_duration(audio_path) |
| if duration is not None and duration <= self.client_fallback_max_duration: |
| return True |
|
|
| return False |
|
|
| def clear_cache(self) -> int: |
| """ |
| Clear all cached waveform files. |
| |
| Returns: |
| Number of files removed |
| """ |
| count = 0 |
| with self._cache_lock: |
| for cache_path in list(self._cache_order.keys()): |
| if os.path.exists(cache_path): |
| try: |
| os.remove(cache_path) |
| count += 1 |
| except OSError as e: |
| logger.warning(f"Failed to remove {cache_path}: {e}") |
| self._cache_order.clear() |
|
|
| logger.info(f"Cleared {count} cached waveform files") |
| return count |
|
|
| def get_cache_stats(self) -> Dict: |
| """ |
| Get statistics about the waveform cache. |
| |
| Returns: |
| Dictionary with cache statistics |
| """ |
| with self._cache_lock: |
| cached_files = len(self._cache_order) |
| total_size = 0 |
| for cache_path in self._cache_order.keys(): |
| if os.path.exists(cache_path): |
| total_size += os.path.getsize(cache_path) |
|
|
| return { |
| 'cached_files': cached_files, |
| 'max_files': self.cache_max_size, |
| 'total_size_bytes': total_size, |
| 'total_size_mb': round(total_size / (1024 * 1024), 2), |
| 'cache_dir': self.cache_dir, |
| 'audiowaveform_available': self._audiowaveform_available, |
| } |
|
|
|
|
| |
| _waveform_service: Optional[WaveformService] = None |
|
|
|
|
| def get_waveform_service() -> Optional[WaveformService]: |
| """Get the global WaveformService instance.""" |
| return _waveform_service |
|
|
|
|
| def init_waveform_service( |
| cache_dir: str, |
| look_ahead: int = WaveformService.DEFAULT_LOOK_AHEAD, |
| cache_max_size: int = WaveformService.DEFAULT_CACHE_MAX_SIZE, |
| client_fallback_max_duration: int = WaveformService.DEFAULT_CLIENT_FALLBACK_MAX_DURATION |
| ) -> WaveformService: |
| """ |
| Initialize the global WaveformService instance. |
| |
| Args: |
| cache_dir: Directory to store generated waveform files |
| look_ahead: Number of instances to pre-compute ahead |
| cache_max_size: Maximum number of cached waveform files |
| client_fallback_max_duration: Max duration for client-side fallback |
| |
| Returns: |
| The initialized WaveformService instance |
| """ |
| global _waveform_service |
| _waveform_service = WaveformService( |
| cache_dir=cache_dir, |
| look_ahead=look_ahead, |
| cache_max_size=cache_max_size, |
| client_fallback_max_duration=client_fallback_max_duration |
| ) |
| return _waveform_service |
|
|