Let's fucking go

This commit is contained in:
2025-10-14 23:21:00 +02:00
parent cec7ed8bd4
commit 267323abf2
10 changed files with 994 additions and 45 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

View File

@ -6,6 +6,9 @@
import FileBrowser from './lib/FileBrowser.svelte';
import SidePanel from './lib/SidePanel.svelte';
import Popup from './lib/Popup.svelte';
import ResizablePopup from './lib/ResizablePopup.svelte';
import AudioScope from './lib/AudioScope.svelte';
import Spectrogram from './lib/Spectrogram.svelte';
import { csound, csoundLogs, type LogEntry } from './lib/csound';
import { projectManager, type CsoundProject } from './lib/project-system';
import ConfirmDialog from './lib/ConfirmDialog.svelte';
@ -18,13 +21,54 @@
PanelBottomClose,
PanelBottomOpen,
LayoutGrid,
Save
Save,
Share2,
Activity
} from 'lucide-svelte';
let sidePanelVisible = $state(true);
let sidePanelPosition = $state<'left' | 'right' | 'bottom'>('right');
let popupVisible = $state(false);
let editorValue = $state('<CsoundSynthesizer>\n<CsOptions>\n-odac\n</CsOptions>\n<CsInstruments>\n\nsr = 44100\nksmps = 32\nnchnls = 2\n0dbfs = 1\n\ninstr 1\n aOut oscili 0.5, 440\n outs aOut, aOut\nendin\n\n</CsInstruments>\n<CsScore>\ni 1 0 2\n</CsScore>\n</CsoundSynthesizer>\n');
let sharePopupVisible = $state(false);
let shareUrl = $state('');
let audioPermissionPopupVisible = $state(true);
let scopePopupVisible = $state(false);
let spectrogramPopupVisible = $state(false);
let analyserNode = $state<AnalyserNode | null>(null);
let editorValue = $state(`<CsoundSynthesizer>
<CsOptions>
-odac
</CsOptions>
<CsInstruments>
sr = 44100
ksmps = 32
nchnls = 2
0dbfs = 1
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>
`);
let interpreterLogs = $state<LogEntry[]>([]);
let sidePanelRef: SidePanel;
@ -37,10 +81,42 @@
let showUnsavedDialog = $state(false);
let showSaveAsDialog = $state(false);
const TEMPLATE_CONTENT = '<CsoundSynthesizer>\n<CsOptions>\n-odac\n</CsOptions>\n<CsInstruments>\n\nsr = 44100\nksmps = 32\nnchnls = 2\n0dbfs = 1\n\ninstr 1\n aOut oscili 0.5, 440\n outs aOut, aOut\nendin\n\n</CsInstruments>\n<CsScore>\ni 1 0 2\n</CsScore>\n</CsoundSynthesizer>\n';
const TEMPLATE_CONTENT = `<CsoundSynthesizer>
<CsOptions>
-odac
</CsOptions>
<CsInstruments>
sr = 44100
ksmps = 32
nchnls = 2
0dbfs = 1
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>
`;
onMount(async () => {
await csound.init();
await projectManager.init();
const result = await projectManager.getAllProjects();
@ -62,6 +138,15 @@
};
});
async function handleEnableAudio() {
try {
await csound.init();
audioPermissionPopupVisible = false;
} catch (error) {
console.error('Failed to initialize audio:', error);
}
}
onDestroy(async () => {
await csound.destroy();
});
@ -203,6 +288,41 @@
}
}
async function handleShare() {
if (!currentProjectId) return;
const result = await projectManager.exportProjectToUrl(currentProjectId);
if (result.success) {
shareUrl = result.data;
try {
await navigator.clipboard.writeText(shareUrl);
} catch (err) {
console.error('Failed to copy to clipboard:', err);
}
sharePopupVisible = true;
}
}
function handleOpenScope() {
scopePopupVisible = true;
}
function handleOpenSpectrogram() {
spectrogramPopupVisible = true;
}
$effect(() => {
if (scopePopupVisible || spectrogramPopupVisible) {
const interval = setInterval(() => {
analyserNode = csound.getAnalyserNode();
}, 100);
return () => clearInterval(interval);
}
});
const panelTabs = [
{
id: 'editor',
@ -232,16 +352,18 @@
{/snippet}
<div class="app-container">
<TopBar title="oldboy">
<button
class="icon-button"
onclick={saveCurrentProject}
disabled={!hasUnsavedChanges}
title="Save {hasUnsavedChanges ? '(unsaved changes)' : ''}"
class:has-changes={hasUnsavedChanges}
>
<Save size={18} />
</button>
<TopBar title="OldBoy">
{#snippet leftActions()}
<button
class="icon-button"
onclick={saveCurrentProject}
disabled={!hasUnsavedChanges}
title="Save {hasUnsavedChanges ? '(unsaved changes)' : ''}"
class:has-changes={hasUnsavedChanges}
>
<Save size={18} />
</button>
{/snippet}
<button onclick={toggleSidePanel} class="icon-button">
{#if sidePanelVisible}
{#if sidePanelPosition === 'left'}
@ -264,6 +386,34 @@
<button onclick={cyclePanelPosition} class="icon-button" title="Change panel position">
<LayoutGrid size={18} />
</button>
<button
onclick={handleShare}
class="icon-button"
disabled={!currentProjectId}
title="Share project"
>
<Share2 size={18} />
</button>
<button
onclick={handleOpenScope}
class="icon-button"
title="Open audio scope"
>
<Activity size={18} />
</button>
<button
onclick={handleOpenSpectrogram}
class="icon-button"
title="Open spectrogram"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18"/>
<path d="M3 15h18"/>
<path d="M9 3v18"/>
<path d="M15 3v18"/>
</svg>
</button>
</TopBar>
<div class="main-content" class:panel-bottom={sidePanelPosition === 'bottom'}>
@ -321,6 +471,88 @@
<p>It stays on top of everything else.</p>
</Popup>
<ResizablePopup
bind:visible={sharePopupVisible}
title="Share Project"
x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 300 : 300)}
y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 100 : 200)}
width={600}
height={200}
minWidth={400}
minHeight={150}
>
{#snippet children()}
<div class="share-content">
<p>Link copied to clipboard!</p>
<div class="share-url-container">
<input
type="text"
readonly
value={shareUrl}
class="share-url-input"
onclick={(e) => e.currentTarget.select()}
/>
</div>
<p class="share-instructions">Anyone with this link can import the project.</p>
</div>
{/snippet}
</ResizablePopup>
<ResizablePopup
visible={audioPermissionPopupVisible}
title="Audio Permission Required"
x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 250 : 250)}
y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 125 : 200)}
width={500}
height={250}
minWidth={400}
minHeight={200}
closable={false}
>
{#snippet children()}
<div class="audio-permission-content">
<h3>Enable Audio Context</h3>
<p>OldBoy needs permission to use audio playback.</p>
<p>Click the button below to enable audio and start using the application.</p>
<button class="enable-audio-button" onclick={handleEnableAudio}>
Enable Audio
</button>
</div>
{/snippet}
</ResizablePopup>
<ResizablePopup
bind:visible={scopePopupVisible}
title="Audio Scope"
x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 400 : 100)}
y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 300 : 100)}
width={800}
height={600}
minWidth={400}
minHeight={300}
noPadding={true}
>
{#snippet children()}
<AudioScope analyserNode={analyserNode} />
{/snippet}
</ResizablePopup>
<ResizablePopup
bind:visible={spectrogramPopupVisible}
title="Spectrogram"
x={(typeof window !== 'undefined' ? window.innerWidth / 2 - 400 : 150)}
y={(typeof window !== 'undefined' ? window.innerHeight / 2 - 300 : 150)}
width={800}
height={600}
minWidth={400}
minHeight={300}
noPadding={true}
>
{#snippet children()}
<Spectrogram analyserNode={analyserNode} />
{/snippet}
</ResizablePopup>
<ConfirmDialog
bind:visible={showUnsavedDialog}
title="Unsaved Changes"
@ -399,4 +631,75 @@
color: rgba(255, 255, 255, 0.7);
line-height: 1.6;
}
.share-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.share-url-container {
width: 100%;
}
.share-url-input {
width: 100%;
padding: 0.75rem;
background-color: #2a2a2a;
border: 1px solid #3a3a3a;
color: rgba(255, 255, 255, 0.87);
font-size: 0.875rem;
font-family: monospace;
outline: none;
box-sizing: border-box;
}
.share-url-input:focus {
border-color: #646cff;
}
.share-instructions {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.5);
margin: 0;
}
.audio-permission-content {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
text-align: center;
padding: 1rem;
}
.audio-permission-content h3 {
margin: 0;
font-size: 1.25rem;
}
.audio-permission-content p {
margin: 0;
font-size: 0.95rem;
}
.enable-audio-button {
margin-top: 1rem;
padding: 0.75rem 2rem;
background-color: #646cff;
color: white;
border: none;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.enable-audio-button:hover {
background-color: #535bdb;
}
.enable-audio-button:active {
background-color: #424ab8;
}
</style>

141
src/lib/AudioScope.svelte Normal file
View File

@ -0,0 +1,141 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
interface Props {
analyserNode?: AnalyserNode | null;
}
let { analyserNode }: Props = $props();
let container: HTMLDivElement;
let canvas: HTMLCanvasElement;
let canvasContext: CanvasRenderingContext2D | null = null;
let animationFrameId: number | null = null;
let dataArray: Uint8Array | null = null;
let width = $state(800);
let height = $state(400);
$effect(() => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
if (analyserNode && canvasContext) {
setupScope(analyserNode);
}
});
function setupScope(node: AnalyserNode) {
try {
const bufferLength = node.frequencyBinCount;
dataArray = new Uint8Array(bufferLength);
startDrawing(node);
} catch (error) {
console.error('Failed to setup scope:', error);
}
}
function startDrawing(node: AnalyserNode) {
if (!canvasContext || !dataArray) return;
const draw = () => {
if (!canvasContext || !dataArray || !node || !canvas) return;
animationFrameId = requestAnimationFrame(draw);
node.getByteTimeDomainData(dataArray);
canvasContext.fillStyle = '#0a0a0a';
canvasContext.fillRect(0, 0, canvas.width, canvas.height);
canvasContext.strokeStyle = '#333';
canvasContext.lineWidth = 1;
canvasContext.beginPath();
canvasContext.moveTo(0, canvas.height / 2);
canvasContext.lineTo(canvas.width, canvas.height / 2);
canvasContext.stroke();
canvasContext.lineWidth = 2;
canvasContext.strokeStyle = '#646cff';
canvasContext.beginPath();
const sliceWidth = canvas.width / dataArray.length;
let x = 0;
for (let i = 0; i < dataArray.length; i++) {
const v = dataArray[i] / 255.0;
const y = v * canvas.height;
if (i === 0) {
canvasContext.moveTo(x, y);
} else {
canvasContext.lineTo(x, y);
}
x += sliceWidth;
}
canvasContext.stroke();
};
draw();
}
function updateSize() {
if (container && canvas) {
const rect = container.getBoundingClientRect();
width = Math.floor(rect.width);
height = Math.floor(rect.height);
}
}
onMount(() => {
if (canvas) {
canvasContext = canvas.getContext('2d');
}
updateSize();
const resizeObserver = new ResizeObserver(() => {
updateSize();
});
if (container) {
resizeObserver.observe(container);
}
return () => {
resizeObserver.disconnect();
};
});
onDestroy(() => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
});
</script>
<div class="audio-scope" bind:this={container}>
<canvas bind:this={canvas} {width} {height}></canvas>
</div>
<style>
.audio-scope {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #0a0a0a;
overflow: hidden;
}
canvas {
display: block;
width: 100%;
height: 100%;
background-color: #0a0a0a;
}
</style>

View File

@ -169,6 +169,22 @@
onchange={handleMetadataChange}
/>
</div>
<div class="field readonly">
<label>Number of Saves</label>
<div class="readonly-value">{selectedProject.saveCount}</div>
</div>
<div class="field readonly">
<label>Date Created</label>
<div class="readonly-value">{formatDate(selectedProject.dateCreated)}</div>
</div>
<div class="field readonly">
<label>Date Modified</label>
<div class="readonly-value">{formatDate(selectedProject.dateModified)}</div>
</div>
<div class="field readonly">
<label>Csound Version</label>
<div class="readonly-value">{selectedProject.csoundVersion}</div>
</div>
</div>
</div>
{/if}
@ -344,4 +360,13 @@
.field input:focus {
border-color: #646cff;
}
.field.readonly .readonly-value {
padding: 0.5rem;
background-color: #1a1a1a;
border: 1px solid #2a2a2a;
color: rgba(255, 255, 255, 0.6);
font-size: 0.875rem;
font-family: monospace;
}
</style>

View File

@ -0,0 +1,274 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
title?: string;
visible?: boolean;
x?: number;
y?: number;
width?: number;
height?: number;
minWidth?: number;
minHeight?: number;
closable?: boolean;
noPadding?: boolean;
onClose?: () => void;
children?: import('svelte').Snippet;
}
let {
title = 'Popup',
visible = $bindable(false),
x = 100,
y = 100,
width = 400,
height = 300,
minWidth = 300,
minHeight = 150,
closable = true,
noPadding = false,
onClose,
children
}: Props = $props();
let isDragging = $state(false);
let isResizing = $state(false);
let resizeDirection = $state('');
let dragStartX = $state(0);
let dragStartY = $state(0);
let popupX = $state(x);
let popupY = $state(y);
let popupWidth = $state(width);
let popupHeight = $state(height);
let startWidth = $state(0);
let startHeight = $state(0);
let startX = $state(0);
let startY = $state(0);
function handleDragStart(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('popup-header')) {
isDragging = true;
dragStartX = e.clientX - popupX;
dragStartY = e.clientY - popupY;
e.preventDefault();
}
}
function handleResizeStart(e: MouseEvent, direction: string) {
isResizing = true;
resizeDirection = direction;
dragStartX = e.clientX;
dragStartY = e.clientY;
startWidth = popupWidth;
startHeight = popupHeight;
startX = popupX;
startY = popupY;
e.preventDefault();
e.stopPropagation();
}
function handleMouseMove(e: MouseEvent) {
if (isDragging) {
popupX = e.clientX - dragStartX;
popupY = e.clientY - dragStartY;
} else if (isResizing) {
const deltaX = e.clientX - dragStartX;
const deltaY = e.clientY - dragStartY;
if (resizeDirection.includes('e')) {
popupWidth = Math.max(minWidth, startWidth + deltaX);
}
if (resizeDirection.includes('w')) {
const newWidth = Math.max(minWidth, startWidth - deltaX);
popupWidth = newWidth;
popupX = startX + (startWidth - newWidth);
}
if (resizeDirection.includes('s')) {
popupHeight = Math.max(minHeight, startHeight + deltaY);
}
if (resizeDirection.includes('n')) {
const newHeight = Math.max(minHeight, startHeight - deltaY);
popupHeight = newHeight;
popupY = startY + (startHeight - newHeight);
}
}
}
function handleMouseUp() {
isDragging = false;
isResizing = false;
resizeDirection = '';
}
function handleClose() {
visible = false;
if (onClose) {
onClose();
}
}
onMount(() => {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
});
</script>
{#if visible}
<div
class="resizable-popup"
style="left: {popupX}px; top: {popupY}px; width: {popupWidth}px; height: {popupHeight}px;"
>
<div class="popup-header" onmousedown={handleDragStart}>
<span class="popup-title">{title}</span>
{#if closable}
<button class="close-button" onclick={handleClose}>×</button>
{/if}
</div>
<div class="popup-content" class:no-padding={noPadding}>
{@render children?.()}
</div>
<div class="resize-handle n" onmousedown={(e) => handleResizeStart(e, 'n')}></div>
<div class="resize-handle s" onmousedown={(e) => handleResizeStart(e, 's')}></div>
<div class="resize-handle e" onmousedown={(e) => handleResizeStart(e, 'e')}></div>
<div class="resize-handle w" onmousedown={(e) => handleResizeStart(e, 'w')}></div>
<div class="resize-handle ne" onmousedown={(e) => handleResizeStart(e, 'ne')}></div>
<div class="resize-handle nw" onmousedown={(e) => handleResizeStart(e, 'nw')}></div>
<div class="resize-handle se" onmousedown={(e) => handleResizeStart(e, 'se')}></div>
<div class="resize-handle sw" onmousedown={(e) => handleResizeStart(e, 'sw')}></div>
</div>
{/if}
<style>
.resizable-popup {
position: fixed;
z-index: 9999;
background-color: #1a1a1a;
border: 1px solid #333;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background-color: #252525;
border-bottom: 1px solid #333;
cursor: move;
user-select: none;
}
.popup-title {
font-weight: 600;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.87);
}
.close-button {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
font-size: 1.5rem;
line-height: 1;
padding: 0;
cursor: pointer;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.close-button:hover {
color: rgba(255, 255, 255, 1);
}
.popup-content {
flex: 1;
overflow: auto;
padding: 1rem;
}
.popup-content.no-padding {
padding: 0;
}
.resize-handle {
position: absolute;
background: transparent;
z-index: 10;
}
.resize-handle.n,
.resize-handle.s {
left: 0;
right: 0;
height: 8px;
cursor: ns-resize;
}
.resize-handle.n {
top: 0;
}
.resize-handle.s {
bottom: 0;
}
.resize-handle.e,
.resize-handle.w {
top: 0;
bottom: 0;
width: 8px;
cursor: ew-resize;
}
.resize-handle.e {
right: 0;
}
.resize-handle.w {
left: 0;
}
.resize-handle.ne,
.resize-handle.nw,
.resize-handle.se,
.resize-handle.sw {
width: 12px;
height: 12px;
}
.resize-handle.ne {
top: 0;
right: 0;
cursor: nesw-resize;
}
.resize-handle.nw {
top: 0;
left: 0;
cursor: nwse-resize;
}
.resize-handle.se {
bottom: 0;
right: 0;
cursor: nwse-resize;
}
.resize-handle.sw {
bottom: 0;
left: 0;
cursor: nesw-resize;
}
</style>

133
src/lib/Spectrogram.svelte Normal file
View File

@ -0,0 +1,133 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
interface Props {
analyserNode?: AnalyserNode | null;
}
let { analyserNode }: Props = $props();
let container: HTMLDivElement;
let canvas: HTMLCanvasElement;
let canvasContext: CanvasRenderingContext2D | null = null;
let animationFrameId: number | null = null;
let dataArray: Uint8Array | null = null;
let width = $state(800);
let height = $state(600);
$effect(() => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
if (analyserNode && canvasContext) {
setupSpectrogram(analyserNode);
}
});
function setupSpectrogram(node: AnalyserNode) {
try {
const bufferLength = node.frequencyBinCount;
dataArray = new Uint8Array(bufferLength);
startDrawing(node);
} catch (error) {
console.error('Failed to setup spectrogram:', error);
}
}
function startDrawing(node: AnalyserNode) {
if (!canvasContext || !dataArray) return;
const draw = () => {
if (!canvasContext || !dataArray || !node || !canvas) return;
animationFrameId = requestAnimationFrame(draw);
node.getByteFrequencyData(dataArray);
const imageData = canvasContext.getImageData(1, 0, canvas.width - 1, canvas.height);
canvasContext.putImageData(imageData, 0, 0);
const barWidth = 1;
const x = canvas.width - barWidth;
for (let i = 0; i < dataArray.length; i++) {
const value = dataArray[i];
const percent = value / 255;
const y = canvas.height - (i / dataArray.length) * canvas.height;
const height = (canvas.height / dataArray.length);
const hue = (1 - percent) * 240;
const saturation = 100;
const lightness = percent * 50;
canvasContext.fillStyle = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
canvasContext.fillRect(x, y - height, barWidth, height);
}
};
draw();
}
function updateSize() {
if (container && canvas) {
const rect = container.getBoundingClientRect();
width = Math.floor(rect.width);
height = Math.floor(rect.height);
}
}
onMount(() => {
if (canvas) {
canvasContext = canvas.getContext('2d');
if (canvasContext) {
canvasContext.fillStyle = '#000';
canvasContext.fillRect(0, 0, canvas.width, canvas.height);
}
}
updateSize();
const resizeObserver = new ResizeObserver(() => {
updateSize();
});
if (container) {
resizeObserver.observe(container);
}
return () => {
resizeObserver.disconnect();
};
});
onDestroy(() => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
});
</script>
<div class="spectrogram" bind:this={container}>
<canvas bind:this={canvas} {width} {height}></canvas>
</div>
<style>
.spectrogram {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #0a0a0a;
overflow: hidden;
}
canvas {
display: block;
width: 100%;
height: 100%;
background-color: #0a0a0a;
}
</style>

View File

@ -3,16 +3,20 @@
interface Props {
title?: string;
leftActions?: import('svelte').Snippet;
children?: import('svelte').Snippet;
}
let { title = 'oldboy', children }: Props = $props();
let { title = 'OldBoy', leftActions, children }: Props = $props();
</script>
<div class="top-bar">
<div class="title-section">
<Code2 size={20} />
<span class="title">{title}</span>
<div class="left-section">
<div class="title-section">
<Code2 size={20} />
<span class="title">{title}</span>
</div>
{@render leftActions?.()}
</div>
<div class="actions">
{@render children?.()}
@ -31,6 +35,12 @@
box-sizing: border-box;
}
.left-section {
display: flex;
align-items: center;
gap: 0.5rem;
}
.title-section {
display: flex;
align-items: center;

View File

@ -3,6 +3,7 @@ import { Csound } from '@csound/browser';
export interface CsoundEngineOptions {
onMessage?: (message: string) => void;
onError?: (error: string) => void;
onPerformanceEnd?: () => void;
}
export class CsoundEngine {
@ -10,6 +11,9 @@ export class CsoundEngine {
private initialized = false;
private running = false;
private options: CsoundEngineOptions;
private scopeNode: AnalyserNode | null = null;
private audioNode: AudioNode | null = null;
private audioContext: AudioContext | null = null;
constructor(options: CsoundEngineOptions = {}) {
this.options = options;
@ -18,26 +22,12 @@ export class CsoundEngine {
async init(): Promise<void> {
if (this.initialized) return;
try {
this.csound = await Csound();
this.csound.on('message', (message: string) => {
this.options.onMessage?.(message);
});
await this.csound.setOption('-odac');
this.initialized = true;
this.log('Csound initialized successfully');
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Failed to initialize Csound';
this.error(errorMsg);
throw error;
}
this.initialized = true;
this.log('Csound ready');
}
async evaluateCode(code: string): Promise<void> {
if (!this.initialized || !this.csound) {
if (!this.initialized) {
throw new Error('Csound not initialized. Call init() first.');
}
@ -46,8 +36,29 @@ export class CsoundEngine {
await this.stop();
}
this.log('Resetting Csound...');
await this.csound.reset();
this.scopeNode = null;
this.log('Creating new Csound instance...');
this.csound = await Csound();
this.csound.on('message', (message: string) => {
this.options.onMessage?.(message);
});
this.csound.on('onAudioNodeCreated', (node: AudioNode) => {
this.audioNode = node;
this.audioContext = node.context as AudioContext;
this.log('Audio node created and captured');
});
this.csound.on('realtimePerformanceEnded', async () => {
try {
await this.csound?.cleanup();
} catch {}
this.running = false;
this.log('Performance complete');
this.options.onPerformanceEnd?.();
});
this.log('Setting audio output...');
await this.csound.setOption('-odac');
@ -63,22 +74,28 @@ export class CsoundEngine {
const sco = scoMatch[1].trim();
this.log('Compiling orchestra...');
await this.csound.compileOrc(orc);
const compileResult = await this.csound.compileOrc(orc);
if (compileResult !== 0) {
throw new Error('Failed to compile orchestra');
}
this.log('Reading score...');
await this.csound.readScore(sco);
this.log('Starting...');
this.log('Starting performance...');
this.running = true;
await this.csound.start();
this.log('Performing...');
this.running = true;
await this.csound.perform();
this.setupAnalyser();
this.log('Performance complete');
this.running = false;
} catch (error) {
this.running = false;
if (this.csound) {
try {
await this.csound.cleanup();
} catch {}
}
const errorMsg = error instanceof Error ? error.message : 'Evaluation failed';
this.error(errorMsg);
throw error;
@ -89,8 +106,9 @@ export class CsoundEngine {
if (!this.csound || !this.running) return;
try {
this.log('Stopping...');
await this.csound.stop();
await this.csound.reset();
await this.csound.cleanup();
this.running = false;
this.log('Stopped');
} catch (error) {
@ -107,6 +125,36 @@ export class CsoundEngine {
return this.initialized;
}
getAudioContext(): AudioContext | null {
return this.audioContext;
}
private setupAnalyser(): void {
if (!this.audioNode || !this.audioContext) {
this.log('Warning: Audio node not available yet');
return;
}
try {
this.scopeNode = this.audioContext.createAnalyser();
this.scopeNode.fftSize = 2048;
this.scopeNode.smoothingTimeConstant = 0.3;
this.audioNode.disconnect();
this.audioNode.connect(this.scopeNode);
this.scopeNode.connect(this.audioContext.destination);
this.log('Analyser node created and connected');
} catch (error) {
console.error('Failed to setup analyser:', error);
this.log('Error setting up analyser: ' + error);
}
}
getAnalyserNode(): AnalyserNode | null {
return this.scopeNode;
}
private log(message: string): void {
this.options.onMessage?.(message);
}

View File

@ -92,6 +92,20 @@ function createCsoundStore() {
}));
},
getAudioContext(): AudioContext | null {
if (engine) {
return engine.getAudioContext();
}
return null;
},
getAnalyserNode(): AnalyserNode | null {
if (engine) {
return engine.getAnalyserNode();
}
return null;
},
async destroy() {
if (engine) {
await engine.destroy();

1
web-ide Submodule

Submodule web-ide added at d74138bb72