Browse Source

feat: Initialize React PWA and implement core OSC/state management

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
google-labs-jules[bot] 4 months ago
parent
commit
19073d97bb
27 changed files with 8127 additions and 0 deletions
  1. +6
    -0
      package-lock.json
  2. +24
    -0
      react/.gitignore
  3. +71
    -0
      react/App.tsx
  4. +54
    -0
      react/README.md
  5. +17
    -0
      react/components.json
  6. +7113
    -0
      react/package-lock.json
  7. +44
    -0
      react/package.json
  8. +0
    -0
      react/public/apple-touch-icon.png
  9. +0
    -0
      react/public/favicon.ico
  10. +0
    -0
      react/public/mask-icon.svg
  11. +0
    -0
      react/public/pwa-192x192.png
  12. +0
    -0
      react/public/pwa-512x512.png
  13. +1
    -0
      react/public/vite.svg
  14. +42
    -0
      react/src/App.css
  15. +82
    -0
      react/src/App.tsx
  16. +1
    -0
      react/src/assets/react.svg
  17. +164
    -0
      react/src/components/CarlaInfoDisplay.tsx
  18. +76
    -0
      react/src/index.css
  19. +6
    -0
      react/src/lib/utils.ts
  20. +10
    -0
      react/src/main.tsx
  21. +134
    -0
      react/src/osc-worker.ts
  22. +179
    -0
      react/src/services/useOsc.ts
  23. +1
    -0
      react/src/vite-env.d.ts
  24. +27
    -0
      react/tsconfig.app.json
  25. +7
    -0
      react/tsconfig.json
  26. +25
    -0
      react/tsconfig.node.json
  27. +43
    -0
      react/vite.config.ts

+ 6
- 0
package-lock.json View File

@@ -0,0 +1,6 @@
{
"name": "app",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

+ 24
- 0
react/.gitignore View File

@@ -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?

+ 71
- 0
react/App.tsx View File

@@ -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;

+ 54
- 0
react/README.md View File

@@ -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,
},
})
```

+ 17
- 0
react/components.json View File

@@ -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"
}
}

+ 7113
- 0
react/package-lock.json
File diff suppressed because it is too large
View File


+ 44
- 0
react/package.json View File

@@ -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
react/public/apple-touch-icon.png View File


+ 0
- 0
react/public/favicon.ico View File


+ 0
- 0
react/public/mask-icon.svg View File


+ 0
- 0
react/public/pwa-192x192.png View File


+ 0
- 0
react/public/pwa-512x512.png View File


+ 1
- 0
react/public/vite.svg View File

@@ -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>

+ 42
- 0
react/src/App.css View File

@@ -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;
}

+ 82
- 0
react/src/App.tsx View File

@@ -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;

+ 1
- 0
react/src/assets/react.svg View File

@@ -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>

+ 164
- 0
react/src/components/CarlaInfoDisplay.tsx View File

@@ -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} &rarr; 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>
);
};

+ 76
- 0
react/src/index.css View File

@@ -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;
}
}

+ 6
- 0
react/src/lib/utils.ts View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

+ 10
- 0
react/src/main.tsx View File

@@ -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>,
);

+ 134
- 0
react/src/osc-worker.ts View File

@@ -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;

+ 179
- 0
react/src/services/useOsc.ts View File

@@ -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 };
};

+ 1
- 0
react/src/vite-env.d.ts View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

+ 27
- 0
react/tsconfig.app.json View File

@@ -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"]
}

+ 7
- 0
react/tsconfig.json View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

+ 25
- 0
react/tsconfig.node.json View File

@@ -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"]
}

+ 43
- 0
react/vite.config.ts View File

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

Loading…
Cancel
Save