This commit includes the initial setup for the React PWA version of Carla Control.
Key features implemented:
- Vite-based React project with TypeScript.
- Shadcn-UI and Tailwind CSS for styling.
- OSC communication layer using Comlink web workers (osc-js).
- Tanstack Query for state management, including:
- Fetching and caching plugin lists, details, and parameters.
- Fetching and caching patchbay connections.
- Mutations for adding/removing plugins.
- Mutations for connecting/disconnecting ports.
- Mutations for setting parameter values with optimistic updates and basic rollback on error.
- Basic UI structure in App.tsx to test these functionalities.
- Initial (and possibly incomplete) setup for @xyflow/react components for the patchbay.
pull/1992/head
| @@ -0,0 +1,6 @@ | |||
| { | |||
| "name": "app", | |||
| "lockfileVersion": 3, | |||
| "requires": true, | |||
| "packages": {} | |||
| } | |||
| @@ -0,0 +1,24 @@ | |||
| # Logs | |||
| logs | |||
| *.log | |||
| npm-debug.log* | |||
| yarn-debug.log* | |||
| yarn-error.log* | |||
| pnpm-debug.log* | |||
| lerna-debug.log* | |||
| node_modules | |||
| dist | |||
| dist-ssr | |||
| *.local | |||
| # Editor directories and files | |||
| .vscode/* | |||
| !.vscode/extensions.json | |||
| .idea | |||
| .DS_Store | |||
| *.suo | |||
| *.ntvs* | |||
| *.njsproj | |||
| *.sln | |||
| *.sw? | |||
| @@ -0,0 +1,71 @@ | |||
| import React, { useState } from 'react'; | |||
| import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; | |||
| import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; | |||
| import { useOsc } from './services/useOsc'; | |||
| const queryClient = new QueryClient(); | |||
| function App() { | |||
| const { connect, disconnect, sendMessage, connectionStatus, lastMessage } = useOsc(); | |||
| const [host, setHost] = useState('127.0.0.1'); | |||
| // Default Carla OSC TCP port for remote control is often 22752, but can be configured. | |||
| // The Python client uses CARLA_DEFAULT_OSC_TCP_PORT_NUMBER which might be defined elsewhere. | |||
| // For now, let's use a common default or make it configurable. | |||
| const [tcpPort, setTcpPort] = useState('22752'); | |||
| const [udpPort, setUdpPort] = useState('22752'); // Conceptual, as worker uses TCP for all | |||
| const handleConnect = () => { | |||
| connect(host, parseInt(tcpPort, 10), parseInt(udpPort, 10)); | |||
| }; | |||
| const handleSendTestMessage = () => { | |||
| // Example: Requesting engine status (path might need adjustment based on Carla's OSC API) | |||
| sendMessage('/ctrl/engine_status_req'); // Replace with actual path | |||
| sendMessage('/Carla/ping'); // A common OSC ping path, might need adjustment | |||
| }; | |||
| return ( | |||
| <div className='container mx-auto p-4'> | |||
| <h1 className='text-2xl font-bold mb-4'>Carla Control PWA</h1> | |||
| <div className='space-y-2 mb-4'> | |||
| <div> | |||
| <label htmlFor='host' className='block text-sm font-medium text-gray-700'>Host:</label> | |||
| <input type='text' id='host' value={host} onChange={(e) => setHost(e.target.value)} className='mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm' /> | |||
| </div> | |||
| <div> | |||
| <label htmlFor='tcpPort' className='block text-sm font-medium text-gray-700'>TCP Port:</label> | |||
| <input type='text' id='tcpPort' value={tcpPort} onChange={(e) => setTcpPort(e.target.value)} className='mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm' /> | |||
| </div> | |||
| {/* UDP Port input can be kept for conceptual completeness or removed if not actively used for distinct connections */} | |||
| <div> | |||
| <label htmlFor='udpPort' className='block text-sm font-medium text-gray-700'>UDP Port (Conceptual):</label> | |||
| <input type='text' id='udpPort' value={udpPort} onChange={(e) => setUdpPort(e.target.value)} className='mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm' /> | |||
| </div> | |||
| </div> | |||
| <div className='space-x-2 mb-4'> | |||
| <button onClick={handleConnect} className='px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-700'>Connect</button> | |||
| <button onClick={() => disconnect()} className='px-4 py-2 bg-red-500 text-white rounded hover:bg-red-700'>Disconnect</button> | |||
| <button onClick={handleSendTestMessage} className='px-4 py-2 bg-green-500 text-white rounded hover:bg-green-700'>Send Test Message</button> | |||
| </div> | |||
| <p className='mb-2'>Status: <span className='font-semibold'>{connectionStatus}</span></p> | |||
| {lastMessage && ( | |||
| <div className='p-2 bg-gray-100 rounded'> | |||
| <p className='font-semibold'>Last Message:</p> | |||
| <p>Address: {lastMessage.address}</p> | |||
| <p>Args: {JSON.stringify(lastMessage.args)}</p> | |||
| </div> | |||
| )} | |||
| </div> | |||
| ); | |||
| } | |||
| function RootApp() { | |||
| return ( | |||
| <QueryClientProvider client={queryClient}> | |||
| <App /> | |||
| <ReactQueryDevtools initialIsOpen={false} /> | |||
| </QueryClientProvider> | |||
| ) | |||
| } | |||
| export default RootApp; | |||
| @@ -0,0 +1,54 @@ | |||
| # React + TypeScript + Vite | |||
| This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. | |||
| Currently, two official plugins are available: | |||
| - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh | |||
| - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh | |||
| ## Expanding the ESLint configuration | |||
| If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: | |||
| ```js | |||
| export default tseslint.config({ | |||
| extends: [ | |||
| // Remove ...tseslint.configs.recommended and replace with this | |||
| ...tseslint.configs.recommendedTypeChecked, | |||
| // Alternatively, use this for stricter rules | |||
| ...tseslint.configs.strictTypeChecked, | |||
| // Optionally, add this for stylistic rules | |||
| ...tseslint.configs.stylisticTypeChecked, | |||
| ], | |||
| languageOptions: { | |||
| // other options... | |||
| parserOptions: { | |||
| project: ['./tsconfig.node.json', './tsconfig.app.json'], | |||
| tsconfigRootDir: import.meta.dirname, | |||
| }, | |||
| }, | |||
| }) | |||
| ``` | |||
| You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: | |||
| ```js | |||
| // eslint.config.js | |||
| import reactX from 'eslint-plugin-react-x' | |||
| import reactDom from 'eslint-plugin-react-dom' | |||
| export default tseslint.config({ | |||
| plugins: { | |||
| // Add the react-x and react-dom plugins | |||
| 'react-x': reactX, | |||
| 'react-dom': reactDom, | |||
| }, | |||
| rules: { | |||
| // other rules... | |||
| // Enable its recommended typescript rules | |||
| ...reactX.configs['recommended-typescript'].rules, | |||
| ...reactDom.configs.recommended.rules, | |||
| }, | |||
| }) | |||
| ``` | |||
| @@ -0,0 +1,17 @@ | |||
| { | |||
| "$schema": "https://ui.shadcn.com/schema.json", | |||
| "style": "default", | |||
| "rsc": false, | |||
| "tsx": true, | |||
| "tailwind": { | |||
| "config": "tailwind.config.js", | |||
| "css": "src/index.css", | |||
| "baseColor": "slate", | |||
| "cssVariables": true, | |||
| "prefix": "" | |||
| }, | |||
| "aliases": { | |||
| "components": "@/components", | |||
| "utils": "@/lib/utils" | |||
| } | |||
| } | |||
| @@ -0,0 +1,44 @@ | |||
| { | |||
| "name": "react", | |||
| "private": true, | |||
| "version": "0.0.0", | |||
| "type": "module", | |||
| "scripts": { | |||
| "dev": "vite", | |||
| "build": "tsc -b && vite build", | |||
| "lint": "eslint .", | |||
| "preview": "vite preview" | |||
| }, | |||
| "dependencies": { | |||
| "@tanstack/react-query": "^5.79.2", | |||
| "@xyflow/react": "^12.6.4", | |||
| "autoprefixer": "^10.4.21", | |||
| "clsx": "^2.1.1", | |||
| "comlink": "^4.4.2", | |||
| "lucide-react": "^0.511.0", | |||
| "osc-js": "^2.4.1", | |||
| "postcss": "^8.5.4", | |||
| "react": "^19.1.0", | |||
| "react-dom": "^19.1.0", | |||
| "shadcn-ui": "^0.9.5", | |||
| "tailwind-merge": "^3.3.0", | |||
| "tailwindcss": "^4.1.8", | |||
| "vite-plugin-pwa": "^1.0.0" | |||
| }, | |||
| "devDependencies": { | |||
| "@eslint/js": "^9.25.0", | |||
| "@tanstack/react-query-devtools": "^5.79.2", | |||
| "@types/node": "^22.15.29", | |||
| "@types/react": "^19.1.2", | |||
| "@types/react-dom": "^19.1.2", | |||
| "@vitejs/plugin-react": "^4.5.1", | |||
| "eslint": "^9.25.0", | |||
| "eslint-plugin-react-hooks": "^5.2.0", | |||
| "eslint-plugin-react-refresh": "^0.4.19", | |||
| "globals": "^16.0.0", | |||
| "typescript": "~5.8.3", | |||
| "typescript-eslint": "^8.30.1", | |||
| "vite": "^6.3.5", | |||
| "vite-plugin-comlink": "^5.1.0" | |||
| } | |||
| } | |||
| @@ -0,0 +1 @@ | |||
| <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg> | |||
| @@ -0,0 +1,42 @@ | |||
| #root { | |||
| max-width: 1280px; | |||
| margin: 0 auto; | |||
| padding: 2rem; | |||
| text-align: center; | |||
| } | |||
| .logo { | |||
| height: 6em; | |||
| padding: 1.5em; | |||
| will-change: filter; | |||
| transition: filter 300ms; | |||
| } | |||
| .logo:hover { | |||
| filter: drop-shadow(0 0 2em #646cffaa); | |||
| } | |||
| .logo.react:hover { | |||
| filter: drop-shadow(0 0 2em #61dafbaa); | |||
| } | |||
| @keyframes logo-spin { | |||
| from { | |||
| transform: rotate(0deg); | |||
| } | |||
| to { | |||
| transform: rotate(360deg); | |||
| } | |||
| } | |||
| @media (prefers-reduced-motion: no-preference) { | |||
| a:nth-of-type(2) .logo { | |||
| animation: logo-spin infinite 20s linear; | |||
| } | |||
| } | |||
| .card { | |||
| padding: 2em; | |||
| } | |||
| .read-the-docs { | |||
| color: #888; | |||
| } | |||
| @@ -0,0 +1,82 @@ | |||
| import { useState, useEffect } from 'react'; // Corrected: Removed 'React,' | |||
| import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; // Ensure QueryClient is imported | |||
| import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; | |||
| import { useOsc } from './services/useOsc'; | |||
| import { CarlaInfoDisplay } from './components/CarlaInfoDisplay'; | |||
| const queryClient = new QueryClient(); | |||
| function App() { | |||
| const { | |||
| connect, disconnect, sendMessage, | |||
| addPlugin, removePlugin, | |||
| connectPorts, disconnectPorts, | |||
| setParameterValue, | |||
| connectionStatus, lastMessage | |||
| } = useOsc(); | |||
| const [host, setHost] = useState('127.0.0.1'); | |||
| const [tcpPort, setTcpPort] = useState('22752'); | |||
| const [pluginIdText, setPluginIdText] = useState('mda Delay'); | |||
| const [srcPlugId, setSrcPlugId] = useState(''); | |||
| const [srcPortIdx, setSrcPortIdx] = useState(''); | |||
| const [tgtPlugId, setTgtPlugId] = useState(''); | |||
| const [tgtPortIdx, setTgtPortIdx] = useState(''); | |||
| const handleConnect = () => connect(host, parseInt(tcpPort, 10), 0); | |||
| useEffect(() => { if (connectionStatus === 'OSC TCP Connected') sendMessage('/ctrl/patchbay_refresh'); }, [connectionStatus, sendMessage]); | |||
| const handleAddPlugin = () => { if (pluginIdText.trim()) addPlugin(pluginIdText.trim()); else alert('Plugin ID needed'); }; | |||
| const handleConnectPorts = () => { | |||
| if (srcPlugId && srcPortIdx && tgtPlugId && tgtPortIdx) { | |||
| connectPorts(parseInt(srcPlugId), parseInt(srcPortIdx), parseInt(tgtPlugId), parseInt(tgtPortIdx)); | |||
| } else { alert('All connection fields required.'); } | |||
| }; | |||
| return ( | |||
| <QueryClientProvider client={queryClient}> | |||
| <div className='container mx-auto p-4'> | |||
| <h1 className='text-2xl font-bold mb-4'>Carla Control PWA</h1> | |||
| {/* Connection UI */} | |||
| <div className='space-y-4 mb-4 p-4 border rounded-lg shadow bg-white'> | |||
| <h2 className='text-lg font-semibold'>Connection</h2> | |||
| <div className='grid grid-cols-1 md:grid-cols-3 gap-4 items-end'> | |||
| <div><label>Host:</label><input type='text' value={host} onChange={(e) => setHost(e.target.value)} className='mt-1 block w-full input input-bordered input-sm' /></div> | |||
| <div><label>TCP Port:</label><input type='text' value={tcpPort} onChange={(e) => setTcpPort(e.target.value)} className='mt-1 block w-full input input-bordered input-sm' /></div> | |||
| <div className='flex space-x-2 pt-4'><button onClick={handleConnect} className='btn btn-primary btn-sm w-full'>Connect</button><button onClick={disconnect} className='btn btn-secondary btn-sm w-full'>Disconnect</button></div> | |||
| </div> | |||
| <p>Status: {connectionStatus}</p> | |||
| </div> | |||
| {/* Add Plugin UI */} | |||
| <div className='space-y-2 mb-4 p-4 border rounded-lg shadow bg-white'> | |||
| <h2 className='text-lg font-semibold'>Add Plugin</h2> | |||
| <div className='flex items-end gap-2'> | |||
| <div className='flex-grow'><label>Plugin ID:</label><input type='text' value={pluginIdText} onChange={(e) => setPluginIdText(e.target.value)} className='mt-1 block w-full input input-bordered input-sm' /></div> | |||
| <button onClick={handleAddPlugin} className='btn btn-accent btn-sm'>Add Plugin</button> | |||
| </div> | |||
| </div> | |||
| {/* Test Connect Ports UI */} | |||
| <div className='space-y-2 mb-4 p-4 border rounded-lg shadow bg-white'> | |||
| <h2 className='text-lg font-semibold'>Test Connect Ports</h2> | |||
| <div className='grid grid-cols-2 md:grid-cols-5 gap-2 items-end'> | |||
| <div><label className='text-xs'>SrcPlgID:</label><input type='text' value={srcPlugId} onChange={e=>setSrcPlugId(e.target.value)} className='input input-bordered input-xs w-full'/></div> | |||
| <div><label className='text-xs'>SrcPrtIdx:</label><input type='text' value={srcPortIdx} onChange={e=>setSrcPortIdx(e.target.value)} className='input input-bordered input-xs w-full'/></div> | |||
| <div><label className='text-xs'>TgtPlgID:</label><input type='text' value={tgtPlugId} onChange={e=>setTgtPlugId(e.target.value)} className='input input-bordered input-xs w-full'/></div> | |||
| <div><label className='text-xs'>TgtPrtIdx:</label><input type='text' value={tgtPortIdx} onChange={e=>setTgtPortIdx(e.target.value)} className='input input-bordered input-xs w-full'/></div> | |||
| <button onClick={handleConnectPorts} className='btn btn-info btn-sm'>Connect Ports</button> | |||
| </div> | |||
| </div> | |||
| {lastMessage && <div className='mt-2 p-2 bg-gray-100 rounded text-xs border'><p>Last OSC: {lastMessage.address} {JSON.stringify(lastMessage.args)}</p></div>} | |||
| <CarlaInfoDisplay | |||
| removePlugin={removePlugin} | |||
| connectPortsRequest={connectPorts} | |||
| disconnectPortsRequest={disconnectPorts} | |||
| setParameterValueRequest={setParameterValue} | |||
| /> | |||
| </div> | |||
| <ReactQueryDevtools initialIsOpen={false} /> | |||
| </QueryClientProvider> | |||
| ); | |||
| } | |||
| export default App; | |||
| @@ -0,0 +1 @@ | |||
| <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg> | |||
| @@ -0,0 +1,164 @@ | |||
| import React, { useState, useEffect } from 'react'; | |||
| import type { ChangeEvent } from 'react'; // For verbatimModuleSyntax | |||
| import { useQuery } from '@tanstack/react-query'; // Removed useQueryClient | |||
| // === Type Definitions === | |||
| export interface PluginParameter { /* ... existing ... */ | |||
| id: number; name: string; unit?: string; comment?: string; groupName?: string; | |||
| type?: number; hints?: number; midiChannel?: number; mappedControlIndex?: number; | |||
| mappedMinimum?: number; mappedMaximum?: number; defaultValue?: number; minimum?: number; | |||
| maximum?: number; step?: number; stepSmall?: number; stepLarge?: number; value?: number; | |||
| } | |||
| export interface PortCounts { /* ... existing ... */ | |||
| audioIns: number; audioOuts: number; midiIns: number; midiOuts: number; | |||
| cvIns?: number; cvOuts?: number; paramTotal?: number; | |||
| } | |||
| export interface ProgramCounts { /* ... existing ... */ programs: number; midiPrograms: number; } | |||
| export interface InternalParams { /* ... existing ... */ | |||
| active: boolean; dryWet: number; volume: number; balanceLeft: number; | |||
| balanceRight: number; panning: number; ctrlChannel: number; | |||
| } | |||
| export interface PluginInfo { /* ... existing ... */ | |||
| id: number; type?: number; category?: string; hints?: number; uniqueId?: number; | |||
| optionsAvailable?: number; optionsEnabled?: number; name: string; filename?: string; | |||
| iconName?: string; realName?: string; label?: string; maker?: string; copyright?: string; | |||
| ports?: PortCounts; programCounts?: ProgramCounts; internalParams?: InternalParams; | |||
| } | |||
| export interface EngineInfo { [key: string]: any; } | |||
| export interface PatchbayConnection { /* ... existing ... */ | |||
| id: string; sourcePluginId: number; sourcePortIndex: number; | |||
| targetPluginId: number; targetPortIndex: number; | |||
| sourcePortName?: string; targetPortName?: string; | |||
| } | |||
| // === Components === | |||
| interface CarlaInfoDisplayProps { | |||
| removePlugin: (pluginId: number) => void; | |||
| connectPortsRequest: (sPlugId: number, sPortIdx: number, tPlugId: number, tPortIdx: number) => void; | |||
| disconnectPortsRequest: (connId: string) => void; | |||
| setParameterValueRequest: (pluginId: number, paramId: number, value: number) => void; | |||
| } | |||
| const ParameterInput: React.FC<{ pluginId: number, param: PluginParameter, setParameterValueRequest: Function }> = ({ pluginId, param, setParameterValueRequest }) => { | |||
| const [localValue, setLocalValue] = useState(param.value?.toString() ?? ''); | |||
| useEffect(() => { | |||
| setLocalValue(param.value?.toString() ?? ''); | |||
| }, [param.value]); | |||
| const handleChange = (e: ChangeEvent<HTMLInputElement>) => { | |||
| setLocalValue(e.target.value); | |||
| }; | |||
| const handleBlur = () => { | |||
| const numValue = parseFloat(localValue); | |||
| if (!isNaN(numValue) && numValue !== param.value) { | |||
| setParameterValueRequest(pluginId, param.id, numValue); | |||
| } | |||
| }; | |||
| const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => { | |||
| if (e.key === 'Enter') { | |||
| handleBlur(); | |||
| (e.target as HTMLInputElement).blur(); // Remove focus | |||
| } | |||
| }; | |||
| // Basic number input, could be a slider if min/max/step are known | |||
| return ( | |||
| <input | |||
| type='number' | |||
| value={localValue} | |||
| onChange={handleChange} | |||
| onBlur={handleBlur} | |||
| onKeyPress={handleKeyPress} | |||
| step={param.stepSmall || param.step || 0.01} // Provide a sensible default step | |||
| min={param.minimum} | |||
| max={param.maximum} | |||
| title={`Min: ${param.minimum ?? 'N/A'}, Max: ${param.maximum ?? 'N/A'}, Default: ${param.defaultValue ?? 'N/A'}`} | |||
| className='input input-bordered input-xs w-24 ml-2' | |||
| /> | |||
| ); | |||
| }; | |||
| const PluginParamsDisplay: React.FC<{ pluginId: number, setParameterValueRequest: Function }> = ({ pluginId, setParameterValueRequest }) => { | |||
| const { data: parameters, isLoading } = useQuery<PluginParameter[], Error>({ | |||
| queryKey: ['pluginParameters', pluginId], | |||
| }); | |||
| if (isLoading) return <p className='text-xs text-gray-500'>Loading parameters...</p>; | |||
| if (!parameters || parameters.length === 0) return <p className='text-xs text-gray-500'>No parameters.</p>; | |||
| return ( | |||
| <div className='mt-1 pl-4 border-l-2 border-gray-200'> | |||
| <h4 className='text-xs font-semibold text-gray-700'>Parameters:</h4> | |||
| <ul className='space-y-1 text-xs'> | |||
| {parameters.map(param => ( | |||
| <li key={param.id} title={`ID: ${param.id}, Unit: ${param.unit || 'N/A'}`} className='flex items-center justify-between'> | |||
| <span> | |||
| {param.groupName && <span className='text-gray-500'>[${param.groupName}] </span>} | |||
| {param.name}: {param.value?.toFixed(4) ?? 'N/A'} | |||
| </span> | |||
| <ParameterInput pluginId={pluginId} param={param} setParameterValueRequest={setParameterValueRequest} /> | |||
| </li> | |||
| ))} | |||
| </ul> | |||
| </div> | |||
| ); | |||
| }; | |||
| const PatchbayConnectionsDisplay: React.FC<{ disconnectPortsRequest: (connId: string) => void }> = ({ disconnectPortsRequest }) => { /* ... existing ... */ | |||
| const { data: connections } = useQuery<PatchbayConnection[], Error>({ queryKey: ['patchbayConnections'] }); | |||
| if (!connections || connections.length === 0) return <p className='text-xs text-gray-500'>No patchbay connections.</p>; | |||
| return ( | |||
| <div className='mt-3'> | |||
| <h3 className='font-medium mb-1'>Connections ({connections.length}):</h3> | |||
| <ul className='space-y-1 text-xs'> | |||
| {connections.map(conn => ( | |||
| <li key={conn.id} className='flex justify-between items-center p-1 bg-gray-100 rounded'> | |||
| <span>(ID: {conn.id}) Plg {conn.sourcePluginId}:{conn.sourcePortIndex} → Plg {conn.targetPluginId}:{conn.targetPortIndex}</span> | |||
| <button onClick={() => disconnectPortsRequest(conn.id)} className='btn btn-warning btn-xs'>X</button> | |||
| </li> | |||
| ))} | |||
| </ul> | |||
| </div> | |||
| ); | |||
| }; | |||
| export const CarlaInfoDisplay: React.FC<CarlaInfoDisplayProps> = ({ removePlugin, disconnectPortsRequest, setParameterValueRequest }) => { | |||
| const { data: engineInfo, error: engineInfoError } = useQuery<EngineInfo, Error>({ queryKey: ['engineInfo'] }); | |||
| const { data: pluginList } = useQuery<PluginInfo[], Error>({ queryKey: ['pluginList'] }); | |||
| const [expandedPluginId, setExpandedPluginId] = React.useState<number | null>(null); | |||
| const togglePluginDetails = (pluginId: number) => setExpandedPluginId(expandedPluginId === pluginId ? null : pluginId); | |||
| return ( | |||
| <div className='mt-4 p-4 border rounded-md bg-gray-50'> | |||
| <h2 className='text-xl font-semibold mb-2'>Carla Host Information</h2> | |||
| {engineInfo && <div className='mb-3'><h3 className='font-medium'>Engine Info:</h3><pre className='text-sm bg-white p-2 rounded overflow-x-auto'>{JSON.stringify(engineInfo, null, 2)}</pre></div>} | |||
| {engineInfoError && <p>Error loading engine info: {engineInfoError.message}</p>} | |||
| {pluginList && pluginList.length > 0 ? ( | |||
| <div className='mb-3'> | |||
| <h3 className='font-medium mb-1'>Plugins ({pluginList.length}):</h3> | |||
| <ul className='space-y-2'> | |||
| {pluginList.map((plugin) => ( | |||
| <li key={plugin.id} className='text-sm border p-3 rounded bg-white shadow-sm'> | |||
| <div className='flex justify-between items-center'> | |||
| <div> | |||
| <strong onClick={() => togglePluginDetails(plugin.id)} className='cursor-pointer hover:text-blue-600'>{plugin.name}</strong> (ID: {plugin.id}) | |||
| {plugin.label && <span className='text-xs'>, Label: {plugin.label}</span>} | |||
| </div> | |||
| <button onClick={() => removePlugin(plugin.id)} className='btn btn-error btn-xs'>Remove</button> | |||
| </div> | |||
| {expandedPluginId === plugin.id && <PluginParamsDisplay pluginId={plugin.id} setParameterValueRequest={setParameterValueRequest} />} | |||
| </li> | |||
| ))} | |||
| </ul> | |||
| </div> | |||
| ) : ( | |||
| <p className='text-xs text-gray-500'>No plugins loaded.</p> | |||
| )} | |||
| <PatchbayConnectionsDisplay disconnectPortsRequest={disconnectPortsRequest} /> | |||
| </div> | |||
| ); | |||
| }; | |||
| @@ -0,0 +1,76 @@ | |||
| @tailwind base; | |||
| @tailwind components; | |||
| @tailwind utilities; | |||
| @layer base { | |||
| :root { | |||
| --background: 0 0% 100%; | |||
| --foreground: 222.2 84% 4.9%; | |||
| --card: 0 0% 100%; | |||
| --card-foreground: 222.2 84% 4.9%; | |||
| --popover: 0 0% 100%; | |||
| --popover-foreground: 222.2 84% 4.9%; | |||
| --primary: 222.2 47.4% 11.2%; | |||
| --primary-foreground: 210 40% 98%; | |||
| --secondary: 210 40% 96.1%; | |||
| --secondary-foreground: 222.2 47.4% 11.2%; | |||
| --muted: 210 40% 96.1%; | |||
| --muted-foreground: 215.4 16.3% 46.9%; | |||
| --accent: 210 40% 96.1%; | |||
| --accent-foreground: 222.2 47.4% 11.2%; | |||
| --destructive: 0 84.2% 60.2%; | |||
| --destructive-foreground: 210 40% 98%; | |||
| --border: 214.3 31.8% 91.4%; | |||
| --input: 214.3 31.8% 91.4%; | |||
| --ring: 222.2 84% 4.9%; | |||
| --radius: 0.5rem; | |||
| } | |||
| .dark { | |||
| --background: 222.2 84% 4.9%; | |||
| --foreground: 210 40% 98%; | |||
| --card: 222.2 84% 4.9%; | |||
| --card-foreground: 210 40% 98%; | |||
| --popover: 222.2 84% 4.9%; | |||
| --popover-foreground: 210 40% 98%; | |||
| --primary: 210 40% 98%; | |||
| --primary-foreground: 222.2 47.4% 11.2%; | |||
| --secondary: 217.2 32.6% 17.5%; | |||
| --secondary-foreground: 210 40% 98%; | |||
| --muted: 217.2 32.6% 17.5%; | |||
| --muted-foreground: 215 20.2% 65.1%; | |||
| --accent: 217.2 32.6% 17.5%; | |||
| --accent-foreground: 210 40% 98%; | |||
| --destructive: 0 62.8% 30.6%; | |||
| --destructive-foreground: 210 40% 98%; | |||
| --border: 217.2 32.6% 17.5%; | |||
| --input: 217.2 32.6% 17.5%; | |||
| --ring: 215 20.2% 65.1%; | |||
| } | |||
| } | |||
| @layer base { | |||
| * { | |||
| @apply border-border; | |||
| } | |||
| body { | |||
| @apply bg-background text-foreground; | |||
| } | |||
| } | |||
| @@ -0,0 +1,6 @@ | |||
| import { type ClassValue, clsx } from "clsx" | |||
| import { twMerge } from "tailwind-merge" | |||
| export function cn(...inputs: ClassValue[]) { | |||
| return twMerge(clsx(inputs)) | |||
| } | |||
| @@ -0,0 +1,10 @@ | |||
| import React from 'react'; | |||
| import ReactDOM from 'react-dom/client'; | |||
| import App from './App.tsx'; // App.tsx now includes QueryClientProvider | |||
| import './index.css'; | |||
| ReactDOM.createRoot(document.getElementById('root')!).render( | |||
| <React.StrictMode> | |||
| <App /> | |||
| </React.StrictMode>, | |||
| ); | |||
| @@ -0,0 +1,134 @@ | |||
| import OSC from 'osc-js'; | |||
| import { expose } from 'comlink'; | |||
| let oscOverTCP: OSC | null = null; | |||
| let oscOverUDP: OSC | null = null; // Placeholder for UDP-like communication | |||
| let statusCallback: ((status: string) => void) | null = null; | |||
| let messageCallback: ((message: OSC.Message) => void) | null = null; | |||
| const workerMethods = { | |||
| connect: async (host: string, tcpPort: number, udpPort: number) => { | |||
| console.log(`[Worker] Attempting to connect to ${host}, TCP: ${tcpPort}, UDP (conceptual): ${udpPort}`); | |||
| statusCallback?.('Connecting...'); | |||
| // Close existing connections if any | |||
| if (oscOverTCP) { | |||
| oscOverTCP.close(); | |||
| oscOverTCP = null; | |||
| } | |||
| // Placeholder for UDP, actual implementation will depend on server capabilities for WebSocket | |||
| if (oscOverUDP) { | |||
| oscOverUDP.close(); | |||
| oscOverUDP = null; | |||
| } | |||
| // TCP Connection | |||
| // Note: Browsers use WebSockets for OSC. The server (Carla) must have a WebSocket OSC interface. | |||
| // The standard 'osc-js' WebsocketClientPlugin connects to a WebSocket server that then bridges to OSC. | |||
| // If Carla doesn't directly support WebSocket to OSC, a bridge server (like a small Node.js app) might be needed. | |||
| // For now, assuming Carla or an intermediary bridge provides a WebSocket endpoint for OSC messages. | |||
| oscOverTCP = new OSC({ | |||
| plugin: new OSC.WebsocketClientPlugin({ host, port: tcpPort, secure: false }) | |||
| }); | |||
| oscOverTCP.on('open', () => { | |||
| console.log('[Worker] OSC TCP (WebSocket) connection opened.'); | |||
| statusCallback?.('OSC TCP Connected'); | |||
| // Send a registration message, similar to how the Python client does. | |||
| // The path /register and the format of the client URL might need to be exact. | |||
| // Example: oscOverTCP?.send(new OSC.Message('/register', `osc.tcp://${someClientIdentifier}:${someClientPort}/CarlaCtrlPWA`)); | |||
| // For now, we are not sending any specific registration message until server expectation is clear. | |||
| }); | |||
| oscOverTCP.on('close', () => { | |||
| console.log('[Worker] OSC TCP (WebSocket) connection closed.'); | |||
| statusCallback?.('OSC TCP Disconnected'); | |||
| }); | |||
| oscOverTCP.on('error', (err: Error) => { | |||
| console.error('[Worker] OSC TCP (WebSocket) Error:', err); | |||
| statusCallback?.(`OSC TCP Error: ${err.message}`); | |||
| }); | |||
| oscOverTCP.on('*', (message: OSC.Message) => { | |||
| console.log('[Worker] OSC TCP (WebSocket) Message Received:', message.address, message.args); | |||
| messageCallback?.(message); | |||
| }); | |||
| try { | |||
| oscOverTCP.open(); // This is asynchronous for WebSocket connections | |||
| } catch (error) { | |||
| console.error('[Worker] Failed to initiate OSC TCP (WebSocket) connection:', error); | |||
| statusCallback?.(`OSC TCP Connection Failed: ${(error as Error).message}`); | |||
| return; | |||
| } | |||
| // UDP Communication Placeholder: | |||
| // Browsers cannot directly create UDP sockets. | |||
| // Options: | |||
| // 1. Send all messages (including those traditionally over UDP) via the single TCP/WebSocket connection. | |||
| // Carla's OSC server would need to handle this. This is the most likely scenario for a pure web PWA. | |||
| // 2. Use a separate WebSocket connection to a different port/path on the server if the server | |||
| // is designed to treat that as a 'UDP-like' channel (e.g., for high-frequency, non-critical messages). | |||
| // 3. A server-side bridge that accepts WebSockets and forwards to Carla via UDP. | |||
| // For this initial implementation, we will assume all messages go through oscOverTCP. | |||
| // The 'sendDataMessage' method will just use oscOverTCP. | |||
| console.log('[Worker] UDP communication is conceptual. All messages will be sent via the primary TCP/WebSocket connection.'); | |||
| statusCallback?.('UDP conceptual (using TCP WebSocket)'); | |||
| }, | |||
| sendMessage: (address: string, ...args: any[]) => { | |||
| if (oscOverTCP && oscOverTCP.status() === OSC.STATUS.IS_OPEN) { | |||
| console.log(`[Worker] Sending OSC (via TCP WebSocket): ${address}`, args); | |||
| try { | |||
| oscOverTCP.send(new OSC.Message(address, ...args)); | |||
| } catch (error) { | |||
| console.error(`[Worker] Error sending OSC message ${address} via TCP WebSocket:`, error); | |||
| statusCallback?.(`Error sending message: ${(error as Error).message}`); | |||
| } | |||
| } else { | |||
| console.warn('[Worker] OSC TCP (WebSocket) connection not open. Message not sent:', address, args); | |||
| statusCallback?.('Error: OSC TCP not connected. Cannot send message.'); | |||
| } | |||
| }, | |||
| // This function is kept for conceptual separation, but it will use the TCP WebSocket. | |||
| sendDataMessage: (address: string, ...args: any[]) => { | |||
| if (oscOverTCP && oscOverTCP.status() === OSC.STATUS.IS_OPEN) { | |||
| console.log(`[Worker] Sending Data OSC (via TCP WebSocket): ${address}`, args); | |||
| try { | |||
| oscOverTCP.send(new OSC.Message(address, ...args)); | |||
| } catch (error) { | |||
| console.error(`[Worker] Error sending Data OSC message ${address} via TCP WebSocket:`, error); | |||
| statusCallback?.(`Error sending data message: ${(error as Error).message}`); | |||
| } | |||
| } else { | |||
| console.warn('[Worker] OSC TCP (WebSocket) connection not open for data message. Message not sent:', address, args); | |||
| statusCallback?.('Error: OSC TCP not connected. Cannot send data message.'); | |||
| } | |||
| }, | |||
| onStatusChange: (callback: (status: string) => void) => { | |||
| statusCallback = callback; | |||
| }, | |||
| onMessage: (callback: (message: OSC.Message) => void) => { | |||
| messageCallback = callback; | |||
| }, | |||
| disconnect: () => { | |||
| if (oscOverTCP) { | |||
| oscOverTCP.close(); // This is asynchronous | |||
| oscOverTCP = null; | |||
| } | |||
| // oscOverUDP cleanup if it were used | |||
| console.log('[Worker] OSC connections commanded to close.'); | |||
| statusCallback?.('Disconnected'); | |||
| } | |||
| }; | |||
| expose(workerMethods); | |||
| // This export is crucial for Comlink in the main thread to get type inference | |||
| export type OscWorkerType = typeof workerMethods; | |||
| @@ -0,0 +1,179 @@ | |||
| import * as Comlink from 'comlink'; | |||
| import { useEffect, useState, useCallback, useRef } from 'react'; | |||
| import { type OscWorkerType } from '../osc-worker'; | |||
| import { useQueryClient } from '@tanstack/react-query'; | |||
| import { type PluginInfo, type PortCounts, type ProgramCounts, type InternalParams, type PluginParameter, type PatchbayConnection } from '../components/CarlaInfoDisplay'; | |||
| interface ReceivedOSCMessage { address: string; args: any[]; } | |||
| const worker = new Worker(new URL('../osc-worker.ts', import.meta.url), { type: 'module' }); | |||
| const oscWorker = Comlink.wrap<OscWorkerType>(worker); | |||
| interface PendingMutation { | |||
| type: string; | |||
| pluginId?: number; | |||
| paramId?: number; | |||
| originalValue?: any; | |||
| connectionId?: string; | |||
| } | |||
| const pendingMutations = new Map<number, PendingMutation>(); | |||
| export interface OSCMessageArgs { address: string; args: any[]; } | |||
| export interface UseOsc { | |||
| connect: (host: string, tcpPort: number, udpPort: number) => Promise<void>; | |||
| disconnect: () => void; | |||
| sendMessage: (address: string, ...args: any[]) => void; | |||
| addPlugin: (pluginIdentifier: string) => void; | |||
| removePlugin: (pluginId: number) => void; | |||
| connectPorts: (sPlugId: number, sPortIdx: number, tPlugId: number, tPortIdx: number) => void; | |||
| disconnectPorts: (connectionId: string) => void; | |||
| setParameterValue: (pluginId: number, paramId: number, value: number) => void; | |||
| connectionStatus: string; | |||
| lastMessage: OSCMessageArgs | null; | |||
| } | |||
| let nextMessageId = 1; | |||
| const OSC_TARGET_NAME = 'Carla'; | |||
| export const useOsc = (): UseOsc => { | |||
| const [connectionStatus, setConnectionStatus] = useState<string>('Idle'); | |||
| const [lastMessage, setLastMessage] = useState<OSCMessageArgs | null>(null); | |||
| const queryClient = useQueryClient(); | |||
| const statusCallbackRef = useRef(setConnectionStatus); | |||
| const paramUpdate = useCallback((pluginId: number, paramId: number, updates: Partial<PluginParameter>) => { | |||
| queryClient.setQueryData<PluginParameter[]>(['pluginParameters', pluginId], (oldParams = []) => { | |||
| const paramIndex = oldParams.findIndex(p => p.id === paramId); | |||
| if (paramIndex !== -1) { | |||
| const newParams = [...oldParams]; | |||
| newParams[paramIndex] = { ...newParams[paramIndex], ...updates }; | |||
| return newParams; | |||
| } | |||
| return [...oldParams, { id: paramId, name: `Param ${paramId}`, ...updates }]; | |||
| }); | |||
| }, [queryClient]); | |||
| const messageCallbackRef = useRef((msg: ReceivedOSCMessage) => { | |||
| setLastMessage({ address: msg.address, args: msg.args }); | |||
| console.log('Message received in useOsc:', msg.address, msg.args); | |||
| if (msg.address === '/ctrl/info') { | |||
| const [pluginId, type_, category, hints, uniqueId, optsAvail, optsEnabled, name, filename, iconName, realName, label, maker, copyright] = msg.args; | |||
| const newPluginData: PluginInfo = { id: pluginId, type: type_, category, hints, uniqueId, optionsAvailable: optsAvail, optionsEnabled: optsEnabled, name, filename, iconName, realName, label, maker, copyright }; | |||
| queryClient.setQueryData<PluginInfo[]>(['pluginList'], (oldData = []) => { | |||
| const existingPluginIndex = oldData.findIndex(p => p.id === pluginId); | |||
| if (existingPluginIndex !== -1) { const updatedData = [...oldData]; updatedData[existingPluginIndex] = { ...updatedData[existingPluginIndex], ...newPluginData }; return updatedData; } | |||
| return [...oldData, newPluginData]; | |||
| }); | |||
| } | |||
| if (msg.address === '/ctrl/ports') { | |||
| const [pluginId, audioIns, audioOuts, midiIns, midiOuts, paramIns, paramOuts, paramTotal] = msg.args; | |||
| const portData: PortCounts = { audioIns, audioOuts, midiIns, midiOuts, cvIns: paramIns, cvOuts: paramOuts, paramTotal }; | |||
| queryClient.setQueryData<PluginInfo[]>(['pluginList'], (oldData = []) => oldData.map(p => p.id === pluginId ? { ...p, ports: portData } : p)); | |||
| } | |||
| if (msg.address === '/ctrl/pcount') { | |||
| const [pluginId, pcount, mpcount] = msg.args; | |||
| const programCountsData: ProgramCounts = { programs: pcount, midiPrograms: mpcount }; | |||
| queryClient.setQueryData<PluginInfo[]>(['pluginList'], (oldData = []) => oldData.map(p => p.id === pluginId ? { ...p, programCounts: programCountsData } : p)); | |||
| } | |||
| if (msg.address === '/ctrl/iparams') { | |||
| const [pluginId, active, drywet, volume, balLeft, balRight, pan, ctrlChan] = msg.args; | |||
| const internalParamsData: InternalParams = { active: Boolean(active), dryWet: drywet, volume, balanceLeft: balLeft, balanceRight: balRight, panning: pan, ctrlChannel: ctrlChan }; | |||
| queryClient.setQueryData<PluginInfo[]>(['pluginList'], (oldData = []) => oldData.map(p => p.id === pluginId ? { ...p, internalParams: internalParamsData } : p)); | |||
| } | |||
| if (msg.address === '/ctrl/paramInfo') { const [pluginId, paramId, name, unit, comment, groupName] = msg.args; paramUpdate(pluginId, paramId, { name, unit, comment, groupName }); } | |||
| if (msg.address === '/ctrl/paramData') { const [pluginId, paramId, type, hints, midiChannel, mappedControlIndex, mappedMinimum, mappedMaximum, value] = msg.args; paramUpdate(pluginId, paramId, { type, hints, midiChannel, mappedControlIndex, mappedMinimum, mappedMaximum, value }); } | |||
| if (msg.address === '/ctrl/paramRanges') { const [pluginId, paramId, defaultValue, minimum, maximum, step, stepSmall, stepLarge] = msg.args; paramUpdate(pluginId, paramId, { defaultValue, minimum, maximum, step, stepSmall, stepLarge }); } | |||
| if (msg.address === '/ctrl/param') { const [pluginId, paramId, value] = msg.args; paramUpdate(pluginId, paramId, { value }); } | |||
| if (msg.address === '/ctrl/cb') { | |||
| const [action, value1, _value2, _value3, _valuef, valueStr] = msg.args; | |||
| if (action === 2 /* PLUGIN_REMOVE */) { | |||
| queryClient.setQueryData<PluginInfo[]>(['pluginList'], (oldData = []) => oldData.filter(p => p.id !== value1)); | |||
| queryClient.removeQueries({ queryKey: ['pluginParameters', value1], exact: true }); | |||
| } | |||
| if (action === 3 /* PORTS_CONNECT */) { | |||
| const connId = valueStr; | |||
| const parts = connId.match(/(\d+):(\d+)>(\d+):(\d+)/); | |||
| if (parts) { const newConn: PatchbayConnection = { id: connId, sourcePluginId: parseInt(parts[1]), sourcePortIndex: parseInt(parts[2]), targetPluginId: parseInt(parts[3]), targetPortIndex: parseInt(parts[4]) }; | |||
| queryClient.setQueryData<PatchbayConnection[]>(['patchbayConnections'], (old = []) => old.find(c=>c.id===connId)?old:[...old, newConn]);} | |||
| } | |||
| if (action === 4 /* PORTS_DISCONNECT */) { | |||
| queryClient.setQueryData<PatchbayConnection[]>(['patchbayConnections'], (old = []) => old.filter(c => c.id !== String(value1))); | |||
| } | |||
| } | |||
| if (msg.address === '/ctrl/resp') { | |||
| const [messageId, errorStr] = msg.args; | |||
| const mutationDetails = pendingMutations.get(messageId); | |||
| if (mutationDetails) { | |||
| if (errorStr && errorStr.length > 0) { | |||
| console.error(`Mutation Error (ID: ${messageId}, Type: ${mutationDetails.type}): ${errorStr}`); // Corrected | |||
| if (mutationDetails.type === 'setParameterValue' && mutationDetails.pluginId && mutationDetails.paramId && mutationDetails.originalValue !== undefined) { | |||
| paramUpdate(mutationDetails.pluginId, mutationDetails.paramId, { value: mutationDetails.originalValue }); | |||
| } | |||
| } else { | |||
| console.log(`Mutation Success (ID: ${messageId}, Type: ${mutationDetails.type})`); // Corrected | |||
| } | |||
| pendingMutations.delete(messageId); | |||
| } else { | |||
| console.warn(`Received /ctrl/resp for unknown messageId: ${messageId}`); // Corrected | |||
| } | |||
| } | |||
| }); | |||
| useEffect(() => { statusCallbackRef.current = setConnectionStatus; }, [setConnectionStatus]); | |||
| useEffect(() => { | |||
| oscWorker.onStatusChange(Comlink.proxy(statusCallbackRef.current)); | |||
| oscWorker.onMessage(Comlink.proxy(messageCallbackRef.current)); | |||
| return () => { /* Cleanup */ }; | |||
| }, [messageCallbackRef]); | |||
| const connect = useCallback(async (host: string, tcpPort: number, udpPort: number) => { | |||
| try { await oscWorker.connect(host, tcpPort, udpPort); } catch (e) { setConnectionStatus(`Failed: ${(e as Error).message}`); } // Corrected | |||
| }, []); | |||
| const disconnect = useCallback(() => { oscWorker.disconnect(); }, []); | |||
| const sendMessage = useCallback((address: string, ...args: any[]) => { oscWorker.sendMessage(address, ...args); }, []); | |||
| const addPlugin = useCallback((pluginIdentifier: string) => { | |||
| const mid = nextMessageId++; pendingMutations.set(mid, {type: 'addPlugin'}); | |||
| oscWorker.sendMessage('/ctrl/add_plugin', mid, -1, '', '', pluginIdentifier, '', pluginIdentifier); | |||
| }, []); | |||
| const removePlugin = useCallback((pluginId: number) => { | |||
| const mid = nextMessageId++; | |||
| const oldPlugins = queryClient.getQueryData<PluginInfo[]>(['pluginList']) || []; | |||
| const pluginToRemove = oldPlugins.find(p => p.id === pluginId); | |||
| pendingMutations.set(mid, {type: 'removePlugin', pluginId, originalValue: pluginToRemove }); | |||
| queryClient.setQueryData<PluginInfo[]>(['pluginList'], oldPlugins.filter(p => p.id !== pluginId)); | |||
| queryClient.removeQueries({queryKey:['pluginParameters',pluginId],exact:true}); | |||
| oscWorker.sendMessage('/ctrl/remove_plugin', mid, pluginId); | |||
| }, [queryClient]); | |||
| const connectPorts = useCallback((sPlugId: number, sPortIdx: number, tPlugId: number, tPortIdx: number) => { | |||
| const mid = nextMessageId++; pendingMutations.set(mid, {type: 'connectPorts'}); | |||
| oscWorker.sendMessage('/ctrl/patchbay_connect', mid, sPlugId, sPortIdx, tPlugId, tPortIdx); | |||
| }, []); | |||
| const disconnectPorts = useCallback((connectionId: string) => { | |||
| const mid = nextMessageId++; | |||
| const oldConns = queryClient.getQueryData<PatchbayConnection[]>(['patchbayConnections']) || []; | |||
| pendingMutations.set(mid, {type: 'disconnectPorts', connectionId, originalValue: oldConns.find(c => c.id === connectionId)}); | |||
| queryClient.setQueryData<PatchbayConnection[]>(['patchbayConnections'], oldConns.filter(c => c.id !== connectionId)); | |||
| oscWorker.sendMessage('/ctrl/patchbay_disconnect', mid, connectionId); | |||
| }, [queryClient]); | |||
| const setParameterValue = useCallback((pluginId: number, paramId: number, value: number) => { | |||
| const mid = nextMessageId++; | |||
| const params = queryClient.getQueryData<PluginParameter[]>(['pluginParameters', pluginId]) || []; | |||
| const originalParam = params.find(p => p.id === paramId); | |||
| pendingMutations.set(mid, { type: 'setParameterValue', pluginId, paramId, originalValue: originalParam?.value }); | |||
| paramUpdate(pluginId, paramId, { value }); // Optimistic update | |||
| const path = `/${OSC_TARGET_NAME}/${pluginId}/set_parameter_value`; | |||
| oscWorker.sendMessage(path, mid, paramId, value); | |||
| }, [queryClient, paramUpdate]); | |||
| return { connect, disconnect, sendMessage, addPlugin, removePlugin, connectPorts, disconnectPorts, setParameterValue, connectionStatus, lastMessage }; | |||
| }; | |||
| @@ -0,0 +1 @@ | |||
| /// <reference types="vite/client" /> | |||
| @@ -0,0 +1,27 @@ | |||
| { | |||
| "compilerOptions": { | |||
| "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", | |||
| "target": "ES2020", | |||
| "useDefineForClassFields": true, | |||
| "lib": ["ES2020", "DOM", "DOM.Iterable"], | |||
| "module": "ESNext", | |||
| "skipLibCheck": true, | |||
| /* Bundler mode */ | |||
| "moduleResolution": "bundler", | |||
| "allowImportingTsExtensions": true, | |||
| "verbatimModuleSyntax": true, | |||
| "moduleDetection": "force", | |||
| "noEmit": true, | |||
| "jsx": "react-jsx", | |||
| /* Linting */ | |||
| "strict": true, | |||
| "noUnusedLocals": true, | |||
| "noUnusedParameters": true, | |||
| "erasableSyntaxOnly": true, | |||
| "noFallthroughCasesInSwitch": true, | |||
| "noUncheckedSideEffectImports": true | |||
| }, | |||
| "include": ["src"] | |||
| } | |||
| @@ -0,0 +1,7 @@ | |||
| { | |||
| "files": [], | |||
| "references": [ | |||
| { "path": "./tsconfig.app.json" }, | |||
| { "path": "./tsconfig.node.json" } | |||
| ] | |||
| } | |||
| @@ -0,0 +1,25 @@ | |||
| { | |||
| "compilerOptions": { | |||
| "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", | |||
| "target": "ES2022", | |||
| "lib": ["ES2023"], | |||
| "module": "ESNext", | |||
| "skipLibCheck": true, | |||
| /* Bundler mode */ | |||
| "moduleResolution": "bundler", | |||
| "allowImportingTsExtensions": true, | |||
| "verbatimModuleSyntax": true, | |||
| "moduleDetection": "force", | |||
| "noEmit": true, | |||
| /* Linting */ | |||
| "strict": true, | |||
| "noUnusedLocals": true, | |||
| "noUnusedParameters": true, | |||
| "erasableSyntaxOnly": true, | |||
| "noFallthroughCasesInSwitch": true, | |||
| "noUncheckedSideEffectImports": true | |||
| }, | |||
| "include": ["vite.config.ts"] | |||
| } | |||
| @@ -0,0 +1,43 @@ | |||
| import { defineConfig } from 'vite' | |||
| import react from '@vitejs/plugin-react' | |||
| import { comlink } from 'vite-plugin-comlink' | |||
| import { VitePWA } from 'vite-plugin-pwa' | |||
| import path from 'path' | |||
| // https://vitejs.dev/config/ | |||
| export default defineConfig({ | |||
| plugins: [ | |||
| react(), | |||
| comlink(), | |||
| VitePWA({ | |||
| registerType: 'autoUpdate', | |||
| includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'], | |||
| manifest: { | |||
| name: 'Carla Control PWA', | |||
| short_name: 'CarlaCtrl', | |||
| description: 'A React PWA for controlling Carla.', | |||
| theme_color: '#ffffff', | |||
| icons: [ | |||
| { | |||
| src: 'pwa-192x192.png', | |||
| sizes: '192x192', | |||
| type: 'image/png' | |||
| }, | |||
| { | |||
| src: 'pwa-512x512.png', | |||
| sizes: '512x512', | |||
| type: 'image/png' | |||
| } | |||
| ] | |||
| } | |||
| }) | |||
| ], | |||
| worker: { | |||
| plugins: () => [comlink()] | |||
| }, | |||
| resolve: { | |||
| alias: { | |||
| '@': path.resolve(__dirname, './src'), | |||
| } | |||
| } | |||
| }) | |||