Open Sound Control (OSC) Plugin for NativePHP Mobile#
A NativePHP Mobile plugin for sending and receiving Open Sound Control (OSC) messages over UDP on iOS and Android. It ships the PHP facade, native Swift/Kotlin bridge implementations, typed JavaScript client, event payloads, listener status telemetry, and foreground-first lifecycle handling for show-control, audio, lighting, Arduino, ESP32, and other LAN OSC workflows.
License: proprietary - see License and LICENSE.md before use.
Installation#
composer require weswecan/nativephp-mobile-osc php artisan native:plugin:register weswecan/nativephp-mobile-osc
NativePHP only compiles plugins that are explicitly registered. If your app has not published the NativePHP plugins provider yet, run this first:
php artisan vendor:publish --tag=nativephp-plugins-provider
After registration, your NativePHP plugins provider should include:
Weswecan\MobileOsc\MobileOscServiceProvider::class
JavaScript Setup#
Add the #mobile-osc import mapping to your app's package.json:
{ "imports": { "#mobile-osc": { "types": "./vendor/weswecan/nativephp-mobile-osc/resources/js/dist/index.d.ts", "default": "./vendor/weswecan/nativephp-mobile-osc/resources/js/dist/index.js" } }}
Quick Start#
JavaScript (Vue/React/Inertia)#
import { mobileOsc, OscEvent, OscErrorCode, OscArgumentType } from '#mobile-osc'; const addresses = await mobileOsc.getLocalAddresses();console.log('Local OSC targets:', addresses); // Start a foreground listener on UDP port 9000.await mobileOsc.startListening(9000, { id: 'main', label: 'Main OSC input',}); // Receive messages.const cleanupMessage = mobileOsc.on(OscEvent.MessageReceived, (message) => { console.log(message.address, message.arguments);}); // Send a message with inferred int, float, and string arguments.await mobileOsc.send('192.168.1.50', 9001, '/cue/go', [1, 0.75, 'stage-left']); // Send a typed blob argument as base64.await mobileOsc.send('192.168.1.50', 9001, '/asset/blob', [ { type: OscArgumentType.Blob, value: btoa('payload') },]); // Listen for errors.const cleanupError = mobileOsc.on(OscEvent.Error, (error) => { if (error.code === OscErrorCode.ListenerFailed) { console.error('Listener failed:', error.message); }}); // Clean up listeners when your component unmounts.cleanupMessage();cleanupError();await mobileOsc.stopListening('main');
PHP (Livewire/Blade)#
use Weswecan\MobileOsc\Facades\MobileOsc; $status = MobileOsc::getStatus();$addresses = MobileOsc::getLocalAddresses(); MobileOsc::startListening(9000, [ 'id' => 'main', 'label' => 'Main OSC input',]); MobileOsc::send('192.168.1.50', 9001, '/cue/go', [1, 0.75, 'stage-left']);
Platform Support Matrix#
| Feature | iOS | Android | Notes |
|---|---|---|---|
getStatus() |
Yes | Yes | Returns plugin readiness, lifecycle fields, listener counters |
getLocalAddresses() |
Yes | Yes | Returns LAN/loopback addresses and interface metadata |
startListening() |
Yes | Yes | Foreground UDP listener |
stopListening() |
Yes | Yes | Stops one listener by listenerId |
stopAllListeners() |
Yes | Yes | Stops every listener |
send() |
Yes | Yes | Sends one OSC message |
sendBundle() |
Yes | Yes | Sends an OSC bundle |
| Multiple listeners | Yes | Yes | Use distinct id values and ports |
| OSC int/float/string/blob arguments | Yes | Yes | Blob values are base64 strings |
| Background receive guarantee | No | No | Best-effort only; no foreground service/background mode |
Platform Requirements#
| Platform | Requirement |
|---|---|
| PHP | ^8.2 |
| NativePHP Mobile | ^3 |
| iOS | iOS 15.0+ |
| Android | API level 21+ |
Permissions#
The plugin declares the permissions needed for UDP-based OSC networking.
iOS#
nativephp.json injects NSLocalNetworkUsageDescription:
This app uses the local network to send and receive OSC messages with audio, lighting, and show-control devices.
The first LAN operation may show Apple's local-network permission prompt. The user must allow access before other devices on the network can reliably reach the app.
This plugin does not add UIBackgroundModes. OSC receive behavior follows the normal iOS app lifecycle.
Android#
nativephp.json declares:
android.permission.INTERNET
The plugin does not create a foreground service or persistent notification. Long-running background reception remains best-effort and can be affected by Android lifecycle and power management.
Platform-Specific Notes#
Foreground-First Listening#
OSC reception is designed to work while the app is active in the foreground. When the app is backgrounded, iOS may suspend the process and Android may throttle or stop work depending on the device and power settings. If packets stop arriving while the app is not open, treat that as expected OS behavior rather than a plugin defect.
background Option#
startListening() accepts background: true in JavaScript and ['background' => true] in PHP. This does not mean "keep receiving OSC forever while suspended."
On iOS, it marks the listener for best-effort rebind when the app becomes active again. The native runtime may call rebindInactiveBestEffortListeners() for listeners that are no longer listening or starting.
Status Payload Is Telemetry#
getStatus() includes:
backgroundMode: "best_effort"lifecycle.isBackgroundedlifecycle.backgroundTransitionslifecycle.foregroundTransitions- Per-listener status, packet count, message count, and error count
Use these fields for debugging and UI. They are not an uptime SLA.
Simulator, Emulator, and Physical Devices#
On the iOS Simulator, 127.0.0.1 often maps to the host Mac, which is useful with desktop OSC tools. On a physical iPhone, loopback is the phone itself. Use the device's LAN IPv4 address from getLocalAddresses() when another device needs to send OSC to the app.
Android emulator networking can differ from physical hardware. Validate real show-control, lighting, audio, Arduino, or ESP32 workflows on a physical Android device connected to the same Wi-Fi network.
Manual Listening#
Listening is always opt-in. Your app must call startListening() when it wants a socket, for example from a button, settings screen, or your own bootstrap logic.
There is no plugin-level .env auto-start contract. Keep listener startup in application code so the user can see and control network behavior.
API Reference#
getStatus()#
Return plugin readiness, lifecycle state, background mode, and listener counters.
const status = await mobileOsc.getStatus(); console.log(status.initialized);console.log(status.backgroundMode); // "best_effort"console.log(status.listeners);
getLocalAddresses()#
Return local IPv4/IPv6 addresses and interface metadata for display, diagnostics, or instructions to another OSC device.
const addresses = await mobileOsc.getLocalAddresses(); addresses.forEach((address) => { console.log(address.address, address.interface, address.isLoopback);});
startListening(port, options?)#
Start a UDP listener on a local port.
await mobileOsc.startListening(9000, { id: 'main', address: '0.0.0.0', label: 'Main input', background: true,});
MobileOsc::startListening(9000, [ 'id' => 'main', 'address' => '0.0.0.0', 'label' => 'Main input', 'background' => true,]);
stopListening(listenerId?)#
Stop one listener. The default listener id is default.
await mobileOsc.stopListening('main');
stopAllListeners()#
Stop every active listener.
await mobileOsc.stopAllListeners();
send(host, port, address, arguments?, options?)#
Send one OSC message.
await mobileOsc.send('192.168.1.50', 9001, '/lighting/intensity', [ 7, 0.8, 'front',]);
Typed arguments are supported when inference is not enough:
await mobileOsc.send('192.168.1.50', 9001, '/blob', [ { type: 'blob', value: btoa('binary-ish payload') },]);
MobileOsc::send('192.168.1.50', 9001, '/lighting/intensity', [ 7, 0.8, 'front',]); MobileOsc::send('192.168.1.50', 9001, '/blob', [ ['type' => 'blob', 'value' => base64_encode('binary-ish payload')],]);
sendBundle(host, port, messages, options?)#
Send an OSC bundle.
await mobileOsc.sendBundle('192.168.1.50', 9001, [ { address: '/cue/prepare', arguments: [12] }, { address: '/cue/go', arguments: ['now'] },], { timetag: 1,});
MobileOsc::sendBundle('192.168.1.50', 9001, [ ['address' => '/cue/prepare', 'arguments' => [12]], ['address' => '/cue/go', 'arguments' => ['now']],], [ 'timetag' => 1,]);
normalizeArguments(arguments)#
Normalize shorthand OSC argument input into typed argument maps. This is useful when you want to preview or test payloads before sending.
const args = mobileOsc.normalizeArguments([1, 0.5, 'go']);// [// { type: 'int', value: 1 },// { type: 'float', value: 0.5 },// { type: 'string', value: 'go' }// ]
Argument Types#
| Type | JavaScript input | PHP input | Notes |
|---|---|---|---|
int |
1 or { type: 'int', value: 1 } |
1 or ['type' => 'int', 'value' => 1] |
Must fit signed int32 |
float |
0.75 or { type: 'float', value: 0.75 } |
0.75 or ['type' => 'float', 'value' => 0.75] |
Encoded as OSC float |
string |
'go' or { type: 'string', value: 'go' } |
'go' or ['type' => 'string', 'value' => 'go'] |
UTF-8 string |
blob |
{ type: 'blob', value: base64 } |
['type' => 'blob', 'value' => base64] |
Value must be valid base64 |
Events#
Subscribe on the JavaScript side with mobileOsc.on(...) and clean up with the returned callback. Subscribe on the PHP side with NativePHP's native event attribute.
JavaScript#
import { mobileOsc, OscEvent } from '#mobile-osc'; const cleanup = mobileOsc.on(OscEvent.MessageReceived, (payload) => { console.log(payload.listenerId, payload.address, payload.arguments);}); // Later:cleanup();
PHP / Livewire#
use Native\Mobile\Attributes\OnNative;use Weswecan\MobileOsc\Events\OscMessageReceived; #[OnNative(OscMessageReceived::class)]public function handleOscMessage( string $listenerId, string $remoteHost, int $remotePort, string $address, array $arguments, string $receivedAt,): void { // Handle the OSC message.}
Event Payloads#
OscMessageReceived
Dispatched when a complete OSC message is received.
listenerId- Listener that received the packetremoteHost- Sender IP addressremotePort- Sender UDP portaddress- OSC address pattern, for example/cue/goarguments- Typed OSC argument arrayreceivedAt- Native timestamp string
OscBundleReceived
Dispatched when an OSC bundle is received.
listenerId- Listener that received the packetremoteHost- Sender IP addressremotePort- Sender UDP porttimetag- OSC timetag stringelements- Bundle elements decoded by the native OSC codecreceivedAt- Native timestamp string
OscListenerStarted
Dispatched when a listener reaches a listening state.
listenerId- Listener idport- UDP portaddress- Bound local addressstatus- Listener status
OscListenerStopped
Dispatched when a listener stops.
listenerId- Listener idreason- Stop reason
OscMessageSent
Dispatched when a send completes.
host- Destination hostport- Destination portaddress- OSC addressbytes- Encoded packet sizeid- Optional correlation id
OscError
Dispatched when a listener, send path, codec path, or parameter validation fails.
operation- Operation that failedcode- Error codemessage- Human-readable messagelistenerId- Optional listener id
Error Codes#
| Code | Description | Action |
|---|---|---|
INVALID_PARAMETERS |
Required host, port, address, or payload input is missing or invalid | Check function arguments |
INVALID_ADDRESS |
OSC address does not start with / |
Use a valid OSC address pattern |
INVALID_PACKET |
Packet or bundle data could not be encoded/decoded | Check OSC payload shape |
UNSUPPORTED_TYPE |
Unsupported OSC argument type | Use int, float, string, or blob |
LISTENER_FAILED |
Listener could not start or crashed | Check port conflicts and status payload |
LISTENER_NOT_FOUND |
Requested listener does not exist | Check listener id |
SEND_FAILED |
UDP send failed | Check host, port, network, and permissions |
UNSUPPORTED_PLATFORM |
Operation is unavailable on the current platform | Guard platform-specific code |
Framework Examples#
Vue (Inertia)#
<script setup lang="ts">import { onUnmounted, ref } from 'vue';import { mobileOsc, OscEvent, type OscMessageReceivedPayload, type OscErrorPayload,} from '#mobile-osc'; const isListening = ref(false);const lastMessage = ref<OscMessageReceivedPayload | null>(null);const error = ref<string | null>(null); const cleanupMessage = mobileOsc.on(OscEvent.MessageReceived, (payload) => { lastMessage.value = payload;}); const cleanupError = mobileOsc.on(OscEvent.Error, (payload: OscErrorPayload) => { error.value = `${payload.code}: ${payload.message}`; isListening.value = false;}); onUnmounted(() => { cleanupMessage(); cleanupError(); mobileOsc.stopListening('main');}); async function start() { error.value = null; await mobileOsc.startListening(9000, { id: 'main', label: 'Main input' }); isListening.value = true;} async function stop() { await mobileOsc.stopListening('main'); isListening.value = false;} async function sendGo() { await mobileOsc.send('192.168.1.50', 9001, '/cue/go', [1]);}</script> <template> <div> <button :disabled="isListening" @click="start"> Start OSC </button> <button :disabled="!isListening" @click="stop"> Stop OSC </button> <button @click="sendGo"> Send Go </button> <div v-if="lastMessage"> <p>Address: {{ lastMessage.address }}</p> <p>From: {{ lastMessage.remoteHost }}:{{ lastMessage.remotePort }}</p> </div> <p v-if="error" style="color: red;">{{ error }}</p> </div></template>
React (Inertia)#
import { useCallback, useEffect, useState } from 'react';import { mobileOsc, OscEvent, type OscMessageReceivedPayload } from '#mobile-osc'; export default function OscPanel() { const [isListening, setIsListening] = useState(false); const [lastMessage, setLastMessage] = useState<OscMessageReceivedPayload | null>(null); const [error, setError] = useState<string | null>(null); useEffect(() => { const cleanupMessage = mobileOsc.on(OscEvent.MessageReceived, setLastMessage); const cleanupError = mobileOsc.on(OscEvent.Error, (payload) => { setError(`${payload.code}: ${payload.message}`); setIsListening(false); }); return () => { cleanupMessage(); cleanupError(); mobileOsc.stopListening('main'); }; }, []); const start = useCallback(async () => { setError(null); await mobileOsc.startListening(9000, { id: 'main', label: 'Main input' }); setIsListening(true); }, []); const stop = useCallback(async () => { await mobileOsc.stopListening('main'); setIsListening(false); }, []); return ( <div> <button onClick={start} disabled={isListening}>Start OSC</button> <button onClick={stop} disabled={!isListening}>Stop OSC</button> <button onClick={() => mobileOsc.send('192.168.1.50', 9001, '/cue/go', [1])}> Send Go </button> {lastMessage && ( <p>{lastMessage.address} from {lastMessage.remoteHost}:{lastMessage.remotePort}</p> )} {error && <p style={{ color: 'red' }}>{error}</p>} </div> );}
Livewire#
Component class:
<?php namespace App\Livewire; use Livewire\Component;use Native\Mobile\Attributes\OnNative;use Weswecan\MobileOsc\Events\OscError;use Weswecan\MobileOsc\Events\OscListenerStarted;use Weswecan\MobileOsc\Events\OscListenerStopped;use Weswecan\MobileOsc\Events\OscMessageReceived;use Weswecan\MobileOsc\Facades\MobileOsc; class OscPanel extends Component{ public bool $isListening = false; public ?string $lastAddress = null; public ?string $lastSender = null; public ?string $error = null; public function start(): void { $this->error = null; MobileOsc::startListening(9000, [ 'id' => 'main', 'label' => 'Main input', ]); } public function stop(): void { MobileOsc::stopListening('main'); } public function sendGo(): void { MobileOsc::send('192.168.1.50', 9001, '/cue/go', [1]); } #[OnNative(OscListenerStarted::class)] public function handleStarted(string $listenerId, int $port, string $address, string $status): void { $this->isListening = true; } #[OnNative(OscListenerStopped::class)] public function handleStopped(string $listenerId, string $reason = 'stopped'): void { $this->isListening = false; } #[OnNative(OscMessageReceived::class)] public function handleMessage( string $listenerId, string $remoteHost, int $remotePort, string $address, array $arguments, string $receivedAt, ): void { $this->lastAddress = $address; $this->lastSender = "{$remoteHost}:{$remotePort}"; } #[OnNative(OscError::class)] public function handleError(string $operation, string $code, string $message, ?string $listenerId = null): void { $this->error = "{$code}: {$message}"; $this->isListening = false; } public function render() { return view('livewire.osc-panel'); }}
Blade template (resources/views/livewire/osc-panel.blade.php):
<div> <button wire:click="start" @disabled($isListening)> Start OSC </button> <button wire:click="stop" @disabled(! $isListening)> Stop OSC </button> <button wire:click="sendGo"> Send Go </button> @if ($lastAddress) <p>Last message: {{ $lastAddress }} from {{ $lastSender }}</p> @endif @if ($error) <p style="color: red;">{{ $error }}</p> @endif</div>
Features Not Included#
The following features are intentionally not included:
Guaranteed Background Reception#
The plugin does not declare iOS background modes and does not create an Android foreground service. OSC listening is foreground-first with best-effort recovery.
TCP OSC#
OSC over TCP is not implemented. This plugin is UDP-only.
OSC Query / Discovery#
OSC Query, Bonjour/mDNS advertisement, and automatic device discovery are not included. Use getLocalAddresses() to show the app's reachable addresses and configure peers explicitly.
Low-Level Network Policy Management#
The plugin does not manage Wi-Fi, VPN, multicast routing, firewall policy, or Android battery optimization settings.
Troubleshooting#
- No local-network access on iOS: Make sure the user accepted the local-network permission prompt. Reinstalling or resetting app permissions may be needed during development.
- Listener stuck in
waitingorfailed: Check port conflicts, binding address, andOscErrorpayloads. CallgetStatus()to inspect per-listener counters. - No packets in the background: Expected. See Foreground-First Listening.
- Desktop tool cannot reach the phone: Use the phone's LAN address from
getLocalAddresses(), not127.0.0.1. - Android emulator behaves differently from a device: Test show-control, lighting, audio, Arduino, and ESP32 workflows on physical hardware connected to the same Wi-Fi network.
- Bridge calls during WebView boot fail on Android: Let the user trigger
getStatus()/getLocalAddresses()after launch, or delay and retry after the NativePHP bridge is ready.
Development#
The package ships TypeScript source in resources/js/src/ and compiled output in resources/js/dist/.
yarn installyarn buildcomposer test
composer test runs the package Pest suite in tests/.
Support#
For technical issues or questions, email [email protected].
For licensing, seats, or purchase, use the same address - see License.
License#
Proprietary software (composer.json declares license: proprietary). Use is governed by the End User License Agreement (EULA) in LICENSE.md. By installing, copying, or using this package, you agree to that agreement.
The following is a summary only and does not replace the full EULA:
| Topic | Summary |
|---|---|
| Copyright | Copyright 2026-present Context Undefined. All rights reserved. |
| Seats | One license per developer who accesses, modifies, or compiles the Software's source; additional developers require additional seats. |
| You may | Use on your projects and in client work when integrated into a final product, not as a standalone offering; modify for integration in authorized projects. |
| You may not | Redistribute, sublicense, or sell the Software; publish source on public GitHub or registries; use it to build a competing product; remove proprietary notices; share license credentials. |
Governing law and liability terms are in LICENSE.md (Netherlands).
Licensing inquiries: [email protected]