Better architectural distinction between live coding mode and composition mode

This commit is contained in:
2025-10-15 11:04:27 +02:00
parent 46925f5c2e
commit bbdb01200e
11 changed files with 434 additions and 160 deletions

View File

@ -10,11 +10,11 @@
import Spectrogram from './lib/components/audio/Spectrogram.svelte';
import ConfirmDialog from './lib/components/ui/ConfirmDialog.svelte';
import InputDialog from './lib/components/ui/InputDialog.svelte';
import { createCsoundDerivedStores, type LogEntry } from './lib/csound';
import TemplateDialog from './lib/components/ui/TemplateDialog.svelte';
import { createCsoundDerivedStores, type LogEntry, type EvalSource } from './lib/csound';
import { type CsoundProject } from './lib/project-system';
import { DEFAULT_CSOUND_TEMPLATE, LIVECODING_TEMPLATE } from './lib/config/templates';
import { templateRegistry, type CsoundTemplate } from './lib/templates/template-registry';
import { createAppContext, setAppContext } from './lib/contexts/app-context';
import { createExecutionStrategy, type ExecutionStrategy } from './lib/csound/execution-strategies';
import type { ProjectMode } from './lib/project-system/types';
import {
PanelLeftClose,
@ -32,13 +32,11 @@
const appContext = createAppContext();
setAppContext(appContext);
const { csound, projectManager, editorSettings, projectEditor, uiState } = appContext;
const { csound, projectManager, editorSettings, projectEditor, uiState, executionContext } = appContext;
const csoundDerived = createCsoundDerivedStores(csound);
let analyserNode = $state<AnalyserNode | null>(null);
let interpreterLogs = $state<LogEntry[]>([]);
let currentStrategy = $state<ExecutionStrategy | null>(null);
let currentMode = $state<ProjectMode>('composition');
let logsUnsubscribe: (() => void) | undefined;
@ -47,12 +45,16 @@
const result = await projectManager.getAllProjects();
if (result.success && result.data.length === 0) {
await projectManager.createProject({
title: 'Template',
author: 'System',
content: DEFAULT_CSOUND_TEMPLATE,
tags: []
});
const classicTemplate = templateRegistry.getById('classic');
if (classicTemplate) {
await projectManager.createProject({
title: 'Welcome',
author: 'System',
content: classicTemplate.content,
tags: [],
mode: classicTemplate.mode
});
}
}
logsUnsubscribe = csoundDerived.logs.subscribe(logs => {
@ -78,9 +80,10 @@
projectEditor.setContent(value);
}
function handleNewFile() {
function handleNewEmptyFile() {
const emptyTemplate = templateRegistry.getEmpty();
const result = projectEditor.requestSwitch(
() => projectEditor.createNew(DEFAULT_CSOUND_TEMPLATE)
() => projectEditor.createNew(emptyTemplate.content)
);
if (result === 'confirm-unsaved') {
@ -88,9 +91,15 @@
}
}
function handleNewLiveCodingFile() {
function handleNewFromTemplate() {
uiState.showTemplateDialog();
}
function handleTemplateSelect(template: CsoundTemplate) {
uiState.hideTemplateDialog();
const result = projectEditor.requestSwitch(
() => projectEditor.createNew(LIVECODING_TEMPLATE)
() => projectEditor.createNew(template.content)
);
if (result === 'confirm-unsaved') {
@ -110,14 +119,9 @@
}
}
async function handleExecute(code: string, source: 'selection' | 'block' | 'document') {
async function handleExecute(code: string, source: EvalSource) {
try {
if (!currentStrategy) {
currentStrategy = createExecutionStrategy(currentMode);
}
const fullContent = projectEditor.content;
await currentStrategy.execute(csound, code, fullContent, source);
await executionContext.execute(code, source);
} catch (error) {
console.error('Execution error:', error);
}
@ -189,28 +193,16 @@
$effect(() => {
const mode = projectEditor.currentProject?.mode || 'composition';
const projectId = projectEditor.currentProjectId;
if (mode !== currentMode) {
const oldMode = currentMode;
currentMode = mode;
if (mode !== executionContext.mode) {
executionContext.switchMode(mode).catch(console.error);
}
// IMPORTANT: Only create new strategy if mode actually changed
// Reset the old strategy if switching away from livecoding
if (oldMode === 'livecoding' && currentStrategy) {
const liveCodingStrategy = currentStrategy as any;
if (liveCodingStrategy.reset) {
liveCodingStrategy.reset();
}
}
executionContext.setContentProvider(() => projectEditor.content);
currentStrategy = createExecutionStrategy(mode);
if (mode === 'livecoding' && oldMode === 'composition') {
console.log('Switched to live coding mode');
} else if (mode === 'composition' && oldMode === 'livecoding') {
csound.stop().catch(console.error);
console.log('Switched to composition mode');
}
if (projectId) {
executionContext.startSession(projectId).catch(console.error);
}
});
@ -236,8 +228,8 @@
<FileBrowser
{projectManager}
onFileSelect={handleFileSelect}
onNewFile={handleNewFile}
onNewLiveCodingFile={handleNewLiveCodingFile}
onNewEmptyFile={handleNewEmptyFile}
onNewFromTemplate={handleNewFromTemplate}
onMetadataUpdate={handleMetadataUpdate}
selectedProjectId={projectEditor.currentProjectId}
/>
@ -325,7 +317,6 @@
onExecute={handleExecute}
logs={interpreterLogs}
{editorSettings}
mode={currentMode}
/>
</div>
@ -448,6 +439,12 @@
placeholder="Untitled"
onConfirm={handleSaveAs}
/>
<TemplateDialog
bind:visible={uiState.templateDialogVisible}
onSelect={handleTemplateSelect}
onCancel={() => uiState.hideTemplateDialog()}
/>
</div>
<style>

View File

@ -28,7 +28,6 @@
onChange?: (value: string) => void;
onExecute?: (code: string, source: 'selection' | 'block' | 'document') => void;
editorSettings: EditorSettingsStore;
mode?: 'composition' | 'livecoding';
}
let {
@ -36,8 +35,7 @@
language = 'javascript',
onChange,
onExecute,
editorSettings,
mode = 'composition'
editorSettings
}: Props = $props();
let editorContainer: HTMLDivElement;
@ -56,25 +54,23 @@
function handleExecute() {
if (!editorView) return;
if (mode === 'composition') {
// Composition mode: always evaluate entire document
const doc = getDocument(editorView.state);
flash(editorView, doc.from, doc.to);
onExecute?.(doc.text, 'document');
} else {
// Live coding mode: evaluate selection or block
const selection = getSelection(editorView.state);
if (selection.text) {
flash(editorView, selection.from, selection.to);
onExecute?.(selection.text, 'selection');
} else {
const block = getBlock(editorView.state);
if (block.text) {
flash(editorView, block.from, block.to);
onExecute?.(block.text, 'block');
}
}
const selection = getSelection(editorView.state);
if (selection.text) {
flash(editorView, selection.from, selection.to);
onExecute?.(selection.text, 'selection');
return;
}
const block = getBlock(editorView.state);
if (block.text) {
flash(editorView, block.from, block.to);
onExecute?.(block.text, 'block');
return;
}
const doc = getDocument(editorView.state);
flash(editorView, doc.from, doc.to);
onExecute?.(doc.text, 'document');
}
const evaluateKeymap = keymap.of([

View File

@ -11,7 +11,6 @@
onExecute?: (code: string, source: 'selection' | 'block' | 'document') => void;
logs?: string[];
editorSettings: EditorSettingsStore;
mode?: 'composition' | 'livecoding';
}
let {
@ -20,8 +19,7 @@
onChange,
onExecute,
logs = [],
editorSettings,
mode = 'composition'
editorSettings
}: Props = $props();
let logPanelRef: LogPanel;
@ -76,7 +74,6 @@
{onChange}
{onExecute}
{editorSettings}
{mode}
/>
</div>

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { File, Plus, Trash2 } from 'lucide-svelte';
import { File, FilePlus, FileStack, Trash2 } from 'lucide-svelte';
import type { CsoundProject, ProjectManager } from '../../project-system';
import ConfirmDialog from './ConfirmDialog.svelte';
@ -9,13 +9,13 @@
interface Props {
projectManager: ProjectManager;
onFileSelect?: (project: CsoundProject | null) => void;
onNewFile?: () => void;
onNewLiveCodingFile?: () => void;
onNewEmptyFile?: () => void;
onNewFromTemplate?: () => void;
onMetadataUpdate?: (projectId: string, updates: { title?: string; author?: string; mode?: ProjectMode }) => void;
selectedProjectId?: string | null;
}
let { projectManager, onFileSelect, onNewFile, onNewLiveCodingFile, onMetadataUpdate, selectedProjectId = null }: Props = $props();
let { projectManager, onFileSelect, onNewEmptyFile, onNewFromTemplate, onMetadataUpdate, selectedProjectId = null }: Props = $props();
let projects = $state<CsoundProject[]>([]);
let loading = $state(true);
@ -68,12 +68,12 @@
}
}
function handleNewFile() {
onNewFile?.();
function handleNewEmptyFile() {
onNewEmptyFile?.();
}
function handleNewLiveCodingFile() {
onNewLiveCodingFile?.();
function handleNewFromTemplate() {
onNewFromTemplate?.();
}
function selectProject(project: CsoundProject) {
@ -127,13 +127,11 @@
<div class="browser-header">
<span class="browser-title">Files</span>
<div class="header-actions">
<button class="action-button" onclick={handleNewFile} title="New composition">
<Plus size={16} />
<button class="action-button" onclick={handleNewEmptyFile} title="New empty file">
<FilePlus size={18} />
</button>
<button class="action-button live-coding" onclick={handleNewLiveCodingFile} title="New live coding template">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
<button class="action-button template-button" onclick={handleNewFromTemplate} title="New from template">
<FileStack size={18} />
</button>
</div>
</div>
@ -263,9 +261,9 @@
}
.action-button {
padding: 0.25rem;
padding: 0.375rem;
background-color: transparent;
color: rgba(255, 255, 255, 0.6);
color: rgba(255, 255, 255, 0.7);
border: none;
cursor: pointer;
display: flex;
@ -275,17 +273,17 @@
}
.action-button:hover {
color: rgba(255, 255, 255, 0.9);
color: rgba(255, 255, 255, 1);
background-color: rgba(255, 255, 255, 0.1);
}
.action-button.live-coding {
color: rgba(100, 108, 255, 0.8);
.action-button.template-button {
color: rgba(100, 200, 255, 0.8);
}
.action-button.live-coding:hover {
color: rgba(100, 108, 255, 1);
background-color: rgba(100, 108, 255, 0.15);
.action-button.template-button:hover {
color: rgba(100, 200, 255, 1);
background-color: rgba(100, 200, 255, 0.15);
}
.browser-content {

View File

@ -0,0 +1,129 @@
<script lang="ts">
import { templateRegistry, type CsoundTemplate } from '../../templates/template-registry';
interface Props {
visible: boolean;
onSelect?: (template: CsoundTemplate) => void;
onCancel?: () => void;
}
let { visible = false, onSelect, onCancel }: Props = $props();
const templates = templateRegistry.getAll();
function handleSelect(template: CsoundTemplate) {
onSelect?.(template);
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onCancel?.();
}
}
</script>
{#if visible}
<div class="modal-backdrop" onclick={handleBackdropClick}>
<div class="modal-content">
<div class="modal-header">
<h2>Choose Template</h2>
<button class="close-button" onclick={() => onCancel?.()}>×</button>
</div>
<div class="template-list">
{#each templates as template}
<button
class="template-item"
onclick={() => handleSelect(template)}
>
{template.name}
</button>
{/each}
</div>
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background-color: #1a1a1a;
border: 1px solid #3a3a3a;
width: 400px;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #3a3a3a;
}
h2 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.close-button {
background: none;
border: none;
font-size: 2rem;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
line-height: 1;
}
.close-button:hover {
color: rgba(255, 255, 255, 0.9);
}
.template-list {
display: flex;
flex-direction: column;
}
.template-item {
padding: 1rem;
background-color: transparent;
border: none;
border-bottom: 1px solid #2a2a2a;
color: rgba(255, 255, 255, 0.87);
text-align: left;
cursor: pointer;
transition: background-color 0.2s;
font-size: 0.875rem;
}
.template-item:hover {
background-color: #2a2a2a;
}
.template-item:last-child {
border-bottom: none;
}
</style>

View File

@ -2,6 +2,7 @@ import { getContext, setContext } from 'svelte';
import { ProjectManager } from '../project-system/project-manager';
import { ProjectDatabase } from '../project-system/db';
import { createCsoundStore } from '../csound/store';
import { ExecutionContext } from '../csound/execution-context';
import { createEditorSettingsStore } from '../stores/editorSettings';
import { ProjectEditor } from '../stores/projectEditor.svelte';
import { UIState } from '../stores/uiState.svelte';
@ -11,6 +12,7 @@ import type { EditorSettingsStore } from '../stores/editorSettings';
export interface AppContext {
projectManager: ProjectManager;
csound: CsoundStore;
executionContext: ExecutionContext;
editorSettings: EditorSettingsStore;
projectEditor: ProjectEditor;
uiState: UIState;
@ -21,9 +23,13 @@ const APP_CONTEXT_KEY = Symbol('app-context');
export function createAppContext(): AppContext {
const db = new ProjectDatabase();
const projectManager = new ProjectManager(db);
const csound = createCsoundStore();
const executionContext = new ExecutionContext(csound, 'composition');
return {
projectManager,
csound: createCsoundStore(),
csound,
executionContext,
editorSettings: createEditorSettingsStore(),
projectEditor: new ProjectEditor(projectManager),
uiState: new UIState()

View File

@ -0,0 +1,98 @@
import type { CsoundStore } from './store';
import type { ProjectMode } from '../project-system/types';
import { CompositionStrategy, LiveCodingStrategy, type ExecutionStrategy } from './execution-strategies';
export type EvalSource = 'selection' | 'block' | 'document';
export interface ExecutionSession {
mode: ProjectMode;
projectId: string | null;
isActive: boolean;
startTime: Date;
}
export class ExecutionContext {
private session: ExecutionSession | null = null;
private strategy: ExecutionStrategy;
private currentMode: ProjectMode;
private contentProvider: (() => string) | null = null;
constructor(
private csound: CsoundStore,
mode: ProjectMode
) {
this.currentMode = mode;
this.strategy = this.createStrategyForMode(mode);
}
get mode(): ProjectMode {
return this.currentMode;
}
get activeSession(): ExecutionSession | null {
return this.session;
}
setContentProvider(provider: () => string): void {
this.contentProvider = provider;
}
async startSession(projectId: string | null): Promise<void> {
await this.endSession();
this.session = {
mode: this.currentMode,
projectId,
isActive: true,
startTime: new Date()
};
if (this.currentMode === 'livecoding') {
this.resetStrategy();
}
}
async endSession(): Promise<void> {
if (!this.session?.isActive) return;
if (this.currentMode === 'livecoding') {
await this.csound.stop();
this.resetStrategy();
}
this.session = { ...this.session, isActive: false };
}
async execute(code: string, source: EvalSource): Promise<void> {
if (this.currentMode === 'livecoding' && (!this.session || !this.session.isActive)) {
await this.startSession(this.session?.projectId ?? null);
}
const fullContent = this.contentProvider?.() ?? '';
await this.strategy.execute(this.csound, code, fullContent, source);
}
async switchMode(newMode: ProjectMode): Promise<void> {
if (newMode === this.currentMode) return;
await this.endSession();
this.currentMode = newMode;
this.strategy = this.createStrategyForMode(newMode);
}
private createStrategyForMode(mode: ProjectMode): ExecutionStrategy {
return mode === 'livecoding'
? new LiveCodingStrategy()
: new CompositionStrategy();
}
private resetStrategy(): void {
if (this.strategy instanceof LiveCodingStrategy) {
this.strategy.reset();
}
}
async destroy(): Promise<void> {
await this.endSession();
}
}

View File

@ -1,12 +1,14 @@
import type { CsoundStore } from './store';
import type { ProjectMode } from '../project-system/types';
export type EvalSource = 'selection' | 'block' | 'document';
export interface ExecutionStrategy {
execute(
csound: CsoundStore,
code: string,
fullContent: string,
source: 'selection' | 'block' | 'document'
source: EvalSource
): Promise<void>;
}
@ -15,7 +17,7 @@ export class CompositionStrategy implements ExecutionStrategy {
csound: CsoundStore,
code: string,
fullContent: string,
source: 'selection' | 'block' | 'document'
source: EvalSource
): Promise<void> {
await csound.evaluate(fullContent);
}
@ -29,7 +31,7 @@ export class LiveCodingStrategy implements ExecutionStrategy {
csound: CsoundStore,
code: string,
fullContent: string,
source: 'selection' | 'block' | 'document'
source: EvalSource
): Promise<void> {
if (!this.isInitialized) {
await this.initializeFromDocument(csound, fullContent);

View File

@ -7,3 +7,5 @@ export type {
} from './engine';
export { createCsoundStore, createCsoundDerivedStores } from './store';
export type { LogEntry, CsoundStore } from './store';
export { ExecutionContext } from './execution-context';
export type { ExecutionSession, EvalSource } from './execution-context';

View File

@ -10,6 +10,7 @@ export class UIState {
audioPermissionPopupVisible = $state(true);
unsavedChangesDialogVisible = $state(false);
saveAsDialogVisible = $state(false);
templateDialogVisible = $state(false);
shareUrl = $state('');
@ -59,4 +60,12 @@ export class UIState {
hideSaveAsDialog() {
this.saveAsDialogVisible = false;
}
showTemplateDialog() {
this.templateDialogVisible = true;
}
hideTemplateDialog() {
this.templateDialogVisible = false;
}
}

View File

@ -1,38 +1,77 @@
export const DEFAULT_CSOUND_TEMPLATE = `<CsoundSynthesizer>
<CsOptions>
</CsOptions>
<CsInstruments>
import type { ProjectMode } from '../project-system/types';
sr = 44100
ksmps = 32
nchnls = 2
0dbfs = 1
export interface CsoundTemplate {
id: string;
name: string;
mode: ProjectMode;
content: string;
}
instr 1
iFreq = p4
iAmp = p5
; ADSR envelope
kEnv madsr 0.01, 0.1, 0.6, 0.2
; Sine wave oscillator
aOsc oscili iAmp * kEnv, iFreq
outs aOsc, aOsc
endin
</CsInstruments>
<CsScore>
; Arpeggio: C4 E4 G4 C5
i 1 0.0 0.5 261.63 0.3
i 1 0.5 0.5 329.63 0.3
i 1 1.0 0.5 392.00 0.3
i 1 1.5 0.5 523.25 0.3
</CsScore>
</CsoundSynthesizer>
`;
export const LIVECODING_TEMPLATE = `<CsoundSynthesizer>
const EMPTY_TEMPLATE: CsoundTemplate = {
id: 'empty',
name: 'Empty',
mode: 'composition',
content: `<CsoundSynthesizer>
<CsOptions>
-odac
</CsOptions>
<CsInstruments>
sr = 48000
ksmps = 32
nchnls = 2
0dbfs = 1
</CsInstruments>
<CsScore>
</CsScore>
</CsoundSynthesizer>
`
};
const CLASSIC_TEMPLATE: CsoundTemplate = {
id: 'classic',
name: 'Classic',
mode: 'composition',
content: `<CsoundSynthesizer>
<CsOptions>
-odac
</CsOptions>
<CsInstruments>
sr = 48000
ksmps = 32
nchnls = 2
0dbfs = 1
instr 1
iFreq = p4
iAmp = p5
kEnv madsr 0.01, 0.1, 0.6, 0.2
aOsc oscili iAmp * kEnv, iFreq
outs aOsc, aOsc
endin
</CsInstruments>
<CsScore>
i 1 0.0 0.5 261.63 0.3
i 1 0.5 0.5 329.63 0.3
i 1 1.0 0.5 392.00 0.3
i 1 1.5 0.5 523.25 0.3
</CsScore>
</CsoundSynthesizer>
`
};
const LIVECODING_TEMPLATE: CsoundTemplate = {
id: 'livecoding',
name: 'Live Coding',
mode: 'livecoding',
content: `<CsoundSynthesizer>
<CsOptions>
-odac
</CsOptions>
@ -43,18 +82,14 @@ ksmps = 32
nchnls = 2
0dbfs = 1
; Live Coding Template
; Press Cmd/Ctrl+E on the full document first to initialize
; Then evaluate individual blocks to trigger sounds
; Global audio bus for effects
gaReverb init 0
instr 1
; Simple synth with channel control
kFreq chnget "freq"
kFreq = (kFreq == 0 ? p4 : kFreq)
kAmp = p5
kEnv linsegr 0, 0.01, 1, 0.1, 0.7, 0.2, 0
@ -67,7 +102,6 @@ instr 1
endin
instr 2
; Bass synth
iFreq = p4
iAmp = p5
@ -80,7 +114,6 @@ instr 2
endin
instr 99
; Global reverb
aL, aR freeverb gaReverb, gaReverb, 0.8, 0.5
outs aL, aR
gaReverb = 0
@ -88,19 +121,14 @@ endin
</CsInstruments>
<CsScore>
; Start reverb (always on)
i 99 0 -1
; Initial events will be sent during initialization
; After that, evaluate blocks below to trigger sounds
</CsScore>
</CsoundSynthesizer>
; LIVE CODING EXAMPLES
; Evaluate each block separately (Cmd/Ctrl+E with cursor on the line)
; Basic note (instrument 1, start now, duration 2s, freq 440Hz, amp 0.3)
; Basic note
i 1 0 2 440 0.3
; Arpeggio
@ -115,35 +143,47 @@ i 2 0.5 0.5 146.83 0.4
i 2 1.0 0.5 164.81 0.4
i 2 1.5 0.5 130.81 0.4
; Long note for testing channel control
; Long note for channel control
i 1 0 30 440 0.3
; While the long note plays, evaluate these to change frequency:
; Change frequency while playing
freq = 440
freq = 554.37
freq = 659.25
freq = 880
; Turn off all instances of instrument 1
; Turn off instrument 1
i -1 0 0
`
};
; Redefine instrument 1 with a different sound
instr 1
kFreq chnget "freq"
kFreq = (kFreq == 0 ? p4 : kFreq)
kAmp = p5
const TEMPLATE_REGISTRY: CsoundTemplate[] = [
EMPTY_TEMPLATE,
LIVECODING_TEMPLATE,
CLASSIC_TEMPLATE
];
kEnv linsegr 0, 0.01, 1, 0.1, 0.7, 0.2, 0
aSaw vco2 kAmp * kEnv, kFreq, 2
aSquare vco2 kAmp * kEnv, kFreq * 1.01, 10
export class TemplateRegistry {
private templates: Map<string, CsoundTemplate>;
aSum = (aSaw + aSquare) * 0.5
aFilt moogladder aSum, 1500, 0.5
constructor() {
this.templates = new Map(
TEMPLATE_REGISTRY.map(template => [template.id, template])
);
}
outs aFilt, aFilt
gaReverb = gaReverb + aFilt * 0.3
endin
`;
getAll(): CsoundTemplate[] {
return Array.from(this.templates.values());
}
getById(id: string): CsoundTemplate | undefined {
return this.templates.get(id);
}
getEmpty(): CsoundTemplate {
return EMPTY_TEMPLATE;
}
}
export const templateRegistry = new TemplateRegistry();