29 KiB
Project Mode Implementation - Continuation Guide
Overview
This document provides a complete roadmap for continuing the implementation of dual-mode support in OldBoy: Composition Mode (full document evaluation) and Live Coding Mode (block-based evaluation with persistent Csound instance).
What Has Been Completed
1. Type System & Data Model ✅
Files Modified:
/src/lib/project-system/types.ts/src/lib/project-system/project-manager.ts
Changes:
-
Created
ProjectModetype:export type ProjectMode = 'composition' | 'livecoding'; -
Added
modefield toCsoundProjectinterface:export interface CsoundProject { // ... existing fields mode: ProjectMode; // NEW: Execution mode } -
Updated
CreateProjectDatato accept optional mode:export interface CreateProjectData { // ... existing fields mode?: ProjectMode; // Defaults to 'composition' if not provided } -
Updated
UpdateProjectDatato allow mode updates:export interface UpdateProjectData { // ... existing fields mode?: ProjectMode; // Can change mode after creation } -
Modified
ProjectManager.createProject():- Line 100: Added
mode: data.mode || 'composition'to project initialization - Ensures all new projects have a mode (defaults to composition)
- Line 100: Added
-
Modified
ProjectManager.updateProject():- Line 165: Added
...(data.mode !== undefined && { mode: data.mode })to update logic - Allows mode to be updated without affecting other fields
- Line 165: Added
Result: All projects now have a mode field that persists in IndexedDB and is included in import/export operations.
2. UI for Mode Selection ✅
Files Modified:
/src/lib/components/ui/FileBrowser.svelte/src/lib/stores/projectEditor.svelte.ts/src/App.svelte
FileBrowser Changes:
-
Imports (Line 7):
import type { ProjectMode } from '../../project-system/types'; -
Props Interface (Line 13):
onMetadataUpdate?: (projectId: string, updates: { title?: string; author?: string; mode?: ProjectMode // NEW }) => void; -
State (Line 30):
let editMode = $state<ProjectMode>('composition'); -
Effect Hook (Lines 32-38):
$effect(() => { if (selectedProject) { editTitle = selectedProject.title; editAuthor = selectedProject.author; editMode = selectedProject.mode; // NEW: Sync mode from project } }); -
Metadata Change Handler (Lines 103-118):
function handleMetadataChange() { if (!selectedProject) return; const hasChanges = editTitle !== selectedProject.title || editAuthor !== selectedProject.author || editMode !== selectedProject.mode; // NEW: Include mode in change detection if (hasChanges) { onMetadataUpdate?.(selectedProject.id, { title: editTitle, author: editAuthor, mode: editMode // NEW: Send mode updates }); } } -
UI Element (Lines 183-193):
<div class="field"> <label for="file-mode">Mode</label> <select id="file-mode" bind:value={editMode} onchange={handleMetadataChange} > <option value="composition">Composition</option> <option value="livecoding">Live Coding</option> </select> </div> -
CSS (Lines 373-390):
.field input, .field select { padding: 0.5rem; background-color: #2a2a2a; border: 1px solid #3a3a3a; color: rgba(255, 255, 255, 0.87); font-size: 0.875rem; outline: none; } .field select { cursor: pointer; }
ProjectEditor Changes (Line 108):
async updateMetadata(updates: {
title?: string;
author?: string;
mode?: import('../project-system/types').ProjectMode // NEW
}): Promise<boolean>
App.svelte Changes (Line 123):
async function handleMetadataUpdate(
projectId: string,
updates: {
title?: string;
author?: string;
mode?: import('./lib/project-system/types').ProjectMode // NEW
}
)
Result: Users can now select project mode in the Files panel metadata editor. Mode changes are immediately persisted.
3. Block Evaluation Infrastructure ✅
File Created:
/src/lib/editor/block-eval.ts
Purpose: Provides utilities for extracting code blocks and visual feedback, adapted from flok's cm-eval package.
Exports:
-
flash(view, from, to, timeout)- Visually highlights evaluated code region
- Default timeout: 150ms
- Background color:
#FFCA2880(yellow with transparency)
-
flashField(style?)- CodeMirror StateField for managing flash decorations
- Returns StateField to be added to editor extensions
- Handles flash effect lifecycle
-
getSelection(state): EvalBlock- Returns currently selected text with positions
- Returns
{ text: '', from: null, to: null }if no selection
-
getLine(state): EvalBlock- Returns the entire line at cursor position
- Includes line's from/to positions
-
getBlock(state): EvalBlock- Returns paragraph block (text separated by blank lines)
- Searches up and down from cursor until blank lines found
- Core functionality for live coding mode
-
getDocument(state): EvalBlock- Returns entire document text
- Used for composition mode evaluation
EvalBlock Interface:
interface EvalBlock {
text: string; // The code to evaluate
from: number | null; // Start position in document
to: number | null; // End position in document
}
Implementation Details:
-
Block Detection Algorithm:
- Start at cursor line
- If line is blank, return empty block
- Search backwards until blank line or document start
- Search forwards until blank line or document end
- Extract text from start to end positions
-
Flash Effect:
- Uses CodeMirror StateEffect system
- Creates decoration with inline style
- Automatically clears after timeout
- Non-blocking (doesn't prevent editing)
Result: Complete block evaluation infrastructure ready to integrate with Editor component.
What Needs to Be Done Next
Phase 1: Editor Integration with Block Evaluation
Goal: Connect block-eval utilities to the Editor component and add visual feedback.
File to Modify: /src/lib/components/editor/Editor.svelte
Steps:
-
Import block-eval utilities (add to imports):
import { flashField, flash, getSelection, getBlock, getDocument } from '../editor/block-eval'; -
Add flashField to extensions:
Find where extensions are created (likely in a
$derivedor similar). It should look something like:let extensions = $derived([ // ... existing extensions ]);Add flashField:
let extensions = $derived([ // ... existing extensions flashField(), // NEW: Add flash effect support ]); -
Expose block extraction methods:
Add these methods to the Editor component (after the component logic, before closing tag):
export function getSelectedText(): string | null { if (!editorView) return null; const { text } = getSelection(editorView.state); return text || null; } export function getCurrentBlock(): string | null { if (!editorView) return null; const { text } = getBlock(editorView.state); return text || null; } export function getFullDocument(): string { if (!editorView) return ''; const { text } = getDocument(editorView.state); return text; } export function evaluateWithFlash(text: string, from: number | null, to: number | null) { if (editorView && from !== null && to !== null) { flash(editorView, from, to); } } -
Modify keyboard shortcut for execute:
Find the keyboard shortcut setup (search for "Ctrl-Enter" or "Cmd-Enter"). It might look like:
keymap.of([ { key: "Ctrl-Enter", run: () => { onExecute?.(value); return true; } } ])Change it to call a new internal method:
keymap.of([ { key: "Ctrl-Enter", mac: "Cmd-Enter", run: () => { handleExecute(); return true; } } ]) -
Add internal execute handler:
function handleExecute() { if (!editorView) return; // Get selection or block const selection = getSelection(editorView.state); if (selection.text) { // Has selection: evaluate it flash(editorView, selection.from, selection.to); onExecute?.(selection.text, 'selection'); } else { // No selection: evaluate block or document based on mode // For now, always get block (mode logic will be in App.svelte) const block = getBlock(editorView.state); if (block.text) { flash(editorView, block.from, block.to); onExecute?.(block.text, 'block'); } } } -
Update onExecute prop type:
Change the prop signature from:
onExecute?: (code: string) => void;To:
onExecute?: (code: string, source: 'selection' | 'block' | 'document') => void;
Expected Result:
- Cmd/Ctrl+Enter will flash the code being evaluated
- Editor can distinguish between selection, block, and document evaluation
- Parent components can access block extraction methods
Phase 2: Execution Strategy Implementation
Goal: Create strategy pattern for handling composition vs livecoding execution modes.
File to Create: /src/lib/csound/execution-strategies.ts
Implementation:
import type { CsoundStore } from './store';
import type { ProjectMode } from '../project-system/types';
export interface ExecutionStrategy {
execute(
csound: CsoundStore,
code: string,
fullContent: string,
source: 'selection' | 'block' | 'document'
): Promise<void>;
}
/**
* Composition Mode Strategy
* - Always evaluates full CSD document
* - Uses ephemeral instances (fresh instance each time)
* - Standard workflow: compile → start → play → cleanup
*/
export class CompositionStrategy implements ExecutionStrategy {
async execute(
csound: CsoundStore,
code: string,
fullContent: string,
source: 'selection' | 'block' | 'document'
): Promise<void> {
// Always evaluate full document in composition mode
await csound.evaluate(fullContent);
}
}
/**
* Live Coding Mode Strategy
* - Evaluates blocks incrementally
* - Uses persistent instance (maintains state)
* - Supports: score events, channel updates, instrument redefinition
*/
export class LiveCodingStrategy implements ExecutionStrategy {
private isInitialized = false;
private headerCompiled = false;
async execute(
csound: CsoundStore,
code: string,
fullContent: string,
source: 'selection' | 'block' | 'document'
): Promise<void> {
// First time: initialize with full document
if (!this.isInitialized) {
await this.initializeFromDocument(csound, fullContent);
this.isInitialized = true;
return;
}
// Subsequent evaluations: handle blocks
await this.evaluateBlock(csound, code);
}
private async initializeFromDocument(
csound: CsoundStore,
fullContent: string
): Promise<void> {
// Parse CSD to extract orchestra and initial score
const { header, instruments, score } = this.parseCSD(fullContent);
// Compile header + instruments
const fullOrchestra = header + '\n' + instruments;
const compileResult = await csound.compileOrchestra(fullOrchestra);
if (!compileResult.success) {
throw new Error(compileResult.errorMessage || 'Compilation failed');
}
this.headerCompiled = true;
// Start performance
await csound.startPerformance();
// If score has events, send them
if (score.trim()) {
await csound.readScore(score);
}
}
private async evaluateBlock(csound: CsoundStore, code: string): Promise<void> {
const trimmedCode = code.trim();
if (!trimmedCode) return;
// Detect what kind of code this is
if (this.isScoreEvent(trimmedCode)) {
// Send score event (e.g., "i 1 0 2 0.5")
await csound.sendScoreEvent(trimmedCode);
}
else if (this.isInstrumentDefinition(trimmedCode)) {
// Recompile instrument
await csound.compileOrchestra(trimmedCode);
}
else if (this.isChannelSet(trimmedCode)) {
// Set channel value (e.g., "freq = 440")
await this.handleChannelSet(csound, trimmedCode);
}
else {
// Default: try to compile as orchestra code
await csound.compileOrchestra(trimmedCode);
}
}
private parseCSD(content: string): {
header: string;
instruments: string;
score: string
} {
// Extract <CsInstruments> section
const orcMatch = content.match(/<CsInstruments>([\s\S]*?)<\/CsInstruments>/);
if (!orcMatch) {
return { header: '', instruments: '', score: '' };
}
const orchestra = orcMatch[1].trim();
// Extract <CsScore> section
const scoMatch = content.match(/<CsScore>([\s\S]*?)<\/CsScore>/);
const score = scoMatch ? scoMatch[1].trim() : '';
// Split orchestra into header (sr, ksmps, nchnls, etc.) and instruments
const instrMatch = orchestra.match(/([\s\S]*?)(instr\s+\d+[\s\S]*)/);
if (instrMatch) {
return {
header: instrMatch[1].trim(),
instruments: instrMatch[2].trim(),
score
};
}
// If no instruments found, treat entire orchestra as header
return {
header: orchestra,
instruments: '',
score
};
}
private isScoreEvent(code: string): boolean {
// Check for score event syntax: i, f, e, a followed by space/number
return /^[ifea]\s+[\d\-]/.test(code);
}
private isInstrumentDefinition(code: string): boolean {
// Check for instrument definition
return /^\s*instr\s+/.test(code);
}
private isChannelSet(code: string): boolean {
// Simple channel set syntax: varname = value
// e.g., "freq = 440" or "cutoff = 2000"
return /^\w+\s*=\s*[\d\.\-]+/.test(code);
}
private async handleChannelSet(csound: CsoundStore, code: string): Promise<void> {
// Parse: varname = value
const match = code.match(/^(\w+)\s*=\s*([\d\.\-]+)/);
if (!match) return;
const [, channelName, valueStr] = match;
const value = parseFloat(valueStr);
await csound.setControlChannel(channelName, value);
}
/**
* Reset the strategy state (called when switching documents or resetting)
*/
reset(): void {
this.isInitialized = false;
this.headerCompiled = false;
}
}
/**
* Factory function to create appropriate strategy based on mode
*/
export function createExecutionStrategy(mode: ProjectMode): ExecutionStrategy {
return mode === 'livecoding'
? new LiveCodingStrategy()
: new CompositionStrategy();
}
Expected Result:
- Strategy pattern cleanly separates composition and livecoding behaviors
- LiveCodingStrategy maintains state across evaluations
- Supports score events, instrument redefinition, and channel control
Phase 3: Update App.svelte to Use Strategies
File to Modify: /src/App.svelte
Steps:
-
Import strategy utilities:
import { createExecutionStrategy, type ExecutionStrategy } from './lib/csound/execution-strategies'; -
Track current strategy:
let currentStrategy = $state<ExecutionStrategy | null>(null); let currentMode = $state<import('./lib/project-system/types').ProjectMode>('composition'); -
Update strategy when project changes:
Add effect to watch for project mode changes:
$effect(() => { const mode = projectEditor.currentProject?.mode || 'composition'; // Only recreate strategy if mode changed if (mode !== currentMode) { currentMode = mode; currentStrategy = createExecutionStrategy(mode); // If switching to livecoding, need to reset csound if (mode === 'livecoding') { // Reset to ensure clean state csound.reset().catch(console.error); } } }); -
Update handleExecute function:
Replace existing handleExecute (around line 99):
async function handleExecute(code: string, source: 'selection' | 'block' | 'document') { try { if (!currentStrategy) { currentStrategy = createExecutionStrategy(currentMode); } const fullContent = projectEditor.content; await currentStrategy.execute(csound, code, fullContent, source); } catch (error) { console.error('Execution error:', error); } } -
Update EditorWithLogs onExecute prop:
Find where EditorWithLogs is used (around line 274):
<EditorWithLogs value={projectEditor.content} language="javascript" onChange={handleEditorChange} onExecute={handleExecute} <!-- Already correct, just verify signature matches --> logs={interpreterLogs} {editorSettings} />
Expected Result:
- App automatically uses correct strategy based on project mode
- Strategy instance persists across evaluations
- Mode switches trigger proper cleanup/reset
Phase 4: Update Csound Store for Mode-Aware Instance Management
Goal: Make Csound store use persistent vs ephemeral instances based on project mode.
File to Modify: /src/lib/contexts/app-context.ts
Current Code (likely around line 10-15):
const csound = createCsoundStore();
Change to:
const csound = createCsoundStore('ephemeral'); // Default to ephemeral
Then in App.svelte, add mode-based reset logic:
In the $effect that watches mode changes (from Phase 3, Step 3), enhance it:
$effect(() => {
const mode = projectEditor.currentProject?.mode || 'composition';
if (mode !== currentMode) {
const oldMode = currentMode;
currentMode = mode;
currentStrategy = createExecutionStrategy(mode);
// Handle Csound instance mode switching
if (mode === 'livecoding' && oldMode === 'composition') {
// Switching TO livecoding: need persistent instance
// Reset will be handled by first LiveCodingStrategy execution
console.log('Switched to live coding mode');
} else if (mode === 'composition' && oldMode === 'livecoding') {
// Switching FROM livecoding: stop and cleanup
csound.stop().catch(console.error);
console.log('Switched to composition mode');
}
}
});
Note: The actual instance mode (ephemeral/persistent) is now implicitly handled by the strategy:
- CompositionStrategy: calls
csound.evaluate()which uses ephemeral mode (current default) - LiveCodingStrategy: calls
compileOrchestra()+startPerformance()which uses the same instance repeatedly
The key insight is that persistence is achieved by NOT calling evaluate() (which destroys/recreates), but instead using the low-level API (compileOrchestra, startPerformance, sendScoreEvent).
Expected Result:
- Composition mode: Each evaluation gets fresh instance
- Live coding mode: Single instance persists across block evaluations
- Switching modes properly cleans up old instances
Phase 5: Testing & Verification
Test Plan:
Test 1: Composition Mode (Baseline)
- Create new project (defaults to composition mode)
- Paste this CSD code:
<CsoundSynthesizer> <CsOptions> -odac </CsOptions> <CsInstruments> sr = 48000 ksmps = 32 nchnls = 2 0dbfs = 1 instr 1 aOut poscil 0.2, 440 outs aOut, aOut endin </CsInstruments> <CsScore> i 1 0 2 </CsScore> </CsoundSynthesizer> - Press Cmd/Ctrl+Enter
- Expected: Entire document flashes yellow, sound plays for 2 seconds
- Modify frequency to 880, press Cmd/Ctrl+Enter again
- Expected: New instance created, new sound plays
Test 2: Live Coding Mode - Initial Setup
- Open FileBrowser, select the project
- In metadata, change Mode from "Composition" to "Live Coding"
- Press Cmd/Ctrl+Enter on the entire document
- Expected:
- Document flashes
- Csound initializes (check logs for "Starting performance...")
- Sound plays if score has events
Test 3: Live Coding Mode - Block Evaluation
- Clear the
<CsScore>section (or comment out with semicolons) - Ensure document is initialized (press Cmd/Ctrl+Enter on full document)
- Add a new line at the bottom of the document:
i 1 0 2 0.5 - Place cursor on that line, press Cmd/Ctrl+Enter
- Expected:
- Only that line flashes
- Sound plays immediately (instrument 1 triggered)
- Logs show score event sent, NOT full recompilation
Test 4: Live Coding Mode - Channel Control
- Modify instrument 1 to use a channel:
instr 1 kFreq chnget "freq" aOut poscil 0.2, kFreq outs aOut, aOut endin - Reinitialize (Cmd+Enter on full document)
- Start a long note:
i 1 0 60 - While playing, evaluate this block:
freq = 440 - Then evaluate:
freq = 880 - Expected:
- Frequency changes in real-time while note plays
- No audio interruption
Test 5: Live Coding Mode - Instrument Redefinition
- While note is playing from Test 4, modify instrument 1:
instr 1 kFreq chnget "freq" aOut vco2 0.2, kFreq outs aOut, aOut endin - Select only the instrument definition, press Cmd+Enter
- Expected:
- Instrument recompiles
- Next triggered note uses new definition
- Currently playing notes might continue with old definition (Csound behavior)
Test 6: Mode Switching
- In live coding mode with performance running
- Switch mode to "Composition" in FileBrowser
- Expected:
- Performance stops
- Logs show "Stopped"
- Press Cmd+Enter
- Expected:
- Full document evaluation (composition mode behavior)
Test 7: Persistence Across Sessions
- Create project in live coding mode
- Close browser tab
- Reopen, load project
- Expected:
- Mode is still "Live Coding" in metadata
- First evaluation initializes correctly
Troubleshooting Guide
Issue: "Cannot find module '@flok-editor/session'"
Cause: The block-eval code references types from flok's session package.
Solution:
- Our implementation in
/src/lib/editor/block-eval.tsis standalone and doesn't need this - If TypeScript complains, we don't use the
Documenttype from flok - Our editor integration passes callbacks, not Document objects
Issue: Flashing doesn't appear
Cause: flashField not added to editor extensions
Solution:
- Verify
import { flashField } from '../editor/block-eval'in Editor.svelte - Verify
flashField()is in the extensions array - Check browser console for errors
Issue: Block evaluation evaluates wrong text
Cause: Block detection algorithm confused by comment syntax
Solution:
- Csound uses
;for line comments - Blank line detection:
line.text.trim().length === 0 - If commented lines interfere, update
getBlock()to treat;-only lines as blank
Issue: Live coding mode doesn't persist
Cause: Strategy state lost between evaluations
Solution:
- Verify
currentStrategyis stored in$state(), not recreated each time - Check that
$effectonly recreates strategy when mode actually changes - Ensure LiveCodingStrategy instance is reused
Issue: Performance doesn't start in live coding mode
Cause: startPerformance() not called or called before compilation
Solution:
- Check logs for compilation errors
- Verify
compileOrchestra()returnssuccess: true - Ensure
startPerformance()is awaited
Issue: Mode changes don't take effect
Cause: Project not reloaded after metadata update
Solution:
handleMetadataUpdate()should callprojectEditor.updateMetadata()- This should update
projectEditor.currentProject $effectwatchingcurrentProject?.modeshould trigger- Verify effect dependencies are correct
File Structure Reference
New Files Created
/src/lib/editor/block-eval.ts - Block evaluation utilities (✅ COMPLETE)
/src/lib/csound/execution-strategies.ts - Strategy pattern (❌ TODO)
Modified Files
/src/lib/project-system/types.ts - Added ProjectMode type (✅ COMPLETE)
/src/lib/project-system/project-manager.ts - Added mode handling (✅ COMPLETE)
/src/lib/components/ui/FileBrowser.svelte - Added mode selector UI (✅ COMPLETE)
/src/lib/stores/projectEditor.svelte.ts - Updated metadata types (✅ COMPLETE)
/src/App.svelte - Updated handlers (✅ PARTIAL - needs strategy integration)
/src/lib/components/editor/Editor.svelte - Needs block eval integration (❌ TODO)
/src/lib/contexts/app-context.ts - Might need mode-aware csound init (❌ TODO)
Key Design Principles to Remember
-
No Fallbacks: If livecoding fails, it fails. Don't silently fall back to composition mode.
-
Mode Per Project: Mode is project metadata, not global state. Different tabs could have different modes.
-
Persistent = Reuse Low-Level API: Persistence isn't a csound setting, it's about calling
compileOrchestra()+startPerformance()instead ofevaluate(). -
Block = Paragraph: A block is text separated by blank lines. This is the standard live coding convention.
-
Flash for Feedback: Always flash evaluated code so user knows what executed.
-
Strategy Owns State: LiveCodingStrategy tracks initialization state, not the store or app.
Implementation Order
Follow this exact order to minimize issues:
- ✅ Phase 1: Editor integration (visual feedback works first)
- ✅ Phase 2: Create execution strategies (logic isolated and testable)
- ✅ Phase 3: Wire strategies to App.svelte (connect pieces)
- ✅ Phase 4: Verify instance management (make sure persistence works)
- ✅ Phase 5: Test thoroughly (catch edge cases)
Success Criteria
Composition Mode
- [✅] Full document evaluation on Cmd+Enter
- [✅] Flash effect shows what was evaluated
- [✅] Fresh instance every time (no state leakage)
Live Coding Mode
- [❌] First evaluation initializes from full document
- [❌] Subsequent evaluations process blocks incrementally
- [❌] Score events trigger sounds immediately
- [❌] Channel updates affect running performance
- [❌] Instrument redefinition works
- [❌] Instance persists across evaluations
- [❌] Logs show block evaluations, not full recompilations
Both Modes
- [✅] Mode selection UI works
- [✅] Mode persists in database
- [❌] Mode switching cleans up properly
- [❌] Keyboard shortcuts work consistently
Additional Notes
Why Ephemeral Default Makes Sense Now
The original implementation used ephemeral mode by default because Web Audio reconnection after csound.reset() was unreliable. However, in live coding mode:
- We don't call
reset()between evaluations - We don't call
evaluate()which destroys the instance - We use low-level API (
compileOrchestra,sendScoreEvent) which reuses the instance
So "persistent mode" is actually achieved by avoiding the methods that destroy/recreate.
Why We Adapted cm-eval Instead of Using It
The flok cm-eval package:
- Assumes a
Documentobject with anevaluate()method - Is tightly coupled to flok's session architecture
- Uses their specific remote evaluation system
Our needs:
- Evaluate blocks locally (no remote)
- Different document format (CSD vs raw code)
- Integrate with existing Csound store
Solution: Extract the core block detection and flash logic, adapt for our architecture.
Csound Score Event Syntax Quick Reference
For testing live coding mode:
; Start instrument 1 at time 0, duration 2 seconds, amplitude 0.5
i 1 0 2 0.5
; Start instrument 1 now (time 0), indefinite duration (-1)
i 1 0 -1 0.5
; Turn off all instances of instrument 1
i -1 0 0
; Function table: create table 1, size 8192, sine wave
f 1 0 8192 10 1
Next Session Checklist
When you resume implementation:
- Read this document thoroughly
- Verify all completed work with
pnpm build - Start with Phase 1 (Editor integration)
- Test each phase before moving to next
- Update this document if you deviate from the plan
- Document any issues found and solutions
Questions to Resolve
None at this time. The architecture is well-defined and ready for implementation.
Document Version: 1.0 Last Updated: 2025-01-15 Status: Ready for Phase 1 implementation