wip live coding mode

This commit is contained in:
2025-10-15 02:53:48 +02:00
parent a4432fa3d9
commit 46925f5c2e
12 changed files with 1599 additions and 24 deletions

0
REVIEW_LIVE_CODING_MODE Normal file
View File

View File

@ -12,8 +12,10 @@
import InputDialog from './lib/components/ui/InputDialog.svelte';
import { createCsoundDerivedStores, type LogEntry } from './lib/csound';
import { type CsoundProject } from './lib/project-system';
import { DEFAULT_CSOUND_TEMPLATE } from './lib/config/templates';
import { DEFAULT_CSOUND_TEMPLATE, LIVECODING_TEMPLATE } from './lib/config/templates';
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,
PanelLeftOpen,
@ -35,6 +37,8 @@
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;
@ -84,6 +88,16 @@
}
}
function handleNewLiveCodingFile() {
const result = projectEditor.requestSwitch(
() => projectEditor.createNew(LIVECODING_TEMPLATE)
);
if (result === 'confirm-unsaved') {
uiState.showUnsavedChangesDialog();
}
}
function handleFileSelect(project: CsoundProject | null) {
if (!project) return;
@ -96,9 +110,14 @@
}
}
async function handleExecute(code: string) {
async function handleExecute(code: string, source: 'selection' | 'block' | 'document') {
try {
await csound.evaluate(code);
if (!currentStrategy) {
currentStrategy = createExecutionStrategy(currentMode);
}
const fullContent = projectEditor.content;
await currentStrategy.execute(csound, code, fullContent, source);
} catch (error) {
console.error('Execution error:', error);
}
@ -120,7 +139,7 @@
}
}
async function handleMetadataUpdate(projectId: string, updates: { title?: string; author?: string }) {
async function handleMetadataUpdate(projectId: string, updates: { title?: string; author?: string; mode?: import('./lib/project-system/types').ProjectMode }) {
await projectEditor.updateMetadata(updates);
}
@ -168,6 +187,33 @@
}
});
$effect(() => {
const mode = projectEditor.currentProject?.mode || 'composition';
if (mode !== currentMode) {
const oldMode = currentMode;
currentMode = mode;
// 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();
}
}
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');
}
}
});
const panelTabs = [
{
id: 'editor',
@ -191,6 +237,7 @@
{projectManager}
onFileSelect={handleFileSelect}
onNewFile={handleNewFile}
onNewLiveCodingFile={handleNewLiveCodingFile}
onMetadataUpdate={handleMetadataUpdate}
selectedProjectId={projectEditor.currentProjectId}
/>
@ -278,6 +325,7 @@
onExecute={handleExecute}
logs={interpreterLogs}
{editorSettings}
mode={currentMode}
/>
</div>

View File

@ -14,13 +14,21 @@
import { oneDark } from '@codemirror/theme-one-dark';
import { vim } from '@replit/codemirror-vim';
import type { EditorSettingsStore } from '../../stores/editorSettings';
import {
flashField,
flash,
getSelection,
getBlock,
getDocument
} from '../../editor/block-eval';
interface Props {
value: string;
language?: 'javascript' | 'html' | 'css';
onChange?: (value: string) => void;
onExecute?: (code: string) => void;
onExecute?: (code: string, source: 'selection' | 'block' | 'document') => void;
editorSettings: EditorSettingsStore;
mode?: 'composition' | 'livecoding';
}
let {
@ -28,7 +36,8 @@
language = 'javascript',
onChange,
onExecute,
editorSettings
editorSettings,
mode = 'composition'
}: Props = $props();
let editorContainer: HTMLDivElement;
@ -44,14 +53,35 @@
const lineWrappingCompartment = new Compartment();
const vimCompartment = new Compartment();
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 evaluateKeymap = keymap.of([
{
key: 'Mod-e',
run: (view) => {
if (onExecute) {
const code = view.state.doc.toString();
onExecute(code);
}
run: () => {
handleExecute();
return true;
}
}
@ -93,6 +123,7 @@
languageExtensions[language],
oneDark,
evaluateKeymap,
flashField(),
lineNumbersCompartment.of(initSettings.showLineNumbers ? lineNumbers() : []),
lineWrappingCompartment.of(initSettings.enableLineWrapping ? EditorView.lineWrapping : []),
vimCompartment.of(initSettings.vimMode ? vim() : []),
@ -141,6 +172,30 @@
});
}
});
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);
}
}
</script>
<div class="editor-wrapper">

View File

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

View File

@ -4,15 +4,18 @@
import type { CsoundProject, ProjectManager } from '../../project-system';
import ConfirmDialog from './ConfirmDialog.svelte';
import type { ProjectMode } from '../../project-system/types';
interface Props {
projectManager: ProjectManager;
onFileSelect?: (project: CsoundProject | null) => void;
onNewFile?: () => void;
onMetadataUpdate?: (projectId: string, updates: { title?: string; author?: string }) => void;
onNewLiveCodingFile?: () => void;
onMetadataUpdate?: (projectId: string, updates: { title?: string; author?: string; mode?: ProjectMode }) => void;
selectedProjectId?: string | null;
}
let { projectManager, onFileSelect, onNewFile, onMetadataUpdate, selectedProjectId = null }: Props = $props();
let { projectManager, onFileSelect, onNewFile, onNewLiveCodingFile, onMetadataUpdate, selectedProjectId = null }: Props = $props();
let projects = $state<CsoundProject[]>([]);
let loading = $state(true);
@ -25,11 +28,13 @@
let editTitle = $state('');
let editAuthor = $state('');
let editMode = $state<ProjectMode>('composition');
$effect(() => {
if (selectedProject) {
editTitle = selectedProject.title;
editAuthor = selectedProject.author;
editMode = selectedProject.mode;
}
});
@ -67,6 +72,10 @@
onNewFile?.();
}
function handleNewLiveCodingFile() {
onNewLiveCodingFile?.();
}
function selectProject(project: CsoundProject) {
onFileSelect?.(project);
}
@ -101,12 +110,14 @@
const hasChanges =
editTitle !== selectedProject.title ||
editAuthor !== selectedProject.author;
editAuthor !== selectedProject.author ||
editMode !== selectedProject.mode;
if (hasChanges) {
onMetadataUpdate?.(selectedProject.id, {
title: editTitle,
author: editAuthor
author: editAuthor,
mode: editMode
});
}
}
@ -115,9 +126,16 @@
<div class="file-browser">
<div class="browser-header">
<span class="browser-title">Files</span>
<button class="action-button" onclick={handleNewFile} title="New file">
<Plus size={16} />
</button>
<div class="header-actions">
<button class="action-button" onclick={handleNewFile} title="New composition">
<Plus size={16} />
</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>
</div>
</div>
<div class="browser-content">
@ -174,6 +192,17 @@
onchange={handleMetadataChange}
/>
</div>
<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>
<div class="field readonly">
<label>Number of Saves</label>
<div class="readonly-value">{selectedProject.saveCount}</div>
@ -228,6 +257,11 @@
letter-spacing: 0.05em;
}
.header-actions {
display: flex;
gap: 0.25rem;
}
.action-button {
padding: 0.25rem;
background-color: transparent;
@ -245,6 +279,15 @@
background-color: rgba(255, 255, 255, 0.1);
}
.action-button.live-coding {
color: rgba(100, 108, 255, 0.8);
}
.action-button.live-coding:hover {
color: rgba(100, 108, 255, 1);
background-color: rgba(100, 108, 255, 0.15);
}
.browser-content {
flex: 1;
overflow-y: auto;
@ -353,7 +396,8 @@
color: rgba(255, 255, 255, 0.6);
}
.field input {
.field input,
.field select {
padding: 0.5rem;
background-color: #2a2a2a;
border: 1px solid #3a3a3a;
@ -362,10 +406,15 @@
outline: none;
}
.field input:focus {
.field input:focus,
.field select:focus {
border-color: #646cff;
}
.field select {
cursor: pointer;
}
.field.readonly .readonly-value {
padding: 0.5rem;
background-color: #1a1a1a;

View File

@ -31,3 +31,119 @@ i 1 1.5 0.5 523.25 0.3
</CsScore>
</CsoundSynthesizer>
`;
export const LIVECODING_TEMPLATE = `<CsoundSynthesizer>
<CsOptions>
-odac
</CsOptions>
<CsInstruments>
sr = 48000
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
aOsc vco2 kAmp * kEnv, kFreq
aFilt moogladder aOsc, 2000, 0.3
outs aFilt, aFilt
gaReverb = gaReverb + aFilt * 0.3
endin
instr 2
; Bass synth
iFreq = p4
iAmp = p5
kEnv linsegr 0, 0.005, 1, 0.05, 0.5, 0.1, 0
aOsc vco2 iAmp * kEnv, iFreq, 10
aFilt butterlp aOsc, 800
outs aFilt, aFilt
endin
instr 99
; Global reverb
aL, aR freeverb gaReverb, gaReverb, 0.8, 0.5
outs aL, aR
gaReverb = 0
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)
i 1 0 2 440 0.3
; Arpeggio
i 1 0 0.5 261.63 0.2
i 1 0.5 0.5 329.63 0.2
i 1 1.0 0.5 392.00 0.2
i 1 1.5 0.5 523.25 0.2
; Bass line
i 2 0 0.5 130.81 0.4
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
i 1 0 30 440 0.3
; While the long note plays, evaluate these to change frequency:
freq = 440
freq = 554.37
freq = 659.25
freq = 880
; Turn off all instances of 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
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
aSum = (aSaw + aSquare) * 0.5
aFilt moogladder aSum, 1500, 0.5
outs aFilt, aFilt
gaReverb = gaReverb + aFilt * 0.3
endin
`;

View File

@ -0,0 +1,148 @@
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>;
}
export class CompositionStrategy implements ExecutionStrategy {
async execute(
csound: CsoundStore,
code: string,
fullContent: string,
source: 'selection' | 'block' | 'document'
): Promise<void> {
await csound.evaluate(fullContent);
}
}
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> {
if (!this.isInitialized) {
await this.initializeFromDocument(csound, fullContent);
this.isInitialized = true;
return;
}
await this.evaluateBlock(csound, code);
}
private async initializeFromDocument(
csound: CsoundStore,
fullContent: string
): Promise<void> {
const { header, instruments, score } = this.parseCSD(fullContent);
const fullOrchestra = header + '\n' + instruments;
const compileResult = await csound.compileOrchestra(fullOrchestra);
if (!compileResult.success) {
throw new Error(compileResult.errorMessage || 'Compilation failed');
}
this.headerCompiled = true;
await csound.startPerformance();
if (score.trim()) {
await csound.readScore(score);
}
}
private async evaluateBlock(csound: CsoundStore, code: string): Promise<void> {
const trimmedCode = code.trim();
if (!trimmedCode) return;
if (this.isScoreEvent(trimmedCode)) {
await csound.sendScoreEvent(trimmedCode);
}
else if (this.isInstrumentDefinition(trimmedCode)) {
await csound.compileOrchestra(trimmedCode);
}
else if (this.isChannelSet(trimmedCode)) {
await this.handleChannelSet(csound, trimmedCode);
}
else {
await csound.compileOrchestra(trimmedCode);
}
}
private parseCSD(content: string): {
header: string;
instruments: string;
score: string
} {
const orcMatch = content.match(/<CsInstruments>([\s\S]*?)<\/CsInstruments>/);
if (!orcMatch) {
return { header: '', instruments: '', score: '' };
}
const orchestra = orcMatch[1].trim();
const scoMatch = content.match(/<CsScore>([\s\S]*?)<\/CsScore>/);
const score = scoMatch ? scoMatch[1].trim() : '';
const instrMatch = orchestra.match(/([\s\S]*?)(instr\s+\d+[\s\S]*)/);
if (instrMatch) {
return {
header: instrMatch[1].trim(),
instruments: instrMatch[2].trim(),
score
};
}
return {
header: orchestra,
instruments: '',
score
};
}
private isScoreEvent(code: string): boolean {
return /^[ifea]\s+[\d\-]/.test(code);
}
private isInstrumentDefinition(code: string): boolean {
return /^\s*instr\s+/.test(code);
}
private isChannelSet(code: string): boolean {
return /^\w+\s*=\s*[\d\.\-]+/.test(code);
}
private async handleChannelSet(csound: CsoundStore, code: string): Promise<void> {
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(): void {
this.isInitialized = false;
this.headerCompiled = false;
}
}
export function createExecutionStrategy(mode: ProjectMode): ExecutionStrategy {
return mode === 'livecoding'
? new LiveCodingStrategy()
: new CompositionStrategy();
}

View File

@ -0,0 +1,111 @@
import { EditorView, Decoration } from '@codemirror/view';
import { EditorState, StateField, StateEffect } from '@codemirror/state';
interface EvalBlock {
text: string;
from: number | null;
to: number | null;
}
type FlashRange = [number, number];
const setFlash = StateEffect.define<FlashRange | null>();
const defaultStyle = {
'background-color': '#FFCA2880',
};
const styleObjectToString = (styleObj: Record<string, string>): string =>
Object.entries(styleObj)
.map(([k, v]) => `${k}:${v}`)
.join(';');
export const flash = (
view: EditorView,
from: number | null,
to: number | null,
timeout: number = 150,
) => {
if (from === null || to === null) return;
view.dispatch({ effects: setFlash.of([from, to]) });
setTimeout(() => {
view.dispatch({ effects: setFlash.of(null) });
}, timeout);
};
export const flashField = (style: Record<string, string> = defaultStyle) =>
StateField.define({
create() {
return Decoration.none;
},
update(flash, tr) {
try {
for (let e of tr.effects) {
if (e.is(setFlash)) {
if (e.value) {
const [from, to] = e.value;
const mark = Decoration.mark({
attributes: { style: styleObjectToString(style) },
});
flash = Decoration.set([mark.range(from, to)]);
} else {
flash = Decoration.set([]);
}
}
}
return flash;
} catch (err) {
console.warn('flash error', err);
return flash;
}
},
provide: (f) => EditorView.decorations.from(f),
});
export function getSelection(state: EditorState): EvalBlock {
if (state.selection.main.empty) return { text: '', from: null, to: null };
let { from, to } = state.selection.main;
let text = state.doc.sliceString(from, to);
return { text, from, to };
}
export function getLine(state: EditorState): EvalBlock {
const line = state.doc.lineAt(state.selection.main.from);
let { from, to } = line;
let text = state.doc.sliceString(from, to);
return { text, from, to };
}
export function getBlock(state: EditorState): EvalBlock {
let { doc, selection } = state;
let { text, number } = state.doc.lineAt(selection.main.from);
if (text.trim().length === 0) return { text: '', from: null, to: null };
let fromL, toL;
fromL = toL = number;
while (fromL > 1 && doc.line(fromL - 1).text.trim().length > 0) {
fromL -= 1;
}
while (toL < doc.lines && doc.line(toL + 1).text.trim().length > 0) {
toL += 1;
}
let { from } = doc.line(fromL);
let { to } = doc.line(toL);
text = state.doc.sliceString(from, to);
return { text, from, to };
}
export function getDocument(state: EditorState): EvalBlock {
const { from } = state.doc.line(1);
const { to } = state.doc.line(state.doc.lines);
const text = state.doc.sliceString(from, to);
return { text, from, to };
}

View File

@ -97,6 +97,7 @@ export class ProjectManager {
content: data.content || '',
tags: data.tags || [],
csoundVersion: CSOUND_VERSION,
mode: data.mode || 'composition',
};
await this.db.put(project);
@ -161,6 +162,7 @@ export class ProjectManager {
...(data.author !== undefined && { author: data.author }),
...(data.content !== undefined && { content: data.content }),
...(data.tags !== undefined && { tags: data.tags }),
...(data.mode !== undefined && { mode: data.mode }),
dateModified: getCurrentTimestamp(),
saveCount: existingProject.saveCount + 1,
};

View File

@ -1,3 +1,5 @@
export type ProjectMode = 'composition' | 'livecoding';
/**
* Core data structure for a Csound project
*/
@ -28,6 +30,9 @@ export interface CsoundProject {
/** Csound version used to create this project */
csoundVersion: string;
/** Execution mode: composition (full document) or livecoding (block evaluation) */
mode: ProjectMode;
}
/**
@ -38,6 +43,7 @@ export interface CreateProjectData {
author: string;
content?: string;
tags?: string[];
mode?: ProjectMode;
}
/**
@ -49,6 +55,7 @@ export interface UpdateProjectData {
author?: string;
content?: string;
tags?: string[];
mode?: ProjectMode;
}
/**

View File

@ -79,6 +79,9 @@ export class ProjectEditor {
if (result.success) {
this.state.hasUnsavedChanges = false;
if (result.data) {
this.state.currentProject = result.data;
}
return true;
}
@ -88,11 +91,14 @@ export class ProjectEditor {
async saveAs(title: string, author: string = 'Anonymous'): Promise<boolean> {
const finalTitle = title.trim() || 'Untitled';
const isLiveCodingTemplate = this.state.content.includes('Live Coding Template');
const result = await this.projectManager.createProject({
title: finalTitle,
author,
content: this.state.content,
tags: []
tags: [],
mode: isLiveCodingTemplate ? 'livecoding' : 'composition'
});
if (result.success) {
@ -105,7 +111,7 @@ export class ProjectEditor {
return false;
}
async updateMetadata(updates: { title?: string; author?: string }): Promise<boolean> {
async updateMetadata(updates: { title?: string; author?: string; mode?: import('../project-system/types').ProjectMode }): Promise<boolean> {
if (!this.state.currentProject) {
return false;
}

1030
to_continue.md Normal file

File diff suppressed because it is too large Load Diff