All files / audio AudioCache.ts

91.34% Statements 95/104
71.21% Branches 47/66
100% Functions 15/15
92.07% Lines 93/101

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384                                                                                                            104x 104x       104x 104x 104x     104x 104x 104x   104x 1x                             611x     611x 611x 1x       611x 611x 110x       109x         611x           611x   611x                             624x 624x 31x 31x   31x       31x     593x   593x       593x                     21x                     4x 4x 3x 3x   3x             3x 2x       2x 2x   2x 2x       3x   1x                   112x 157x 109x     3x               109x 109x     109x 6192x 111x 111x       109x 109x 109x   109x 109x 109x   109x               109x 6x       6x 6x   6x 6x                         32x 276x     32x       32x                                   2x 3x 3x       3x 3x   3x 3x         2x 2x     2x 2x 2x   2x                       2x                                 1x 2x                 1x   1x                         1x     1x 2x     1x              
/**
 * AudioCache - LRU cache for audio assets
 * 오디오 캐시 - 오디오 자산을 위한 LRU 캐시
 *
 * Manages audio asset memory with automatic eviction of least-recently-used assets
 * 최근 사용 빈도가 낮은 자산을 자동으로 제거하여 오디오 자산 메모리를 관리합니다
 */
 
import type { AudioAsset } from "./types";
 
/**
 * Configuration for AudioCache
 * AudioCache 구성
 */
export interface AudioCacheConfig {
  /** Maximum cache size in bytes (default: 30MB) */
  readonly maxSizeBytes: number;
  /** Asset IDs that should never be evicted */
  readonly criticalAssets: readonly string[];
  /** Enable debug logging */
  readonly debug?: boolean;
}
 
/**
 * Cache entry with metadata
 * 메타데이터가 있는 캐시 항목
 */
interface CacheEntry {
  readonly asset: AudioAsset;
  readonly size: number;
  lastAccessed: number;
  isCritical: boolean; // Mutable to allow dynamic updates via updateCriticalAssets()
}
 
/**
 * Cache statistics
 * 캐시 통계
 */
export interface CacheStats {
  readonly totalSize: number;
  readonly assetCount: number;
  readonly criticalCount: number;
  readonly utilizationPercent: number;
  readonly evictionCount: number;
  readonly hitCount: number;
  readonly missCount: number;
  readonly hitRate: number;
}
 
/**
 * AudioCache - LRU cache implementation for audio assets
 * 오디오 자산을 위한 LRU 캐시 구현
 */
export class AudioCache {
  private cache: Map<string, CacheEntry> = new Map();
  private currentSize: number = 0;
  private maxSize: number;
  private criticalAssets: Set<string>;
  private debug: boolean;
  private evictionCount: number = 0;
  private hitCount: number = 0;
  private missCount: number = 0;
 
  constructor(config: AudioCacheConfig) {
    this.maxSize = config.maxSizeBytes;
    this.criticalAssets = new Set(config.criticalAssets);
    this.debug = config.debug ?? false;
 
    if (this.debug) {
      console.log(
        `[AudioCache] Initialized with max size: ${(this.maxSize / 1024 / 1024).toFixed(1)}MB, critical assets: ${this.criticalAssets.size}`
      );
    }
  }
 
  /**
   * Add asset to cache with LRU tracking
   * LRU 추적으로 캐시에 자산 추가
   *
   * @param id - Asset ID
   * @param asset - Audio asset
   * @param sizeBytes - Estimated size in bytes
   */
  set(id: string, asset: AudioAsset, sizeBytes: number): void {
    const isCritical = this.criticalAssets.has(id);
 
    // If asset already exists, remove its size first
    const existing = this.cache.get(id);
    if (existing) {
      this.currentSize -= existing.size;
    }
 
    // Check if we need to evict (only for new assets or size increases)
    const needsSpace = this.currentSize + sizeBytes > this.maxSize;
    if (needsSpace) {
      while (
        this.currentSize + sizeBytes > this.maxSize &&
        this.canEvict()
      ) {
        this.evictLRU();
      }
    }
 
    // Add to cache
    this.cache.set(id, {
      asset,
      size: sizeBytes,
      lastAccessed: Date.now(),
      isCritical,
    });
    this.currentSize += sizeBytes;
 
    Iif (this.debug) {
      console.log(
        `[AudioCache] Added: ${id} (${(sizeBytes / 1024).toFixed(1)}KB)${isCritical ? " [CRITICAL]" : ""} - Total: ${(this.currentSize / 1024 / 1024).toFixed(1)}MB / ${(this.maxSize / 1024 / 1024).toFixed(1)}MB (${((this.currentSize / this.maxSize) * 100).toFixed(1)}%)`
      );
    }
  }
 
  /**
   * Get asset from cache and update access time
   * 캐시에서 자산을 가져오고 액세스 시간 업데이트
   *
   * @param id - Asset ID
   * @returns Audio asset or undefined if not found
   */
  get(id: string): AudioAsset | undefined {
    const cached = this.cache.get(id);
    if (cached) {
      cached.lastAccessed = Date.now(); // Update LRU
      this.hitCount++;
 
      Iif (this.debug) {
        console.log(`[AudioCache] Hit: ${id}`);
      }
 
      return cached.asset;
    }
 
    this.missCount++;
 
    Iif (this.debug) {
      console.log(`[AudioCache] Miss: ${id}`);
    }
 
    return undefined;
  }
 
  /**
   * Check if asset exists in cache
   * 캐시에 자산이 있는지 확인
   *
   * @param id - Asset ID
   * @returns True if asset is cached
   */
  has(id: string): boolean {
    return this.cache.has(id);
  }
 
  /**
   * Remove asset from cache
   * 캐시에서 자산 제거
   *
   * @param id - Asset ID
   * @returns True if asset was removed
   */
  remove(id: string): boolean {
    const entry = this.cache.get(id);
    if (entry) {
      this.cache.delete(id);
      this.currentSize -= entry.size;
 
      Iif (this.debug) {
        console.log(
          `[AudioCache] Removed: ${id} (${(entry.size / 1024).toFixed(1)}KB)`
        );
      }
 
      // Unload audio element to free memory
      if (entry.asset && "src" in entry.asset) {
        const audioElement = entry.asset as AudioAsset & {
          src?: string;
          pause?: () => void;
        };
        Eif (audioElement.pause) {
          audioElement.pause();
        }
        Eif (audioElement.src !== undefined) {
          audioElement.src = "";
        }
      }
 
      return true;
    }
    return false;
  }
 
  /**
   * Check if can evict (has non-critical assets)
   * 제거 가능 여부 확인 (중요하지 않은 자산이 있는지)
   *
   * @returns True if there are non-critical assets to evict
   */
  private canEvict(): boolean {
    for (const entry of this.cache.values()) {
      if (!entry.isCritical) {
        return true;
      }
    }
    return false;
  }
 
  /**
   * Evict least-recently-used non-critical asset
   * 최근 사용 빈도가 가장 낮은 중요하지 않은 자산 제거
   */
  private evictLRU(): void {
    let oldestId: string | null = null;
    let oldestTime = Infinity;
 
    // Find oldest non-critical asset
    for (const [id, entry] of this.cache) {
      if (!entry.isCritical && entry.lastAccessed < oldestTime) {
        oldestId = id;
        oldestTime = entry.lastAccessed;
      }
    }
 
    Eif (oldestId) {
      const evicted = this.cache.get(oldestId);
      Iif (!evicted) return; // Should not happen, but handle safely
      
      this.cache.delete(oldestId);
      this.currentSize -= evicted.size;
      this.evictionCount++;
 
      Iif (this.debug) {
        const age = Date.now() - evicted.lastAccessed;
        console.log(
          `[AudioCache] Evicted: ${oldestId} (${(evicted.size / 1024).toFixed(1)}KB, age: ${(age / 1000).toFixed(1)}s) - Total: ${(this.currentSize / 1024 / 1024).toFixed(1)}MB`
        );
      }
 
      // Unload audio element to free memory
      if (evicted.asset && "src" in evicted.asset) {
        const audioElement = evicted.asset as AudioAsset & {
          src?: string;
          pause?: () => void;
        };
        Eif (audioElement.pause) {
          audioElement.pause();
        }
        Eif (audioElement.src !== undefined) {
          audioElement.src = "";
        }
      }
    }
  }
 
  /**
   * Get cache statistics
   * 캐시 통계 가져오기
   *
   * @returns Cache statistics
   */
  getStats(): CacheStats {
    const criticalCount = Array.from(this.cache.values()).filter(
      (e) => e.isCritical
    ).length;
    const hitRate =
      this.hitCount + this.missCount > 0
        ? this.hitCount / (this.hitCount + this.missCount)
        : 0;
 
    return {
      totalSize: this.currentSize,
      assetCount: this.cache.size,
      criticalCount,
      utilizationPercent: (this.currentSize / this.maxSize) * 100,
      evictionCount: this.evictionCount,
      hitCount: this.hitCount,
      missCount: this.missCount,
      hitRate,
    };
  }
 
  /**
   * Clear entire cache
   * 전체 캐시 지우기
   */
  clear(): void {
    // Unload all audio elements
    for (const entry of this.cache.values()) {
      Eif (entry.asset && "src" in entry.asset) {
        const audioElement = entry.asset as AudioAsset & {
          src?: string;
          pause?: () => void;
        };
        Eif (audioElement.pause) {
          audioElement.pause();
        }
        Eif (audioElement.src !== undefined) {
          audioElement.src = "";
        }
      }
    }
 
    this.cache.clear();
    this.currentSize = 0;
    
    // Reset statistics counters to maintain accurate statistics after clear
    this.evictionCount = 0;
    this.hitCount = 0;
    this.missCount = 0;
 
    Iif (this.debug) {
      console.log("[AudioCache] Cleared entire cache and reset statistics");
    }
  }
 
  /**
   * Get all cached asset IDs
   * 모든 캐시된 자산 ID 가져오기
   *
   * @returns Array of asset IDs
   */
  getCachedAssetIds(): readonly string[] {
    return Array.from(this.cache.keys());
  }
 
  /**
   * Get detailed cache information for debugging
   * 디버깅을 위한 상세 캐시 정보 가져오기
   */
  getDebugInfo(): {
    readonly entries: ReadonlyArray<{
      id: string;
      size: number;
      isCritical: boolean;
      lastAccessed: number;
      age: number;
    }>;
    readonly stats: CacheStats;
  } {
    const now = Date.now();
    const entries = Array.from(this.cache.entries()).map(([id, entry]) => ({
      id,
      size: entry.size,
      isCritical: entry.isCritical,
      lastAccessed: entry.lastAccessed,
      age: now - entry.lastAccessed,
    }));
 
    // Sort by last accessed (oldest first)
    entries.sort((a, b) => a.lastAccessed - b.lastAccessed);
 
    return {
      entries,
      stats: this.getStats(),
    };
  }
 
  /**
   * Update critical assets list
   * 중요 자산 목록 업데이트
   *
   * @param criticalAssets - New list of critical asset IDs
   */
  updateCriticalAssets(criticalAssets: readonly string[]): void {
    this.criticalAssets = new Set(criticalAssets);
 
    // Update isCritical flag for existing entries
    for (const [id, entry] of this.cache) {
      entry.isCritical = this.criticalAssets.has(id);
    }
 
    Iif (this.debug) {
      console.log(
        `[AudioCache] Updated critical assets: ${this.criticalAssets.size}`
      );
    }
  }
}