├── src
├── vite-env.d.ts
├── types
│ └── index.ts
├── components
│ ├── edges
│ │ └── index.ts
│ ├── nodes
│ │ ├── instruments
│ │ │ ├── pad-utils
│ │ │ │ ├── index.ts
│ │ │ │ ├── pad-button.tsx
│ │ │ │ ├── modifiers.tsx
│ │ │ │ └── button-utils.ts
│ │ │ ├── custom-node.tsx
│ │ │ ├── beat-machine-node.tsx
│ │ │ └── arpeggiator-node.tsx
│ │ ├── effects
│ │ │ ├── palindrome-node.tsx
│ │ │ ├── rev-node.tsx
│ │ │ ├── fm-node.tsx
│ │ │ ├── crush-node.tsx
│ │ │ ├── attack-node.tsx
│ │ │ ├── sustain-node.tsx
│ │ │ ├── postgain-node.tsx
│ │ │ ├── release-node.tsx
│ │ │ ├── fast-node.tsx
│ │ │ ├── slow-node.tsx
│ │ │ ├── gain-node.tsx
│ │ │ ├── distort-node.tsx
│ │ │ ├── jux-node.tsx
│ │ │ ├── lpf-node.tsx
│ │ │ ├── pan-node.tsx
│ │ │ ├── phaser-node.tsx
│ │ │ ├── room-node.tsx
│ │ │ ├── ply-node.tsx
│ │ │ ├── mask-node.tsx
│ │ │ └── late-node.tsx
│ │ ├── synths
│ │ │ ├── synth-select-node.tsx
│ │ │ └── drum-sounds-node.tsx
│ │ └── workflow-node.tsx
│ ├── ui
│ │ ├── skeleton.tsx
│ │ ├── separator.tsx
│ │ ├── textarea.tsx
│ │ ├── collapsible.tsx
│ │ ├── input.tsx
│ │ ├── switch.tsx
│ │ ├── popover.tsx
│ │ ├── tooltip.tsx
│ │ ├── slider.tsx
│ │ ├── accordion.tsx
│ │ ├── button.tsx
│ │ ├── dialog.tsx
│ │ └── sheet.tsx
│ ├── layouts
│ │ └── sidebar-layout
│ │ │ └── index.tsx
│ ├── base-node.tsx
│ ├── base-handle.tsx
│ ├── delete-edge.tsx
│ ├── button-edge.tsx
│ ├── button-handle.tsx
│ ├── workflow
│ │ ├── useDragAndDrop.ts
│ │ ├── index.tsx
│ │ └── controls.tsx
│ ├── app-dropdown-menu.tsx
│ ├── cpm.tsx
│ ├── pattern-panel.tsx
│ ├── save-project-dialog.tsx
│ ├── pattern-popup.tsx
│ ├── zoom-slider.tsx
│ ├── share-url-popover.tsx
│ ├── app-info-popover.tsx
│ └── node-header.tsx
├── lib
│ ├── utils.ts
│ ├── node-registry.ts
│ ├── graph-utils.ts
│ ├── state-serialization.ts
│ └── strudel.ts
├── store
│ ├── strudel-store.ts
│ ├── app-context.ts
│ ├── index.tsx
│ └── app-store.ts
├── data
│ ├── workflow-data.ts
│ ├── icon-mapping.ts
│ └── css
│ │ ├── themes.ts
│ │ ├── theme-doom-64.css
│ │ ├── theme-mono.css
│ │ └── theme-neo-brutalism.css
├── hooks
│ ├── use-mobile.tsx
│ ├── use-dropdown.tsx
│ ├── use-theme-css.tsx
│ ├── use-url-state.tsx
│ ├── use-global-playback.tsx
│ └── use-workflow-runner.tsx
└── main.tsx
├── .github
├── FUNDING.yml
└── dependabot.yml
├── public
├── favicon.ico
└── strudel-flow-og.png
├── .prettierrc
├── tsconfig.node.json
├── config.json
├── .gitignore
├── .eslintrc.cjs
├── components.json
├── vite.config.js
├── tsconfig.json
├── package.json
├── index.html
└── README.md
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: xyflow
2 | open_collective: xyflow
3 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xyflow/strudel-flow/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 2,
3 | "useTabs": false,
4 | "singleQuote": true
5 | }
6 |
--------------------------------------------------------------------------------
/public/strudel-flow-og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xyflow/strudel-flow/HEAD/public/strudel-flow-og.png
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | // This file can be used for other type definitions if needed in the future
2 | export {};
3 |
--------------------------------------------------------------------------------
/src/components/edges/index.ts:
--------------------------------------------------------------------------------
1 | import DeleteEdge from '@/components/delete-edge';
2 |
3 | export const edgeTypes = {
4 | default: DeleteEdge,
5 | deleteEdge: DeleteEdge,
6 | };
7 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "allowSyntheticDefaultImports": true
6 | },
7 | "include": ["vite.config.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "strudel-flow",
3 | "name": "Strudel Flow",
4 | "skipBuild": true,
5 | "description": "A visual drum machine and pattern sequencer built with Strudel and React Flow.",
6 | "tags": [],
7 | "hidden": true,
8 | "published": false
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/nodes/instruments/pad-utils/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Pad node utilities for step sequencing and pattern modification
3 | */
4 |
5 | // Utilities
6 | export * from './button-utils';
7 |
8 | // UI Components and Types
9 | export * from './modifiers';
10 | export * from './pad-button';
11 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # docs:
2 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
3 |
4 | version: 2
5 | updates:
6 | - package-ecosystem: 'npm'
7 | directory: '/'
8 | schedule:
9 | interval: 'daily'
10 | allow:
11 | - dependency-name: '@xyflow/react'
12 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
4 | return (
5 |
10 | )
11 | }
12 |
13 | export { Skeleton }
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | ],
9 | ignorePatterns: ['dist', '.eslintrc.cjs'],
10 | parser: '@typescript-eslint/parser',
11 | plugins: ['react-refresh'],
12 | rules: {
13 | 'react-refresh/only-export-components': [
14 | 'warn',
15 | { allowConstantExport: true },
16 | ],
17 | },
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/palindrome-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 |
4 | export function PalindromeNode({ id, data }: WorkflowNodeProps) {
5 | return ;
6 | }
7 |
8 | PalindromeNode.strudelOutput = (_: AppNode, strudelString: string) => {
9 | const palindromeCall = `palindrome()`;
10 | return strudelString ? `${strudelString}.${palindromeCall}` : palindromeCall;
11 | };
12 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "src/index.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/rev-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 |
4 | export function RevNode({ id, data }: WorkflowNodeProps) {
5 | return ;
6 | }
7 |
8 | RevNode.strudelOutput = (_: AppNode, strudelString: string) => {
9 | // Rev effect is always active when node exists
10 | const revCall = `rev()`;
11 | return strudelString ? `${strudelString}.${revCall}` : revCall;
12 | };
13 |
--------------------------------------------------------------------------------
/src/store/strudel-store.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 |
3 | type StrudelStore = {
4 | pattern: string;
5 | cpm: string;
6 | bpc: string;
7 | setPattern: (pattern: string) => void;
8 | setCpm: (cpm: string) => void;
9 | setBpc: (bpc: string) => void;
10 | };
11 |
12 | export const useStrudelStore = create((set) => ({
13 | pattern: '',
14 | cpm: '120',
15 | bpc: '4',
16 | setPattern: (pattern: string) => set({ pattern: pattern }),
17 | setCpm: (cpm: string) => set({ cpm: cpm }),
18 | setBpc: (bpc: string) => set({ bpc: bpc }),
19 | }));
20 |
--------------------------------------------------------------------------------
/src/lib/node-registry.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Registry for node types and their strudel output methods
3 | */
4 |
5 | import { nodeTypes } from '@/components/nodes';
6 | import { AppNode } from '@/components/nodes';
7 |
8 | // Type for components that have strudelOutput method
9 | type NodeWithStrudelOutput = {
10 | strudelOutput?: (node: AppNode, strudelString: string) => string;
11 | };
12 |
13 | export function getNodeStrudelOutput(nodeType: string) {
14 | const NodeComponent = nodeTypes[nodeType as keyof typeof nodeTypes] as NodeWithStrudelOutput;
15 | return NodeComponent?.strudelOutput;
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/layouts/sidebar-layout/index.tsx:
--------------------------------------------------------------------------------
1 | import { AppSidebar } from '@/components/layouts/sidebar-layout/app-sidebar';
2 | import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
3 |
4 | export default function SidebarLayout({
5 | children,
6 | }: {
7 | children: React.ReactNode;
8 | title?: string;
9 | }) {
10 | return (
11 |
12 |
13 |
14 |
15 | {children}
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/data/workflow-data.ts:
--------------------------------------------------------------------------------
1 | import { AppNode, createNodeByType } from '@/components/nodes';
2 | import { Edge } from '@xyflow/react';
3 |
4 | export const initialNodes: AppNode[] = [
5 | createNodeByType({
6 | type: 'pad-node',
7 | id: 'padNode_1',
8 | position: { x: 0, y: 0 },
9 | }),
10 | createNodeByType({
11 | type: 'synth-select-node',
12 | id: 'synthSelectNode_1',
13 | position: { x: 0, y: 600 },
14 | }),
15 | ];
16 |
17 | export const initialEdges: Edge[] = [
18 | {
19 | id: 'edge_1',
20 | source: 'padNode_1',
21 | target: 'synthSelectNode_1',
22 | type: 'default',
23 | },
24 | ];
25 |
--------------------------------------------------------------------------------
/src/components/base-node.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef, HTMLAttributes } from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | export const BaseNode = forwardRef<
6 | HTMLDivElement,
7 | HTMLAttributes & { selected?: boolean }
8 | >(({ className, selected, ...props }, ref) => (
9 |
20 | ));
21 |
22 | BaseNode.displayName = 'BaseNode';
23 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react';
3 | import path from 'path';
4 | import tailwindcss from '@tailwindcss/vite';
5 | import { fileURLToPath } from 'url';
6 | import process from 'node:process';
7 |
8 | // https://vitejs.dev/config/
9 | export default defineConfig(() => ({
10 | plugins: [react(), tailwindcss()],
11 | resolve: {
12 | alias: {
13 | '@': path.resolve(fileURLToPath(new URL('.', import.meta.url)), 'src'),
14 | },
15 | },
16 | base:
17 | process.env.VERCEL_ENV === 'production'
18 | ? 'https://flow-machine-xyflow.vercel.app/'
19 | : '/',
20 | }));
21 |
--------------------------------------------------------------------------------
/src/hooks/use-mobile.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | const MOBILE_BREAKPOINT = 768;
4 |
5 | export function useIsMobile() {
6 | const [isMobile, setIsMobile] = useState(undefined);
7 |
8 | useEffect(() => {
9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
10 | const onChange = () => {
11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
12 | };
13 | mql.addEventListener('change', onChange);
14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
15 |
16 | return () => mql.removeEventListener('change', onChange);
17 | }, []);
18 |
19 | return !!isMobile;
20 | }
21 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import { ReactFlowProvider } from '@xyflow/react';
4 | import { AppStoreProvider } from '@/store';
5 | import { defaultState } from '@/store/app-store';
6 | import SidebarLayout from '@/components/layouts/sidebar-layout';
7 | import Workflow from '@/components/workflow';
8 |
9 | import './index.css';
10 |
11 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | );
22 |
--------------------------------------------------------------------------------
/src/components/base-handle.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef } from 'react';
2 | import { Handle, HandleProps } from '@xyflow/react';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | export type BaseHandleProps = HandleProps;
7 |
8 | export const BaseHandle = forwardRef(
9 | ({ className, children, ...props }, ref) => {
10 | return (
11 |
20 | {children}
21 |
22 | );
23 | }
24 | );
25 |
26 | BaseHandle.displayName = 'BaseHandle';
27 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 | "baseUrl": ".",
9 | "paths": {
10 | "@/*": ["./src/*"]
11 | },
12 | /* Bundler mode */
13 | "moduleResolution": "bundler",
14 | "allowImportingTsExtensions": true,
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "noEmit": true,
18 | "jsx": "react-jsx",
19 |
20 | /* Linting */
21 | "strict": true,
22 | "noUnusedLocals": true,
23 | "noUnusedParameters": true,
24 | "noFallthroughCasesInSwitch": true
25 | },
26 | "include": ["src"],
27 | "references": [{ "path": "./tsconfig.node.json" }]
28 | }
29 |
--------------------------------------------------------------------------------
/src/store/app-context.ts:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from 'react';
2 | import { useStore } from 'zustand';
3 |
4 | import { AppState, type AppStore, createAppStore } from '@/store/app-store';
5 |
6 | export type AppStoreApi = ReturnType;
7 |
8 | export const AppStoreContext = createContext(
9 | undefined
10 | );
11 |
12 | export interface AppStoreProviderProps {
13 | children: React.ReactNode;
14 | initialState?: AppState;
15 | }
16 |
17 | export const useAppStore = (selector: (store: AppStore) => T): T => {
18 | const appStoreContext = useContext(AppStoreContext);
19 |
20 | if (!appStoreContext) {
21 | throw new Error(`useAppStore must be used within AppStoreProvider`);
22 | }
23 |
24 | return useStore(appStoreContext, selector);
25 | };
26 |
--------------------------------------------------------------------------------
/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as SeparatorPrimitive from '@radix-ui/react-separator';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | function Separator({
7 | className,
8 | orientation = 'horizontal',
9 | decorative = true,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
23 | );
24 | }
25 |
26 | export { Separator };
27 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6 | return (
7 |
15 | )
16 | }
17 |
18 | export { Textarea }
19 |
--------------------------------------------------------------------------------
/src/hooks/use-dropdown.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from 'react';
2 |
3 | export function useDropdown() {
4 | const [isOpen, setIsOpen] = useState(false);
5 | const ref = useRef(null);
6 |
7 | const toggleDropdown = () => setIsOpen((prev) => !prev);
8 |
9 | const closeDropdown = () => setIsOpen(false);
10 |
11 | useEffect(() => {
12 | const handleClickOutside = (event: MouseEvent) => {
13 | if (ref.current && !ref.current.contains(event.target as Node)) {
14 | closeDropdown();
15 | }
16 | };
17 |
18 | if (isOpen) {
19 | document.addEventListener('click', handleClickOutside);
20 | }
21 |
22 | return () => {
23 | document.removeEventListener('click', handleClickOutside);
24 | };
25 | }, [isOpen]);
26 |
27 | return { isOpen, toggleDropdown, closeDropdown, ref };
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/delete-edge.tsx:
--------------------------------------------------------------------------------
1 | import { EdgeProps, useReactFlow } from '@xyflow/react';
2 | import { memo } from 'react';
3 | import { X } from 'lucide-react';
4 |
5 | import { Button } from '@/components/ui/button';
6 | import { ButtonEdge } from '@/components/button-edge';
7 |
8 | const DeleteEdge = memo((props: EdgeProps) => {
9 | const { setEdges } = useReactFlow();
10 |
11 | const onDeleteEdge = () => {
12 | setEdges((edges) => edges.filter((edge) => edge.id !== props.id));
13 | };
14 |
15 | return (
16 |
17 |
25 |
26 | );
27 | });
28 |
29 | DeleteEdge.displayName = 'DeleteEdge';
30 |
31 | export default DeleteEdge;
32 |
--------------------------------------------------------------------------------
/src/components/ui/collapsible.tsx:
--------------------------------------------------------------------------------
1 | import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
2 |
3 | function Collapsible({
4 | ...props
5 | }: React.ComponentProps) {
6 | return ;
7 | }
8 |
9 | function CollapsibleTrigger({
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
17 | );
18 | }
19 |
20 | function CollapsibleContent({
21 | ...props
22 | }: React.ComponentProps) {
23 | return (
24 |
28 | );
29 | }
30 |
31 | export { Collapsible, CollapsibleTrigger, CollapsibleContent };
32 |
--------------------------------------------------------------------------------
/src/hooks/use-theme-css.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { themeStyles } from '@/data/css/themes';
3 |
4 | export function useThemeCss(theme: string) {
5 | useEffect(() => {
6 | const id = 'theme-css';
7 | let styleElement = document.getElementById(id) as HTMLStyleElement | null;
8 |
9 | // Remove existing style element if it exists
10 | if (styleElement) {
11 | styleElement.remove();
12 | }
13 |
14 | // Create new style element
15 | styleElement = document.createElement('style');
16 | styleElement.id = id;
17 | styleElement.type = 'text/css';
18 |
19 | // Get the theme CSS content
20 | const themeCSS = themeStyles[theme];
21 | if (themeCSS) {
22 | styleElement.textContent = themeCSS;
23 | document.head.appendChild(styleElement);
24 | } else {
25 | console.warn(
26 | `Theme "${theme}" not found. Available themes:`,
27 | Object.keys(themeStyles)
28 | );
29 | }
30 | }, [theme]);
31 | }
32 |
--------------------------------------------------------------------------------
/src/store/index.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect } from 'react';
2 | // @ts-expect-error - Missing type declarations for @strudel/web
3 | import { initStrudel, samples } from '@strudel/web';
4 |
5 | import { createAppStore } from '@/store/app-store';
6 | import {
7 | AppStoreContext,
8 | type AppStoreProviderProps,
9 | } from '@/store/app-context';
10 |
11 | export const AppStoreProvider = ({
12 | children,
13 | initialState,
14 | }: AppStoreProviderProps) => {
15 | const storeRef = useRef>();
16 | if (!storeRef.current) {
17 | storeRef.current = createAppStore(initialState);
18 | }
19 |
20 | // Initialize Strudel once when the app starts
21 | useEffect(() => {
22 | console.log('Initializing Strudel audio engine...');
23 | initStrudel();
24 | samples('github:tidalcycles/dirt-samples');
25 | }, []);
26 |
27 | return (
28 |
29 | {children}
30 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6 | return (
7 |
18 | )
19 | }
20 |
21 | export { Input }
22 |
--------------------------------------------------------------------------------
/src/components/button-edge.tsx:
--------------------------------------------------------------------------------
1 | import { type ReactNode } from "react";
2 |
3 | import {
4 | BaseEdge,
5 | EdgeLabelRenderer,
6 | getBezierPath,
7 | type EdgeProps,
8 | } from "@xyflow/react";
9 |
10 | export const ButtonEdge = ({
11 | sourceX,
12 | sourceY,
13 | targetX,
14 | targetY,
15 | sourcePosition,
16 | targetPosition,
17 | style = {},
18 | markerEnd,
19 | children,
20 | }: EdgeProps & { children: ReactNode }) => {
21 | const [edgePath, labelX, labelY] = getBezierPath({
22 | sourceX,
23 | sourceY,
24 | sourcePosition,
25 | targetX,
26 | targetY,
27 | targetPosition,
28 | });
29 |
30 | return (
31 | <>
32 |
33 |
34 |
40 | {children}
41 |
42 |
43 | >
44 | );
45 | };
46 |
--------------------------------------------------------------------------------
/src/components/nodes/instruments/pad-utils/pad-button.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { getButtonGroupIndex, getButtonClasses } from './button-utils';
3 |
4 | interface PadButtonProps {
5 | stepIdx: number;
6 | noteIdx: number;
7 | on: boolean;
8 | isSelected: boolean;
9 | noteGroups: Record;
10 | toggleCell: (
11 | stepIdx: number,
12 | noteIdx: number,
13 | event?: React.MouseEvent
14 | ) => void;
15 | }
16 |
17 | export const PadButton: React.FC = ({
18 | stepIdx,
19 | noteIdx,
20 | on,
21 | isSelected,
22 | noteGroups,
23 | toggleCell,
24 | }) => {
25 | const groupIndex = getButtonGroupIndex(stepIdx, noteIdx, noteGroups);
26 | const isInGroup = groupIndex >= 0;
27 |
28 | const buttonClass = `${getButtonClasses(
29 | isSelected,
30 | isInGroup,
31 | groupIndex,
32 | on
33 | )} w-12 h-10`;
34 |
35 | return (
36 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as SwitchPrimitive from '@radix-ui/react-switch';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | function Switch({
7 | className,
8 | ...props
9 | }: React.ComponentProps) {
10 | return (
11 |
19 |
25 |
26 | );
27 | }
28 |
29 | export { Switch };
30 |
--------------------------------------------------------------------------------
/src/data/icon-mapping.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Rocket,
3 | Spline,
4 | Split,
5 | Merge,
6 | CheckCheck,
7 | Ban,
8 | Music,
9 | Music2,
10 | List,
11 | Zap,
12 | Filter,
13 | Volume2,
14 | VolumeX,
15 | Move,
16 | Radio,
17 | Waves,
18 | Grid3x3,
19 | Circle,
20 | Activity,
21 | BarChart3,
22 | Hash,
23 | FastForward,
24 | Rewind,
25 | Code,
26 | Dice1,
27 | Layers,
28 | Maximize,
29 | EyeOff,
30 | Copy,
31 | Clock,
32 | // Import other icons as needed
33 | } from 'lucide-react';
34 |
35 | export const iconMapping: Record<
36 | string,
37 | React.FC>
38 | > = {
39 | Rocket: Rocket,
40 | Spline: Spline,
41 | Split: Split,
42 | Merge: Merge,
43 | CheckCheck: CheckCheck,
44 | Ban: Ban,
45 | Music: Music,
46 | Music2: Music2,
47 | List: List,
48 | Zap: Zap,
49 | Filter: Filter,
50 | Volume2: Volume2,
51 | VolumeX: VolumeX,
52 | Move: Move,
53 | Radio: Radio,
54 | Waves: Waves,
55 | Grid3x3: Grid3x3,
56 | Circle: Circle,
57 | Activity: Activity,
58 | BarChart3: BarChart3,
59 | Hash: Hash,
60 | FastForward: FastForward,
61 | Rewind: Rewind,
62 | Code: Code,
63 | Dice1: Dice1,
64 | Layers: Layers,
65 | Maximize: Maximize,
66 | EyeOff: EyeOff,
67 | Copy: Copy,
68 | Clock: Clock,
69 | // Add other mappings here
70 | };
71 |
--------------------------------------------------------------------------------
/src/lib/graph-utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Utilities for working with workflow graphs and node connections
3 | */
4 |
5 | import { Edge } from '@xyflow/react';
6 | import { AppNode } from '@/components/nodes';
7 |
8 | /**
9 | * Find connected components in the graph
10 | */
11 | export function findConnectedComponents(
12 | nodes: AppNode[],
13 | edges: Edge[]
14 | ): string[][] {
15 | const visited = new Set();
16 | const components: string[][] = [];
17 |
18 | nodes.forEach((node) => {
19 | if (!visited.has(node.id)) {
20 | const component: string[] = [];
21 | dfs(node.id, visited, component, edges);
22 | if (component.length > 0) {
23 | components.push(component);
24 | }
25 | }
26 | });
27 |
28 | return components;
29 | }
30 |
31 | /**
32 | * Depth-first search to find connected nodes
33 | */
34 | export function dfs(
35 | nodeId: string,
36 | visited: Set,
37 | component: string[],
38 | edges: Edge[]
39 | ) {
40 | visited.add(nodeId);
41 | component.push(nodeId);
42 |
43 | edges.forEach((edge) => {
44 | if (edge.source === nodeId && !visited.has(edge.target)) {
45 | dfs(edge.target, visited, component, edges);
46 | } else if (edge.target === nodeId && !visited.has(edge.source)) {
47 | dfs(edge.source, visited, component, edges);
48 | }
49 | });
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/fm-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 | import { useAppStore } from '@/store/app-context';
4 | import { Slider } from '@/components/ui/slider';
5 |
6 | export function FmNode({ id, data }: WorkflowNodeProps) {
7 | const updateNodeData = useAppStore((state) => state.updateNodeData);
8 | const fm = parseFloat(data.fm || '0');
9 |
10 | return (
11 |
12 |
13 |
14 |
17 |
20 | updateNodeData(id, { fm: value[0].toString() })
21 | }
22 | min={0}
23 | max={10}
24 | step={0.1}
25 | className="w-full"
26 | />
27 |
28 |
29 |
30 | );
31 | }
32 |
33 | FmNode.strudelOutput = (node: AppNode, strudelString: string) => {
34 | const fm = parseFloat(node.data.fm || '0');
35 | if (fm === 0) return strudelString;
36 |
37 | const fmCall = `fm(${fm})`;
38 | return strudelString ? `${strudelString}.${fmCall}` : fmCall;
39 | };
40 |
--------------------------------------------------------------------------------
/src/data/css/themes.ts:
--------------------------------------------------------------------------------
1 | // Import all theme CSS files as text
2 | import sunsetHorizonCss from './theme-sunset-horizon.css?inline';
3 | import boldTechCss from './theme-bold-tech.css?inline';
4 | import catppuccinCss from './theme-catppuccin.css?inline';
5 | import claymorphismCss from './theme-claymorphism.css?inline';
6 | import cosmicNightCss from './theme-cosmic-night.css?inline';
7 | import doom64Css from './theme-doom-64.css?inline';
8 | import monoCss from './theme-mono.css?inline';
9 | import neoBrutalismCss from './theme-neo-brutalism.css?inline';
10 | import pastelDreamsCss from './theme-pastel-dreams.css?inline';
11 | import quantumRoseCss from './theme-quantum-rose.css?inline';
12 | import softPopCss from './theme-soft-pop.css?inline';
13 | import supabaseCss from './theme-supabase.css?inline';
14 |
15 | // Map theme names to their CSS content
16 | export const themeStyles: Record = {
17 | 'sunset-horizon': sunsetHorizonCss,
18 | 'bold-tech': boldTechCss,
19 | catppuccin: catppuccinCss,
20 | claymorphism: claymorphismCss,
21 | 'cosmic-night': cosmicNightCss,
22 | 'doom-64': doom64Css,
23 | mono: monoCss,
24 | 'neo-brutalism': neoBrutalismCss,
25 | 'pastel-dreams': pastelDreamsCss,
26 | 'quantum-rose': quantumRoseCss,
27 | 'soft-pop': softPopCss,
28 | supabase: supabaseCss,
29 | };
30 |
31 | export const themeNames = Object.keys(themeStyles);
32 |
--------------------------------------------------------------------------------
/src/components/button-handle.tsx:
--------------------------------------------------------------------------------
1 | import { HandleProps, Position } from "@xyflow/react";
2 | import { BaseHandle } from "@/components/base-handle";
3 |
4 | const wrapperClassNames: Record = {
5 | [Position.Top]:
6 | "flex-col-reverse left-1/2 -translate-y-full -translate-x-1/2",
7 | [Position.Bottom]: "flex-col left-1/2 translate-y-[10px] -translate-x-1/2",
8 | [Position.Left]:
9 | "flex-row-reverse top-1/2 -translate-x-full -translate-y-1/2",
10 | [Position.Right]: "top-1/2 -translate-y-1/2 translate-x-[10px]",
11 | };
12 |
13 | export const ButtonHandle = ({
14 | showButton = true,
15 | position = Position.Bottom,
16 | children,
17 | ...props
18 | }: HandleProps & { showButton?: boolean }) => {
19 | const wrapperClassName = wrapperClassNames[position || Position.Bottom];
20 | const vertical = position === Position.Top || position === Position.Bottom;
21 |
22 | return (
23 |
24 | {showButton && (
25 |
28 |
31 |
{children}
32 |
33 | )}
34 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/crush-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 | import { useAppStore } from '@/store/app-context';
4 | import { Slider } from '@/components/ui/slider';
5 |
6 | export function CrushNode({ id, data }: WorkflowNodeProps) {
7 | const updateNodeData = useAppStore((state) => state.updateNodeData);
8 | const crush = parseFloat(data.crush || '4');
9 |
10 | return (
11 |
12 |
13 |
14 |
17 |
20 | updateNodeData(id, { crush: value[0].toString() })
21 | }
22 | min={1}
23 | max={16}
24 | step={0.1}
25 | className="w-full"
26 | />
27 |
28 |
29 |
30 | );
31 | }
32 |
33 | CrushNode.strudelOutput = (node: AppNode, strudelString: string) => {
34 | const crush = parseFloat(node.data.crush || '4');
35 | if (crush === 4) return strudelString;
36 |
37 | const crushCall = `crush(${crush})`;
38 | return strudelString ? `${strudelString}.${crushCall}` : crushCall;
39 | };
40 |
--------------------------------------------------------------------------------
/src/components/workflow/useDragAndDrop.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useMemo } from 'react';
2 | import { useReactFlow } from '@xyflow/react';
3 | import { useShallow } from 'zustand/react/shallow';
4 |
5 | import { AppNode, createNodeByType } from '@/components/nodes';
6 | import { useAppStore } from '@/store/app-context';
7 | import { AppStore } from '@/store/app-store';
8 |
9 | const selector = (state: AppStore) => ({
10 | addNode: state.addNode,
11 | });
12 |
13 | export function useDragAndDrop() {
14 | const { screenToFlowPosition } = useReactFlow();
15 | const { addNode } = useAppStore(useShallow(selector));
16 |
17 | const onDrop: React.DragEventHandler = useCallback(
18 | (event) => {
19 | const nodeProps = JSON.parse(
20 | event.dataTransfer.getData('application/reactflow')
21 | );
22 |
23 | if (!nodeProps) return;
24 |
25 | const position = screenToFlowPosition({
26 | x: event.clientX,
27 | y: event.clientY,
28 | });
29 |
30 | const newNode: AppNode = createNodeByType({
31 | type: nodeProps.id,
32 | position,
33 | });
34 | addNode(newNode);
35 | },
36 | [addNode, screenToFlowPosition]
37 | );
38 |
39 | const onDragOver: React.DragEventHandler = useCallback(
40 | (event) => event.preventDefault(),
41 | []
42 | );
43 |
44 | return useMemo(() => ({ onDrop, onDragOver }), [onDrop, onDragOver]);
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/attack-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 | import { useAppStore } from '@/store/app-context';
4 | import { Slider } from '@/components/ui/slider';
5 |
6 | export function AttackNode({ id, data }: WorkflowNodeProps) {
7 | const updateNodeData = useAppStore((state) => state.updateNodeData);
8 | const attack = parseFloat(data.attack || '0.01');
9 |
10 | return (
11 |
12 |
13 |
14 |
17 |
20 | updateNodeData(id, { attack: value[0].toString() })
21 | }
22 | min={0.001}
23 | max={2.0}
24 | step={0.001}
25 | className="w-full"
26 | />
27 |
28 |
29 |
30 | );
31 | }
32 |
33 | AttackNode.strudelOutput = (node: AppNode, strudelString: string) => {
34 | const attack = parseFloat(node.data.attack || '0.01');
35 | if (attack === 0.01) return strudelString;
36 | const attackCall = `attack("${attack}")`;
37 | return strudelString ? `${strudelString}.${attackCall}` : attackCall;
38 | };
39 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/sustain-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 | import { useAppStore } from '@/store/app-context';
4 | import { Slider } from '@/components/ui/slider';
5 |
6 | export function SustainNode({ id, data }: WorkflowNodeProps) {
7 | const updateNodeData = useAppStore((state) => state.updateNodeData);
8 | const sustain = parseFloat(data.sustain || '1');
9 |
10 | return (
11 |
12 |
13 |
14 |
17 |
20 | updateNodeData(id, { sustain: value[0].toString() })
21 | }
22 | min={0}
23 | max={1}
24 | step={0.01}
25 | className="w-full"
26 | />
27 |
28 |
29 |
30 | );
31 | }
32 |
33 | SustainNode.strudelOutput = (node: AppNode, strudelString: string) => {
34 | const sustain = parseFloat(node.data.sustain || '1');
35 | if (sustain === 1) return strudelString;
36 |
37 | const sustainCall = `sustain("${sustain}")`;
38 | return strudelString ? `${strudelString}.${sustainCall}` : sustainCall;
39 | };
40 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/postgain-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 | import { useAppStore } from '@/store/app-context';
4 | import { Slider } from '@/components/ui/slider';
5 |
6 | export function PostGainNode({ id, data }: WorkflowNodeProps) {
7 | const updateNodeData = useAppStore((state) => state.updateNodeData);
8 | const postgain = parseFloat(data.postgain || '1');
9 |
10 | return (
11 |
12 |
13 |
14 |
17 |
20 | updateNodeData(id, { postgain: value[0].toString() })
21 | }
22 | min={0.1}
23 | max={5}
24 | step={0.1}
25 | className="w-full"
26 | />
27 |
28 |
29 |
30 | );
31 | }
32 |
33 | PostGainNode.strudelOutput = (node: AppNode, strudelString: string) => {
34 | const postgain = parseFloat(node.data.postgain || '1');
35 | if (postgain === 1) return strudelString;
36 |
37 | const postgainCall = `postgain(${postgain})`;
38 | return strudelString ? `${strudelString}.${postgainCall}` : postgainCall;
39 | };
40 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/release-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 | import { useAppStore } from '@/store/app-context';
4 | import { Slider } from '@/components/ui/slider';
5 |
6 | export function ReleaseNode({ id, data }: WorkflowNodeProps) {
7 | const updateNodeData = useAppStore((state) => state.updateNodeData);
8 | const release = parseFloat(data.release || '0.1');
9 |
10 | return (
11 |
12 |
13 |
14 |
17 |
20 | updateNodeData(id, { release: value[0].toString() })
21 | }
22 | min={0.001}
23 | max={2.0}
24 | step={0.001}
25 | className="w-full"
26 | />
27 |
28 |
29 |
30 | );
31 | }
32 |
33 | ReleaseNode.strudelOutput = (node: AppNode, strudelString: string) => {
34 | const release = parseFloat(node.data.release || '0.1');
35 | if (release === 0.1) return strudelString;
36 |
37 | const releaseCall = `release("${release}")`;
38 | return strudelString ? `${strudelString}.${releaseCall}` : releaseCall;
39 | };
40 |
--------------------------------------------------------------------------------
/src/components/app-dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | DropdownMenu,
3 | DropdownMenuContent,
4 | DropdownMenuItem,
5 | DropdownMenuLabel,
6 | DropdownMenuTrigger,
7 | } from '@/components/ui/dropdown-menu';
8 | import nodesConfig, { AppNodeType, NodeConfig } from '@/components/nodes';
9 | import { iconMapping } from '@/data/icon-mapping';
10 |
11 | export function AppDropdownMenu({
12 | onAddNode,
13 | filterNodes = () => true,
14 | }: {
15 | onAddNode: (type: AppNodeType) => void;
16 | filterNodes?: (node: NodeConfig) => boolean;
17 | }) {
18 | return (
19 |
20 |
21 |
22 | Nodes
23 | {Object.values(nodesConfig)
24 | .filter(filterNodes)
25 | .map((item) => {
26 | const IconComponent = item?.icon
27 | ? iconMapping[item.icon]
28 | : undefined;
29 | return (
30 | onAddNode(item.id)}>
31 |
32 | {IconComponent ? (
33 |
34 | ) : null}
35 | New {item.title}
36 |
37 |
38 | );
39 | })}
40 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/nodes/synths/synth-select-node.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Select,
3 | SelectContent,
4 | SelectItem,
5 | SelectTrigger,
6 | SelectValue,
7 | } from '@/components/ui/select';
8 | import { useAppStore } from '@/store/app-context';
9 | import { WorkflowNodeProps, AppNode } from '..';
10 | import WorkflowNode from '@/components/nodes/workflow-node';
11 | import { SOUND_OPTIONS } from '@/data/sound-options';
12 |
13 | export function SynthSelectNode({ id, data }: WorkflowNodeProps) {
14 | const updateNodeData = useAppStore((state) => state.updateNodeData);
15 | const sound = data.sound || '';
16 |
17 | const handleValueChange = (value: string) => {
18 | updateNodeData(id, { sound: value });
19 | };
20 |
21 | return (
22 |
23 |
24 |
36 |
37 |
38 | );
39 | }
40 |
41 | SynthSelectNode.strudelOutput = (node: AppNode, strudelString: string) => {
42 | if (!node.data.sound) return strudelString;
43 |
44 | const soundCall = `sound("${node.data.sound}")`;
45 | return strudelString ? `${strudelString}.${soundCall}` : soundCall;
46 | };
47 |
--------------------------------------------------------------------------------
/src/components/nodes/synths/drum-sounds-node.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Select,
3 | SelectContent,
4 | SelectItem,
5 | SelectTrigger,
6 | SelectValue,
7 | } from '@/components/ui/select';
8 | import { useAppStore } from '@/store/app-context';
9 | import { WorkflowNodeProps, AppNode } from '..';
10 | import WorkflowNode from '@/components/nodes/workflow-node';
11 | import { DRUM_OPTIONS } from '@/data/sound-options';
12 |
13 | export function DrumSoundsNode({ id, data }: WorkflowNodeProps) {
14 | const updateNodeData = useAppStore((state) => state.updateNodeData);
15 |
16 | const sound = data.sound || '';
17 |
18 | const handleValueChange = (value: string) => {
19 | updateNodeData(id, { sound: value });
20 | };
21 |
22 | return (
23 |
24 |
25 |
37 |
38 |
39 | );
40 | }
41 |
42 | DrumSoundsNode.strudelOutput = (node: AppNode, strudelString: string) => {
43 | if (!node.data.sound) return strudelString;
44 |
45 | const soundCall = `sound("${node.data.sound}")`;
46 | return strudelString ? `${strudelString}.${soundCall}` : soundCall;
47 | };
48 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/fast-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 | import { useAppStore } from '@/store/app-context';
4 | import { Slider } from '@/components/ui/slider';
5 |
6 | export function FastNode({ id, data }: WorkflowNodeProps) {
7 | const updateNodeData = useAppStore((state) => state.updateNodeData);
8 | const factor = data.fast ? parseFloat(data.fast) : 2;
9 |
10 | const handleSliderChange = (values: number[]) => {
11 | const value = values[0];
12 | updateNodeData(id, { fast: value.toString() });
13 | };
14 |
15 | return (
16 |
17 |
18 |
19 |
20 | {factor}x
21 |
22 |
23 |
31 |
32 |
33 | 0.5x
34 | 8x
35 |
36 |
37 |
38 | );
39 | }
40 |
41 | FastNode.strudelOutput = (node: AppNode, strudelString: string) => {
42 | const fast = node.data.fast ? parseFloat(node.data.fast) : 2;
43 | if (fast === 2) return strudelString;
44 |
45 | const fastCall = `fast(${fast})`;
46 | return strudelString ? `${strudelString}.${fastCall}` : fastCall;
47 | };
48 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/slow-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 | import { useAppStore } from '@/store/app-context';
4 | import { Slider } from '@/components/ui/slider';
5 |
6 | export function SlowNode({ id, data }: WorkflowNodeProps) {
7 | const updateNodeData = useAppStore((state) => state.updateNodeData);
8 | const factor = data.slow ? parseFloat(data.slow) : 0.5;
9 |
10 | const handleSliderChange = (values: number[]) => {
11 | const value = values[0];
12 | updateNodeData(id, { slow: value.toString() });
13 | };
14 |
15 | return (
16 |
17 |
18 |
19 |
20 | {factor}x
21 |
22 |
23 |
31 |
32 |
33 | 0.1x
34 | 2x
35 |
36 |
37 |
38 | );
39 | }
40 |
41 | SlowNode.strudelOutput = (node: AppNode, strudelString: string) => {
42 | const slow = node.data.slow ? parseFloat(node.data.slow) : 0.5;
43 | if (slow === 0.5) return strudelString;
44 |
45 | const slowCall = `slow(${slow})`;
46 | return strudelString ? `${strudelString}.${slowCall}` : slowCall;
47 | };
48 |
--------------------------------------------------------------------------------
/src/components/cpm.tsx:
--------------------------------------------------------------------------------
1 | import { useStrudelStore } from '@/store/strudel-store';
2 | import { Slider } from '@/components/ui/slider';
3 |
4 | export function CPM() {
5 | const globalCpm = useStrudelStore((state) => state.cpm);
6 | const globalBpc = useStrudelStore((state) => state.bpc);
7 | const setGlobalCpm = useStrudelStore((state) => state.setCpm);
8 | const setGlobalBpc = useStrudelStore((state) => state.setBpc);
9 |
10 | const handleCpmChange = (value: number[]) => {
11 | setGlobalCpm(value[0].toString());
12 | };
13 |
14 | const handleBpcChange = (value: number[]) => {
15 | setGlobalBpc(value[0].toString());
16 | };
17 |
18 | const bpm = parseInt(globalCpm) || 120;
19 | const bpc = parseInt(globalBpc) || 4;
20 |
21 | return (
22 |
23 |
24 |
25 |
28 |
36 |
37 |
38 |
39 |
42 |
50 |
51 |
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/src/components/pattern-panel.tsx:
--------------------------------------------------------------------------------
1 | import { useStrudelStore } from '@/store/strudel-store';
2 | import { Copy } from 'lucide-react';
3 | import { useState } from 'react';
4 | import {
5 | Popover,
6 | PopoverTrigger,
7 | PopoverContent,
8 | } from '@/components/ui/popover';
9 | import { Button } from '@/components/ui/button';
10 |
11 | export function PatternPanel({ isVisible }: { isVisible: boolean }) {
12 | const pattern = useStrudelStore((s) => s.pattern) || 'No pattern.';
13 | const [isCopied, setIsCopied] = useState(false);
14 |
15 | if (!isVisible) return null;
16 |
17 | const handleCopy = () => {
18 | navigator.clipboard.writeText(pattern);
19 | setIsCopied(true);
20 | setTimeout(() => setIsCopied(false), 2000);
21 | };
22 |
23 | return (
24 |
25 |
26 |
Generated Pattern
27 |
28 |
29 |
32 |
33 | {isCopied && (
34 |
39 | Copied!
40 |
41 | )}
42 |
43 |
44 |
45 | {pattern}
46 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as PopoverPrimitive from '@radix-ui/react-popover';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | function Popover({
7 | ...props
8 | }: React.ComponentProps) {
9 | return ;
10 | }
11 |
12 | function PopoverTrigger({
13 | ...props
14 | }: React.ComponentProps) {
15 | return ;
16 | }
17 |
18 | function PopoverContent({
19 | className,
20 | align = 'center',
21 | sideOffset = 4,
22 | ...props
23 | }: React.ComponentProps) {
24 | return (
25 |
26 |
36 |
37 | );
38 | }
39 |
40 | function PopoverAnchor({
41 | ...props
42 | }: React.ComponentProps) {
43 | return ;
44 | }
45 |
46 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
47 |
--------------------------------------------------------------------------------
/src/components/save-project-dialog.tsx:
--------------------------------------------------------------------------------
1 | import { Save } from 'lucide-react';
2 |
3 | import {
4 | Dialog,
5 | DialogContent,
6 | DialogHeader,
7 | DialogTitle,
8 | DialogDescription,
9 | DialogFooter,
10 | DialogTrigger,
11 | } from '@/components/ui/dialog';
12 | import { Input } from '@/components/ui/input';
13 | import { Button } from '@/components/ui/button';
14 |
15 | interface SaveProjectDialogProps {
16 | filename: string;
17 | onFilenameChange: (filename: string) => void;
18 | onSave: () => void;
19 | children: React.ReactNode;
20 | isOpen: boolean;
21 | onOpenChange: (isOpen: boolean) => void;
22 | }
23 |
24 | export function SaveProjectDialog({
25 | filename,
26 | onFilenameChange,
27 | onSave,
28 | children,
29 | isOpen,
30 | onOpenChange,
31 | }: SaveProjectDialogProps) {
32 | const handleSave = () => {
33 | if (filename) {
34 | onSave();
35 | onOpenChange(false);
36 | }
37 | };
38 |
39 | return (
40 |
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/gain-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 | import { useAppStore } from '@/store/app-context';
4 | import { Slider } from '@/components/ui/slider';
5 |
6 | export function GainNode({ id, data }: WorkflowNodeProps) {
7 | const updateNodeData = useAppStore((state) => state.updateNodeData);
8 | const gain = data.gain ? parseFloat(data.gain) : 1;
9 |
10 | // Handler for gain changes
11 | const handleGainChange = (value: number[]) => {
12 | updateNodeData(id, { gain: value[0].toString() });
13 | };
14 |
15 | return (
16 |
17 |
18 | {/* Gain control */}
19 |
20 |
21 |
22 |
23 | {gain.toFixed(1)}x{' '}
24 | {gain > 1
25 | ? `(+${(20 * Math.log10(gain)).toFixed(1)}dB)`
26 | : gain < 1
27 | ? `(${(20 * Math.log10(gain)).toFixed(1)}dB)`
28 | : '(0dB)'}
29 |
30 |
31 |
39 |
40 |
41 |
42 | );
43 | }
44 |
45 | GainNode.strudelOutput = (node: AppNode, strudelString: string) => {
46 | const gain = node.data.gain ? parseFloat(node.data.gain) : 1;
47 | if (gain === 1) return strudelString;
48 |
49 | const gainCall = `gain(${gain})`;
50 | return strudelString ? `${strudelString}.${gainCall}` : gainCall;
51 | };
52 |
--------------------------------------------------------------------------------
/src/components/pattern-popup.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { getNodeStrudelOutput } from '@/lib/node-registry';
3 | import { useReactFlow } from '@xyflow/react';
4 | import { useAppStore } from '@/store/app-context';
5 | import { AppNode } from '@/components/nodes';
6 |
7 | export default function PatternPopup({
8 | className = '',
9 | id,
10 | rows = 3,
11 | }: {
12 | className?: string;
13 | id: string;
14 | rows?: number;
15 | }) {
16 | const { getNode } = useReactFlow();
17 | // Subscribe to nodes to trigger re-renders when node data changes
18 | const nodes = useAppStore((state) => state.nodes);
19 | const [strudelPattern, setStrudelPattern] = useState('');
20 |
21 | useEffect(() => {
22 | const node = getNode(id);
23 | if (!node || !node.type) {
24 | setStrudelPattern('');
25 | return;
26 | }
27 |
28 | const strudelOutput = getNodeStrudelOutput(node.type);
29 | if (strudelOutput) {
30 | const pattern = strudelOutput(node as AppNode, '');
31 | setStrudelPattern(pattern);
32 | } else {
33 | setStrudelPattern('');
34 | }
35 | }, [getNode, id, nodes]);
36 |
37 | return (
38 |
41 |
44 |
54 | {strudelPattern
55 | ? strudelPattern.replace(/\./g, '.\u200B')
56 | : 'No pattern.'}
57 |
58 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as TooltipPrimitive from '@radix-ui/react-tooltip';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | function TooltipProvider({
7 | delayDuration = 0,
8 | ...props
9 | }: React.ComponentProps) {
10 | return (
11 |
16 | );
17 | }
18 |
19 | function Tooltip({
20 | ...props
21 | }: React.ComponentProps) {
22 | return (
23 |
24 |
25 |
26 | );
27 | }
28 |
29 | function TooltipTrigger({
30 | ...props
31 | }: React.ComponentProps) {
32 | return ;
33 | }
34 |
35 | function TooltipContent({
36 | className,
37 | sideOffset = 0,
38 | children,
39 | ...props
40 | }: React.ComponentProps) {
41 | return (
42 |
43 |
52 | {children}
53 |
54 |
55 |
56 | );
57 | }
58 |
59 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
60 |
--------------------------------------------------------------------------------
/src/hooks/use-url-state.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Hook to load workflow state from URL parameters
3 | */
4 | import { useEffect } from 'react';
5 | import { useAppStore } from '@/store/app-context';
6 | import { loadStateFromUrl } from '@/lib/state-serialization';
7 | import { AppNode } from '@/components/nodes';
8 | import { useStrudelStore } from '@/store/strudel-store';
9 |
10 | /**
11 | * Hook to load state from URL parameters on app startup
12 | */
13 | export function useUrlStateLoader() {
14 | const { setNodes, setEdges, setTheme, setColorMode } = useAppStore(
15 | (state) => state
16 | );
17 | const setCpm = useStrudelStore((state) => state.setCpm);
18 | const setBpc = useStrudelStore((state) => state.setBpc);
19 |
20 | useEffect(() => {
21 | const urlState = loadStateFromUrl();
22 |
23 | if (urlState) {
24 | console.log('🔄 Loading state from URL:', {
25 | theme: urlState.theme,
26 | colorMode: urlState.colorMode,
27 | nodeCount: urlState.nodes.length,
28 | edgeCount: urlState.edges.length,
29 | cpm: urlState.cpm,
30 | bpc: urlState.bpc,
31 | });
32 |
33 | // Restore theme settings FIRST to ensure CSS loads before nodes render
34 | if (urlState.theme) {
35 | setTheme(urlState.theme);
36 | }
37 | if (urlState.colorMode) {
38 | setColorMode(urlState.colorMode);
39 | }
40 | if (urlState.cpm) {
41 | setCpm(urlState.cpm);
42 | }
43 | if (urlState.bpc) {
44 | setBpc(urlState.bpc);
45 | }
46 |
47 | // Small delay to ensure theme CSS loads on mobile before nodes render
48 | setTimeout(() => {
49 | // Set all nodes to paused state on load
50 | const nodes = (urlState.nodes as AppNode[]).map((node) => ({
51 | ...node,
52 | data: {
53 | ...node.data,
54 | state: 'paused' as const,
55 | },
56 | }));
57 |
58 | setNodes(nodes);
59 | setEdges(urlState.edges);
60 | }, 50); // Small delay for mobile CSS loading
61 | }
62 | }, [setNodes, setEdges, setTheme, setColorMode, setCpm, setBpc]);
63 | }
64 |
--------------------------------------------------------------------------------
/src/components/zoom-slider.tsx:
--------------------------------------------------------------------------------
1 | import { Maximize, Minus, Plus } from 'lucide-react';
2 | import {
3 | Panel,
4 | useViewport,
5 | useStore,
6 | useReactFlow,
7 | PanelProps,
8 | } from '@xyflow/react';
9 |
10 | import { Slider } from '@/components/ui/slider';
11 | import { Button } from '@/components/ui/button';
12 | import { cn } from '@/lib/utils';
13 |
14 | type ZoomSliderProps = Omit;
15 |
16 | function ZoomSlider({ className, ...props }: ZoomSliderProps) {
17 | const { zoom } = useViewport();
18 | const { zoomTo, zoomIn, zoomOut, fitView } = useReactFlow();
19 |
20 | const { minZoom, maxZoom } = useStore(
21 | (state) => ({
22 | minZoom: state.minZoom,
23 | maxZoom: state.maxZoom,
24 | }),
25 | (a, b) => a.minZoom !== b.minZoom || a.maxZoom !== b.maxZoom
26 | );
27 |
28 | return (
29 |
36 |
43 | zoomTo(values[0])}
50 | />
51 |
58 |
65 |
72 |
73 | );
74 | }
75 |
76 | ZoomSlider.displayName = 'ZoomSlider';
77 |
78 | export { ZoomSlider };
79 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "strudel-flow",
3 | "version": "0.0.0",
4 | "type": "module",
5 | "description": "A visual drum machine and pattern sequencer built with Strudel and React Flow.",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@dagrejs/dagre": "^1.1.4",
14 | "@radix-ui/react-accordion": "^1.2.11",
15 | "@radix-ui/react-collapsible": "^1.1.8",
16 | "@radix-ui/react-context-menu": "^2.2.12",
17 | "@radix-ui/react-dialog": "^1.1.11",
18 | "@radix-ui/react-dropdown-menu": "^2.1.12",
19 | "@radix-ui/react-popover": "^1.1.11",
20 | "@radix-ui/react-select": "^2.2.2",
21 | "@radix-ui/react-separator": "^1.1.4",
22 | "@radix-ui/react-slider": "^1.3.2",
23 | "@radix-ui/react-slot": "^1.2.3",
24 | "@radix-ui/react-switch": "^1.2.2",
25 | "@radix-ui/react-tooltip": "^1.2.4",
26 | "@strudel/web": "^1.2.3",
27 | "@xyflow/react": "^12.10.0",
28 | "class-variance-authority": "^0.7.1",
29 | "clsx": "^2.1.1",
30 | "lucide-react": "^0.456.0",
31 | "lz-string": "^1.5.0",
32 | "nanoid": "^5.0.9",
33 | "react": "19.0.0",
34 | "react-dom": "19.0.0",
35 | "tailwind-merge": "^3.3.1",
36 | "tailwindcss": "^4.1.10",
37 | "zustand": "^5.0.1"
38 | },
39 | "license": "MIT",
40 | "devDependencies": {
41 | "@tailwindcss/vite": "^4.1.10",
42 | "@types/node": "^24.0.1",
43 | "@types/react": "^18.2.53",
44 | "@types/react-dom": "^18.2.18",
45 | "@typescript-eslint/eslint-plugin": "^6.20.0",
46 | "@typescript-eslint/parser": "^6.20.0",
47 | "@vitejs/plugin-react": "^4.2.1",
48 | "autoprefixer": "^10.4.21",
49 | "eslint": "^8.56.0",
50 | "eslint-plugin-react-hooks": "^4.6.0",
51 | "eslint-plugin-react-refresh": "^0.4.5",
52 | "tw-animate-css": "^1.3.4",
53 | "typescript": "^5.8.3",
54 | "vite": "^5.0.12"
55 | },
56 | "packageManager": "pnpm@9.11.0+sha512.0a203ffaed5a3f63242cd064c8fb5892366c103e328079318f78062f24ea8c9d50bc6a47aa3567cabefd824d170e78fa2745ed1f16b132e16436146b7688f19b"
57 | }
58 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/distort-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 | import { useAppStore } from '@/store/app-context';
4 | import { Slider } from '@/components/ui/slider';
5 |
6 | export function DistortNode({ id, data }: WorkflowNodeProps) {
7 | const updateNodeData = useAppStore((state) => state.updateNodeData);
8 | const distortValue = data.distort || '0.5';
9 |
10 | // Extract values or set defaults
11 | const amount = distortValue.includes(':')
12 | ? parseFloat(distortValue.split(':')[0])
13 | : parseFloat(distortValue);
14 | const postgain = distortValue.includes(':')
15 | ? parseFloat(distortValue.split(':')[1])
16 | : 1;
17 |
18 | // Handler for distortion amount changes
19 | const handleAmountChange = (value: number[]) => {
20 | const newAmount = value[0];
21 | const distortValue =
22 | postgain !== 1 ? `${newAmount}:${postgain}` : `${newAmount}`;
23 | updateNodeData(id, { distort: distortValue });
24 | };
25 |
26 | return (
27 |
28 |
29 | {/* Distortion amount */}
30 |
31 |
32 |
33 |
34 | {amount.toFixed(1)}
35 |
36 |
37 |
45 |
46 |
47 |
48 | );
49 | }
50 |
51 | DistortNode.strudelOutput = (node: AppNode, strudelString: string) => {
52 | const distort = node.data.distort || '0.5';
53 | if (distort === '0.5') return strudelString;
54 |
55 | const distortCall = `distort(${distort})`;
56 | return strudelString ? `${strudelString}.${distortCall}` : distortCall;
57 | };
58 |
--------------------------------------------------------------------------------
/src/components/workflow/index.tsx:
--------------------------------------------------------------------------------
1 | import { Background, ReactFlow } from '@xyflow/react';
2 | import { useShallow } from 'zustand/react/shallow';
3 |
4 | import { nodeTypes } from '@/components/nodes';
5 | import { edgeTypes } from '@/components/edges';
6 | import { useAppStore } from '@/store/app-context';
7 | import { WorkflowControls } from './controls';
8 | import { useDragAndDrop } from './useDragAndDrop';
9 | import { useUrlStateLoader } from '@/hooks/use-url-state';
10 | import { useGlobalPlayback } from '@/hooks/use-global-playback';
11 | import { useThemeCss } from '@/hooks/use-theme-css';
12 |
13 | export default function Workflow() {
14 | useUrlStateLoader();
15 | useGlobalPlayback(); // Enable global spacebar pause/play
16 |
17 | const {
18 | nodes,
19 | edges,
20 | colorMode,
21 | theme,
22 | onNodesChange,
23 | onEdgesChange,
24 | onConnect,
25 | onNodeDragStart,
26 | onNodeDragStop,
27 | } = useAppStore(
28 | useShallow((state) => ({
29 | nodes: state.nodes,
30 | edges: state.edges,
31 | colorMode: state.colorMode,
32 | theme: state.theme,
33 | onNodesChange: state.onNodesChange,
34 | onEdgesChange: state.onEdgesChange,
35 | onConnect: state.onConnect,
36 | onNodeDragStart: state.onNodeDragStart,
37 | onNodeDragStop: state.onNodeDragStop,
38 | }))
39 | );
40 |
41 | // Load theme CSS at the app level - fixes mobile color loading
42 | useThemeCss(theme);
43 |
44 | const { onDragOver, onDrop } = useDragAndDrop();
45 |
46 | return (
47 |
48 |
64 |
65 |
66 |
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/src/components/ui/slider.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as SliderPrimitive from '@radix-ui/react-slider';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | function Slider({
7 | className,
8 | defaultValue,
9 | value,
10 | min = 0,
11 | max = 100,
12 | ...props
13 | }: React.ComponentProps) {
14 | const _values = React.useMemo(
15 | () =>
16 | Array.isArray(value)
17 | ? value
18 | : Array.isArray(defaultValue)
19 | ? defaultValue
20 | : [min, max],
21 | [value, defaultValue, min, max]
22 | );
23 |
24 | return (
25 |
37 |
43 |
49 |
50 | {Array.from({ length: _values.length }, (_, index) => (
51 |
56 | ))}
57 |
58 | );
59 | }
60 |
61 | export { Slider };
62 |
--------------------------------------------------------------------------------
/src/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as AccordionPrimitive from "@radix-ui/react-accordion"
3 | import { ChevronDownIcon } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | function Accordion({
8 | ...props
9 | }: React.ComponentProps) {
10 | return
11 | }
12 |
13 | function AccordionItem({
14 | className,
15 | ...props
16 | }: React.ComponentProps) {
17 | return (
18 |
23 | )
24 | }
25 |
26 | function AccordionTrigger({
27 | className,
28 | children,
29 | ...props
30 | }: React.ComponentProps) {
31 | return (
32 |
33 | svg]:rotate-180",
37 | className
38 | )}
39 | {...props}
40 | >
41 | {children}
42 |
43 |
44 |
45 | )
46 | }
47 |
48 | function AccordionContent({
49 | className,
50 | children,
51 | ...props
52 | }: React.ComponentProps) {
53 | return (
54 |
59 | {children}
60 |
61 | )
62 | }
63 |
64 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
65 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
16 | outline:
17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20 | ghost:
21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22 | link: "text-primary underline-offset-4 hover:underline",
23 | },
24 | size: {
25 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28 | icon: "size-9",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | }
36 | )
37 |
38 | function Button({
39 | className,
40 | variant,
41 | size,
42 | asChild = false,
43 | ...props
44 | }: React.ComponentProps<"button"> &
45 | VariantProps & {
46 | asChild?: boolean
47 | }) {
48 | const Comp = asChild ? Slot : "button"
49 |
50 | return (
51 |
56 | )
57 | }
58 |
59 | export { Button, buttonVariants }
60 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/jux-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 | import { useAppStore } from '@/store/app-context';
4 | import { Button } from '@/components/ui/button';
5 |
6 | export function JuxNode({ id, data }: WorkflowNodeProps) {
7 | const updateNodeData = useAppStore((state) => state.updateNodeData);
8 | const effect = data.jux || 'rev';
9 |
10 | // Available jux effects
11 | const effects = [
12 | { name: 'Reverse', value: 'rev', description: 'Reverse right channel' },
13 | { name: 'Press', value: 'press', description: 'Compress right channel' },
14 | { name: 'Crush', value: 'crush', description: 'Bitcrush right channel' },
15 | { name: 'Delay', value: 'delay', description: 'Delay right channel' },
16 | ];
17 |
18 | // Handler for effect changes
19 | const handleEffectChange = (newEffect: string) => {
20 | updateNodeData(id, { jux: newEffect });
21 | };
22 |
23 | return (
24 |
25 |
26 | {/* Title and description */}
27 |
28 |
Jux
29 |
30 | Apply effect to right channel
31 |
32 |
33 |
34 | {/* Effect selection */}
35 |
36 |
37 |
38 | {effects.map((eff) => (
39 |
48 | ))}
49 |
50 |
51 |
52 |
53 | );
54 | }
55 |
56 | JuxNode.strudelOutput = (node: AppNode, strudelString: string) => {
57 | const jux = node.data.jux || 'rev';
58 | if (jux === 'rev') return strudelString;
59 |
60 | const juxCall = `jux(${jux})`;
61 | return strudelString ? `${strudelString}.${juxCall}` : juxCall;
62 | };
63 |
--------------------------------------------------------------------------------
/src/hooks/use-global-playback.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useCallback, useState } from 'react';
2 | import { useAppStore } from '@/store/app-context';
3 | import { useWorkflowRunner } from './use-workflow-runner';
4 | // @ts-expect-error - Missing type declarations for @strudel/web
5 | import { hush } from '@strudel/web';
6 |
7 | export function useGlobalPlayback() {
8 | const { runWorkflow, stopWorkflow } = useWorkflowRunner();
9 | const nodes = useAppStore((state) => state.nodes);
10 | const updateNodeData = useAppStore((state) => state.updateNodeData);
11 |
12 | const [isGloballyPaused, setIsGloballyPaused] = useState(false);
13 | const nodeStatesBeforePause = useRef<
14 | Record
15 | >({});
16 |
17 | const globalPause = useCallback(() => {
18 | if (isGloballyPaused) return;
19 |
20 | nodeStatesBeforePause.current = {};
21 | nodes.forEach((node) => {
22 | const currentState = node.data.state || 'paused';
23 | if (currentState === 'running') {
24 | nodeStatesBeforePause.current[node.id] = 'running';
25 | updateNodeData(node.id, { state: 'paused' });
26 | }
27 | });
28 |
29 | hush();
30 | stopWorkflow();
31 | setIsGloballyPaused(true);
32 | }, [nodes, stopWorkflow, isGloballyPaused, updateNodeData]);
33 |
34 | const globalPlay = useCallback(() => {
35 | if (!isGloballyPaused) return;
36 |
37 | Object.keys(nodeStatesBeforePause.current).forEach((nodeId) => {
38 | if (nodeStatesBeforePause.current[nodeId] === 'running') {
39 | updateNodeData(nodeId, { state: 'running' });
40 | }
41 | });
42 |
43 | nodeStatesBeforePause.current = {};
44 | setIsGloballyPaused(false);
45 | runWorkflow();
46 | }, [updateNodeData, runWorkflow, isGloballyPaused]);
47 |
48 | const toggleGlobalPlayback = useCallback(() => {
49 | if (isGloballyPaused) {
50 | globalPlay();
51 | } else {
52 | globalPause();
53 | }
54 | }, [globalPlay, globalPause, isGloballyPaused]);
55 |
56 | useEffect(() => {
57 | const handleKeyPress = (event: KeyboardEvent) => {
58 | if (
59 | event.code === 'Space' &&
60 | !['INPUT', 'TEXTAREA'].includes((event.target as HTMLElement)?.tagName)
61 | ) {
62 | event.preventDefault();
63 | toggleGlobalPlayback();
64 | }
65 | };
66 |
67 | window.addEventListener('keydown', handleKeyPress);
68 | return () => window.removeEventListener('keydown', handleKeyPress);
69 | }, [toggleGlobalPlayback]);
70 |
71 | return {
72 | isGloballyPaused,
73 | globalPause,
74 | globalPlay,
75 | toggleGlobalPlayback,
76 | };
77 | }
78 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/lpf-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 | import { useAppStore } from '@/store/app-context';
4 | import { Slider } from '@/components/ui/slider';
5 |
6 | export function LpfNode({ id, data }: WorkflowNodeProps) {
7 | const updateNodeData = useAppStore((state) => state.updateNodeData);
8 | const lpfValue = data.lpf || '1000 1';
9 |
10 | // Extract values or set defaults
11 | const parts = lpfValue.split(' ');
12 | const frequency = parseFloat(parts[0]) || 1000;
13 | const resonance = parseFloat(parts[1]) || 1;
14 |
15 | // Handler for frequency changes
16 | const handleFrequencyChange = (value: number[]) => {
17 | const newFrequency = value[0];
18 | updateNodeData(id, { lpf: `${newFrequency} ${resonance}` });
19 | };
20 |
21 | // Handler for resonance changes
22 | const handleResonanceChange = (value: number[]) => {
23 | const newResonance = value[0];
24 | updateNodeData(id, { lpf: `${frequency} ${newResonance}` });
25 | };
26 |
27 | return (
28 |
29 |
30 | {/* Frequency control */}
31 |
32 |
33 |
34 | {frequency}Hz
35 |
36 |
44 |
45 |
46 | {/* Resonance control */}
47 |
48 |
49 |
50 | {resonance}
51 |
52 |
60 |
61 |
62 |
63 | );
64 | }
65 |
66 | LpfNode.strudelOutput = (node: AppNode, strudelString: string) => {
67 | const lpf = node.data.lpf || '1000 1';
68 | if (lpf === '1000 1') return strudelString;
69 |
70 | const lpfCall = `lpf("${lpf}")`;
71 | return strudelString ? `${strudelString}.${lpfCall}` : lpfCall;
72 | };
73 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/pan-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 | import { useAppStore } from '@/store/app-context';
4 | import { Slider } from '@/components/ui/slider';
5 |
6 | export function PanNode({ id, data }: WorkflowNodeProps) {
7 | const updateNodeData = useAppStore((state) => state.updateNodeData);
8 | const pan = data.pan ? parseFloat(data.pan) : 0.5;
9 |
10 | // Get pan description
11 | const getPanDescription = () => {
12 | if (pan < 0.3) return 'Left';
13 | if (pan > 0.7) return 'Right';
14 | return 'Center';
15 | };
16 |
17 | // Handler for pan changes
18 | const handlePanChange = (value: number[]) => {
19 | updateNodeData(id, { pan: value[0].toString() });
20 | };
21 |
22 | return (
23 |
24 |
25 | {/* Pan control */}
26 |
27 |
28 |
29 |
30 | {pan.toFixed(2)} ({getPanDescription()})
31 |
32 |
33 |
34 | {' '}
35 |
43 |
44 | L
45 | C
46 | R
47 |
48 |
49 |
50 |
51 | {/* Visual pan indicator */}
52 |
63 |
64 |
65 | );
66 | }
67 |
68 | PanNode.strudelOutput = (node: AppNode, strudelString: string) => {
69 | const pan = node.data.pan ? parseFloat(node.data.pan) : 0.5;
70 | if (pan === 0.5) return strudelString;
71 |
72 | const panCall = `pan(${pan})`;
73 | return strudelString ? `${strudelString}.${panCall}` : panCall;
74 | };
75 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Strudel Flow by xyflow
8 |
9 |
10 |
14 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
35 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
52 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/src/components/share-url-popover.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { Link, Check, Copy } from 'lucide-react';
3 | import {
4 | Popover,
5 | PopoverContent,
6 | PopoverTrigger,
7 | } from '@/components/ui/popover';
8 | import { Button } from '@/components/ui/button';
9 | import { Input } from '@/components/ui/input';
10 | import { useAppStore } from '@/store/app-context';
11 | import { useStrudelStore } from '@/store/strudel-store';
12 |
13 | import { generateShareableUrl } from '@/lib/state-serialization';
14 |
15 | export function ShareUrlPopover() {
16 | const [isCopied, setIsCopied] = useState(false);
17 | const [isPopoverOpen, setIsPopoverOpen] = useState(false);
18 |
19 | const { nodes, edges, theme, colorMode } = useAppStore((state) => state);
20 | const { cpm, bpc } = useStrudelStore((state) => state);
21 |
22 | const handleCopyUrl = async () => {
23 | try {
24 | const shareableUrl = generateShareableUrl(
25 | nodes,
26 | edges,
27 | theme,
28 | colorMode,
29 | cpm,
30 | bpc
31 | );
32 | await navigator.clipboard.writeText(shareableUrl);
33 | setIsCopied(true);
34 |
35 | // Reset the copied state after 2 seconds
36 | setTimeout(() => {
37 | setIsCopied(false);
38 | }, 2000);
39 | } catch (error) {
40 | console.error('Failed to copy URL:', error);
41 | }
42 | };
43 |
44 | const displayUrl = generateShareableUrl(
45 | nodes,
46 | edges,
47 | theme,
48 | colorMode,
49 | cpm,
50 | bpc
51 | );
52 |
53 | return (
54 |
55 |
56 |
62 |
63 |
64 |
65 |
66 |
Share Your Patterns
67 |
68 | Copy this URL to share your workflow with others.
69 |
70 |
71 |
72 |
73 | e.currentTarget.select()}
78 | />
79 |
96 |
97 |
98 |
99 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/src/components/nodes/instruments/pad-utils/modifiers.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import {
3 | Popover,
4 | PopoverContent,
5 | PopoverTrigger,
6 | } from '@/components/ui/popover';
7 |
8 | // Simple type - just track what modifier is selected
9 | export type CellState = { type: 'off' } | { type: 'modifier'; value: string }; // value like "!2", "/3", "@4", "*2"
10 |
11 | // Simple options grouped by type
12 | const MODIFIER_GROUPS = {
13 | Replicate: [
14 | { value: '!2', label: '!2' },
15 | { value: '!3', label: '!3' },
16 | { value: '!4', label: '!4' },
17 | ],
18 | Slow: [
19 | { value: '/2', label: '/2' },
20 | { value: '/3', label: '/3' },
21 | { value: '/4', label: '/4' },
22 | ],
23 | Elongate: [
24 | { value: '@2', label: '@2' },
25 | { value: '@3', label: '@3' },
26 | { value: '@4', label: '@4' },
27 | ],
28 | Speed: [
29 | { value: '*2', label: '*2' },
30 | { value: '*3', label: '*3' },
31 | { value: '*4', label: '*4' },
32 | ],
33 | };
34 |
35 | export interface ModifierDropdownProps {
36 | currentState: CellState;
37 | onModifierSelect: (modifier: CellState) => void;
38 | }
39 |
40 | export function ModifierDropdown({
41 | currentState,
42 | onModifierSelect,
43 | }: ModifierDropdownProps) {
44 | const [isOpen, setIsOpen] = useState(false);
45 |
46 | const displayText =
47 | currentState.type === 'modifier' ? currentState.value : '○';
48 |
49 | return (
50 |
51 |
52 |
61 |
62 |
63 |
64 |
75 |
76 | {Object.entries(MODIFIER_GROUPS).map(([groupName, modifiers]) => (
77 |
78 |
79 | {groupName}
80 |
81 | {modifiers.map((mod) => (
82 |
97 | ))}
98 |
99 | ))}
100 |
101 |
102 |
103 | );
104 | }
105 |
--------------------------------------------------------------------------------
/src/lib/state-serialization.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * State serialization utilities for workflow persistence
3 | */
4 | import { compressToBase64, decompressFromBase64 } from 'lz-string';
5 | import { Node, Edge, ColorMode } from '@xyflow/react';
6 |
7 | export interface SerializableState {
8 | nodes: Node[];
9 | edges: Edge[];
10 | theme: string;
11 | colorMode: ColorMode;
12 | cpm: string;
13 | bpc?: string;
14 | }
15 |
16 | /**
17 | * Serialize nodes, edges, and theme to a compressed base64 string
18 | */
19 | export function serializeState(
20 | nodes: Node[],
21 | edges: Edge[],
22 | theme: string,
23 | colorMode: ColorMode,
24 | cpm: string,
25 | bpc?: string
26 | ): string {
27 | const state: SerializableState = { nodes, edges, theme, colorMode, cpm, bpc };
28 | return compressToBase64(JSON.stringify(state));
29 | }
30 |
31 | /**
32 | * Deserialize a compressed base64 string back to nodes, edges, and theme
33 | */
34 | export function deserializeState(compressed: string): SerializableState | null {
35 | try {
36 | const jsonString = decompressFromBase64(compressed);
37 | if (!jsonString) return null;
38 |
39 | const state = JSON.parse(jsonString) as SerializableState;
40 | return state;
41 | } catch (error) {
42 | console.error('Failed to deserialize state:', error);
43 | return null;
44 | }
45 | }
46 |
47 | /**
48 | * Serialize state to a JSON string for file saving
49 | */
50 | export function serializeStateForFile(
51 | nodes: Node[],
52 | edges: Edge[],
53 | theme: string,
54 | colorMode: ColorMode,
55 | cpm: string,
56 | bpc?: string
57 | ): string {
58 | const state: SerializableState = { nodes, edges, theme, colorMode, cpm, bpc };
59 | return JSON.stringify(state, null, 2);
60 | }
61 |
62 | /**
63 | * Deserialize a JSON string from a file
64 | */
65 | export function deserializeStateFromFile(jsonString: string): SerializableState | null {
66 | try {
67 | const state = JSON.parse(jsonString) as SerializableState;
68 | return state;
69 | } catch (error) {
70 | console.error('Failed to deserialize state from file:', error);
71 | return null;
72 | }
73 | }
74 |
75 | /**
76 | * Generate a shareable URL with the current state
77 | */
78 | export function generateShareableUrl(
79 | nodes: Node[],
80 | edges: Edge[],
81 | theme: string,
82 | colorMode: ColorMode,
83 | cpm: string,
84 | bpc?: string
85 | ): string {
86 | const url = new URL(window.location.href);
87 | url.searchParams.set(
88 | 'state',
89 | serializeState(nodes, edges, theme, colorMode, cpm, bpc)
90 | );
91 | return url.toString();
92 | }
93 |
94 | /**
95 | * Load state from URL parameters
96 | */
97 | export function loadStateFromUrl(): SerializableState | null {
98 | const stateParam = new URLSearchParams(window.location.search).get('state');
99 | return stateParam ? deserializeState(stateParam) : null;
100 | }
101 |
102 | /**
103 | * Save state to a .json file
104 | */
105 | export function saveStateToFile(
106 | nodes: Node[],
107 | edges: Edge[],
108 | theme: string,
109 | colorMode: ColorMode,
110 | cpm: string,
111 | bpc?: string,
112 | filename: string = 'strudel-flow-project.json'
113 | ): void {
114 | const jsonString = serializeStateForFile(
115 | nodes,
116 | edges,
117 | theme,
118 | colorMode,
119 | cpm,
120 | bpc
121 | );
122 | const blob = new Blob([jsonString], { type: 'application/json' });
123 | const url = URL.createObjectURL(blob);
124 | const a = document.createElement('a');
125 | a.href = url;
126 | a.download = filename;
127 | document.body.appendChild(a);
128 | a.click();
129 | document.body.removeChild(a);
130 | URL.revokeObjectURL(url);
131 | }
132 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/phaser-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 | import { useAppStore } from '@/store/app-context';
4 | import { Slider } from '@/components/ui/slider';
5 |
6 | export function PhaserNode({ id, data }: WorkflowNodeProps) {
7 | const updateNodeData = useAppStore((state) => state.updateNodeData);
8 |
9 | // Extract values or set defaults
10 | const speed = data.phaser ? parseFloat(data.phaser) : 1;
11 | const depth = data.phaserdepth ? parseFloat(data.phaserdepth) : 0.5;
12 |
13 | // Handler for speed changes
14 | const handleSpeedChange = (value: number[]) => {
15 | updateNodeData(id, {
16 | phaser: value[0].toString(),
17 | phaserdepth: depth.toString(),
18 | });
19 | };
20 |
21 | // Handler for depth changes
22 | const handleDepthChange = (value: number[]) => {
23 | updateNodeData(id, {
24 | phaser: speed.toString(),
25 | phaserdepth: value[0].toString(),
26 | });
27 | };
28 |
29 | return (
30 |
31 |
32 | {/* Speed control */}
33 |
34 |
35 |
36 | {speed}x
37 |
38 |
46 |
47 |
48 | {/* Depth control */}
49 |
50 |
51 |
52 |
53 | {depth.toFixed(2)}
54 |
55 |
56 |
64 |
65 |
66 | {/* Visual phaser indicator */}
67 |
68 |
69 |
70 | {[...Array(8)].map((_, i) => (
71 |
84 | ))}
85 |
86 |
87 |
88 |
89 |
90 | );
91 | }
92 |
93 | PhaserNode.strudelOutput = (node: AppNode, strudelString: string) => {
94 | const phaser = node.data.phaser;
95 | const phaserdepth = node.data.phaserdepth;
96 |
97 | if (!phaser || !phaserdepth) return strudelString;
98 |
99 | const phaserCall = `phaser(${phaser}).phaserdepth(${phaserdepth})`;
100 | return strudelString ? `${strudelString}.${phaserCall}` : phaserCall;
101 | };
102 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/room-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 | import { useAppStore } from '@/store/app-context';
4 | import { Slider } from '@/components/ui/slider';
5 |
6 | export function RoomNode({ id, data }: WorkflowNodeProps) {
7 | const updateNodeData = useAppStore((state) => state.updateNodeData);
8 |
9 | // Extract values or set defaults
10 | const room = data.room ? parseFloat(data.room) : 0;
11 | const roomsize = data.roomsize ? parseFloat(data.roomsize) : 1;
12 | const roomfade = data.roomfade ? parseFloat(data.roomfade) : 0.5;
13 | const roomlp = data.roomlp ? parseFloat(data.roomlp) : 10000;
14 | const roomdim = data.roomdim ? parseFloat(data.roomdim) : 8000;
15 |
16 | // Handlers for each property
17 | const handleSliderChange = (key: string, value: number[]) => {
18 | updateNodeData(id, { [key]: value[0].toFixed(2) });
19 | };
20 |
21 | return (
22 |
23 |
24 |
25 |
handleSliderChange('room', value)}
28 | min={0}
29 | max={1}
30 | step={0.01}
31 | className="w-full"
32 | />
33 |
34 | Current room: {room.toFixed(2)}
35 |
36 |
37 |
38 | handleSliderChange('roomsize', value)}
41 | min={0}
42 | max={10}
43 | step={0.1}
44 | className="w-full"
45 | />
46 |
47 | Current room size: {roomsize.toFixed(1)}
48 |
49 |
50 |
51 | handleSliderChange('roomfade', value)}
54 | min={0}
55 | max={10}
56 | step={0.1}
57 | className="w-full"
58 | />
59 |
60 | Current room fade: {roomfade.toFixed(1)}
61 |
62 |
63 |
64 | handleSliderChange('roomlp', value)}
67 | min={0}
68 | max={20000}
69 | step={100}
70 | className="w-full"
71 | />
72 |
73 | Current room lowpass: {roomlp} Hz
74 |
75 |
76 |
77 | handleSliderChange('roomdim', value)}
80 | min={0}
81 | max={20000}
82 | step={100}
83 | className="w-full"
84 | />
85 |
86 | Current room dimension: {roomdim} Hz
87 |
88 |
89 |
90 | );
91 | }
92 |
93 | RoomNode.strudelOutput = (node: AppNode, strudelString: string) => {
94 | const room = node.data.room;
95 | const roomsize = node.data.roomsize;
96 | const roomfade = node.data.roomfade;
97 | const roomlp = node.data.roomlp;
98 | const roomdim = node.data.roomdim;
99 |
100 | const calls = [];
101 |
102 | if (room) calls.push(`room("${room}")`);
103 | if (roomsize) calls.push(`rsize(${roomsize})`);
104 | if (roomfade) calls.push(`rfade(${roomfade})`);
105 | if (roomlp) calls.push(`rlp(${roomlp})`);
106 | if (roomdim) calls.push(`rdim(${roomdim})`);
107 |
108 | if (calls.length === 0) return strudelString;
109 |
110 | const roomCalls = calls.join('.');
111 | return strudelString ? `${strudelString}.${roomCalls}` : roomCalls;
112 | };
113 |
--------------------------------------------------------------------------------
/src/components/nodes/workflow-node.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useState, useMemo } from 'react';
2 | import { Play, Pause, Trash, NotebookText } from 'lucide-react';
3 |
4 | import {
5 | NodeHeaderTitle,
6 | NodeHeader,
7 | NodeHeaderActions,
8 | NodeHeaderAction,
9 | NodeHeaderIcon,
10 | } from '@/components/node-header';
11 | import { WorkflowNodeData, AppNodeType } from '@/components/nodes/';
12 | import nodesConfig from '@/components/nodes/';
13 | import { useWorkflowRunner } from '@/hooks/use-workflow-runner';
14 | import { iconMapping } from '@/data/icon-mapping';
15 | import { BaseNode } from '@/components/base-node';
16 | import { useAppStore } from '@/store/app-context';
17 | import PatternPopup from '@/components/pattern-popup';
18 | import { useStrudelStore } from '@/store/strudel-store';
19 | import { BaseHandle } from '@/components/base-handle';
20 | import { Position } from '@xyflow/react';
21 | import { findConnectedComponents } from '@/lib/graph-utils';
22 |
23 | function WorkflowNode({
24 | id,
25 | data,
26 | type,
27 | children,
28 | }: {
29 | id: string;
30 | data: WorkflowNodeData;
31 | type?: AppNodeType;
32 | children?: React.ReactNode;
33 | }) {
34 | useStrudelStore((s) => s.pattern);
35 |
36 | const { runWorkflow } = useWorkflowRunner();
37 | const [show, setShow] = useState(false);
38 |
39 | const { removeNode, edges, nodes, updateNodeData } = useAppStore(
40 | (state) => state
41 | );
42 | const nodeState = useAppStore((state) => state.nodes.find((n) => n.id === id))
43 | ?.data?.state;
44 |
45 | const isPaused = nodeState === 'paused';
46 |
47 | // Determine if this node is an instrument based on its type
48 | const isInstrument = type
49 | ? nodesConfig[type]?.category === 'Instruments'
50 | : false;
51 |
52 | // Find all connected nodes for this group using findConnectedComponents
53 | const { connectedNodeIds } = useMemo(() => {
54 | const allComponents = findConnectedComponents(nodes, edges);
55 | const connectedComponent = allComponents.find((component) =>
56 | component.includes(id)
57 | ) || [id];
58 | const nodeIds = new Set(connectedComponent);
59 | return { connectedNodeIds: nodeIds };
60 | }, [nodes, edges, id]);
61 |
62 | const onPlay = useCallback(() => {
63 | connectedNodeIds.forEach((nodeId) => {
64 | updateNodeData(nodeId, { state: 'running' });
65 | });
66 | runWorkflow();
67 | }, [runWorkflow, connectedNodeIds, updateNodeData]);
68 |
69 | const onPause = useCallback(() => {
70 | // Pause this specific group
71 | connectedNodeIds.forEach((nodeId) => {
72 | updateNodeData(nodeId, { state: 'paused' });
73 | });
74 | }, [connectedNodeIds, updateNodeData]);
75 |
76 | const onDelete = useCallback(() => {
77 | removeNode(id);
78 | }, [id, removeNode]);
79 |
80 | const IconComponent = data?.icon ? iconMapping[data.icon] : undefined;
81 |
82 | return (
83 |
84 |
85 |
86 |
87 |
88 | {IconComponent ? : null}
89 |
90 | {data?.title}
91 |
92 | {isInstrument && (
93 |
98 | {isPaused ? : }
99 |
100 | )}
101 | setShow(!show)}
104 | >
105 |
106 |
107 |
112 |
113 |
114 |
115 |
116 | {children}
117 | {show && }
118 |
119 | );
120 | }
121 |
122 | export default WorkflowNode;
123 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as DialogPrimitive from '@radix-ui/react-dialog';
3 | import { XIcon } from 'lucide-react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | function Dialog({
8 | ...props
9 | }: React.ComponentProps) {
10 | return ;
11 | }
12 |
13 | function DialogTrigger({
14 | ...props
15 | }: React.ComponentProps) {
16 | return ;
17 | }
18 |
19 | function DialogPortal({
20 | ...props
21 | }: React.ComponentProps) {
22 | return ;
23 | }
24 |
25 | function DialogClose({
26 | ...props
27 | }: React.ComponentProps) {
28 | return ;
29 | }
30 |
31 | function DialogOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | );
45 | }
46 |
47 | function DialogContent({
48 | className,
49 | children,
50 | ...props
51 | }: React.ComponentProps) {
52 | return (
53 |
54 |
55 |
63 | {children}
64 |
65 |
66 | Close
67 |
68 |
69 |
70 | );
71 | }
72 |
73 | function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
74 | return (
75 |
80 | );
81 | }
82 |
83 | function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
84 | return (
85 |
93 | );
94 | }
95 |
96 | function DialogTitle({
97 | className,
98 | ...props
99 | }: React.ComponentProps) {
100 | return (
101 |
106 | );
107 | }
108 |
109 | function DialogDescription({
110 | className,
111 | ...props
112 | }: React.ComponentProps) {
113 | return (
114 |
119 | );
120 | }
121 |
122 | export {
123 | Dialog,
124 | DialogClose,
125 | DialogContent,
126 | DialogDescription,
127 | DialogFooter,
128 | DialogHeader,
129 | DialogOverlay,
130 | DialogPortal,
131 | DialogTitle,
132 | DialogTrigger,
133 | };
134 |
--------------------------------------------------------------------------------
/src/lib/strudel.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Strudel pattern generation and optimization
3 | */
4 |
5 | import { Edge } from '@xyflow/react';
6 | import { AppNode } from '@/components/nodes';
7 | import nodesConfig from '@/components/nodes';
8 | import { useStrudelStore } from '@/store/strudel-store';
9 | import { getNodeStrudelOutput } from './node-registry';
10 | import { findConnectedComponents } from './graph-utils';
11 |
12 | /**
13 | * Optimize consecutive .sound() calls by combining them recursively
14 | */
15 | function optimizeSoundCalls(strudelString: string): string {
16 | let optimized = strudelString;
17 | let previousLength = 0;
18 |
19 | // Keep applying optimization until no more changes are made
20 | while (optimized.length !== previousLength) {
21 | previousLength = optimized.length;
22 | // Replace consecutive .sound() calls with combined ones
23 | // This regex matches: .sound("something").sound("something else")
24 | optimized = optimized.replace(
25 | /\.sound\("([^"]+)"\)\.sound\("([^"]+)"\)/g,
26 | '.sound("$1 $2")'
27 | );
28 | }
29 |
30 | return optimized;
31 | }
32 | /**
33 | * Check if a node generates patterns (vs processes/modifies them)
34 | */
35 | function isSoundSource(node: AppNode): boolean {
36 | const category = nodesConfig[node.type]?.category;
37 | return category === 'Instruments';
38 | }
39 |
40 | /**
41 | * Generate complete Strudel output from nodes and edges
42 | */
43 | export function generateOutput(
44 | nodes: AppNode[],
45 | edges: Edge[],
46 | cpm?: string,
47 | bpc?: string
48 | ): string {
49 | // Use passed values or fallback to store values
50 | const currentCpm = cpm || useStrudelStore.getState().cpm;
51 | const currentBpc = bpc || useStrudelStore.getState().bpc;
52 |
53 | const nodePatterns: Record = {};
54 | for (const node of nodes) {
55 | const strudelOutput = getNodeStrudelOutput(node.type);
56 | if (!strudelOutput) continue;
57 |
58 | try {
59 | const pattern = strudelOutput(node, '');
60 | if (pattern?.trim()) {
61 | nodePatterns[node.id] = pattern;
62 | }
63 | } catch (err) {
64 | console.warn(`Error generating pattern for node ${node.type}:`, err);
65 | }
66 | }
67 |
68 | const components = findConnectedComponents(nodes, edges);
69 | const finalPatterns: { pattern: string; paused: boolean }[] = [];
70 |
71 | for (const componentNodeIds of components) {
72 | const componentNodes = componentNodeIds
73 | .map((id) => nodes.find((n) => n.id === id))
74 | .filter(Boolean) as AppNode[];
75 |
76 | const [sources, effects] = componentNodes.reduce<[AppNode[], AppNode[]]>(
77 | ([src, eff], node) => {
78 | isSoundSource(node) ? src.push(node) : eff.push(node);
79 | return [src, eff];
80 | },
81 | [[], []]
82 | );
83 |
84 | if (sources.length === 0) continue;
85 |
86 | const allSourcesPaused = sources.every(
87 | (node) => node.data.state === 'paused'
88 | );
89 | const activePatterns = (
90 | allSourcesPaused
91 | ? sources
92 | : sources.filter((node) => node.data.state !== 'paused')
93 | )
94 | .map((node) => nodePatterns[node.id])
95 | .filter(Boolean);
96 |
97 | if (activePatterns.length === 0) continue;
98 |
99 | let pattern =
100 | activePatterns.length === 1
101 | ? activePatterns[0]
102 | : `stack(${activePatterns.join(', ')})`;
103 |
104 | for (const effect of effects) {
105 | const strudelOutput = getNodeStrudelOutput(effect.type);
106 | if (strudelOutput && pattern) {
107 | pattern = strudelOutput(effect, pattern);
108 | }
109 | }
110 |
111 | if (pattern) {
112 | finalPatterns.push({
113 | pattern: optimizeSoundCalls(pattern),
114 | paused: allSourcesPaused,
115 | });
116 | }
117 | }
118 |
119 | if (finalPatterns.length === 0) return '';
120 |
121 | const result = finalPatterns
122 | .map(({ pattern, paused }) => {
123 | const line = `$: ${pattern}`;
124 | return paused ? `// ${line}` : line;
125 | })
126 | .join('\n');
127 |
128 | // Always add setcpm if there's sound (like other node outputs)
129 | if (result) {
130 | const bpm = parseInt(currentCpm) || 120;
131 | const beatsPerCycle = parseInt(currentBpc) || 4;
132 | return `setcpm(${bpm}/${beatsPerCycle})\n${result}`;
133 | }
134 |
135 | return result;
136 | }
137 |
--------------------------------------------------------------------------------
/src/data/css/theme-doom-64.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --background: oklch(0.8452 0 0);
3 | --foreground: oklch(0.2393 0 0);
4 | --card: oklch(0.7572 0 0);
5 | --card-foreground: oklch(0.2393 0 0);
6 | --popover: oklch(0.7572 0 0);
7 | --popover-foreground: oklch(0.2393 0 0);
8 | --primary: oklch(0.5016 0.1887 27.4816);
9 | --primary-foreground: oklch(1 0 0);
10 | --secondary: oklch(0.4955 0.0896 126.1858);
11 | --secondary-foreground: oklch(1 0 0);
12 | --muted: oklch(0.7826 0 0);
13 | --muted-foreground: oklch(0.4091 0 0);
14 | --accent: oklch(0.588 0.0993 245.7394);
15 | --accent-foreground: oklch(1 0 0);
16 | --destructive: oklch(0.7076 0.1975 46.4558);
17 | --destructive-foreground: oklch(0 0 0);
18 | --border: oklch(0.4313 0 0);
19 | --input: oklch(0.4313 0 0);
20 | --ring: oklch(0.5016 0.1887 27.4816);
21 | --chart-1: oklch(0.5016 0.1887 27.4816);
22 | --chart-2: oklch(0.4955 0.0896 126.1858);
23 | --chart-3: oklch(0.588 0.0993 245.7394);
24 | --chart-4: oklch(0.7076 0.1975 46.4558);
25 | --chart-5: oklch(0.5656 0.0431 40.4319);
26 | --sidebar: oklch(0.7572 0 0);
27 | --sidebar-foreground: oklch(0.2393 0 0);
28 | --sidebar-primary: oklch(0.5016 0.1887 27.4816);
29 | --sidebar-primary-foreground: oklch(1 0 0);
30 | --sidebar-accent: oklch(0.588 0.0993 245.7394);
31 | --sidebar-accent-foreground: oklch(1 0 0);
32 | --sidebar-border: oklch(0.4313 0 0);
33 | --sidebar-ring: oklch(0.5016 0.1887 27.4816);
34 | --font-sans: 'Oxanium', sans-serif;
35 | --font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
36 | --font-mono: 'Source Code Pro', monospace;
37 | --radius: 0px;
38 | --shadow-2xs: 0px 2px 4px 0px hsl(0 0% 0% / 0.2);
39 | --shadow-xs: 0px 2px 4px 0px hsl(0 0% 0% / 0.2);
40 | --shadow-sm: 0px 2px 4px 0px hsl(0 0% 0% / 0.4),
41 | 0px 1px 2px -1px hsl(0 0% 0% / 0.4);
42 | --shadow: 0px 2px 4px 0px hsl(0 0% 0% / 0.4),
43 | 0px 1px 2px -1px hsl(0 0% 0% / 0.4);
44 | --shadow-md: 0px 2px 4px 0px hsl(0 0% 0% / 0.4),
45 | 0px 2px 4px -1px hsl(0 0% 0% / 0.4);
46 | --shadow-lg: 0px 2px 4px 0px hsl(0 0% 0% / 0.4),
47 | 0px 4px 6px -1px hsl(0 0% 0% / 0.4);
48 | --shadow-xl: 0px 2px 4px 0px hsl(0 0% 0% / 0.4),
49 | 0px 8px 10px -1px hsl(0 0% 0% / 0.4);
50 | --shadow-2xl: 0px 2px 4px 0px hsl(0 0% 0% / 1);
51 | }
52 |
53 | .dark {
54 | --background: oklch(0.2178 0 0);
55 | --foreground: oklch(0.9067 0 0);
56 | --card: oklch(0.285 0 0);
57 | --card-foreground: oklch(0.9067 0 0);
58 | --popover: oklch(0.285 0 0);
59 | --popover-foreground: oklch(0.9067 0 0);
60 | --primary: oklch(0.6083 0.209 27.0276);
61 | --primary-foreground: oklch(1 0 0);
62 | --secondary: oklch(0.6423 0.1467 133.0145);
63 | --secondary-foreground: oklch(0 0 0);
64 | --muted: oklch(0.2645 0 0);
65 | --muted-foreground: oklch(0.7058 0 0);
66 | --accent: oklch(0.7482 0.1235 244.7492);
67 | --accent-foreground: oklch(0 0 0);
68 | --destructive: oklch(0.7839 0.1719 68.0943);
69 | --destructive-foreground: oklch(0 0 0);
70 | --border: oklch(0.4091 0 0);
71 | --input: oklch(0.4091 0 0);
72 | --ring: oklch(0.6083 0.209 27.0276);
73 | --chart-1: oklch(0.6083 0.209 27.0276);
74 | --chart-2: oklch(0.6423 0.1467 133.0145);
75 | --chart-3: oklch(0.7482 0.1235 244.7492);
76 | --chart-4: oklch(0.7839 0.1719 68.0943);
77 | --chart-5: oklch(0.6471 0.0334 40.7963);
78 | --sidebar: oklch(0.1913 0 0);
79 | --sidebar-foreground: oklch(0.9067 0 0);
80 | --sidebar-primary: oklch(0.6083 0.209 27.0276);
81 | --sidebar-primary-foreground: oklch(1 0 0);
82 | --sidebar-accent: oklch(0.7482 0.1235 244.7492);
83 | --sidebar-accent-foreground: oklch(0 0 0);
84 | --sidebar-border: oklch(0.4091 0 0);
85 | --sidebar-ring: oklch(0.6083 0.209 27.0276);
86 | --font-sans: 'Oxanium', sans-serif;
87 | --font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
88 | --font-mono: 'Source Code Pro', monospace;
89 | --radius: 0px;
90 | --shadow-2xs: 0px 2px 5px 0px hsl(0 0% 0% / 0.3);
91 | --shadow-xs: 0px 2px 5px 0px hsl(0 0% 0% / 0.3);
92 | --shadow-sm: 0px 2px 5px 0px hsl(0 0% 0% / 0.6),
93 | 0px 1px 2px -1px hsl(0 0% 0% / 0.6);
94 | --shadow: 0px 2px 5px 0px hsl(0 0% 0% / 0.6),
95 | 0px 1px 2px -1px hsl(0 0% 0% / 0.6);
96 | --shadow-md: 0px 2px 5px 0px hsl(0 0% 0% / 0.6),
97 | 0px 2px 4px -1px hsl(0 0% 0% / 0.6);
98 | --shadow-lg: 0px 2px 5px 0px hsl(0 0% 0% / 0.6),
99 | 0px 4px 6px -1px hsl(0 0% 0% / 0.6);
100 | --shadow-xl: 0px 2px 5px 0px hsl(0 0% 0% / 0.6),
101 | 0px 8px 10px -1px hsl(0 0% 0% / 0.6);
102 | --shadow-2xl: 0px 2px 5px 0px hsl(0 0% 0% / 1.5);
103 | }
104 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/ply-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 | import { useAppStore } from '@/store/app-context';
4 | import { Button } from '@/components/ui/button';
5 |
6 | const PLY_MULTIPLIERS = [
7 | { id: 'x2', label: '×2', multiplier: '2', description: 'Double each note' },
8 | { id: 'x3', label: '×3', multiplier: '3', description: 'Triple each note' },
9 | {
10 | id: 'x4',
11 | label: '×4',
12 | multiplier: '4',
13 | description: 'Quadruple each note',
14 | },
15 | { id: 'x5', label: '×5', multiplier: '5', description: '5 times each note' },
16 | { id: 'x8', label: '×8', multiplier: '8', description: '8 times each note' },
17 | {
18 | id: 'random',
19 | label: 'Rand',
20 | multiplier: 'rand.range(2,5)',
21 | description: 'Random 2-5x',
22 | },
23 | ];
24 |
25 | const PROBABILITY_OPTIONS = [
26 | {
27 | id: 'always',
28 | label: 'Always',
29 | probability: '1',
30 | description: 'Always apply',
31 | },
32 | {
33 | id: 'often',
34 | label: 'Often',
35 | probability: '0.8',
36 | description: '80% chance',
37 | },
38 | {
39 | id: 'sometimes',
40 | label: 'Sometimes',
41 | probability: '0.5',
42 | description: '50% chance',
43 | },
44 | {
45 | id: 'rarely',
46 | label: 'Rarely',
47 | probability: '0.2',
48 | description: '20% chance',
49 | },
50 | ];
51 |
52 | export function PlyNode({ id, data }: WorkflowNodeProps) {
53 | const updateNodeData = useAppStore((state) => state.updateNodeData);
54 |
55 | // Read current values from node.data
56 | const selectedMultiplier = data.plyMultiplierId || 'x2';
57 | const selectedProbability = data.plyProbabilityId || 'always';
58 |
59 | const handleMultiplierChange = (multiplierId: string) => {
60 | const multiplierData = PLY_MULTIPLIERS.find((m) => m.id === multiplierId);
61 | if (multiplierData) {
62 | updateNodeData(id, {
63 | plyMultiplierId: multiplierId,
64 | plyMultiplier: multiplierData.multiplier,
65 | });
66 | }
67 | };
68 |
69 | const handleProbabilityChange = (probabilityId: string) => {
70 | const probabilityData = PROBABILITY_OPTIONS.find(
71 | (p) => p.id === probabilityId
72 | );
73 | if (probabilityData) {
74 | updateNodeData(id, {
75 | plyProbabilityId: probabilityId,
76 | plyProbability: probabilityData.probability,
77 | });
78 | }
79 | };
80 |
81 | return (
82 |
83 |
84 |
85 |
86 |
87 | {PLY_MULTIPLIERS.map((multiplier) => (
88 |
99 | ))}
100 |
101 |
102 |
103 |
104 |
105 |
106 | {PROBABILITY_OPTIONS.map((prob) => (
107 |
118 | ))}
119 |
120 |
121 |
122 |
123 | );
124 | }
125 |
126 | PlyNode.strudelOutput = (node: AppNode, strudelString: string) => {
127 | const multiplier = node.data.plyMultiplier;
128 | const probability = node.data.plyProbability;
129 |
130 | if (!multiplier) return strudelString;
131 |
132 | let plyCall = `.ply(${multiplier})`;
133 | if (probability && probability !== '1') {
134 | plyCall = `.sometimes(${probability}, x => x${plyCall})`;
135 | }
136 | return strudelString + plyCall;
137 | };
138 |
--------------------------------------------------------------------------------
/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as SheetPrimitive from '@radix-ui/react-dialog';
3 | import { XIcon } from 'lucide-react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | function Sheet({ ...props }: React.ComponentProps) {
8 | return ;
9 | }
10 |
11 | function SheetTrigger({
12 | ...props
13 | }: React.ComponentProps) {
14 | return ;
15 | }
16 |
17 | function SheetClose({
18 | ...props
19 | }: React.ComponentProps) {
20 | return ;
21 | }
22 |
23 | function SheetPortal({
24 | ...props
25 | }: React.ComponentProps) {
26 | return ;
27 | }
28 |
29 | function SheetOverlay({
30 | className,
31 | ...props
32 | }: React.ComponentProps) {
33 | return (
34 |
42 | );
43 | }
44 |
45 | function SheetContent({
46 | className,
47 | children,
48 | side = 'right',
49 | ...props
50 | }: React.ComponentProps & {
51 | side?: 'top' | 'right' | 'bottom' | 'left';
52 | }) {
53 | return (
54 |
55 |
56 |
72 | {children}
73 |
74 |
75 | Close
76 |
77 |
78 |
79 | );
80 | }
81 |
82 | function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
83 | return (
84 |
89 | );
90 | }
91 |
92 | function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
93 | return (
94 |
99 | );
100 | }
101 |
102 | function SheetTitle({
103 | className,
104 | ...props
105 | }: React.ComponentProps) {
106 | return (
107 |
112 | );
113 | }
114 |
115 | function SheetDescription({
116 | className,
117 | ...props
118 | }: React.ComponentProps) {
119 | return (
120 |
125 | );
126 | }
127 |
128 | export {
129 | Sheet,
130 | SheetTrigger,
131 | SheetClose,
132 | SheetContent,
133 | SheetHeader,
134 | SheetFooter,
135 | SheetTitle,
136 | SheetDescription,
137 | };
138 |
--------------------------------------------------------------------------------
/src/components/nodes/instruments/custom-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 | import { useAppStore } from '@/store/app-context';
4 | import {
5 | Accordion,
6 | AccordionContent,
7 | AccordionItem,
8 | AccordionTrigger,
9 | } from '@/components/ui/accordion';
10 | import { Textarea } from '@/components/ui/textarea';
11 |
12 | export function CustomNode({ id, data, type }: WorkflowNodeProps) {
13 | const updateNodeData = useAppStore((state) => state.updateNodeData);
14 |
15 | // Use node data directly with defaults
16 | const customPattern = data.customPattern || 'sound("bd sd hh sd")';
17 |
18 | const handlePatternChange = (
19 | event: React.ChangeEvent
20 | ) => {
21 | updateNodeData(id, { customPattern: event.target.value });
22 | };
23 |
24 | return (
25 |
26 |
27 |
28 |
31 |
38 |
39 |
40 |
41 |
42 | Examples & Help
43 |
44 |
45 |
46 |
47 |
Examples:
48 |
49 |
50 | • sound("bd sd hh sd")
51 |
52 |
53 | • n("0 2 4 2").scale("C4:major")
54 |
55 |
56 | • sound("bd*2 sd").gain(0.8)
57 |
58 |
59 | • note("c3 eb3 g3").slow(2)
60 |
61 |
62 |
63 |
64 |
Tips:
65 |
66 |
67 | • Use sound() for samples
68 |
69 |
70 | • Use n().scale() for melodies
71 |
72 |
73 | • Use note() for specific notes
74 |
75 |
76 | • Chain effects: .gain().lpf()
77 |
78 |
79 |
80 |
81 |
Learn More:
82 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 | );
102 | }
103 |
104 | CustomNode.strudelOutput = (node: AppNode, strudelString: string) => {
105 | const customPattern = node.data.customPattern;
106 |
107 | if (!customPattern || !customPattern.trim()) return strudelString;
108 |
109 | const pattern = customPattern.trim();
110 | return strudelString ? `${strudelString}.stack(${pattern})` : pattern;
111 | };
112 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/mask-node.tsx:
--------------------------------------------------------------------------------
1 | import { useAppStore } from '@/store/app-context';
2 | import WorkflowNode from '@/components/nodes/workflow-node';
3 | import { WorkflowNodeProps, AppNode } from '..';
4 | import { Button } from '@/components/ui/button';
5 |
6 | const MASK_PATTERNS = [
7 | {
8 | id: 'quarter',
9 | label: '1/4',
10 | pattern: '1 0 0 0',
11 | description: 'Every 4th step',
12 | },
13 | { id: 'half', label: '1/2', pattern: '1 0', description: 'Every 2nd step' },
14 | {
15 | id: 'alternate',
16 | label: 'Alt',
17 | pattern: '1 0 1 0',
18 | description: 'Alternating',
19 | },
20 | {
21 | id: 'syncopated',
22 | label: 'Sync',
23 | pattern: '0 1 0 1',
24 | description: 'Off-beat',
25 | },
26 | {
27 | id: 'triplet',
28 | label: '3/4',
29 | pattern: '1 1 1 0',
30 | description: 'Triplet feel',
31 | },
32 | {
33 | id: 'complex',
34 | label: 'Comp',
35 | pattern: '1 0 1 1 0 0 1 0',
36 | description: 'Complex pattern',
37 | },
38 | ];
39 |
40 | const PROBABILITY_OPTIONS = [
41 | {
42 | id: 'always',
43 | label: 'Always',
44 | probability: '1',
45 | description: 'Always apply',
46 | },
47 | {
48 | id: 'often',
49 | label: 'Often',
50 | probability: '0.8',
51 | description: '80% chance',
52 | },
53 | {
54 | id: 'sometimes',
55 | label: 'Sometimes',
56 | probability: '0.5',
57 | description: '50% chance',
58 | },
59 | {
60 | id: 'rarely',
61 | label: 'Rarely',
62 | probability: '0.2',
63 | description: '20% chance',
64 | },
65 | ];
66 |
67 | export function MaskNode({ id, data }: WorkflowNodeProps) {
68 | const updateNodeData = useAppStore((state) => state.updateNodeData);
69 |
70 | // Read current values from node.data
71 | const selectedPattern = data.maskPatternId || 'half';
72 | const selectedProbability = data.maskProbabilityId || 'always';
73 |
74 | const handlePatternChange = (patternId: string) => {
75 | const patternData = MASK_PATTERNS.find((p) => p.id === patternId);
76 | if (patternData) {
77 | updateNodeData(id, {
78 | maskPatternId: patternId,
79 | maskPattern: patternData.pattern,
80 | });
81 | }
82 | };
83 |
84 | const handleProbabilityChange = (probabilityId: string) => {
85 | const probabilityData = PROBABILITY_OPTIONS.find(
86 | (p) => p.id === probabilityId
87 | );
88 | if (probabilityData) {
89 | updateNodeData(id, {
90 | maskProbabilityId: probabilityId,
91 | maskProbability: probabilityData.probability,
92 | });
93 | }
94 | };
95 |
96 | return (
97 |
98 |
99 |
100 |
101 |
102 | {MASK_PATTERNS.map((pattern) => (
103 |
112 | ))}
113 |
114 |
115 |
116 |
117 |
118 |
119 | {PROBABILITY_OPTIONS.map((prob) => (
120 |
131 | ))}
132 |
133 |
134 |
135 |
136 | );
137 | }
138 |
139 | MaskNode.strudelOutput = (node: AppNode, strudelString: string) => {
140 | const pattern = node.data.maskPattern;
141 | const probability = node.data.maskProbability;
142 |
143 | if (!pattern) return strudelString;
144 |
145 | let maskCall = `.mask("${pattern}")`;
146 | if (probability && probability !== '1') {
147 | maskCall = `.sometimes(${probability}, x => x${maskCall})`;
148 | }
149 | return strudelString + maskCall;
150 | };
151 |
--------------------------------------------------------------------------------
/src/components/workflow/controls.tsx:
--------------------------------------------------------------------------------
1 | import { ZoomSlider } from '@/components/zoom-slider';
2 | import { Panel } from '@xyflow/react';
3 | import { useState } from 'react';
4 | import { NotebookText, Timer, Play, Pause, Menu, X } from 'lucide-react';
5 | import { PatternPanel } from '@/components/pattern-panel';
6 | import { useGlobalPlayback } from '@/hooks/use-global-playback';
7 | import { CPM } from '@/components/cpm';
8 | import { ShareUrlPopover } from '@/components/share-url-popover';
9 | import { PresetPopover } from '@/components/preset-popover';
10 | import { AppInfoPopover } from '@/components/app-info-popover';
11 | import { useIsMobile } from '@/hooks/use-mobile';
12 |
13 | function PlayPauseButton() {
14 | const { isGloballyPaused, toggleGlobalPlayback } = useGlobalPlayback();
15 |
16 | return (
17 |
32 | );
33 | }
34 |
35 | function PatternPanelButton({ onToggle }: { onToggle: () => void }) {
36 | return (
37 |
44 | );
45 | }
46 |
47 | function CPMPanelButton({ onToggle }: { onToggle: () => void }) {
48 | return (
49 |
56 | );
57 | }
58 |
59 | export function WorkflowControls() {
60 | const [isPatternPanelVisible, setPatternPanelVisible] = useState(false);
61 | const [isCpmPanelVisible, setCpmPanelVisible] = useState(false);
62 | const [isMobileMenuOpen, setMobileMenuOpen] = useState(false);
63 | const isMobile = useIsMobile();
64 |
65 | if (isMobile) {
66 | return (
67 | <>
68 |
69 |
80 |
81 | {isMobileMenuOpen && (
82 |
83 |
84 |
85 |
setPatternPanelVisible((prev) => !prev)}
87 | />
88 |
89 | setCpmPanelVisible((prev) => !prev)}
91 | />
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 | )}
100 |
101 | {isCpmPanelVisible && }
102 |
103 |
104 |
105 |
106 |
107 | >
108 | );
109 | }
110 |
111 | return (
112 | <>
113 |
114 |
115 |
116 |
117 |
118 | setPatternPanelVisible((prev) => !prev)}
120 | />
121 |
122 | setCpmPanelVisible((prev) => !prev)} />
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 | {isCpmPanelVisible && }
131 |
132 |
133 |
134 |
135 |
136 | >
137 | );
138 | }
139 |
--------------------------------------------------------------------------------
/src/components/nodes/effects/late-node.tsx:
--------------------------------------------------------------------------------
1 | import { useAppStore } from '@/store/app-context';
2 | import WorkflowNode from '@/components/nodes/workflow-node';
3 | import { WorkflowNodeProps, AppNode } from '..';
4 | import { Button } from '@/components/ui/button';
5 |
6 | const LATE_OFFSETS = [
7 | { id: 'micro', label: '0.01s', offset: '0.01', description: 'Micro delay' },
8 | { id: 'small', label: '0.05s', offset: '0.05', description: 'Small delay' },
9 | { id: 'medium', label: '0.1s', offset: '0.1', description: 'Medium delay' },
10 | { id: 'large', label: '0.25s', offset: '0.25', description: 'Large delay' },
11 | { id: 'half', label: '0.5s', offset: '0.5', description: 'Half second' },
12 | ];
13 |
14 | const LATE_PATTERNS = [
15 | {
16 | id: 'constant',
17 | label: 'Constant',
18 | pattern: null,
19 | description: 'Fixed offset',
20 | },
21 | {
22 | id: 'alternating',
23 | label: 'Alt',
24 | pattern: '[0 {offset}]',
25 | description: 'Alternating offset',
26 | },
27 | {
28 | id: 'swing',
29 | label: 'Swing',
30 | pattern: '[0 {offset}]*2',
31 | description: 'Swing feel',
32 | },
33 | {
34 | id: 'triplet',
35 | label: 'Triplet',
36 | pattern: '[0 {offset} {offset}]',
37 | description: 'Triplet timing',
38 | },
39 | ];
40 |
41 | export function LateNode({ id, data }: WorkflowNodeProps) {
42 | const updateNodeData = useAppStore((state) => state.updateNodeData);
43 |
44 | // Read current values from node.data
45 | const selectedOffset = data.lateOffsetId || 'small';
46 | const selectedPattern = data.latePatternId || 'constant';
47 |
48 | const handleOffsetChange = (offsetId: string) => {
49 | const offsetData = LATE_OFFSETS.find((o) => o.id === offsetId);
50 | const patternData = LATE_PATTERNS.find((p) => p.id === selectedPattern);
51 |
52 | if (offsetData && patternData) {
53 | let finalPattern;
54 | if (patternData.pattern) {
55 | finalPattern = patternData.pattern.replace(
56 | /{offset}/g,
57 | offsetData.offset
58 | );
59 | } else {
60 | finalPattern = offsetData.offset;
61 | }
62 |
63 | updateNodeData(id, {
64 | lateOffsetId: offsetId,
65 | lateOffset: offsetData.offset,
66 | latePattern: finalPattern,
67 | });
68 | }
69 | };
70 |
71 | const handlePatternChange = (patternId: string) => {
72 | const offsetData = LATE_OFFSETS.find((o) => o.id === selectedOffset);
73 | const patternData = LATE_PATTERNS.find((p) => p.id === patternId);
74 |
75 | if (offsetData && patternData) {
76 | let finalPattern;
77 | if (patternData.pattern) {
78 | finalPattern = patternData.pattern.replace(
79 | /{offset}/g,
80 | offsetData.offset
81 | );
82 | } else {
83 | finalPattern = offsetData.offset;
84 | }
85 |
86 | updateNodeData(id, {
87 | latePatternId: patternId,
88 | lateOffset: offsetData.offset,
89 | latePattern: finalPattern,
90 | });
91 | }
92 | };
93 | return (
94 |
95 |
96 |
97 |
98 |
99 | {LATE_OFFSETS.map((offset) => (
100 |
109 | ))}
110 |
111 |
112 |
113 |
114 |
115 |
116 | {LATE_PATTERNS.map((pattern) => (
117 |
126 | ))}
127 |
128 |
129 |
130 |
131 | );
132 | }
133 |
134 | LateNode.strudelOutput = (node: AppNode, strudelString: string) => {
135 | const offset = node.data.lateOffset;
136 | const pattern = node.data.latePattern;
137 |
138 | if (!offset) return strudelString;
139 |
140 | const lateCall =
141 | pattern && pattern !== offset ? `.late("${pattern}")` : `.late(${offset})`;
142 |
143 | return strudelString + lateCall;
144 | };
145 |
--------------------------------------------------------------------------------
/src/components/nodes/instruments/pad-utils/button-utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Button utility functions
3 | */
4 |
5 | /**
6 | * Check if a button is part of a group
7 | */
8 | export function getButtonGroupIndex(
9 | stepIdx: number,
10 | trackIdx: number,
11 | soundGroups: Record
12 | ): number {
13 | const stepGroups = soundGroups[stepIdx] || [];
14 | return stepGroups.findIndex((group) => group.includes(trackIdx));
15 | }
16 |
17 | /**
18 | * Create groups from selected buttons in a step
19 | */
20 | export function createGroupsFromSelection(
21 | selectedButtons: Set,
22 | stepIdx: number,
23 | currentGroups: Record
24 | ): Record {
25 | const stepButtons = Array.from(selectedButtons)
26 | .filter((key) => key.startsWith(`${stepIdx}-`))
27 | .map((key) => parseInt(key.split('-')[1]))
28 | .sort((a, b) => a - b);
29 |
30 | if (stepButtons.length < 2) return currentGroups;
31 |
32 | const newGroups = { ...currentGroups };
33 | if (!newGroups[stepIdx]) newGroups[stepIdx] = [];
34 |
35 | // Only add if group doesn't already exist
36 | const exists = newGroups[stepIdx].some(
37 | (group) =>
38 | group.length === stepButtons.length &&
39 | group.every((val, i) => val === stepButtons[i])
40 | );
41 |
42 | if (!exists) {
43 | newGroups[stepIdx].push(stepButtons);
44 | }
45 |
46 | return newGroups;
47 | }
48 |
49 | export function toggleCell(
50 | stepIdx: number,
51 | noteIdx: number,
52 | grid: boolean[][],
53 | noteGroups: Record,
54 | selectedButtons: Set,
55 | updateNodeData: (nodeId: string, updates: Record) => void,
56 | setNoteGroups: (groups: Record) => void,
57 | setSelectedButtons: (buttons: Set) => void,
58 | nodeId: string,
59 | event?: React.MouseEvent
60 | ) {
61 | const buttonKey = `${stepIdx}-${noteIdx}`;
62 |
63 | if (event?.shiftKey) {
64 | const newSelected = new Set(selectedButtons);
65 | if (newSelected.has(buttonKey)) {
66 | newSelected.delete(buttonKey);
67 | } else {
68 | newSelected.add(buttonKey);
69 | }
70 |
71 | const newGroups = createGroupsFromSelection(
72 | newSelected,
73 | stepIdx,
74 | noteGroups
75 | );
76 | if (newGroups !== noteGroups) {
77 | setNoteGroups(newGroups);
78 | const clearedSelection = new Set(
79 | Array.from(newSelected).filter((key) => !key.startsWith(`${stepIdx}-`))
80 | );
81 | setSelectedButtons(clearedSelection);
82 | } else {
83 | setSelectedButtons(newSelected);
84 | }
85 | } else {
86 | const newGrid = grid.map((row) => [...row]);
87 | const wasOn = newGrid[stepIdx][noteIdx];
88 |
89 | // Check if this button is part of a group
90 | const stepGroups = noteGroups[stepIdx] || [];
91 | const groupIndex = stepGroups.findIndex((group) => group.includes(noteIdx));
92 | const isInGroup = groupIndex >= 0;
93 |
94 | // If button is in a group and currently on, clicking should turn it off and remove from group
95 | if (isInGroup && wasOn) {
96 | // Turn off the button
97 | newGrid[stepIdx][noteIdx] = false;
98 |
99 | // Remove this button from the group
100 | const updatedGroup = stepGroups[groupIndex].filter(
101 | (idx) => idx !== noteIdx
102 | );
103 | const newGroups = { ...noteGroups };
104 |
105 | if (updatedGroup.length < 2) {
106 | // If group has less than 2 members, remove the entire group
107 | newGroups[stepIdx] = stepGroups.filter((_, idx) => idx !== groupIndex);
108 | if (newGroups[stepIdx].length === 0) {
109 | delete newGroups[stepIdx];
110 | }
111 | } else {
112 | // Update the group with remaining members
113 | newGroups[stepIdx] = stepGroups.map((group, idx) =>
114 | idx === groupIndex ? updatedGroup : group
115 | );
116 | }
117 |
118 | // Update state (no more buttonModifiers)
119 | updateNodeData(nodeId, { grid: newGrid });
120 | setNoteGroups(newGroups);
121 | } else {
122 | // Normal toggle behavior for non-grouped buttons or turning on
123 | newGrid[stepIdx][noteIdx] = !wasOn;
124 | updateNodeData(nodeId, { grid: newGrid });
125 | }
126 | }
127 | }
128 |
129 | export function isButtonSelected(
130 | stepIdx: number,
131 | noteIdx: number,
132 | selectedButtons: Set
133 | ): boolean {
134 | return selectedButtons.has(`${stepIdx}-${noteIdx}`);
135 | }
136 |
137 | export const getButtonClasses = (
138 | isSelected: boolean,
139 | isInGroup: boolean,
140 | groupIndex: number,
141 | isPressed: boolean
142 | ) => {
143 | const base =
144 | 'transition-all ease-out duration-150 rounded-md text-xs font-mono select-none';
145 | if (isSelected) return `${base} bg-accent-foreground`;
146 | if (isInGroup) {
147 | const groupColors = [
148 | 'bg-chart-5',
149 | 'bg-chart-2',
150 | 'bg-chart-3',
151 | 'bg-chart-4',
152 | ];
153 | return `${base} ${groupColors[groupIndex % groupColors.length]}`;
154 | }
155 | if (isPressed) return `${base} !duration-0 bg-primary`;
156 | return `${base} bg-card-foreground/20 hover:bg-popover-foreground/50`;
157 | };
158 |
--------------------------------------------------------------------------------
/src/components/nodes/instruments/beat-machine-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 | import { useAppStore } from '@/store/app-context';
4 | import { Button } from '@/components/ui/button';
5 | import { PadButton } from './pad-utils/pad-button';
6 | import { DRUM_OPTIONS } from '@/data/sound-options';
7 | import {
8 | Select,
9 | SelectContent,
10 | SelectItem,
11 | SelectTrigger,
12 | SelectValue,
13 | } from '@/components/ui/select';
14 |
15 | interface BeatMachineRow {
16 | instrument: string;
17 | pattern: boolean[];
18 | }
19 |
20 | const patternToString = (pattern: boolean[]) => {
21 | return pattern.map((active) => (active ? '1' : '~')).join(' ');
22 | };
23 |
24 | function SequencerRow({
25 | row,
26 | rowIndex,
27 | onStepClick,
28 | onInstrumentChange,
29 | }: {
30 | row: BeatMachineRow;
31 | rowIndex: number;
32 | onStepClick: (rowIndex: number, step: number) => void;
33 | onInstrumentChange: (rowIndex: number, instrument: string) => void;
34 | }) {
35 | return (
36 |
37 |
52 |
53 | {row.pattern.map((isActive, step) => (
54 |
onStepClick(rowIndex, step)}
62 | />
63 | ))}
64 |
65 |
66 | );
67 | }
68 |
69 | export function BeatMachineNode({ id, data, type }: WorkflowNodeProps) {
70 | const updateNodeData = useAppStore((state) => state.updateNodeData);
71 |
72 | // Use node data directly with defaults
73 | const rows = data.rows || [
74 | { instrument: 'bd', pattern: Array(8).fill(false) },
75 | { instrument: 'sd', pattern: Array(8).fill(false) },
76 | { instrument: 'hh', pattern: Array(8).fill(false) },
77 | ];
78 |
79 | const toggleStep = (rowIndex: number, step: number) => {
80 | const newRows = rows.map((row, rIndex) => {
81 | if (rIndex === rowIndex) {
82 | const newPattern = row.pattern.map((val, pIndex) =>
83 | pIndex === step ? !val : val
84 | );
85 | return { ...row, pattern: newPattern };
86 | }
87 | return row;
88 | });
89 | updateNodeData(id, { rows: newRows });
90 | };
91 |
92 | const handleInstrumentChange = (rowIndex: number, instrument: string) => {
93 | const newRows = [...rows];
94 | newRows[rowIndex].instrument = instrument;
95 | updateNodeData(id, { rows: newRows });
96 | };
97 |
98 | const clearAll = () => {
99 | const newRows = rows.map((row) => ({
100 | ...row,
101 | pattern: Array(8).fill(false),
102 | }));
103 | updateNodeData(id, { rows: newRows });
104 | };
105 |
106 | return (
107 |
108 |
109 |
110 | {rows.map((row, index) => (
111 |
118 | ))}
119 |
120 |
121 |
129 |
130 |
131 |
132 | );
133 | }
134 |
135 | BeatMachineNode.strudelOutput = (node: AppNode, strudelString: string) => {
136 | const data = node.data;
137 | const rows = data.rows || [
138 | { instrument: 'bd', pattern: Array(8).fill(false) },
139 | { instrument: 'sd', pattern: Array(8).fill(false) },
140 | { instrument: 'hh', pattern: Array(8).fill(false) },
141 | ];
142 |
143 | const patterns = rows.map(
144 | (row) =>
145 | `sound("${row.instrument}").struct("${patternToString(row.pattern)}")`
146 | );
147 |
148 | const validPatterns = patterns.filter(
149 | (p) => !p.includes(Array(8).fill('~').join(''))
150 | );
151 |
152 | if (validPatterns.length === 0) {
153 | return strudelString;
154 | }
155 |
156 | const beatCall =
157 | validPatterns.length === 1
158 | ? validPatterns[0]
159 | : `stack(${validPatterns.join(', ')})`;
160 |
161 | return strudelString ? `${strudelString}.stack(${beatCall})` : beatCall;
162 | };
163 |
--------------------------------------------------------------------------------
/src/components/app-info-popover.tsx:
--------------------------------------------------------------------------------
1 | import { Info } from 'lucide-react';
2 | import {
3 | Popover,
4 | PopoverContent,
5 | PopoverTrigger,
6 | } from '@/components/ui/popover';
7 |
8 | export function AppInfoPopover() {
9 | return (
10 |
11 |
12 |
18 |
19 |
20 |
21 | {/* Header Section */}
22 |
48 |
49 | {/* Getting Started */}
50 |
51 |
52 | 🎵 Getting Started
53 |
54 |
55 | -
56 |
57 | 1.
58 |
59 | Drag nodes from the sidebar to your workspace.
60 |
61 | -
62 |
63 | 2.
64 |
65 |
66 | Connect nodes using the handles to create complex patterns.
67 |
68 |
69 | -
70 |
71 | 3.
72 |
73 | Share your patterns with the world.
74 |
75 |
76 |
77 |
78 |
79 |
80 | 🚀 Advanced Features
81 |
82 |
83 |
84 | Multi-select:{' '}
85 |
86 | Shift + click
87 | {' '}
88 | to select multiple steps.
89 |
90 |
91 | Pattern Preview: Click notebook icon in
92 | headers.
93 |
94 |
95 |
96 | Add Modifiers: Right-click buttons for repeats
97 | & speed changes.
98 |
99 |
100 | Tempo Control: Use timer icon in top controls.
101 |
102 |
103 |
104 |
105 |
146 |
147 |
148 |
149 | );
150 | }
151 |
--------------------------------------------------------------------------------
/src/store/app-store.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { subscribeWithSelector } from 'zustand/middleware';
3 | import {
4 | addEdge,
5 | applyEdgeChanges,
6 | applyNodeChanges,
7 | ColorMode,
8 | OnConnect,
9 | OnEdgesChange,
10 | OnNodeDrag,
11 | OnNodesChange,
12 | XYPosition,
13 | Edge,
14 | } from '@xyflow/react';
15 |
16 | import { AppNode, AppNodeType, createNodeByType } from '@/components/nodes';
17 | import { initialEdges, initialNodes } from '@/data/workflow-data';
18 |
19 | export type AppState = {
20 | nodes: AppNode[];
21 | edges: Edge[];
22 | colorMode: ColorMode;
23 | theme: string;
24 | draggedNodes: Map;
25 | connectionSites: Map;
26 | };
27 |
28 | /**
29 | * You can potentially connect to an already existing edge or to a free handle of a node.
30 | */
31 | export type PotentialConnection = {
32 | id: string;
33 | position: XYPosition;
34 | type?: 'source' | 'target';
35 | source?: ConnectionHandle;
36 | target?: ConnectionHandle;
37 | };
38 | export type ConnectionHandle = {
39 | node: string;
40 | handle?: string | null;
41 | };
42 |
43 | export type AppActions = {
44 | toggleDarkMode: () => void;
45 | setColorMode: (colorMode: ColorMode) => void;
46 | onNodesChange: OnNodesChange;
47 | setNodes: (nodes: AppNode[]) => void;
48 | addNode: (node: AppNode) => void;
49 | removeNode: (nodeId: string) => void;
50 | addNodeByType: (type: AppNodeType, position: XYPosition) => null | string;
51 | updateNodeData: (nodeId: string, updates: Record) => void;
52 | getNodes: () => AppNode[];
53 | setEdges: (edges: Edge[]) => void;
54 | getEdges: () => Edge[];
55 | addEdge: (edge: Edge) => void;
56 | removeEdge: (edgeId: string) => void;
57 | onConnect: OnConnect;
58 | setTheme: (theme: string) => void;
59 | onEdgesChange: OnEdgesChange;
60 | onNodeDragStart: OnNodeDrag;
61 | onNodeDragStop: OnNodeDrag;
62 | };
63 |
64 | export type AppStore = AppState & AppActions;
65 |
66 | export const defaultState: AppState = {
67 | nodes: initialNodes,
68 | edges: initialEdges,
69 | colorMode: 'light',
70 | theme: 'supabase',
71 | draggedNodes: new Map(),
72 | connectionSites: new Map(),
73 | };
74 |
75 | export const createAppStore = (initialState: AppState = defaultState) => {
76 | const store = create()(
77 | subscribeWithSelector((set, get) => ({
78 | ...initialState,
79 |
80 | onNodesChange: async (changes) => {
81 | const nextNodes = applyNodeChanges(changes, get().nodes);
82 | set({ nodes: nextNodes });
83 | },
84 |
85 | setNodes: (nodes) => set({ nodes }),
86 |
87 | addNode: (node) => {
88 | const nextNodes = [...get().nodes, node];
89 | set({ nodes: nextNodes });
90 | },
91 |
92 | removeNode: (nodeId) =>
93 | set({ nodes: get().nodes.filter((node) => node.id !== nodeId) }),
94 |
95 | addNodeByType: (type, position) => {
96 | const newNode = createNodeByType({ type, position });
97 |
98 | if (!newNode) return null;
99 |
100 | get().addNode(newNode);
101 |
102 | return newNode.id;
103 | },
104 | getNodes: () => get().nodes,
105 |
106 | setEdges: (edges) => set({ edges }),
107 |
108 | getEdges: () => get().edges,
109 |
110 | addEdge: (edge) => {
111 | const nextEdges = addEdge(edge, get().edges);
112 | set({ edges: nextEdges });
113 | },
114 |
115 | removeEdge: (edgeId) => {
116 | set({ edges: get().edges.filter((edge) => edge.id !== edgeId) });
117 | },
118 |
119 | onEdgesChange: (changes) => {
120 | const nextEdges = applyEdgeChanges(changes, get().edges);
121 | set({ edges: nextEdges });
122 | },
123 |
124 | onConnect: (connection) => {
125 | // Prevent self-connecting nodes
126 | if (connection.source === connection.target) {
127 | return;
128 | }
129 | // Only include handles if they are not null/undefined
130 | const { source, target, sourceHandle, targetHandle } = connection;
131 | const newEdge: Edge = {
132 | id: `${source}-${target}`,
133 | source,
134 | target,
135 | type: 'default',
136 | ...(sourceHandle ? { sourceHandle } : {}),
137 | ...(targetHandle ? { targetHandle } : {}),
138 | };
139 | get().addEdge(newEdge);
140 | },
141 | setTheme: (theme) => set({ theme }),
142 | toggleDarkMode: () =>
143 | set((state) => ({
144 | colorMode: state.colorMode === 'dark' ? 'light' : 'dark',
145 | })),
146 | setColorMode: (colorMode) => set({ colorMode }),
147 |
148 | onNodeDragStart: (_, __, nodes) => {
149 | set({ draggedNodes: new Map(nodes.map((node) => [node.id, node])) });
150 | },
151 | onNodeDragStop: () => {
152 | set({ draggedNodes: new Map() });
153 | },
154 | updateNodeData: (nodeId, updates) => {
155 | set((state) => {
156 | const updatedNodes = state.nodes.map((node) =>
157 | node.id === nodeId
158 | ? { ...node, data: { ...node.data, ...updates } }
159 | : node
160 | );
161 |
162 | return { nodes: updatedNodes };
163 | });
164 | },
165 | }))
166 | );
167 |
168 | store.subscribe(
169 | (state) => state.colorMode,
170 | async (colorMode: ColorMode) => {
171 | document
172 | .querySelector('html')
173 | ?.classList.toggle('dark', colorMode === 'dark');
174 | }
175 | );
176 |
177 | return store;
178 | };
179 |
--------------------------------------------------------------------------------
/src/hooks/use-workflow-runner.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useCallback, useMemo } from 'react';
2 | import { useStrudelStore } from '@/store/strudel-store';
3 | import { useAppStore } from '@/store/app-context';
4 | import { generateOutput } from '@/lib/strudel';
5 | // @ts-expect-error - Missing type declarations for @strudel/web
6 | import { evaluate, hush } from '@strudel/web';
7 |
8 | export function useWorkflowRunner() {
9 | const isRunning = useRef(false);
10 | const lastEvaluatedPattern = useRef('');
11 | const debounceTimerId = useRef(null);
12 | const pattern = useStrudelStore((s) => s.pattern);
13 | const setPattern = useStrudelStore((s) => s.setPattern);
14 | const cpm = useStrudelStore((s) => s.cpm);
15 | const bpc = useStrudelStore((s) => s.bpc);
16 |
17 | const nodes = useAppStore((state) => state.nodes);
18 | const edges = useAppStore((state) => state.edges);
19 |
20 | // Memoize pattern generation to avoid unnecessary recalculations
21 | // Include cpm and bpc as dependencies so pattern regenerates when tempo changes
22 | const generatedPattern = useMemo(() => {
23 | return generateOutput(nodes, edges, cpm, bpc);
24 | }, [nodes, edges, cpm, bpc]);
25 | // Update pattern when graph changes
26 | useEffect(() => {
27 | setPattern(generatedPattern);
28 | }, [generatedPattern, setPattern]);
29 |
30 | const getActivePattern = useCallback((p: string) => {
31 | return p
32 | .split('\n')
33 | .filter((line) => !line.trim().startsWith('//'))
34 | .join('\n');
35 | }, []);
36 |
37 | // Smart pattern comparison - only evaluate if pattern actually changed
38 | const shouldEvaluatePattern = useCallback(
39 | (newPattern: string) => {
40 | const activePattern = getActivePattern(newPattern);
41 | const hasContent = activePattern
42 | .replace(/setcpm\([^)]+\)\s*/g, '')
43 | .trim();
44 |
45 | // Don't evaluate if no content
46 | if (!hasContent) return false;
47 |
48 | // Don't evaluate if pattern hasn't changed
49 | if (activePattern === lastEvaluatedPattern.current) return false;
50 |
51 | return true;
52 | },
53 | [getActivePattern]
54 | );
55 |
56 | const evaluatePattern = useCallback(
57 | (patternToEvaluate: string) => {
58 | const activePattern = getActivePattern(patternToEvaluate);
59 | const hasContent = activePattern
60 | .replace(/setcpm\([^)]+\)\s*/g, '')
61 | .trim();
62 |
63 | if (!hasContent) {
64 | if (isRunning.current) {
65 | console.log('No active pattern - hushing');
66 | hush();
67 | isRunning.current = false;
68 | }
69 | lastEvaluatedPattern.current = '';
70 | return;
71 | }
72 |
73 | // Skip if pattern hasn't actually changed
74 | if (activePattern === lastEvaluatedPattern.current) {
75 | return;
76 | }
77 |
78 | console.log('Evaluating new pattern:', activePattern);
79 | isRunning.current = true;
80 | lastEvaluatedPattern.current = activePattern;
81 |
82 | try {
83 | evaluate(activePattern);
84 | } catch (err) {
85 | const errorMessage = err instanceof Error ? err.message : String(err);
86 | const isKnownWarning =
87 | errorMessage.includes('got "undefined" instead of pattern') ||
88 | errorMessage.includes('Cannot read properties of undefined');
89 |
90 | if (isKnownWarning) {
91 | console.warn('Strudel pattern warning (suppressed):', errorMessage);
92 | } else {
93 | console.error('Strudel evaluation error:', err);
94 | }
95 | }
96 | },
97 | [getActivePattern]
98 | );
99 |
100 | // Debounced evaluation to batch rapid changes
101 | const debouncedEvaluate = useCallback(
102 | (patternToEvaluate: string) => {
103 | // Clear any existing debounce timer
104 | if (debounceTimerId.current !== null) {
105 | window.clearTimeout(debounceTimerId.current);
106 | }
107 |
108 | // For immediate changes (tempo, key changes), evaluate right away
109 | const isImmediateChange =
110 | patternToEvaluate.includes('setcpm(') ||
111 | patternToEvaluate.includes('scale(');
112 |
113 | if (isImmediateChange) {
114 | evaluatePattern(patternToEvaluate);
115 | return;
116 | }
117 |
118 | // For other changes, debounce to batch rapid UI interactions
119 | debounceTimerId.current = window.setTimeout(() => {
120 | evaluatePattern(patternToEvaluate);
121 | debounceTimerId.current = null;
122 | }, 50); // 50ms debounce for UI interactions
123 | },
124 | [evaluatePattern]
125 | );
126 |
127 | // Event-driven pattern evaluation - only when pattern actually changes
128 | useEffect(() => {
129 | if (!pattern || !pattern.trim()) {
130 | // Clear timers and hush if pattern is empty
131 | if (debounceTimerId.current !== null) {
132 | window.clearTimeout(debounceTimerId.current);
133 | debounceTimerId.current = null;
134 | }
135 | if (isRunning.current) {
136 | hush();
137 | isRunning.current = false;
138 | }
139 | lastEvaluatedPattern.current = '';
140 | return;
141 | }
142 |
143 | // Only evaluate if the pattern should actually change
144 | if (shouldEvaluatePattern(pattern)) {
145 | debouncedEvaluate(pattern);
146 | }
147 | }, [pattern, shouldEvaluatePattern, debouncedEvaluate]);
148 |
149 | return {
150 | runWorkflow: () => debouncedEvaluate(pattern),
151 | stopWorkflow: () => {
152 | console.log('Stopping workflow...');
153 | if (debounceTimerId.current !== null) {
154 | window.clearTimeout(debounceTimerId.current);
155 | debounceTimerId.current = null;
156 | }
157 | isRunning.current = false;
158 | lastEvaluatedPattern.current = '';
159 | hush();
160 | },
161 | isRunning: () => isRunning.current,
162 | };
163 | }
164 |
--------------------------------------------------------------------------------
/src/components/node-header.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | forwardRef,
3 | useCallback,
4 | HTMLAttributes,
5 | ReactNode,
6 | ComponentProps,
7 | } from 'react';
8 | import { useNodeId, useReactFlow } from '@xyflow/react';
9 | import { EllipsisVertical, Trash } from 'lucide-react';
10 |
11 | import { cn } from '@/lib/utils';
12 | import { Slot } from '@radix-ui/react-slot';
13 | import { Button } from '@/components/ui/button';
14 | import {
15 | DropdownMenu,
16 | DropdownMenuTrigger,
17 | DropdownMenuContent,
18 | } from '@/components/ui/dropdown-menu';
19 |
20 | /* NODE HEADER -------------------------------------------------------------- */
21 |
22 | export type NodeHeaderProps = HTMLAttributes;
23 |
24 | type ButtonProps = ComponentProps;
25 |
26 | /**
27 | * A container for a consistent header layout intended to be used inside the
28 | * `` component.
29 | */
30 | export const NodeHeader = forwardRef(
31 | ({ className, ...props }, ref) => {
32 | return (
33 | ` component.
40 | className
41 | )}
42 | />
43 | );
44 | }
45 | );
46 |
47 | NodeHeader.displayName = 'NodeHeader';
48 |
49 | /* NODE HEADER TITLE -------------------------------------------------------- */
50 |
51 | export type NodeHeaderTitleProps = HTMLAttributes & {
52 | asChild?: boolean;
53 | };
54 |
55 | /**
56 | * The title text for the node. To maintain a native application feel, the title
57 | * text is not selectable.
58 | */
59 | export const NodeHeaderTitle = forwardRef<
60 | HTMLHeadingElement,
61 | NodeHeaderTitleProps
62 | >(({ className, asChild, ...props }, ref) => {
63 | const Comp = asChild ? Slot : 'h3';
64 |
65 | return (
66 |
71 | );
72 | });
73 |
74 | NodeHeaderTitle.displayName = 'NodeHeaderTitle';
75 |
76 | /* NODE HEADER ICON --------------------------------------------------------- */
77 |
78 | export type NodeHeaderIconProps = HTMLAttributes;
79 |
80 | export const NodeHeaderIcon = forwardRef(
81 | ({ className, ...props }, ref) => {
82 | return (
83 | *]:size-5')} />
84 | );
85 | }
86 | );
87 |
88 | NodeHeaderIcon.displayName = 'NodeHeaderIcon';
89 |
90 | /* NODE HEADER ACTIONS ------------------------------------------------------ */
91 |
92 | export type NodeHeaderActionsProps = HTMLAttributes;
93 |
94 | /**
95 | * A container for right-aligned action buttons in the node header.
96 | */
97 | export const NodeHeaderActions = forwardRef<
98 | HTMLDivElement,
99 | NodeHeaderActionsProps
100 | >(({ className, ...props }, ref) => {
101 | return (
102 |
110 | );
111 | });
112 |
113 | NodeHeaderActions.displayName = 'NodeHeaderActions';
114 |
115 | /* NODE HEADER ACTION ------------------------------------------------------- */
116 |
117 | export type NodeHeaderActionProps = ButtonProps & {
118 | label: string;
119 | };
120 |
121 | /**
122 | * A thin wrapper around the `` component with a fixed sized suitable
123 | * for icons.
124 | *
125 | * Beacuse the `` component is intended to render icons, it's
126 | * important to provide a meaningful and accessible `label` prop that describes
127 | * the action.
128 | */
129 | export const NodeHeaderAction = forwardRef<
130 | HTMLButtonElement,
131 | NodeHeaderActionProps
132 | >(({ className, label, title, ...props }, ref) => {
133 | return (
134 |
142 | );
143 | });
144 |
145 | NodeHeaderAction.displayName = 'NodeHeaderAction';
146 |
147 | //
148 |
149 | export type NodeHeaderMenuActionProps = Omit<
150 | NodeHeaderActionProps,
151 | 'onClick'
152 | > & {
153 | trigger?: ReactNode;
154 | };
155 |
156 | /**
157 | * Renders a header action that opens a dropdown menu when clicked. The dropdown
158 | * trigger is a button with an ellipsis icon. The trigger's content can be changed
159 | * by using the `trigger` prop.
160 | *
161 | * Any children passed to the `` component will be rendered
162 | * inside the dropdown menu. You can read the docs for the shadcn dropdown menu
163 | * here: https://ui.shadcn.com/docs/components/dropdown-menu
164 | *
165 | */
166 | export const NodeHeaderMenuAction = forwardRef<
167 | HTMLButtonElement,
168 | NodeHeaderMenuActionProps
169 | >(({ trigger, children, ...props }, ref) => {
170 | return (
171 |
172 |
173 |
174 | {trigger ?? }
175 |
176 |
177 | {children}
178 |
179 | );
180 | });
181 |
182 | NodeHeaderMenuAction.displayName = 'NodeHeaderMenuAction';
183 |
184 | /* NODE HEADER DELETE ACTION --------------------------------------- */
185 |
186 | export const NodeHeaderDeleteAction = () => {
187 | const id = useNodeId();
188 | const { setNodes } = useReactFlow();
189 |
190 | const handleClick = useCallback(() => {
191 | setNodes((prevNodes) => prevNodes.filter((node) => node.id !== id));
192 | }, [id, setNodes]);
193 |
194 | return (
195 |
196 |
197 |
198 | );
199 | };
200 |
201 | NodeHeaderDeleteAction.displayName = 'NodeHeaderDeleteAction';
202 |
--------------------------------------------------------------------------------
/src/components/nodes/instruments/arpeggiator-node.tsx:
--------------------------------------------------------------------------------
1 | import WorkflowNode from '@/components/nodes/workflow-node';
2 | import { WorkflowNodeProps, AppNode } from '..';
3 | import { useAppStore } from '@/store/app-context';
4 | import { AccordionControls } from '@/components/accordion-controls';
5 | import { cn } from '@/lib/utils';
6 | import { Button } from '@/components/ui/button';
7 |
8 | const ARP_PATTERNS = [
9 | { id: 'up', label: 'Up', pattern: [0, 1, 2] },
10 | { id: 'down', label: 'Down', pattern: [2, 1, 0] },
11 | { id: 'up-down', label: 'Up-Down', pattern: [0, 1, 2, 1] },
12 | { id: 'down-up', label: 'Down-Up', pattern: [2, 1, 0, 1] },
13 | { id: 'inside-out', label: 'Inside-Out', pattern: [1, 0, 2] },
14 | { id: 'outside-in', label: 'Outside-In', pattern: [0, 2, 1] },
15 | ];
16 |
17 | const OCTAVE_RANGES = [
18 | { octaves: 1, label: '1' },
19 | { octaves: 2, label: '2' },
20 | { octaves: 3, label: '3' },
21 | { octaves: 4, label: '4' },
22 | ];
23 |
24 | const expandPatternAcrossOctaves = (
25 | basePattern: number[],
26 | octaves: number
27 | ): string => {
28 | if (octaves === 1) {
29 | return basePattern.join(' ');
30 | }
31 | const expandedPattern: number[] = [];
32 | for (let octave = 0; octave < octaves; octave++) {
33 | const octaveOffset = octave * 7;
34 | basePattern.forEach((note) => {
35 | expandedPattern.push(note + octaveOffset);
36 | });
37 | }
38 | return expandedPattern.join(' ');
39 | };
40 |
41 | function ArpeggioVisualizer({
42 | pattern,
43 | isActive,
44 | }: {
45 | pattern: number[];
46 | isActive: boolean;
47 | }) {
48 | const numRows = 3;
49 | const numCols = pattern.length;
50 |
51 | return (
52 |
53 |
54 | {Array.from({ length: numCols }).map((_, colIndex) => (
55 |
56 | {Array.from({ length: numRows }).map((_, rowIndex) => {
57 | const noteInPattern = pattern[colIndex];
58 | const isSet = noteInPattern === rowIndex;
59 | return (
60 |
71 | );
72 | })}
73 |
74 | ))}
75 |
76 |
77 | );
78 | }
79 |
80 | export function ArpeggiatorNode({ id, data, type }: WorkflowNodeProps) {
81 | const updateNodeData = useAppStore((state) => state.updateNodeData);
82 |
83 | // Use node data directly with defaults
84 | const selectedPattern = data.selectedPattern || '';
85 | const octaveRange = data.octaveRange || 1;
86 | const octave = data.octave || 4;
87 | const selectedChordType = data.selectedChordType || 'major';
88 | const selectedKey = data.selectedKey || 'C';
89 |
90 | return (
91 |
92 |
93 |
94 | {ARP_PATTERNS.map((p) => (
95 |
updateNodeData(id, { selectedPattern: p.id })}
99 | >
100 |
104 |
{p.label}
105 |
106 | ))}
107 |
108 |
updateNodeData(id, { selectedKey: key }),
112 | selectedScale: selectedChordType,
113 | onScaleChange: (scale) =>
114 | updateNodeData(id, { selectedChordType: scale }),
115 | octave,
116 | onOctaveChange: (oct) => updateNodeData(id, { octave: oct }),
117 | }}
118 | >
119 |
120 |
123 |
124 | {OCTAVE_RANGES.map((preset) => (
125 |
138 | ))}
139 |
140 |
141 |
142 |
143 |
144 | );
145 | }
146 |
147 | ArpeggiatorNode.strudelOutput = (node: AppNode, strudelString: string) => {
148 | const data = node.data;
149 | const selectedPattern = data.selectedPattern || '';
150 | const octaveRange = data.octaveRange || 1;
151 | const octave = data.octave || 4;
152 | const selectedChordType = data.selectedChordType || 'major';
153 | const selectedKey = data.selectedKey || 'C';
154 |
155 | if (!selectedPattern) return strudelString;
156 |
157 | const patternData = ARP_PATTERNS.find((p) => p.id === selectedPattern);
158 | if (!patternData) return strudelString;
159 |
160 | const finalPattern = expandPatternAcrossOctaves(
161 | patternData.pattern,
162 | octaveRange
163 | );
164 | const arpCall = `n("${finalPattern}").scale("${selectedKey}${octave}:${selectedChordType}")`;
165 |
166 | return strudelString ? `${strudelString}.stack(${arpCall})` : arpCall;
167 | };
168 |
--------------------------------------------------------------------------------
/src/data/css/theme-mono.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --background: oklch(1 0 0);
3 | --foreground: oklch(0.1448 0 0);
4 | --card: oklch(1 0 0);
5 | --card-foreground: oklch(0.1448 0 0);
6 | --popover: oklch(1 0 0);
7 | --popover-foreground: oklch(0.1448 0 0);
8 | --primary: oklch(0.5555 0 0);
9 | --primary-foreground: oklch(0.9851 0 0);
10 | --secondary: oklch(0.9702 0 0);
11 | --secondary-foreground: oklch(0.2046 0 0);
12 | --muted: oklch(0.9702 0 0);
13 | --muted-foreground: oklch(0.5486 0 0);
14 | --accent: oklch(0.9702 0 0);
15 | --accent-foreground: oklch(0.2046 0 0);
16 | --destructive: oklch(0.583 0.2387 28.4765);
17 | --destructive-foreground: oklch(0.9702 0 0);
18 | --border: oklch(0.9219 0 0);
19 | --input: oklch(0.9219 0 0);
20 | --ring: oklch(0.709 0 0);
21 | --chart-1: oklch(0.5555 0 0);
22 | --chart-2: oklch(0.5555 0 0);
23 | --chart-3: oklch(0.5555 0 0);
24 | --chart-4: oklch(0.5555 0 0);
25 | --chart-5: oklch(0.5555 0 0);
26 | --sidebar: oklch(0.9851 0 0);
27 | --sidebar-foreground: oklch(0.1448 0 0);
28 | --sidebar-primary: oklch(0.2046 0 0);
29 | --sidebar-primary-foreground: oklch(0.9851 0 0);
30 | --sidebar-accent: oklch(0.9702 0 0);
31 | --sidebar-accent-foreground: oklch(0.2046 0 0);
32 | --sidebar-border: oklch(0.9219 0 0);
33 | --sidebar-ring: oklch(0.709 0 0);
34 | --font-sans: Geist Mono, monospace;
35 | --font-serif: Geist Mono, monospace;
36 | --font-mono: Geist Mono, monospace;
37 | --radius: 0rem;
38 | --shadow-2xs: 0px 1px 0px 0px hsl(0 0% 0% / 0);
39 | --shadow-xs: 0px 1px 0px 0px hsl(0 0% 0% / 0);
40 | --shadow-sm: 0px 1px 0px 0px hsl(0 0% 0% / 0),
41 | 0px 1px 2px -1px hsl(0 0% 0% / 0);
42 | --shadow: 0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px -1px hsl(0 0% 0% / 0);
43 | --shadow-md: 0px 1px 0px 0px hsl(0 0% 0% / 0),
44 | 0px 2px 4px -1px hsl(0 0% 0% / 0);
45 | --shadow-lg: 0px 1px 0px 0px hsl(0 0% 0% / 0),
46 | 0px 4px 6px -1px hsl(0 0% 0% / 0);
47 | --shadow-xl: 0px 1px 0px 0px hsl(0 0% 0% / 0),
48 | 0px 8px 10px -1px hsl(0 0% 0% / 0);
49 | --shadow-2xl: 0px 1px 0px 0px hsl(0 0% 0% / 0);
50 | --tracking-normal: 0em;
51 | --spacing: 0.25rem;
52 | }
53 |
54 | .dark {
55 | --background: oklch(0.1448 0 0);
56 | --foreground: oklch(0.9851 0 0);
57 | --card: oklch(0.2134 0 0);
58 | --card-foreground: oklch(0.9851 0 0);
59 | --popover: oklch(0.2686 0 0);
60 | --popover-foreground: oklch(0.9851 0 0);
61 | --primary: oklch(0.5555 0 0);
62 | --primary-foreground: oklch(0.9851 0 0);
63 | --secondary: oklch(0.2686 0 0);
64 | --secondary-foreground: oklch(0.9851 0 0);
65 | --muted: oklch(0.2686 0 0);
66 | --muted-foreground: oklch(0.709 0 0);
67 | --accent: oklch(0.3715 0 0);
68 | --accent-foreground: oklch(0.9851 0 0);
69 | --destructive: oklch(0.7022 0.1892 22.2279);
70 | --destructive-foreground: oklch(0.2686 0 0);
71 | --border: oklch(0.3407 0 0);
72 | --input: oklch(0.4386 0 0);
73 | --ring: oklch(0.5555 0 0);
74 | --chart-1: oklch(0.5555 0 0);
75 | --chart-2: oklch(0.5555 0 0);
76 | --chart-3: oklch(0.5555 0 0);
77 | --chart-4: oklch(0.5555 0 0);
78 | --chart-5: oklch(0.5555 0 0);
79 | --sidebar: oklch(0.2046 0 0);
80 | --sidebar-foreground: oklch(0.9851 0 0);
81 | --sidebar-primary: oklch(0.9851 0 0);
82 | --sidebar-primary-foreground: oklch(0.2046 0 0);
83 | --sidebar-accent: oklch(0.2686 0 0);
84 | --sidebar-accent-foreground: oklch(0.9851 0 0);
85 | --sidebar-border: oklch(1 0 0);
86 | --sidebar-ring: oklch(0.4386 0 0);
87 | --font-sans: Geist Mono, monospace;
88 | --font-serif: Geist Mono, monospace;
89 | --font-mono: Geist Mono, monospace;
90 | --radius: 0rem;
91 | --shadow-2xs: 0px 1px 0px 0px hsl(0 0% 0% / 0);
92 | --shadow-xs: 0px 1px 0px 0px hsl(0 0% 0% / 0);
93 | --shadow-sm: 0px 1px 0px 0px hsl(0 0% 0% / 0),
94 | 0px 1px 2px -1px hsl(0 0% 0% / 0);
95 | --shadow: 0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px -1px hsl(0 0% 0% / 0);
96 | --shadow-md: 0px 1px 0px 0px hsl(0 0% 0% / 0),
97 | 0px 2px 4px -1px hsl(0 0% 0% / 0);
98 | --shadow-lg: 0px 1px 0px 0px hsl(0 0% 0% / 0),
99 | 0px 4px 6px -1px hsl(0 0% 0% / 0);
100 | --shadow-xl: 0px 1px 0px 0px hsl(0 0% 0% / 0),
101 | 0px 8px 10px -1px hsl(0 0% 0% / 0);
102 | --shadow-2xl: 0px 1px 0px 0px hsl(0 0% 0% / 0);
103 | }
104 |
105 | @theme inline {
106 | --color-background: var(--background);
107 | --color-foreground: var(--foreground);
108 | --color-card: var(--card);
109 | --color-card-foreground: var(--card-foreground);
110 | --color-popover: var(--popover);
111 | --color-popover-foreground: var(--popover-foreground);
112 | --color-primary: var(--primary);
113 | --color-primary-foreground: var(--primary-foreground);
114 | --color-secondary: var(--secondary);
115 | --color-secondary-foreground: var(--secondary-foreground);
116 | --color-muted: var(--muted);
117 | --color-muted-foreground: var(--muted-foreground);
118 | --color-accent: var(--accent);
119 | --color-accent-foreground: var(--accent-foreground);
120 | --color-destructive: var(--destructive);
121 | --color-destructive-foreground: var(--destructive-foreground);
122 | --color-border: var(--border);
123 | --color-input: var(--input);
124 | --color-ring: var(--ring);
125 | --color-chart-1: var(--chart-1);
126 | --color-chart-2: var(--chart-2);
127 | --color-chart-3: var(--chart-3);
128 | --color-chart-4: var(--chart-4);
129 | --color-chart-5: var(--chart-5);
130 | --color-sidebar: var(--sidebar);
131 | --color-sidebar-foreground: var(--sidebar-foreground);
132 | --color-sidebar-primary: var(--sidebar-primary);
133 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
134 | --color-sidebar-accent: var(--sidebar-accent);
135 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
136 | --color-sidebar-border: var(--sidebar-border);
137 | --color-sidebar-ring: var(--sidebar-ring);
138 |
139 | --font-sans: var(--font-sans);
140 | --font-mono: var(--font-mono);
141 | --font-serif: var(--font-serif);
142 |
143 | --radius-sm: calc(var(--radius) - 4px);
144 | --radius-md: calc(var(--radius) - 2px);
145 | --radius-lg: var(--radius);
146 | --radius-xl: calc(var(--radius) + 4px);
147 |
148 | --shadow-2xs: var(--shadow-2xs);
149 | --shadow-xs: var(--shadow-xs);
150 | --shadow-sm: var(--shadow-sm);
151 | --shadow: var(--shadow);
152 | --shadow-md: var(--shadow-md);
153 | --shadow-lg: var(--shadow-lg);
154 | --shadow-xl: var(--shadow-xl);
155 | --shadow-2xl: var(--shadow-2xl);
156 | }
157 |
--------------------------------------------------------------------------------
/src/data/css/theme-neo-brutalism.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --background: oklch(1 0 0);
3 | --foreground: oklch(0 0 0);
4 | --card: oklch(1 0 0);
5 | --card-foreground: oklch(0 0 0);
6 | --popover: oklch(1 0 0);
7 | --popover-foreground: oklch(0 0 0);
8 | --primary: oklch(0.6489 0.237 26.9728);
9 | --primary-foreground: oklch(1 0 0);
10 | --secondary: oklch(0.968 0.211 109.7692);
11 | --secondary-foreground: oklch(0 0 0);
12 | --muted: oklch(0.9551 0 0);
13 | --muted-foreground: oklch(0.3211 0 0);
14 | --accent: oklch(0.5635 0.2408 260.8178);
15 | --accent-foreground: oklch(1 0 0);
16 | --destructive: oklch(0 0 0);
17 | --destructive-foreground: oklch(1 0 0);
18 | --border: oklch(0 0 0);
19 | --input: oklch(0 0 0);
20 | --ring: oklch(0.6489 0.237 26.9728);
21 | --chart-1: oklch(0.6489 0.237 26.9728);
22 | --chart-2: oklch(0.968 0.211 109.7692);
23 | --chart-3: oklch(0.5635 0.2408 260.8178);
24 | --chart-4: oklch(0.7323 0.2492 142.4953);
25 | --chart-5: oklch(0.5931 0.2726 328.3634);
26 | --sidebar: oklch(0.9551 0 0);
27 | --sidebar-foreground: oklch(0 0 0);
28 | --sidebar-primary: oklch(0.6489 0.237 26.9728);
29 | --sidebar-primary-foreground: oklch(1 0 0);
30 | --sidebar-accent: oklch(0.5635 0.2408 260.8178);
31 | --sidebar-accent-foreground: oklch(1 0 0);
32 | --sidebar-border: oklch(0 0 0);
33 | --sidebar-ring: oklch(0.6489 0.237 26.9728);
34 | --font-sans: DM Sans, sans-serif;
35 | --font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
36 | --font-mono: Space Mono, monospace;
37 | --radius: 0px;
38 | --shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.5);
39 | --shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.5);
40 | --shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1),
41 | 4px 1px 2px -1px hsl(0 0% 0% / 1);
42 | --shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1), 4px 1px 2px -1px hsl(0 0% 0% / 1);
43 | --shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1),
44 | 4px 2px 4px -1px hsl(0 0% 0% / 1);
45 | --shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1),
46 | 4px 4px 6px -1px hsl(0 0% 0% / 1);
47 | --shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1),
48 | 4px 8px 10px -1px hsl(0 0% 0% / 1);
49 | --shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.5);
50 | --tracking-normal: 0em;
51 | --spacing: 0.25rem;
52 | }
53 |
54 | .dark {
55 | --background: oklch(0 0 0);
56 | --foreground: oklch(1 0 0);
57 | --card: oklch(0.3211 0 0);
58 | --card-foreground: oklch(1 0 0);
59 | --popover: oklch(0.3211 0 0);
60 | --popover-foreground: oklch(1 0 0);
61 | --primary: oklch(0.7044 0.1872 23.1858);
62 | --primary-foreground: oklch(0 0 0);
63 | --secondary: oklch(0.9691 0.2005 109.6228);
64 | --secondary-foreground: oklch(0 0 0);
65 | --muted: oklch(0.3211 0 0);
66 | --muted-foreground: oklch(0.8452 0 0);
67 | --accent: oklch(0.6755 0.1765 252.2592);
68 | --accent-foreground: oklch(0 0 0);
69 | --destructive: oklch(1 0 0);
70 | --destructive-foreground: oklch(0 0 0);
71 | --border: oklch(1 0 0);
72 | --input: oklch(1 0 0);
73 | --ring: oklch(0.7044 0.1872 23.1858);
74 | --chart-1: oklch(0.7044 0.1872 23.1858);
75 | --chart-2: oklch(0.9691 0.2005 109.6228);
76 | --chart-3: oklch(0.6755 0.1765 252.2592);
77 | --chart-4: oklch(0.7395 0.2268 142.8504);
78 | --chart-5: oklch(0.6131 0.2458 328.0714);
79 | --sidebar: oklch(0 0 0);
80 | --sidebar-foreground: oklch(1 0 0);
81 | --sidebar-primary: oklch(0.7044 0.1872 23.1858);
82 | --sidebar-primary-foreground: oklch(0 0 0);
83 | --sidebar-accent: oklch(0.6755 0.1765 252.2592);
84 | --sidebar-accent-foreground: oklch(0 0 0);
85 | --sidebar-border: oklch(1 0 0);
86 | --sidebar-ring: oklch(0.7044 0.1872 23.1858);
87 | --font-sans: DM Sans, sans-serif;
88 | --font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
89 | --font-mono: Space Mono, monospace;
90 | --radius: 0px;
91 | --shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.5);
92 | --shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.5);
93 | --shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1),
94 | 4px 1px 2px -1px hsl(0 0% 0% / 1);
95 | --shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1), 4px 1px 2px -1px hsl(0 0% 0% / 1);
96 | --shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1),
97 | 4px 2px 4px -1px hsl(0 0% 0% / 1);
98 | --shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1),
99 | 4px 4px 6px -1px hsl(0 0% 0% / 1);
100 | --shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1),
101 | 4px 8px 10px -1px hsl(0 0% 0% / 1);
102 | --shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.5);
103 | }
104 |
105 | @theme inline {
106 | --color-background: var(--background);
107 | --color-foreground: var(--foreground);
108 | --color-card: var(--card);
109 | --color-card-foreground: var(--card-foreground);
110 | --color-popover: var(--popover);
111 | --color-popover-foreground: var(--popover-foreground);
112 | --color-primary: var(--primary);
113 | --color-primary-foreground: var(--primary-foreground);
114 | --color-secondary: var(--secondary);
115 | --color-secondary-foreground: var(--secondary-foreground);
116 | --color-muted: var(--muted);
117 | --color-muted-foreground: var(--muted-foreground);
118 | --color-accent: var(--accent);
119 | --color-accent-foreground: var(--accent-foreground);
120 | --color-destructive: var(--destructive);
121 | --color-destructive-foreground: var(--destructive-foreground);
122 | --color-border: var(--border);
123 | --color-input: var(--input);
124 | --color-ring: var(--ring);
125 | --color-chart-1: var(--chart-1);
126 | --color-chart-2: var(--chart-2);
127 | --color-chart-3: var(--chart-3);
128 | --color-chart-4: var(--chart-4);
129 | --color-chart-5: var(--chart-5);
130 | --color-sidebar: var(--sidebar);
131 | --color-sidebar-foreground: var(--sidebar-foreground);
132 | --color-sidebar-primary: var(--sidebar-primary);
133 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
134 | --color-sidebar-accent: var(--sidebar-accent);
135 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
136 | --color-sidebar-border: var(--sidebar-border);
137 | --color-sidebar-ring: var(--sidebar-ring);
138 |
139 | --font-sans: var(--font-sans);
140 | --font-mono: var(--font-mono);
141 | --font-serif: var(--font-serif);
142 |
143 | --radius-sm: calc(var(--radius) - 4px);
144 | --radius-md: calc(var(--radius) - 2px);
145 | --radius-lg: var(--radius);
146 | --radius-xl: calc(var(--radius) + 4px);
147 |
148 | --shadow-2xs: var(--shadow-2xs);
149 | --shadow-xs: var(--shadow-xs);
150 | --shadow-sm: var(--shadow-sm);
151 | --shadow: var(--shadow);
152 | --shadow-md: var(--shadow-md);
153 | --shadow-lg: var(--shadow-lg);
154 | --shadow-xl: var(--shadow-xl);
155 | --shadow-2xl: var(--shadow-2xl);
156 | }
157 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Strudel Flow
2 |
3 | A visual drum machine and pattern sequencer built with [Strudel.cc](https://strudel.cc), [React Flow](https://reactflow.dev), and styled using [Tailwind CSS](https://tailwindcss.com/) and [shadcn/ui](https://ui.shadcn.com/). Create complex musical patterns by connecting instrument nodes to effect nodes with a drag-and-drop interface.
4 |
5 | [Live Demo](https://xyflow.com/strudel-flow)
6 |
7 | ## Table of Contents
8 |
9 | - [Getting Started](#getting-started)
10 | - [Tech Stack](#tech-stack)
11 | - [Node Types](#node-types)
12 | - [Usage Guide](#usage-guide)
13 | - [Pattern Syntax](#pattern-syntax)
14 | - [Development](#development)
15 | - [Contributing](#contributing)
16 |
17 | ## Getting Started
18 |
19 | To get started, follow these steps:
20 |
21 | 1. **Install dependencies**:
22 |
23 | ```bash
24 | npm install
25 | # or
26 | yarn install
27 | # or
28 | pnpm install
29 | # or
30 | bun install
31 | ```
32 |
33 | 2. **Run the development server**:
34 |
35 | ```bash
36 | npm run dev
37 | # or
38 | yarn dev
39 | # or
40 | pnpm dev
41 | # or
42 | bun dev
43 | ```
44 |
45 | ## Tech Stack
46 |
47 | - **Audio Engine**: [Strudel.cc](https://strudel.cc) - Web-based live coding environment
48 |
49 | - **React Flow Components**: The project uses [React Flow Components](https://reactflow.dev/components) to build nodes. These components are designed to help you quickly get up to speed on projects.
50 |
51 | - **shadcn CLI**: The project uses the [shadcn CLI](https://ui.shadcn.com/docs/cli) to manage UI components. This tool builds on top of [Tailwind CSS](https://tailwindcss.com/) and [shadcn/ui](https://ui.shadcn.com/) components, making it easy to add and customize UI elements.
52 |
53 | - **State Management with Zustand**: The application uses Zustand for state management, providing a simple and efficient way to manage the state of nodes, edges, and other workflow-related data.
54 |
55 | ## Node Types
56 |
57 | ### 🎵 Instruments
58 |
59 | - **Pad Node** - Grid-based step sequencer with scales and modifiers
60 | - **Beat Machine** - Classic drum machine with multiple instrument tracks
61 | - **Arpeggiator** - Pattern-based arpeggiated sequences with visual feedback
62 | - **Chord Node** - Interactive chord player with scale selection
63 | - **Polyrhythm** - Multiple overlapping rhythmic patterns
64 | - **Custom Node** - Direct Strudel pattern input
65 |
66 | ### 🎛️ Synths
67 |
68 | - **Drum Sounds** - Sample-based drum sound selection
69 | - **Sample Select** - Custom sample playback and selection
70 |
71 | ### 🎚️ Audio Effects
72 |
73 | - **Gain** - Volume control and amplification
74 | - **PostGain** - Secondary gain stage
75 | - **Distortion** - Saturation and harmonic distortion
76 | - **LPF** - Low-pass filtering with cutoff control
77 | - **Pan** - Stereo positioning and width
78 | - **Phaser** - Sweeping phase modulation effect
79 | - **Crush** - Bit-crushing and sample rate reduction
80 | - **Jux** - Alternating left/right channel effects
81 | - **FM** - Frequency modulation synthesis
82 | - **Room** - Realistic acoustic space simulation with size, fade, and filtering controls
83 |
84 | ### ⏱️ Time Effects
85 |
86 | - **Fast** - Speed multiplication (×2, ×3, ×4)
87 | - **Slow** - Speed division (÷2, ÷3, ÷4)
88 | - **Late** - Pattern delay and offset timing
89 | - **Attack** - Note attack time control
90 | - **Release** - Note release time control
91 | - **Sustain** - Note sustain level control
92 | - **Reverse** - Reverse pattern playback
93 | - **Palindrome** - Bidirectional pattern playback
94 | - **Mask** - Probabilistic pattern masking
95 | - **Ply** - Pattern subdivision and multiplication
96 |
97 | ## Usage Guide
98 |
99 | ### Creating Patterns
100 |
101 | 1. **Basic Pattern**:
102 |
103 | - Add a drum machine or pad node
104 | - Click buttons to activate steps
105 | - Adjust tempo with BPM control
106 |
107 | 2. **Complex Patterns**:
108 | - Use Shift+click to select multiple notes for grouping
109 | - Apply row modifiers for per-step effects
110 | - Chain multiple nodes for layered sounds
111 |
112 | ### Connecting Nodes
113 |
114 | - **Source to Effect**: Drag from sound source to effect node
115 | - **Effect Chaining**: Connect multiple effects in series
116 | - **Multiple Sources**: Connect multiple sources to the same effect
117 |
118 | ### Pattern Modifiers
119 |
120 | Each step can have modifiers applied:
121 |
122 | - **Normal**: Standard playback
123 | - **Fast (×2, ×3, ×4)**: Speed multiplication
124 | - **Slow (/2, /3, /4)**: Speed division
125 | - **Replicate (!2, !3, !4)**: Note repetition
126 | - **Elongate (@2, @3, @4)**: Note duration extension
127 |
128 | ### Performance Controls
129 |
130 | - **Global Play/Pause**: Press spacebar to pause/resume all active patterns
131 | - **Group Controls**: Pause/resume connected node groups independently
132 | - **Live Pattern Editing**: Modify patterns while playing with real-time updates
133 | - **Pattern Preview**: View generated Strudel code for each node
134 |
135 | ### Keyboard Shortcuts
136 |
137 | - **Spacebar**: Global play/pause toggle
138 | - **Shift + Click**: Multi-select grid cells for grouping (in Pad nodes)
139 | - **Right-click**: Context menu for pattern modifiers
140 |
141 | ## Development
142 |
143 | ### Project Structure
144 |
145 | ```
146 | src/
147 | ├── components/ # React components
148 | │ ├── nodes/ # Flow node components
149 | │ │ ├── instruments/ # Instrument node implementations
150 | │ │ ├── effects/ # Effect node implementations
151 | │ │ └── synths/ # Synthesizer node implementations
152 | │ ├── ui/ # shadcn/ui components
153 | │ ├── workflow/ # Flow editor components
154 | │ └── edges/ # Custom edge components
155 | ├── data/ # Static data and configurations
156 | ├── hooks/ # Custom React hooks
157 | ├── lib/ # Utility libraries and core logic
158 | ├── store/ # Zustand state management
159 | └── types/ # TypeScript type definitions
160 | ```
161 |
162 | ## Acknowledgments
163 |
164 | - [Strudel.cc](https://strudel.cc)
165 | - [tweakcn](https://tweakcn.com)
166 | - [React Flow](https://reactflow.dev)
167 | - [shadcn/ui](https://ui.shadcn.com)
168 |
169 | ---
170 |
171 | ## Contact Us
172 |
173 | We’re here to help! If you have any questions, feedback, instrument recommendations, or just want to share your project with us, feel free to reach out:
174 |
175 | - **Contact Form**: Use the contact form on our [website](https://xyflow.com/contact).
176 | - **Email**: Drop us an email at [info@xyflow.com](mailto:info@xyflow.com).
177 | - **Discord**: Join our [Discord server](https://discord.com/invite/RVmnytFmGW) to connect with the community and get support.
178 |
--------------------------------------------------------------------------------