Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import dynamic from "next/dynamic";
import StrudelHost from "@/components/StrudelHost";
import type { StrudelAdapter, StrudelEditorHandle } from "@/components/StrudelHost";
import SettingsModal, { loadSettings, type Settings } from "@/components/SettingsModal";
import ExportDropdown from "@/components/ExportDropdown";

const ClaudePanel = dynamic(() => import("@/components/ClaudePanel"), { ssr: false });

Expand All @@ -22,6 +23,7 @@ export default function Home() {
const [settings, setSettings] = useState<Settings>(() => loadSettings());
const [toast, setToast] = useState<string | null>(null);
const [isInfoOpen, setIsInfoOpen] = useState(false);
const [externalPlaybackStarted, setExternalPlaybackStarted] = useState<number>(0);
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
Expand Down Expand Up @@ -174,6 +176,11 @@ export default function Home() {
<line x1="12" y1="2" x2="12" y2="15" />
</svg>
</button>
<ExportDropdown
strudelAdapter={strudelAdapter}
onToast={setToast}
onPlaybackStart={() => setExternalPlaybackStarted(Date.now())}
/>
<button
onClick={() => setIsSettingsOpen(true)}
className="p-2 md:p-3 rounded-lg transition-all"
Expand Down Expand Up @@ -226,7 +233,7 @@ export default function Home() {
: { width: rightPanelWidth ? `${rightPanelWidth}px` : '35%' }
}
>
<ClaudePanel strudelAdapter={strudelAdapter} isMobile={isMobile} settings={settings} onInfoClick={() => setIsInfoOpen(true)} />
<ClaudePanel strudelAdapter={strudelAdapter} isMobile={isMobile} settings={settings} onInfoClick={() => setIsInfoOpen(true)} externalPlaybackStarted={externalPlaybackStarted} />
</div>
</div>

Expand Down
10 changes: 9 additions & 1 deletion components/ClaudePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ interface ClaudePanelProps {
isMobile?: boolean;
settings?: Settings;
onInfoClick?: () => void;
externalPlaybackStarted?: number; // timestamp to trigger sync
}

// quick action presets
Expand All @@ -58,7 +59,7 @@ const QUICK_ACTIONS = [
{ label: "minimal", prompt: "strip it down to essentials" },
];

export default function ClaudePanel({ strudelAdapter, isMobile = false, settings, onInfoClick }: ClaudePanelProps) {
export default function ClaudePanel({ strudelAdapter, isMobile = false, settings, onInfoClick, externalPlaybackStarted }: ClaudePanelProps) {
const [prompt, setPrompt] = useState("");
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -87,6 +88,13 @@ export default function ClaudePanel({ strudelAdapter, isMobile = false, settings
}
}, [currentSession, store]);

// sync isPlaying when external playback starts (e.g., from export)
useEffect(() => {
if (externalPlaybackStarted && strudelAdapter) {
setIsPlaying(strudelAdapter.isPlaying());
}
}, [externalPlaybackStarted, strudelAdapter]);

// sync editor code to session when adapter is ready
useEffect(() => {
if (strudelAdapter && currentSession?.currentCode) {
Expand Down
176 changes: 176 additions & 0 deletions components/ExportDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"use client";

import { useState, useRef, useEffect } from "react";
import type { StrudelAdapter } from "./StrudelHost";

interface ExportDropdownProps {
strudelAdapter: StrudelAdapter | null;
onToast: (message: string) => void;
onPlaybackStart?: () => void;
}

function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}

function downloadCodeAsFile(code: string, filename: string): void {
const blob = new Blob([code], { type: "text/plain;charset=utf-8" });
downloadBlob(blob, filename);
}

export default function ExportDropdown({ strudelAdapter, onToast, onPlaybackStart }: ExportDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);

// Click outside to close
useEffect(() => {
if (!isOpen) return;

const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};

document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);

const handleExportAudio = async () => {
if (!strudelAdapter) {
onToast("audio not ready");
setIsOpen(false);
return;
}

setIsOpen(false);
setIsExporting(true);
onPlaybackStart?.();

try {
const blob = await strudelAdapter.exportOneLoop();
const timestamp = Date.now();
const filename = `audial-${timestamp}.mp3`;
downloadBlob(blob, filename);
onToast("exported!");
} catch (err) {
const message = err instanceof Error ? err.message : "export failed";
onToast(message);
} finally {
setIsExporting(false);
}
};

const handleExportCode = () => {
const code = strudelAdapter?.getCode();
if (!code || !code.trim()) {
onToast("no code to export");
setIsOpen(false);
return;
}

const timestamp = Date.now();
const filename = `audial-${timestamp}.txt`;
downloadCodeAsFile(code, filename);
onToast("code exported!");
setIsOpen(false);
};

return (
<div ref={dropdownRef} className="relative">
<button
onClick={() => !isExporting && setIsOpen(!isOpen)}
disabled={isExporting}
className="p-2 md:p-3 rounded-lg transition-all"
style={{ color: isExporting ? "#FF0059" : "var(--text-alt)" }}
title="Export"
>
{isExporting ? (
<svg
className="w-5 h-5 md:w-7 md:h-7 animate-spin"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
</svg>
) : (
<svg
className="w-5 h-5 md:w-7 md:h-7"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
)}
</button>

{isOpen && (
<div
className="absolute right-0 top-full mt-1 py-1 rounded-lg shadow-lg z-50"
style={{
background: "var(--bg)",
border: "1px solid var(--border)",
minWidth: "180px",
}}
>
<button
onClick={handleExportAudio}
className="w-full px-4 py-2 text-left text-sm flex items-center gap-2 hover:bg-white/5 transition-colors"
style={{ color: "var(--text-alt)" }}
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
Download Audio
</button>
<button
onClick={handleExportCode}
className="w-full px-4 py-2 text-left text-sm flex items-center gap-2 hover:bg-white/5 transition-colors"
style={{ color: "var(--text-alt)" }}
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
Download Code
</button>
</div>
)}
</div>
);
}
90 changes: 48 additions & 42 deletions components/StrudelHost.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
"use client";

// Import and patch audio context BEFORE any other imports that might create AudioContext
import { audioRecorder } from "@/lib/audioRecorder";
audioRecorder.patchAudioContext();

import { useRef, useEffect, useState, useImperativeHandle, forwardRef } from "react";

// strudel error with optional line number for ui display
Expand Down Expand Up @@ -61,7 +65,8 @@ export interface StrudelAdapter {
stop: () => Promise<void>;
isPlaying: () => boolean;
getAudioContext: () => AudioContext | null;
exportAudio: (durationSeconds: number, format?: 'wav' | 'mp3') => Promise<void>;
getCps: () => number;
exportOneLoop: () => Promise<Blob>;
}

export interface StrudelEditorHandle {
Expand Down Expand Up @@ -329,6 +334,10 @@ const StrudelHost = forwardRef<StrudelEditorHandle, StrudelHostProps>(
await strudelRef.current.evaluate();
const { getAudioContext } = await import("@strudel/webaudio");
audioContextRef = getAudioContext();
// Initialize audio recorder with the context
if (audioContextRef) {
audioRecorder.init(audioContextRef);
}
} catch (err) {
// create a structured error with line number extraction
const strudelError = createStrudelError(err, 'strudel error');
Expand All @@ -342,52 +351,49 @@ const StrudelHost = forwardRef<StrudelEditorHandle, StrudelHostProps>(
await strudelRef.current.stop();
}
},
isPlaying: () => playing,
isPlaying: () => {
// Check actual scheduler state to avoid stale closure
const repl = (strudelRef.current as any)?.repl;
return repl?.scheduler?.started ?? false;
},
getAudioContext: () => audioContextRef,
exportAudio: async (durationSeconds: number, _format: 'wav' | 'mp3' = 'wav'): Promise<void> => {
getCps: () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const repl = (strudelRef.current as any)?.repl;
return repl?.scheduler?.cps || 0.5;
},
exportOneLoop: async () => {
if (!strudelRef.current) {
throw new Error("strudel not ready");
throw new Error("Strudel not ready");
}

const code = strudelRef.current.code || "";
if (!code.trim()) {
throw new Error("no code to export");
}

// Use Strudel's built-in renderPatternAudio
const { renderPatternAudio, initAudio } = await import("@strudel/webaudio");

// Stop current playback and evaluate to get the pattern

// 1. Stop any existing playback first
await strudelRef.current.stop();
await strudelRef.current.evaluate();

// Get pattern and cps from the repl
const repl = strudelRef.current.repl;
if (!repl || !repl.state?.pattern) {
throw new Error("no pattern to export");
await new Promise(resolve => setTimeout(resolve, 100));

// 2. Initialize audio recorder if needed
if (!audioRecorder.isReady()) {
const { getAudioContext, initAudio } = await import("@strudel/webaudio");
await initAudio();
const ctx = getAudioContext();
if (ctx) {
audioRecorder.init(ctx);
audioContextRef = ctx;
}
}

const pattern = repl.state.pattern;
const cps = repl.scheduler?.cps || 0.5;

// Calculate cycles from duration
const numCycles = Math.ceil(durationSeconds * cps);

// Use Strudel's renderPatternAudio - it handles everything and triggers download
const downloadName = `audial-${Date.now()}`;
await renderPatternAudio(
pattern,
cps,
0, // begin cycle
numCycles, // end cycle
48000, // sample rate
1024, // max polyphony
true, // multi-channel orbits
downloadName // download name (without extension)
);

// Re-initialize audio after export (renderPatternAudio closes the context)
await initAudio({ maxPolyphony: 1024, multiChannelOrbits: true });

// 3. Get cycle duration
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const repl = (strudelRef.current as any)?.repl;
const cps = repl?.scheduler?.cps || 0.5;
const cycleDuration = 1 / cps;

// 4. Start fresh playback - now audio will route through recorder
await strudelRef.current.evaluate();
await new Promise(resolve => setTimeout(resolve, 300));

// 5. Record one loop and return MP3
return audioRecorder.recordDuration(cycleDuration);
},
};
onReady(adapter);
Expand Down
Loading