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'), | |||||
| } | |||||
| } | |||||
| }) | |||||