Skip to content

Latest commit

 

History

History
584 lines (477 loc) · 20.7 KB

File metadata and controls

584 lines (477 loc) · 20.7 KB

Progress Bar Implementation Critique - Hyphon Web Sequencer

Executive Summary

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.


Current Implementation Analysis

1. The "Progress" UI (StartOverlay Component)

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

2. Initialization Flow Analysis

The initialization happens in multiple stages across different engines:

Stage 1: Pyodide (Python Engine) - NO PROGRESS UPDATES

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:

  1. No progress during Pyodide download - The ~5MB+ pyodide.js script loads without progress tracking
  2. No progress during package loading - NumPy (~8MB) and SciPy (~30MB+) download with no feedback
  3. No incremental updates - User sees "LOADING..." for 10-30+ seconds with no visual change

Stage 2: Audio Engine Initialization - NO PROGRESS UPDATES

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:

  1. 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
  2. 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
  3. No partial progress updates - The entire sequence has only ONE state change at the end


3. State Management - BINARY ONLY

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 progress

No granular state exists for:

  • Which engine is currently initializing
  • Percentage completion
  • Bytes downloaded vs total
  • Time elapsed / estimated time remaining

Detailed Problem Breakdown

Problem 1: Progress Bar Does NOT Move Smoothly

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

Problem 2: Progress Does NOT Reflect Actual Loading State

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

Problem 3: Missing Progress Updates During Async Operations

Missing progress callbacks in:

  1. Pyodide loading - No onProgress callback used
  2. Package loading - No streaming download progress
  3. WebGPU initialization - No adapter request progress
  4. WASM instantiation - No compile/instantiate progress
  5. WAV decoding - No decodeAudioData progress events
  6. AudioWorklet loading - No module load progress

Proposed Fixes

Fix 1: Implement Granular Loading State Management

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' },
];

Fix 2: Update Pyodide Bootstrap with Progress Events

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'));
};

Fix 3: Update useAudioEngine with Stage Reporting

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));
}

Fix 4: Offload CPU-Intensive Operations

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;
}

Fix 5: New Progress Bar Component

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>
    );
};

Fix 6: Updated StartOverlay with Real Progress

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>
    );
};

Implementation Priority

High Priority (MVP)

  1. Add granular stage tracking to usePyodideEngine
  2. Create LoadingProgress component
  3. Update StartOverlay to show real progress

Medium Priority

  1. Add progress reporting to useAudioEngine
  2. Offload CPU-intensive operations (noise buffer, VoiceManager)
  3. Add simulated progress for Pyodide package loading

Low Priority (Polish)

  1. Add bytes-downloaded tracking for large assets
  2. Add estimated time remaining
  3. Add retry logic with exponential backoff
  4. Add detailed error messages per stage

Expected Impact

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)

Files to Modify

  1. src/hooks/usePyodideEngine.ts - Add progress state
  2. src/hooks/useAudioEngine.ts - Add progress callbacks
  3. emscripten/pyodide_bootstrap.js - Dispatch progress events
  4. src/components/StartOverlay.tsx - Show real progress
  5. NEW src/components/LoadingProgress.tsx - Progress bar component
  6. NEW src/hooks/useLoadingState.ts - Centralized loading state
  7. src/App.tsx - Wire up progress state to overlay

Conclusion

The current implementation provides poor user experience due to:

  1. Binary state instead of granular progress
  2. Main thread blocking causing UI freezes
  3. No visibility into which component is loading
  4. 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.