Mina Emadi Claude Sonnet 4.6 commited on
Commit
d2f89e9
·
1 Parent(s): 08bdfee

Add IndexedDB preset caching with background prefetch

Browse files

- Backend: stateless GET /api/preset-stem/{name}/{stem} endpoint serves
int16 PCM without creating a session (used by prefetcher)
- New presetCache.js: IndexedDB wrapper with versioned keys for cache
invalidation when preset audio changes
- New usePresetCache.js: on app mount, checks IndexedDB for each preset
and background-fetches any that are missing
- useAudioEngine: added loadStemsFromBytes() to build AudioBuffers
directly from cached bytes with no network round-trip
- App.jsx: cache-aware handleStemsReady — uses loadStemsFromBytes on
cache hit, falls back to loadStems on miss
- FileUpload: shows ✓ / … status badge per preset in dropdown

First visit: background prefetch populates IndexedDB while user explores
the UI. Subsequent visits: preset loads are instant from local cache.
Uncommented AI continuation section in ControlPanel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

backend/routers/presets.py CHANGED
@@ -6,7 +6,7 @@ import io
6
  import numpy as np
7
  import soundfile as sf
8
  import mido
9
- from fastapi import APIRouter, HTTPException
10
 
11
  from ..models.session import create_session, StemData
12
  from ..models.schemas import UploadResponse
@@ -27,6 +27,34 @@ async def list_presets():
27
  return {"presets": presets}
28
 
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  @router.post("/preset/{preset_name}", response_model=UploadResponse)
31
  async def load_preset(preset_name: str):
32
  """Load a pre-committed preset into a session (no upload needed)."""
 
6
  import numpy as np
7
  import soundfile as sf
8
  import mido
9
+ from fastapi import APIRouter, HTTPException, Response
10
 
11
  from ..models.session import create_session, StemData
12
  from ..models.schemas import UploadResponse
 
27
  return {"presets": presets}
28
 
29
 
30
+ @router.get("/preset-stem/{preset_name}/{stem_name}")
31
+ async def get_preset_stem_pcm(preset_name: str, stem_name: str):
32
+ """Serve a single preset stem as raw int16 PCM — stateless, no session created."""
33
+ wav_path = PRESETS_DIR / preset_name / f"{stem_name}.wav"
34
+ if not wav_path.exists():
35
+ raise HTTPException(status_code=404, detail=f"Stem '{stem_name}' not found in preset '{preset_name}'")
36
+
37
+ audio, sr = sf.read(str(wav_path))
38
+ audio = audio.astype(np.float32)
39
+ if audio.ndim == 2:
40
+ audio = np.mean(audio, axis=1).astype(np.float32)
41
+
42
+ num_frames = len(audio)
43
+ pcm_bytes = (audio * 32767).astype(np.int16).tobytes()
44
+
45
+ return Response(
46
+ content=pcm_bytes,
47
+ media_type="application/octet-stream",
48
+ headers={
49
+ "X-Sample-Rate": str(sr),
50
+ "X-Channels": "1",
51
+ "X-Frames": str(num_frames),
52
+ "X-Bit-Depth": "16",
53
+ "Access-Control-Expose-Headers": "X-Sample-Rate, X-Channels, X-Frames, X-Bit-Depth",
54
+ }
55
+ )
56
+
57
+
58
  @router.post("/preset/{preset_name}", response_model=UploadResponse)
59
  async def load_preset(preset_name: str):
60
  """Load a pre-committed preset into a session (no upload needed)."""
frontend/src/App.jsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState, useCallback, useEffect } from 'react'
2
  import FileUpload from './components/FileUpload'
3
  import AnalysisDisplay from './components/AnalysisDisplay'
4
  import ControlPanel from './components/ControlPanel'
@@ -9,6 +9,8 @@ import ProcessingOverlay from './components/ProcessingOverlay'
9
  import { useSession } from './hooks/useSession'
10
  import { useAudioEngine } from './hooks/useAudioEngine'
11
  import { useProcessingProgress } from './hooks/useProcessingProgress'
 
 
12
 
13
  function App() {
14
  const {
@@ -37,6 +39,7 @@ function App() {
37
  pans,
38
  analyserData,
39
  loadStems,
 
40
  play,
41
  pause,
42
  stop,
@@ -51,6 +54,11 @@ function App() {
51
  clearBufferCache
52
  } = useAudioEngine()
53
 
 
 
 
 
 
54
  const [isProcessing, setIsProcessing] = useState(false)
55
  const [isGenerating, setIsGenerating] = useState(false)
56
  const [continuationReady, setContinuationReady] = useState(false)
@@ -67,9 +75,15 @@ function App() {
67
  const hasRegion = regionStart !== null && regionEnd !== null
68
 
69
  const handleUpload = useCallback(async (files) => {
 
70
  await upload(files)
71
  }, [upload])
72
 
 
 
 
 
 
73
  const handleProcess = useCallback(async (semitones, targetBpm) => {
74
  setIsProcessing(true)
75
  resetProgress()
@@ -133,18 +147,23 @@ function App() {
133
  const handleStemsReady = useCallback(async () => {
134
  if (sessionId && stems.length > 0) {
135
  console.log('=== handleStemsReady called ===')
136
- console.log('Session:', sessionId)
137
- console.log('Stems to load:', stems)
138
- const start = performance.now()
 
 
 
 
 
 
 
139
  try {
140
  await loadStems(sessionId, stems)
141
- const end = performance.now()
142
- console.log(`=== handleStemsReady completed in ${(end - start).toFixed(0)}ms ===`)
143
  } catch (err) {
144
  console.error('Failed to load stems:', err)
145
  }
146
  }
147
- }, [sessionId, stems, loadStems])
148
 
149
  // Track full song duration whenever we're in full mode and duration updates
150
  useEffect(() => {
@@ -167,10 +186,11 @@ function App() {
167
 
168
  <FileUpload
169
  onUpload={handleUpload}
170
- onLoadPreset={loadPreset}
171
  loading={loading}
172
  error={error}
173
  onClearError={clearError}
 
174
  />
175
  </div>
176
  </div>
 
1
+ import React, { useState, useRef, useCallback, useEffect } from 'react'
2
  import FileUpload from './components/FileUpload'
3
  import AnalysisDisplay from './components/AnalysisDisplay'
4
  import ControlPanel from './components/ControlPanel'
 
9
  import { useSession } from './hooks/useSession'
10
  import { useAudioEngine } from './hooks/useAudioEngine'
11
  import { useProcessingProgress } from './hooks/useProcessingProgress'
12
+ import { usePresetCache } from './hooks/usePresetCache'
13
+ import { getCachedPreset } from './utils/presetCache'
14
 
15
  function App() {
16
  const {
 
39
  pans,
40
  analyserData,
41
  loadStems,
42
+ loadStemsFromBytes,
43
  play,
44
  pause,
45
  stop,
 
54
  clearBufferCache
55
  } = useAudioEngine()
56
 
57
+ const { cacheStatus } = usePresetCache()
58
+
59
+ // Tracks which preset is currently being loaded so handleStemsReady can check the cache
60
+ const currentPresetNameRef = useRef(null)
61
+
62
  const [isProcessing, setIsProcessing] = useState(false)
63
  const [isGenerating, setIsGenerating] = useState(false)
64
  const [continuationReady, setContinuationReady] = useState(false)
 
75
  const hasRegion = regionStart !== null && regionEnd !== null
76
 
77
  const handleUpload = useCallback(async (files) => {
78
+ currentPresetNameRef.current = null
79
  await upload(files)
80
  }, [upload])
81
 
82
+ const handleLoadPreset = useCallback(async (presetName) => {
83
+ currentPresetNameRef.current = presetName
84
+ await loadPreset(presetName)
85
+ }, [loadPreset])
86
+
87
  const handleProcess = useCallback(async (semitones, targetBpm) => {
88
  setIsProcessing(true)
89
  resetProgress()
 
147
  const handleStemsReady = useCallback(async () => {
148
  if (sessionId && stems.length > 0) {
149
  console.log('=== handleStemsReady called ===')
150
+ const presetName = currentPresetNameRef.current
151
+ if (presetName) {
152
+ const cached = await getCachedPreset(presetName, stems)
153
+ if (cached) {
154
+ console.log(`[preset] Cache hit for '${presetName}' — loading from IndexedDB`)
155
+ await loadStemsFromBytes(cached)
156
+ return
157
+ }
158
+ console.log(`[preset] Cache miss for '${presetName}' — fetching from network`)
159
+ }
160
  try {
161
  await loadStems(sessionId, stems)
 
 
162
  } catch (err) {
163
  console.error('Failed to load stems:', err)
164
  }
165
  }
166
+ }, [sessionId, stems, loadStems, loadStemsFromBytes])
167
 
168
  // Track full song duration whenever we're in full mode and duration updates
169
  useEffect(() => {
 
186
 
187
  <FileUpload
188
  onUpload={handleUpload}
189
+ onLoadPreset={handleLoadPreset}
190
  loading={loading}
191
  error={error}
192
  onClearError={clearError}
193
+ cacheStatus={cacheStatus}
194
  />
195
  </div>
196
  </div>
frontend/src/components/ControlPanel.jsx CHANGED
@@ -351,7 +351,6 @@ function ControlPanel({ detection, onProcess, isProcessing, hasRegion, isGenerat
351
  {isProcessing ? 'Processing...' : hasChanges ? (hasRegion ? 'Apply to Selection' : 'Apply Changes') : 'No Changes'}
352
  </button>
353
 
354
- {/* AI Continuation — always visible */}
355
  <div className="mt-6 pt-6 border-t border-gray-700/50">
356
  <h3 className="text-sm font-semibold text-white mb-3">AI Continuation</h3>
357
  <input
 
351
  {isProcessing ? 'Processing...' : hasChanges ? (hasRegion ? 'Apply to Selection' : 'Apply Changes') : 'No Changes'}
352
  </button>
353
 
 
354
  <div className="mt-6 pt-6 border-t border-gray-700/50">
355
  <h3 className="text-sm font-semibold text-white mb-3">AI Continuation</h3>
356
  <input
frontend/src/components/FileUpload.jsx CHANGED
@@ -8,7 +8,7 @@ const STEM_TYPES = [
8
  { name: 'click_record', label: 'Click Record', icon: '⏱️' }
9
  ]
10
 
11
- function FileUpload({ onUpload, onLoadPreset, loading, error, onClearError }) {
12
  const [presets, setPresets] = useState([])
13
  const [selectedPreset, setSelectedPreset] = useState('')
14
 
@@ -107,9 +107,15 @@ function FileUpload({ onUpload, onLoadPreset, loading, error, onClearError }) {
107
  className="flex-1 bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-primary-500"
108
  >
109
  <option value="">Select a demo...</option>
110
- {presets.map(p => (
111
- <option key={p} value={p}>{p}</option>
112
- ))}
 
 
 
 
 
 
113
  </select>
114
  <button
115
  onClick={() => selectedPreset && onLoadPreset(selectedPreset)}
@@ -119,6 +125,12 @@ function FileUpload({ onUpload, onLoadPreset, loading, error, onClearError }) {
119
  {loading ? 'Loading...' : 'Load'}
120
  </button>
121
  </div>
 
 
 
 
 
 
122
  </div>
123
  )}
124
 
 
8
  { name: 'click_record', label: 'Click Record', icon: '⏱️' }
9
  ]
10
 
11
+ function FileUpload({ onUpload, onLoadPreset, loading, error, onClearError, cacheStatus = {} }) {
12
  const [presets, setPresets] = useState([])
13
  const [selectedPreset, setSelectedPreset] = useState('')
14
 
 
107
  className="flex-1 bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-primary-500"
108
  >
109
  <option value="">Select a demo...</option>
110
+ {presets.map(p => {
111
+ const status = cacheStatus[p]
112
+ const label = status === 'cached'
113
+ ? `${p} ✓`
114
+ : status === 'loading'
115
+ ? `${p} …`
116
+ : p
117
+ return <option key={p} value={p}>{label}</option>
118
+ })}
119
  </select>
120
  <button
121
  onClick={() => selectedPreset && onLoadPreset(selectedPreset)}
 
125
  {loading ? 'Loading...' : 'Load'}
126
  </button>
127
  </div>
128
+ {selectedPreset && cacheStatus[selectedPreset] === 'cached' && (
129
+ <p className="text-xs text-green-400/70 mt-2">Ready — will load instantly from local cache</p>
130
+ )}
131
+ {selectedPreset && cacheStatus[selectedPreset] === 'loading' && (
132
+ <p className="text-xs text-yellow-400/70 mt-2">Caching in background…</p>
133
+ )}
134
  </div>
135
  )}
136
 
frontend/src/hooks/useAudioEngine.js CHANGED
@@ -262,6 +262,95 @@ export function useAudioEngine() {
262
  console.log(`Duration: ${maxDuration.toFixed(1)}s`)
263
  }, [initAudio])
264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  // Get frequency data for visualization
266
  const getAnalyserData = useCallback(() => {
267
  if (!analyserRef.current) return null
@@ -564,6 +653,7 @@ export function useAudioEngine() {
564
 
565
  // Methods
566
  loadStems,
 
567
  play,
568
  pause,
569
  stop,
 
262
  console.log(`Duration: ${maxDuration.toFixed(1)}s`)
263
  }, [initAudio])
264
 
265
+ // Load stems directly from cached IndexedDB bytes — no network fetch needed.
266
+ // stemsData shape: { stemName: { bytes: ArrayBuffer, sampleRate, numChannels, numFrames } }
267
+ const loadStemsFromBytes = useCallback(async (stemsData) => {
268
+ const totalStart = performance.now()
269
+ console.log('=== STEM LOADING FROM CACHE START ===')
270
+
271
+ const ctx = initAudio()
272
+
273
+ // Disconnect existing audio graph nodes
274
+ Object.values(gainsRef.current).forEach(g => g?.disconnect())
275
+ Object.values(compressorsRef.current).forEach(c => c?.disconnect())
276
+ Object.values(pannersRef.current).forEach(p => p?.disconnect())
277
+ Object.values(reverbGainsRef.current).forEach(r => r?.disconnect())
278
+ Object.values(reverbSendsRef.current).forEach(r => r?.disconnect())
279
+ buffersRef.current = {}
280
+ gainsRef.current = {}
281
+ compressorsRef.current = {}
282
+ pannersRef.current = {}
283
+ reverbGainsRef.current = {}
284
+ reverbSendsRef.current = {}
285
+ setIsLoaded(false)
286
+
287
+ const newVolumes = {}
288
+ let maxDuration = 0
289
+
290
+ for (const [stem, { bytes, sampleRate, numChannels, numFrames }] of Object.entries(stemsData)) {
291
+ const int16 = new Int16Array(bytes)
292
+ const float32 = new Float32Array(int16.length)
293
+ for (let i = 0; i < int16.length; i++) {
294
+ float32[i] = int16[i] / 32767
295
+ }
296
+ const audioBuffer = ctx.createBuffer(numChannels, numFrames, sampleRate)
297
+ audioBuffer.copyToChannel(float32, 0)
298
+
299
+ buffersRef.current[stem] = audioBuffer
300
+ if (audioBuffer.duration > maxDuration) maxDuration = audioBuffer.duration
301
+ newVolumes[stem] = 1
302
+
303
+ // Effect chain: source → gain → compressor → panner → (dry + wet reverb) → master
304
+ gainsRef.current[stem] = ctx.createGain()
305
+
306
+ compressorsRef.current[stem] = ctx.createDynamicsCompressor()
307
+ compressorsRef.current[stem].threshold.value = -24
308
+ compressorsRef.current[stem].knee.value = 30
309
+ compressorsRef.current[stem].ratio.value = 4
310
+ compressorsRef.current[stem].attack.value = 0.003
311
+ compressorsRef.current[stem].release.value = 0.25
312
+
313
+ pannersRef.current[stem] = ctx.createStereoPanner()
314
+ const stemLower = stem.toLowerCase()
315
+ let defaultPan = 0
316
+ if (stemLower.includes('bass')) defaultPan = 0
317
+ else if (stemLower.includes('drum')) defaultPan = 0
318
+ else if (stemLower.includes('guitar')) defaultPan = -0.3
319
+ else if (stemLower.includes('synth')) defaultPan = 0.3
320
+ else if (stemLower.includes('keys')) defaultPan = 0.2
321
+ else if (stemLower.includes('vocal')) defaultPan = 0
322
+ else defaultPan = (Math.random() - 0.5) * 0.4
323
+ pannersRef.current[stem].pan.value = defaultPan
324
+
325
+ reverbSendsRef.current[stem] = ctx.createGain()
326
+ reverbSendsRef.current[stem].gain.value = 0
327
+
328
+ gainsRef.current[stem].connect(compressorsRef.current[stem])
329
+ compressorsRef.current[stem].connect(pannersRef.current[stem])
330
+ pannersRef.current[stem].connect(masterGainRef.current)
331
+ pannersRef.current[stem].connect(reverbSendsRef.current[stem])
332
+ reverbSendsRef.current[stem].connect(convolverRef.current)
333
+ }
334
+
335
+ const newReverbs = {}
336
+ const newPans = {}
337
+ Object.keys(buffersRef.current).forEach(stem => {
338
+ newReverbs[stem] = 0.15
339
+ newPans[stem] = pannersRef.current[stem]?.pan.value || 0
340
+ })
341
+
342
+ setDuration(maxDuration)
343
+ setVolumes(newVolumes)
344
+ setReverbs(newReverbs)
345
+ setPans(newPans)
346
+ setSolos({})
347
+ setMutes({})
348
+ setIsLoaded(true)
349
+ pauseTimeRef.current = 0
350
+
351
+ console.log(`=== STEM LOADING FROM CACHE COMPLETE in ${(performance.now() - totalStart).toFixed(0)}ms ===`)
352
+ }, [initAudio])
353
+
354
  // Get frequency data for visualization
355
  const getAnalyserData = useCallback(() => {
356
  if (!analyserRef.current) return null
 
653
 
654
  // Methods
655
  loadStems,
656
+ loadStemsFromBytes,
657
  play,
658
  pause,
659
  stop,
frontend/src/hooks/usePresetCache.js ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback } from 'react'
2
+ import { isCached, cacheStem } from '../utils/presetCache'
3
+
4
+ const STEM_NAMES = ['guitar', 'drums', 'bass', 'synth', 'click_record']
5
+
6
+ /**
7
+ * Checks IndexedDB for cached presets on mount, then background-fetches
8
+ * any that are missing using the stateless /api/preset-stem endpoint.
9
+ *
10
+ * Returns cacheStatus: { [presetName]: 'cached' | 'loading' | 'uncached' }
11
+ */
12
+ export function usePresetCache() {
13
+ const [cacheStatus, setCacheStatus] = useState({})
14
+
15
+ const prefetchPreset = useCallback(async (presetName) => {
16
+ setCacheStatus(prev => ({ ...prev, [presetName]: 'loading' }))
17
+ try {
18
+ const results = await Promise.all(
19
+ STEM_NAMES.map(async (stemName) => {
20
+ const response = await fetch(`/api/preset-stem/${presetName}/${stemName}`)
21
+ if (!response.ok) return false
22
+
23
+ const sampleRate = parseInt(response.headers.get('X-Sample-Rate'))
24
+ const numChannels = parseInt(response.headers.get('X-Channels'))
25
+ const numFrames = parseInt(response.headers.get('X-Frames'))
26
+ const bytes = await response.arrayBuffer()
27
+
28
+ await cacheStem(presetName, stemName, { bytes, sampleRate, numChannels, numFrames })
29
+ return true
30
+ })
31
+ )
32
+
33
+ const allOk = results.every(Boolean)
34
+ setCacheStatus(prev => ({ ...prev, [presetName]: allOk ? 'cached' : 'uncached' }))
35
+ } catch {
36
+ setCacheStatus(prev => ({ ...prev, [presetName]: 'uncached' }))
37
+ }
38
+ }, [])
39
+
40
+ useEffect(() => {
41
+ let cancelled = false
42
+
43
+ async function init() {
44
+ try {
45
+ const res = await fetch('/api/presets')
46
+ const { presets } = await res.json()
47
+ if (cancelled || !presets?.length) return
48
+
49
+ // Check which presets are already in IndexedDB
50
+ const statuses = {}
51
+ await Promise.all(
52
+ presets.map(async (name) => {
53
+ statuses[name] = (await isCached(name, STEM_NAMES)) ? 'cached' : 'uncached'
54
+ })
55
+ )
56
+ if (cancelled) return
57
+ setCacheStatus(statuses)
58
+
59
+ // Background-fetch uncached presets one at a time to avoid saturating bandwidth
60
+ for (const name of presets) {
61
+ if (cancelled) break
62
+ if (statuses[name] === 'uncached') {
63
+ await prefetchPreset(name)
64
+ }
65
+ }
66
+ } catch {
67
+ // Prefetch is best-effort — silently fail
68
+ }
69
+ }
70
+
71
+ init()
72
+ return () => { cancelled = true }
73
+ }, [prefetchPreset])
74
+
75
+ return { cacheStatus }
76
+ }
frontend/src/utils/presetCache.js ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * IndexedDB cache for preset stem PCM data.
3
+ * Stores raw int16 bytes + metadata so preset loads are instant after first visit.
4
+ *
5
+ * Bump CACHE_VERSION whenever preset audio files change on the server —
6
+ * old entries will be ignored and re-fetched.
7
+ */
8
+
9
+ const DB_NAME = 'jam-tracks-presets'
10
+ const DB_VERSION = 1
11
+ const STORE_NAME = 'stems'
12
+ export const CACHE_VERSION = 1
13
+
14
+ function openDB() {
15
+ return new Promise((resolve, reject) => {
16
+ const req = indexedDB.open(DB_NAME, DB_VERSION)
17
+ req.onupgradeneeded = (e) => {
18
+ const db = e.target.result
19
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
20
+ db.createObjectStore(STORE_NAME)
21
+ }
22
+ }
23
+ req.onsuccess = (e) => resolve(e.target.result)
24
+ req.onerror = (e) => reject(e.target.error)
25
+ })
26
+ }
27
+
28
+ function dbGet(db, key) {
29
+ return new Promise((resolve, reject) => {
30
+ const tx = db.transaction(STORE_NAME, 'readonly')
31
+ const req = tx.objectStore(STORE_NAME).get(key)
32
+ req.onsuccess = (e) => resolve(e.target.result)
33
+ req.onerror = (e) => reject(e.target.error)
34
+ })
35
+ }
36
+
37
+ function dbPut(db, key, value) {
38
+ return new Promise((resolve, reject) => {
39
+ const tx = db.transaction(STORE_NAME, 'readwrite')
40
+ const req = tx.objectStore(STORE_NAME).put(value, key)
41
+ req.onsuccess = () => resolve()
42
+ req.onerror = (e) => reject(e.target.error)
43
+ })
44
+ }
45
+
46
+ function stemKey(presetName, stemName) {
47
+ return `v${CACHE_VERSION}:${presetName}:${stemName}`
48
+ }
49
+
50
+ /** Returns true if all stemNames for this preset are cached. */
51
+ export async function isCached(presetName, stemNames) {
52
+ try {
53
+ const db = await openDB()
54
+ const checks = await Promise.all(stemNames.map(s => dbGet(db, stemKey(presetName, s))))
55
+ return checks.every(v => v != null)
56
+ } catch {
57
+ return false
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Returns cached data for all stems or null if any stem is missing.
63
+ * Shape: { stemName: { bytes: ArrayBuffer, sampleRate, numChannels, numFrames } }
64
+ */
65
+ export async function getCachedPreset(presetName, stemNames) {
66
+ try {
67
+ const db = await openDB()
68
+ const result = {}
69
+ for (const stemName of stemNames) {
70
+ const entry = await dbGet(db, stemKey(presetName, stemName))
71
+ if (!entry) return null
72
+ result[stemName] = entry
73
+ }
74
+ return result
75
+ } catch {
76
+ return null
77
+ }
78
+ }
79
+
80
+ /** Store a single stem's PCM data in IndexedDB. */
81
+ export async function cacheStem(presetName, stemName, data) {
82
+ try {
83
+ const db = await openDB()
84
+ await dbPut(db, stemKey(presetName, stemName), data)
85
+ } catch (err) {
86
+ console.warn('[presetCache] Failed to cache stem:', err)
87
+ }
88
+ }