A JUCE module for embedding Compose Multiplatform UI in audio plugins.
juce-cmp-demo.mov
Note: This project was entirely vibe-coded by Claude Opus 4.5 under human supervision. Final code review pending.
Alpha software.
- macOS implementation complete
- Basic bidirectional message passing (ValueTree payloads)
# Build everything
./demo/scripts/build.sh
# Run demo standalone
./demo/scripts/run_standalone.sh
# Install demo AU plugin
./demo/scripts/install_plugin.shPrerequisites:
- macOS 10.15+
- JDK 21+ (
brew install openjdk@21) - CMake 3.15+ (
brew install cmake) - Xcode Command Line Tools (
xcode-select --install)
┌─────────────────────────────────────────────────────────┐
│ JUCE Plugin (uses juce_cmp module) │
│ - ComposeComponent creates shared IOSurface │
│ - SurfaceView (NSView/CALayer) displays surface │
│ - Transparent JUCE component captures input events │
│ - Launches Compose UI as child process │
└─────────────────┬───────────────────────────────────────┘
│ Mach port (IOSurface sharing)
│ Unix socket (input, messages)
▼
┌─────────────────────────────────────────────────────────┐
│ Child Process (Compose Desktop / Skia / Metal) │
│ - Renders to shared IOSurface via Metal backend │
│ - Receives input events, injects into ComposeScene │
│ - Sends messages back to host via socket │
│ - Zero CPU pixel copies │
└─────────────────────────────────────────────────────────┘
Rendering: The plugin creates an IOSurface and sends it to the child via Mach port. The Compose UI uses Skia's Metal backend to render directly to the shared surface. The host's CALayer displays the surface content.
Input: Mouse/keyboard events are captured by the JUCE component and sent to the child via a 16-byte binary protocol over a Unix socket. The child deserializes and injects them into the Compose scene.
IPC: A single multiplexed Unix socket handles all communication: input events (host→child), resize notifications (host→child), and ValueTree messages (bidirectional). IOSurface sharing uses a separate Mach port channel.
juce_cmp/ # JUCE module (include in your plugin)
juce_cmp.h # Module header with metadata
juce_cmp.cpp # Unity build (C++ implementations)
juce_cmp.mm # Unity build (Objective-C++ implementations)
juce_cmp/ # Implementation files
ComposeComponent.h/cpp # JUCE Component displaying Compose UI
ComposeProvider.h/cpp # Orchestrates embedding lifecycle
ChildProcess.h/cpp # Child process lifecycle (posix_spawn)
Surface.h/mm # IOSurface management (macOS)
SurfaceView.h/mm # NSView/CALayer for display (macOS)
MachPort.h/mm # Mach port IPC for IOSurface sharing
Ipc.h/cpp # Bidirectional socket IPC
input_event.h # 16-byte binary input protocol
ipc_protocol.h # IPC protocol constants
ui_helpers.h # UI utilities
Usage in your plugin:
#include <juce_cmp/juce_cmp.h>
class MyEditor : public juce::AudioProcessorEditor {
juce_cmp::ComposeComponent composeComponent;
MyEditor(AudioProcessor& p) : AudioProcessorEditor(p) {
addAndMakeVisible(composeComponent);
// Handle messages from UI (app interprets ValueTree content)
composeComponent.onEvent([&](const juce::ValueTree& tree) {
if (tree.getType() == juce::Identifier("param")) {
auto paramId = (int)tree.getProperty("id");
auto value = (float)(double)tree.getProperty("value");
// Update your processor parameters
}
});
// Send message to UI
juce::ValueTree tree("param");
tree.setProperty("id", 0, nullptr);
tree.setProperty("value", 0.5, nullptr);
composeComponent.sendEvent(tree);
}
};Add to your CMakeLists.txt:
juce_add_module(path/to/juce_cmp)
target_link_libraries(YourPlugin PRIVATE juce_cmp ...)juce_cmp_ui/ # Kotlin Multiplatform library
lib/
build.gradle.kts # Library build config
src/jvmMain/
kotlin/juce_cmp/
Library.kt # Library initialization
ipc/
Ipc.kt # Socket IPC channel
JuceValueTree.kt # JUCE-compatible ValueTree
input/
InputDispatcher.kt # Injects events into ComposeScene
InputMapper.kt # Maps key codes to Compose
InputEvent.kt # Event data classes
renderer/
IOSurfaceRenderer.kt # Metal rendering to IOSurface
cpp/
iosurface_renderer.m # Native Metal/Mach bridge
Usage in your Compose app:
// settings.gradle.kts
includeBuild("path/to/juce_cmp_ui")
// build.gradle.kts
dependencies {
implementation("com.github.juce-cmp:lib")
}
// main.kt
import juce_cmp.Library
fun main(args: Array<String>) {
Library.init(args) // MUST be first - parses args, sets up IPC
if (Library.hasHost) {
// Embedded mode - render to host's shared surface
Library.host { MyApp() }
} else {
// Standalone window mode
application {
Window(onCloseRequest = ::exitApplication) {
MyApp()
}
}
}
}demo/ # Example plugin using juce_cmp
PluginProcessor.h/cpp # Simple synth processor
PluginEditor.h/cpp # Editor using ComposeComponent
ui/ # Demo Compose UI application
scripts/ # Build and run scripts
CMakeLists.txt # Builds demo plugin
All messages have a 1-byte type prefix:
| Type | Value | Direction | Content |
|---|---|---|---|
| INPUT | 0x00 | Host→Child | 16-byte input event |
| CMP | 0x01 | Child→Host | 1-byte subtype (SURFACE_READY=0) |
| JUCE | 0x02 | Bidirectional | 4-byte size + ValueTree data |
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 1 | type | 0=mouse, 1=key, 2=focus, 3=resize |
| 1 | 1 | action | 0=press, 1=release, 2=move, 3=scroll |
| 2 | 1 | button | 1=left, 2=right, 3=middle |
| 3 | 1 | modifiers | 1=shift, 2=ctrl, 4=alt, 8=meta |
| 4 | 2 | x | Mouse X, key code, or width |
| 6 | 2 | y | Mouse Y or height |
| 8 | 2 | data1 | Scroll delta X (×10000) or codepoint low |
| 10 | 2 | data2 | Scroll delta Y (×10000) or codepoint high |
| 12 | 4 | timestamp | Milliseconds since process start |
Binary format compatible with JUCE's ValueTree::writeToStream(). The library passes ValueTree blobs opaquely—apps define their own schema.
The UI app accepts these flags when launched by the plugin:
--socket-fd=<fd>- Unix socket file descriptor for IPC--mach-service=<name>- Mach service name for IOSurface sharing--scale=<factor>- Display scale factor (e.g., 2.0 for Retina)
Current: macOS 10.15+ (IOSurface + Metal)
Planned:
- Windows (DXGI shared textures)
- Linux (shared memory or Vulkan external memory)
MIT