Real-world inputs¶
Real-world inputs let a Pulp application turn signals from devices and other software into musical control. You can use a facial expression to move a synthesizer filter, move your hands through a granular texture, tilt a phone to change pitch or reverb, or use a gesture to trigger samples. A TouchDesigner or Max/MSP patch can control a Pulp instrument over OSC, and a custom machine-learning model can become an expressive interface. Several physical inputs can feed different controls in the same instrument.
These are integration patterns, not turnkey face tracking or gesture products. Your application or companion process captures the signal and decides what it means; Pulp provides a consistent, real-time-safe way to deliver, validate, and expire the resulting control data.
What is available today¶
Pulp ships a small provider-neutral contract in the runtime SDK:
pulp::runtime::ExternalInputFrame<N> carries one observation and
ExternalInputGate<N> validates it for a consumer. The core replay and ingress
tests and the examples/cv-control application exercise this contract. The
TouchDesigner and Fairlight sections are optional recipes with synthesized
evidence; they do not mean those products are installed or validated on every
machine. A live camera, phone, named host, or model still needs an
application-specific acceptance run.
Pulp handles bounded frame storage, source and restart identity, ordering, confidence and value validation, status changes, and sample-count staleness. It does not capture devices, request permissions, run inference, own model weights, or write saved parameters from a provider thread. Those responsibilities stay with your application.
How the contract works¶
A provider publishes one complete observation at a time. The frame carries a
source identity, a provider restart epoch, sequence and provider time, status,
confidence, and a bounded array of application-defined values. The gate accepts
only well-formed frames that meet the consumer policy. invalid, unavailable,
denied, and paused statuses expire the accepted value. Advance the gate once
per audio block with the block sample count; it never compares a provider clock
with the audio clock.
Providers and applications own capture, permissions, inference, lifecycle, and
mapping. Use an existing TripleBuffer, SPSC queue, OSC, IPC, MIDI, or WAM
handoff; do not add a provider-specific transport or schema to Pulp core. See
the licensing boundary
for why application-selected providers are not Pulp-redistributed dependencies.
Quick start: one value from any provider¶
Capture and inference happen on a provider or worker thread. At the handoff, translate the result into Pulp's frame, submit it to a gate, then read the accepted value while processing audio. The excerpt below uses only the public runtime API and is complete C++ apart from the provider's frame transport. The provider could be a camera model, phone sensor, MIDI device, or OSC receiver.
#include <cstdint>
#include <span>
#include "pulp/runtime/external_input.hpp"
using namespace pulp::runtime;
using Frame = ExternalInputFrame<1>;
void process_block(ExternalInputGate<1>& gate,
const ExternalInputPolicy& policy,
bool has_new_frame,
const Frame& frame,
std::span<float> output,
float base_parameter) noexcept {
// The application reads its latest complete frame from a TripleBuffer,
// OSC/IPC receiver, MIDI adapter, or another existing handoff.
if (has_new_frame)
(void)gate.submit(frame, policy);
// No new frame is still a valid audio block: keep the last accepted value,
// or use the base parameter until the first value is accepted.
const float control = gate.valid() ? gate.value(0, 0.0f) : 0.0f;
const float effective = base_parameter + control;
for (float& sample : output)
sample = effective; // replace with the application's existing DSP
// Advance once with this block's actual sample count.
gate.advance(static_cast<std::uint64_t>(output.size()), policy);
}
The provider constructs a frame away from the callback and the application-owned
handoff supplies has_new_frame and frame. A duplicate sequence returns
unchanged; an older sequence or provider timestamp is rejected. A new epoch
allows a provider restart, while advance expires an accepted value after the
configured sample budget. None of these cases stops the callback from writing
audio: it continues with the last accepted value or the base parameter.
The public runtime types are the frame, policy, gate, verdict, and status shown
above. Keep related values in one frame so a hand pose, face pose, or IMU
orientation cannot be assembled from different updates. The
examples/cv-control application shows a fuller mapping and ingress example;
its ControlSample and SignalMapping remain application-owned.
flowchart LR
A[Camera, sensor, MIDI, OSC, or ML worker] --> B[Application-owned handoff]
B --> C[ExternalInputGate]
C -->|accepted value or base value| D[Existing mapping and modulation]
D --> E[Audio callback]
C -->|stale, invalid, denied, or paused| F[Safe fallback]
F --> D
Choosing a handoff¶
| Source and consumer | Recommended existing path |
|---|---|
| Native provider to the same application or plugin process | Application-owned TripleBuffer<T> or another existing bounded runtime primitive. |
| External process to a desktop/plugin consumer | Existing OSC or IPC. Keep the external process responsible for camera/device permissions and inference. |
| Browser provider to a WAM processor | Existing WAM MIDI ingress; the example reserves channel-16 pitch bend for one compact control packet. |
| Host automation or musical control | Existing MIDI/MPE and StateStore/modulation paths. |
| Mobile app to a mobile plugin or companion process | Application-owned platform handoff or an optional external transport selected by the host. |
The handoff must have one clear writer and one audio-side reader. If several
providers can be selected, the application owns arbitration and provider
lifecycle; do not concurrently write the same TripleBuffer from multiple
providers.
Mapping and failure behavior¶
Keep the saved or host-automated parameter as the base. Add or otherwise explicitly combine the ephemeral input value, and expose the effective value through the existing modulation/display path. Define these cases in the application and test them with replay:
- confidence below the application gate;
- malformed or non-finite values;
- no provider or denied permission;
- provider restart and sequence reset;
- stale input after a provider stops;
- lifecycle pause/resume and unavailable hardware.
Capture and inference must stop before the application tears down their provider resources. Audio processing must remain allocation-free and non-blocking after preparation.
Surface and host boundaries¶
- Web/WASM: camera and sensor APIs require browser permission and an allowed origin. Web Bluetooth, HID, Serial, DeviceMotion, and inference availability vary by browser. The mapping can remain shared with native code while the provider is browser-specific.
- Standalone desktop: native camera, IMU, BLE, serial, USB, HID, or an external provider may own the device. The provider must publish off the audio thread.
- Audio plugins: assume the host may deny camera/device access. Prefer a companion process, OSC/IPC, host MIDI, or host automation when direct access is unavailable.
- iOS and Android: Core Motion, platform sensors, camera, BLE, and mobile ML APIs belong to the application adapter. Permissions and pause/resume are platform-owned. AUv3 or other mobile plugin sandboxes may require an app or companion process to provide the input.
Mobile adapter recipe¶
The mobile adapter can stay small because it only translates a platform event into the same application-owned frame used by the desktop and web providers. For example, an Android app can sample a rotation sensor on a non-audio dispatcher and pass the normalized result to its existing handoff:
private val listener = object : SensorEventListener {
private var sequence = 0L
override fun onSensorChanged(event: SensorEvent) {
if (event.sensor.type != Sensor.TYPE_ROTATION_VECTOR) return
val value = normalizeTilt(event.values) // application calibration
publishFrame(
sequence = ++sequence,
control = value,
confidence = 1.0f,
valid = true,
timestampNanos = event.timestamp,
) // TripleBuffer, OSC/IPC, or the app's native queue
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
}
sensorManager.registerListener(
listener,
sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
?: return unavailable(),
SensorManager.SENSOR_DELAY_GAME,
handler,
)
publishFrame is application code; Pulp does not need to know what the value
means. Stop and unregister the listener on pause, release, permission denial,
or provider restart, and publish one invalid frame if the consumer needs an
immediate loss notification. The audio callback only reads the already
published frame. The existing com.pulp.motion Android package is a UI motion
observability/tracing facade, not a physical sensor API; do not use it as
evidence that an accelerometer or gyroscope is already wired to parameters.
An iOS application follows the same boundary with CMMotionManager:
motion.startDeviceMotionUpdates(to: providerQueue) { [weak self] sample, _ in
guard let sample else { return }
self?.publishFrame(
sequence: self?.nextSequence() ?? 0,
control: normalizeTilt(sample.attitude.roll),
confidence: 1,
valid: true,
timestamp: sample.timestamp
)
}
The optional PulpSwift package includes the small
PulpMotionControlSource adapter for this boundary. A host application only
needs to select its handoff and mapping:
let source = PulpMotionControlSource { frame in
// This closure runs on the provider queue, never in the audio callback.
controlHandoff.publish(frame)
}
guard source.start() else {
controlHandoff.publishUnavailable()
return
}
// Call source.stop() from the app/plugin lifecycle teardown.
PulpMotionControlFrame carries the sequence, normalized value, confidence,
validity, and timestamp as one observation. PulpMotionControlMath is pure
and testable without an iPhone. The package does not request permissions or
write parameters; the containing app owns the Info.plist usage description,
mapping, and external handoff.
These snippets are provider recipes, not a new Pulp sensor subsystem. A mobile plugin that cannot access the sensor permission can receive the same frames from its containing app or an external process over an existing transport.
Native camera and custom-model adapters¶
Desktop applications can keep camera capture and inference in an optional provider target. The application-owned output is the only part shared with the Pulp consumer:
// Optional application target; OpenCV is not a Pulp dependency.
cv::VideoCapture camera(0);
cv::Mat frame;
std::uint64_t sequence = 0;
while (camera.read(frame)) {
const auto result = face_provider.infer(frame); // MediaPipe/OpenCV/custom code
publish(FaceFrame{
.sequence = ++sequence,
.mouth_open = result.mouth_open,
.confidence = result.confidence,
.tracked = result.tracked,
}); // TripleBuffer, OSC/IPC, or another existing handoff
}
For a custom ONNX model, keep the runtime and model package in the application
or an external process. The inference worker publishes one complete
application-owned frame; the audio callback never sees an Ort::Session or a
camera object:
Ort::Session session(env, model_path.c_str(), session_options);
while (running) {
auto input = next_input_frame();
auto outputs = session.Run(Ort::RunOptions{nullptr}, input_names, &input, 1,
output_names, output_count);
publish(ModelFrame{
.sequence = ++sequence,
.drive = normalize(outputs[0]),
.blend = normalize(outputs[1]),
.confidence = confidence(outputs),
.valid = outputs_are_finite(outputs),
});
}
The consumer then uses the existing example-owned ModelMapping and its
normal StateStore/modulation path. ONNX Runtime, OpenCV, model weights, and
their licenses remain optional and application-selected. If the model worker
stops, publishes malformed output, or loses its input, publish one invalid
frame and let the existing stale timeout and replay tests exercise the failure
policy.
OBS Studio, DaVinci Resolve/Fairlight, Max/MSP, TouchDesigner, and similar hosts are compatibility forcing functions. They should consume existing plugin, OSC, MIDI, IPC, WebSocket, or external-process boundaries through optional recipes. Pulp core must not link host SDKs or copy host code. A host adapter becomes implementation work only when a named application requires a live acceptance receipt.
External host boundaries¶
These products are useful design tests, but they do not define a new Pulp provider or transport API:
- OBS Studio: a future integration can use an OBS-side plugin, an external OSC/WebSocket/UDP bridge, or a virtual audio/video device. Keep OBS's GPL code and lifecycle outside Pulp core.
- DaVinci Resolve/Fairlight: VST3/AU is the practical audio-plugin path; Resolve scripting or control-surface APIs can be reached through a separate companion bridge when project or video control is needed. Do not make the Blackmagic SDK or a Resolve installation a Pulp dependency.
- Max/MSP, TouchDesigner, openFrameworks, vvvv, and Pure Data: preserve named, inspectable signals and optional OSC/MIDI/IPC recipes so a thin host adapter can route them without a Pulp-specific node graph or landmark schema.
- Zoom and Google Meet: do not assume that a call host can load an audio plugin, accept arbitrary OSC, or grant a provider camera permission. A companion desktop/browser application, virtual microphone/camera, or a verified product SDK may publish controls into a Pulp app or plugin. Treat direct Zoom/Meet integration as a separately verified product requirement.
For any future host demo, name the boundary being exercised (for example, an OBS filter property, a Fairlight VST3 parameter, or a companion-process input stream). The demo must prove that boundary without implying that Pulp owns the host's permissions, scheduling, or lifecycle.
Optional provider and model dependencies¶
MediaPipe, OpenCV, OpenSeeFace, ONNX Runtime, LiteRT, Core ML, platform sensor APIs, and device SDKs remain application-selected dependencies. Audit model weights, task bundles, codecs, and SDK terms separately from runtime licenses. Keep these dependencies in the application or companion process that selects them, outside Pulp core. Applications that do not use a provider should not inherit its SDK, model weights, permissions, or installation requirements.
Connecting TouchDesigner over OSC¶
TouchDesigner is an example of an external sharing application. This recipe is
outside Pulp and requires a pinned TouchDesigner build on the host. Create
an OSC Out CHOP or DAT that sends UDP to 127.0.0.1:9001 with the existing
examples/cv-control message shape:
The Pulp application maps the message to ExternalInputFrame<1> with its
configured source_id, current provider epoch, sequence, valid status,
confidence, and one value. TouchDesigner owns any camera, sensor, or MediaPipe
operator and its permissions; Pulp receives only the normalized application
value through the existing OSC ingress. MediaPipe may be used inside
TouchDesigner or a companion process as an optional provider, but Pulp does not
bundle its SDK, model, permissions, or landmark schema.
The fixture at
test/fixtures/external-input/t7-touchdesigner-synthesized.jsonl exercises the
positive frame, below-threshold confidence, paused status, restart epoch,
and silence-to-expiry cases. It is explicitly marked synthesized: this host
does not have TouchDesigner installed, so it is not a live TouchDesigner
acceptance receipt. A live receipt must record the exact TouchDesigner version,
OS, destination port, patch/operator identity, and a captured packet sequence
before this recipe can be treated as validated on a live host.
Using Pulp plugins in DaVinci Resolve / Fairlight¶
The Fairlight acceptance path is host-only and does not add a Blackmagic SDK
or Resolve dependency to Pulp. Install an exact Resolve version, place a built
Pulp VST3 in the host's plugin search path, and record the plugin identity,
project, timeline, track, and parameter automation. The receipt must show a
before/after host automation value reaching the plugin's existing
get_modulated path.
examples/cv-control currently declares CvFaceTone as a VST3 instrument and
does not expose an input bus. Verify that the selected Fairlight version loads
VST3 instruments and automates its Brightness parameter before claiming a live
Fairlight integration. If it only accepts effects, use an existing Pulp VST3
effect with a clearly automatable parameter or leave the integration
unverified; do not change core solely to force host compatibility.
The receipt fixture at
test/fixtures/external-input/t10-fairlight-receipt.json is an evidence
template, not a live host result.
This checkout has no DaVinci Resolve installation, so it deliberately records
that live-host evidence is still missing.