The current "loading progress" implementation in the web_sequencer project is not actually a progress bar - it's a binary ready/not-ready state indicator with a pulsing animation. This document analyzes the current implementation, identifies critical issues, and proposes fixes for smooth loading with accurate progress display.
Location: src/components/StartOverlay.tsx (and duplicated inline in App.tsx)
Current Behavior:
// Binary state - either ready or not
<div className="flex justify-between">
<span>CORE (PYODIDE):</span>
{isReady ?
<span className="text-green-400">LOADED</span> :
<span className="text-yellow-400 animate-pulse">LOADING...</span>
}
</div>
<button disabled={!isReady}>
{isReady ? 'INITIALIZE SYSTEM' : 'LOADING RESOURCES...'}
</button>Problems:
- No actual progress percentage (0-100%)
- No intermediate states - just "loading..." vs "loaded"
- No visibility into which component is being initialized
- Uses a pulsing CSS animation that provides no real feedback
The initialization happens in multiple stages across different engines:
Location: emscripten/pyodide_bootstrap.js (lines 731-772)
globalThis.initPyodideSystem = async function() {
// Load Pyodide script
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = "https://cdn.jsdelivr.net/pyodide/v0.26.1/full/pyodide.js";
script.onload = resolve; // <-- Only fires at 100%
script.onerror = reject;
});
// Initialize Pyodide
const pyodide = await globalThis.loadPyodide(); // <-- No progress callbacks
// Load packages (BLOCKING with no updates)
console.log("[C++ -> JS] Loading NumPy & SciPy...");
await pyodide.loadPackage(['numpy', 'scipy']); // <-- Large download, no progress
// Run Python code
await pyodide.runPythonAsync(INTERNAL_PYTHON_CODE);
// FINALLY dispatch ready event
window.dispatchEvent(new CustomEvent('hyphon-pyodide-ready'));
};Critical Issues:
- No progress during Pyodide download - The ~5MB+ pyodide.js script loads without progress tracking
- No progress during package loading - NumPy (~8MB) and SciPy (~30MB+) download with no feedback
- No incremental updates - User sees "LOADING..." for 10-30+ seconds with no visual change
Location: src/hooks/useAudioEngine.ts (lines 93-278)
Sequential blocking operations:
const initializeAudio = useCallback(async () => {
// 1. AudioContext creation
const context = new (window.AudioContext || ...);
// 2. WebGPU Engine init (async)
const gpuEngine = new WebGpuOscillator();
await gpuEngine.init().catch(...); // No progress update
// 3. WASM Oscillator init (async)
const wasmEngine = new WasmOscillator();
await wasmEngine.init().catch(...); // No progress update
// 4. Open303 Engine init (async, with fallback)
const open303Engine = new Open303Oscillator();
await open303Engine.init(...); // No progress update - can take 1-2s
// 5. WAV file loading (async)
const [sawBuf, sqrBuf] = await Promise.all([
loadWav('./assets/saw.wav'),
loadWav('./assets/square.wav')
]); // No progress update
// 6. Voice Managers init (sync but CPU-intensive)
voiceManagerARef.current = new VoiceManager(...); // Blocks main thread
voiceManagerBRef.current = new VoiceManager(...); // Blocks main thread
// 7. AudioWorklets (async)
await context.audioWorklet.addModule(...); // No progress update
// 8. SingingVoice init (async, 3 voices!)
singingVoiceRef.current = new SingingVoice(...);
await singingVoiceRef.current.initWorklet(...); // No progress update
// ... 2 more voices
// 9. Noise buffer generation (CPU-intensive, sync)
for (let i = 0; i < bufferSize; i++) { // 88200 iterations
output[i] = Math.random() * 2 - 1;
}
setIsReady(true); // FINALLY - single state change
}, []);Critical Issues:
-
All async operations fire with no progress tracking - User sees static "LOADING..." during:
- WebGPU adapter request (can take 1-3s on some systems)
- WASM module instantiation (can take 500ms-2s)
- Open303 WASM initialization (lines 134-165 show this has failure handling but no progress)
- WAV file fetching/decoding
- AudioWorklet module loading
-
Synchronous CPU-intensive operations block the main thread:
- VoiceManager initialization (lines 188-192)
- Noise buffer generation loop (lines 280-286) - 88,200 iterations at context.sampleRate * 2
- These cause the pulsing animation to freeze
-
No partial progress updates - The entire sequence has only ONE state change at the end
Location: src/hooks/useAudioEngine.ts (line 48), src/hooks/usePyodideEngine.ts (line 8)
// useAudioEngine.ts
const [isReady, setIsReady] = useState(false);
// Only setIsReady(true) at line 823 - no intermediate progress
// usePyodideEngine.ts
const [isPyodideReady, setIsPyodideReady] = useState(false);
// Only set to true at line 18 - no intermediate progressNo granular state exists for:
- Which engine is currently initializing
- Percentage completion
- Bytes downloaded vs total
- Time elapsed / estimated time remaining
Current: The "progress" is a CSS pulse animation that has no relation to actual loading progress:
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}Why it fails:
- The animation runs independently of actual loading state
- During CPU-intensive operations (VoiceManager init, noise buffer generation), the main thread is blocked and the animation freezes
- Users see a frozen UI with no feedback for 5-15 seconds on slower devices
Current: Binary state (0% → 100% instantly)
Actual loading stages with estimated times on a 2-core system:
| Stage | Time | Current Feedback |
|---|---|---|
| Pyodide script download | 2-5s | "LOADING..." (static) |
| Pyodide init | 1-3s | "LOADING..." (static) |
| NumPy/SciPy download | 10-30s | "LOADING..." (static) |
| Python code execution | 2-5s | "LOADING..." (static) |
| WebGPU init | 1-3s | No feedback (in audio init) |
| WASM oscillators | 0.5-2s | No feedback (in audio init) |
| Open303 init | 1-2s | No feedback (in audio init) |
| WAV loading | 0.5-1s | No feedback (in audio init) |
| Voice managers | 0.5-1s | Animation freezes |
| AudioWorklets | 0.5-1s | No feedback (in audio init) |
| SingingVoice (3x) | 2-5s | No feedback (in audio init) |
| Noise buffer | 0.1s | Animation freezes |
| Total | 20-60s | 1 state change at end |
Missing progress callbacks in:
- Pyodide loading - No
onProgresscallback used - Package loading - No streaming download progress
- WebGPU initialization - No adapter request progress
- WASM instantiation - No compile/instantiate progress
- WAV decoding - No
decodeAudioDataprogress events - AudioWorklet loading - No module load progress
New state structure:
// src/hooks/useLoadingState.ts
interface LoadingStage {
id: string;
name: string;
progress: number; // 0-100 for this stage
weight: number; // Contribution to total progress
status: 'pending' | 'active' | 'complete' | 'error';
error?: string;
}
interface LoadingState {
stages: LoadingStage[];
totalProgress: number;
currentStageId: string | null;
isComplete: boolean;
}
const DEFAULT_STAGES: LoadingStage[] = [
{ id: 'pyodide-script', name: 'Loading Python Engine', progress: 0, weight: 15, status: 'pending' },
{ id: 'pyodide-init', name: 'Initializing Python', progress: 0, weight: 10, status: 'pending' },
{ id: 'numpy-scipy', name: 'Loading Scientific Libraries', progress: 0, weight: 30, status: 'pending' },
{ id: 'python-runtime', name: 'Loading Audio Algorithms', progress: 0, weight: 10, status: 'pending' },
{ id: 'webgpu', name: 'Initializing WebGPU', progress: 0, weight: 5, status: 'pending' },
{ id: 'wasm-oscillators', name: 'Loading WASM Oscillators', progress: 0, weight: 5, status: 'pending' },
{ id: 'open303', name: 'Loading TB-303 Engine', progress: 0, weight: 10, status: 'pending' },
{ id: 'audio-worklets', name: 'Loading Audio Processors', progress: 0, weight: 10, status: 'pending' },
{ id: 'voice-engines', name: 'Initializing Voice Synthesis', progress: 0, weight: 5, status: 'pending' },
];Modified emscripten/pyodide_bootstrap.js:
globalThis.initPyodideSystem = async function() {
const reportProgress = (stage, progress) => {
window.dispatchEvent(new CustomEvent('hyphon-loading-progress', {
detail: { stage, progress }
}));
};
// Stage 1: Script download with XMLHttpRequest for progress
reportProgress('pyodide-script', 0);
await loadScriptWithProgress(
"https://cdn.jsdelivr.net/pyodide/v0.26.1/full/pyodide.js",
(percent) => reportProgress('pyodide-script', percent)
);
reportProgress('pyodide-script', 100);
// Stage 2: Pyodide init
reportProgress('pyodide-init', 0);
const pyodide = await globalThis.loadPyodide({
// Pyodide supports progress callbacks in newer versions
stdout: (msg) => console.log(msg),
});
reportProgress('pyodide-init', 100);
// Stage 3: Package loading with simulated progress
reportProgress('numpy-scipy', 0);
const packagePromise = pyodide.loadPackage(['numpy', 'scipy']);
// Simulate progress while downloading (since pyodide doesn't provide progress)
let simulatedProgress = 0;
const progressInterval = setInterval(() => {
simulatedProgress += 2; // Increment by 2% every 500ms
if (simulatedProgress < 90) {
reportProgress('numpy-scipy', simulatedProgress);
}
}, 500);
await packagePromise;
clearInterval(progressInterval);
reportProgress('numpy-scipy', 100);
// Stage 4: Python runtime
reportProgress('python-runtime', 0);
await pyodide.runPythonAsync(INTERNAL_PYTHON_CODE);
reportProgress('python-runtime', 100);
// Done
globalThis.hyphonPyodide = pyodide;
globalThis.hyphonPyodideReady = true;
window.dispatchEvent(new CustomEvent('hyphon-pyodide-ready'));
};Modified src/hooks/useAudioEngine.ts:
export const useAudioEngine = (pyodide: any, forceScriptProcessor: boolean = false,
onProgress?: (stage: string, progress: number) => void
) => {
const reportProgress = (stage: string, progress: number) => {
onProgress?.(stage, progress);
};
const initializeAudio = useCallback(async () => {
// Stage: WebGPU
reportProgress('webgpu', 0);
const gpuEngine = new WebGpuOscillator();
await gpuEngine.init().catch(...);
reportProgress('webgpu', 100);
// Stage: WASM Oscillators
reportProgress('wasm-oscillators', 0);
const wasmEngine = new WasmOscillator();
await wasmEngine.init().catch(...);
reportProgress('wasm-oscillators', 100);
// Stage: Open303
reportProgress('open303', 0);
const open303Engine = new Open303Oscillator();
await open303Engine.init(...);
reportProgress('open303', 100);
// Stage: Audio Worklets (incremental)
reportProgress('audio-worklets', 0);
await context.audioWorklet.addModule(sustainProcessorUrl);
reportProgress('audio-worklets', 50);
// ... other worklets
reportProgress('audio-worklets', 100);
// Stage: Voice Engines
reportProgress('voice-engines', 0);
await singingVoiceRef.current.initWorklet(...);
reportProgress('voice-engines', 33);
await singingVoiceLeftRef.current.initWorklet(...);
reportProgress('voice-engines', 66);
await singingVoiceRightRef.current.initWorklet(...);
reportProgress('voice-engines', 100);
// Offload CPU-intensive tasks to avoid blocking
await yieldToMainThread();
setIsReady(true);
}, []);
};
// Helper to prevent UI blocking
function yieldToMainThread(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0));
}Problem: VoiceManager init and noise buffer generation block the main thread
Solution: Use setTimeout/yield pattern:
// Instead of:
for (let i = 0; i < bufferSize; i++) {
output[i] = Math.random() * 2 - 1; // Blocks for 88,200 iterations
}
// Use chunked processing:
async function generateNoiseBuffer(context: AudioContext): Promise<AudioBuffer> {
const bufferSize = context.sampleRate * 2;
const buffer = context.createBuffer(1, bufferSize, context.sampleRate);
const output = buffer.getChannelData(0);
const CHUNK_SIZE = 4096; // Process in chunks to allow UI updates
for (let offset = 0; offset < bufferSize; offset += CHUNK_SIZE) {
const end = Math.min(offset + CHUNK_SIZE, bufferSize);
for (let i = offset; i < end; i++) {
output[i] = Math.random() * 2 - 1;
}
// Yield to main thread every chunk
if (offset + CHUNK_SIZE < bufferSize) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
return buffer;
}New file: src/components/LoadingProgress.tsx
import React from 'react';
interface LoadingProgressProps {
stages: Array<{
id: string;
name: string;
progress: number;
status: 'pending' | 'active' | 'complete' | 'error';
}>;
totalProgress: number;
}
export const LoadingProgress: React.FC<LoadingProgressProps> = ({
stages,
totalProgress
}) => {
const activeStage = stages.find(s => s.status === 'active');
return (
<div className="w-full space-y-4">
{/* Main progress bar */}
<div className="relative h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className="absolute inset-y-0 left-0 bg-gradient-to-r from-cyan-500 to-blue-500 transition-all duration-300"
style={{ width: `${totalProgress}%` }}
/>
</div>
{/* Progress text */}
<div className="flex justify-between text-xs font-mono">
<span className="text-cyan-400">
{activeStage ? activeStage.name : 'Complete'}
</span>
<span className="text-gray-400">
{Math.round(totalProgress)}%
</span>
</div>
{/* Stage list (collapsible) */}
<div className="space-y-1">
{stages.map(stage => (
<div key={stage.id} className="flex items-center gap-2 text-xs">
<StatusIcon status={stage.status} />
<span className={getStatusColor(stage.status)}>
{stage.name}
</span>
{stage.status === 'active' && (
<span className="text-gray-500">
{stage.progress}%
</span>
)}
</div>
))}
</div>
</div>
);
};Modified src/components/StartOverlay.tsx:
interface StartOverlayProps {
onStart: () => void;
isReady: boolean;
loadingStages?: Array<{
id: string;
name: string;
progress: number;
status: 'pending' | 'active' | 'complete' | 'error';
}>;
totalProgress?: number;
}
export const StartOverlay: React.FC<StartOverlayProps> = ({
onStart,
isReady,
loadingStages = [],
totalProgress = 0
}) => {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#111827] bg-opacity-95">
<div className="text-center p-8 bg-[#1f2937] border-2 border-cyan-500 rounded-2xl shadow-2xl max-w-lg w-full">
<h1 className="text-4xl font-bold font-orbitron text-cyan-400 mb-2">
HYPHON
</h1>
<p className="text-gray-400 mb-8 font-mono text-sm">
BROWSER AUDIO WORKSTATION
</p>
{/* System check - always ready items */}
<div className="mb-6 p-4 bg-gray-800 rounded-lg border border-gray-700 text-left font-mono text-xs">
<p className="mb-2 text-cyan-500 font-bold">SYSTEM CHECK:</p>
<div className="flex justify-between mb-1">
<span>AUDIO ENGINE:</span>
<span className="text-green-400">READY</span>
</div>
<div className="flex justify-between mb-1">
<span>WEBGPU:</span>
<span className="text-green-400">DETECTED</span>
</div>
</div>
{/* Real progress bar */}
{!isReady && (
<div className="mb-6">
<LoadingProgress
stages={loadingStages}
totalProgress={totalProgress}
/>
</div>
)}
<button
onClick={onStart}
disabled={!isReady}
className={`w-full py-4 rounded-xl font-orbitron text-xl font-bold tracking-widest transition-all duration-300 ${
isReady
? 'bg-cyan-600 hover:bg-cyan-500 text-white shadow-[0_0_20px_rgba(6,182,212,0.6)]'
: 'bg-gray-700 text-gray-500 cursor-wait'
}`}
>
{isReady ? 'INITIALIZE SYSTEM' : `LOADING... ${Math.round(totalProgress)}%`}
</button>
</div>
</div>
);
};- Add granular stage tracking to
usePyodideEngine - Create
LoadingProgresscomponent - Update
StartOverlayto show real progress
- Add progress reporting to
useAudioEngine - Offload CPU-intensive operations (noise buffer, VoiceManager)
- Add simulated progress for Pyodide package loading
- Add bytes-downloaded tracking for large assets
- Add estimated time remaining
- Add retry logic with exponential backoff
- Add detailed error messages per stage
| Metric | Before | After |
|---|---|---|
| Perceived loading time | Feels longer (no feedback) | Feels shorter (visual progress) |
| Animation smoothness | Freezes during CPU tasks | Smooth (yield to main thread) |
| User confusion | High (is it stuck?) | Low (clear progress indication) |
| Progress accuracy | 0% → 100% instantly | Granular 0-100% across 9 stages |
| Time to interactive | Same | Same (but better UX) |
src/hooks/usePyodideEngine.ts- Add progress statesrc/hooks/useAudioEngine.ts- Add progress callbacksemscripten/pyodide_bootstrap.js- Dispatch progress eventssrc/components/StartOverlay.tsx- Show real progress- NEW
src/components/LoadingProgress.tsx- Progress bar component - NEW
src/hooks/useLoadingState.ts- Centralized loading state src/App.tsx- Wire up progress state to overlay
The current implementation provides poor user experience due to:
- Binary state instead of granular progress
- Main thread blocking causing UI freezes
- No visibility into which component is loading
- Long periods (10-30s) with no visual feedback
Implementing the proposed fixes will provide a smooth, informative loading experience that accurately reflects the complex multi-stage initialization process.