Let's fucking go
This commit is contained in:
BIN
.playwright-mcp/audio-scope-black.png
Normal file
BIN
.playwright-mcp/audio-scope-black.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
313
src/App.svelte
313
src/App.svelte
@ -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,7 +352,8 @@
|
||||
{/snippet}
|
||||
|
||||
<div class="app-container">
|
||||
<TopBar title="oldboy">
|
||||
<TopBar title="OldBoy">
|
||||
{#snippet leftActions()}
|
||||
<button
|
||||
class="icon-button"
|
||||
onclick={saveCurrentProject}
|
||||
@ -242,6 +363,7 @@
|
||||
>
|
||||
<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
141
src/lib/AudioScope.svelte
Normal 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>
|
||||
@ -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>
|
||||
|
||||
274
src/lib/ResizablePopup.svelte
Normal file
274
src/lib/ResizablePopup.svelte
Normal 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
133
src/lib/Spectrogram.svelte
Normal 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>
|
||||
@ -3,17 +3,21 @@
|
||||
|
||||
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="left-section">
|
||||
<div class="title-section">
|
||||
<Code2 size={20} />
|
||||
<span class="title">{title}</span>
|
||||
</div>
|
||||
{@render leftActions?.()}
|
||||
</div>
|
||||
<div class="actions">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@ -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;
|
||||
|
||||
@ -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.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);
|
||||
}
|
||||
|
||||
@ -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
1
web-ide
Submodule
Submodule web-ide added at d74138bb72
Reference in New Issue
Block a user