Publish device media through CultMesh

This commit is contained in:
Meta Vens 2026-05-31 14:20:07 +01:00
commit 6facd57ea6
6 changed files with 424 additions and 37 deletions

View file

@ -43,7 +43,8 @@ the installed Android SDK and CultLib's `cultmesh-kotlin` package, without
Gradle or Flutter. It exists to put Eve on Periwinkle immediately: it consumes
typed `mimir.eve_dashboard_state.v1` CultMesh documents, sends
`mimir.eve_dashboard_command.v1` command documents, and publishes timestamped
touch/motion observations as `mimir.eve_sensor_observation.v1`.
touch/motion observations as `mimir.eve_sensor_observation.v1` plus camera/mic
payloads as `mimir.eve_media_observation.v1`.
- `EVEAppDelegate` creates one fullscreen `UIWindow`.
- `EVEViewController` installs:
@ -56,13 +57,13 @@ touch/motion observations as `mimir.eve_sensor_observation.v1`.
- `EVEFrameStreamClient` receives binary JPEG frames from the Starfire CEF relay
and sends touch events back as JSON viewport coordinates.
- `EVESensorUplinkClient` opens separate WebSocket uplinks for camera and
microphone frame-events so sensor traffic does not block display/control
microphone observations so sensor traffic does not block display/control
traffic.
- `EVEDashboardClient` opens the native Mimir dashboard socket and receives
scene/control state snapshots.
- `EVEViewController` captures camera frames with AVFoundation and microphone
blocks with AVAudioEngine, then sends `eve-camera` and `eve-mic` samples to
Mimir. It also renders the dashboard natively with UIKit: a scene graph,
blocks with AVAudioEngine, then sends binary CultMesh `eve-camera` and
`eve-mic` media observations to Mimir. It also renders the dashboard natively with UIKit: a scene graph,
draggable source panels, visibility/reset controls, and multitouch
pan/pinch/rotate transform commands.
@ -131,11 +132,10 @@ dotnet run --project E:\Projects\Mimir\src\Mimir.EveSensorReceiver\Mimir.EveSens
dotnet run --project E:\Projects\Mimir\src\Mimir.EveSensorReceiver\Mimir.EveSensorReceiver.csproj -- --port 8794 --path /eve/mic --source-id eve-mic --type audio-block
```
EveCanvas sends camera frame-events to `ws://192.168.1.66:8793/eve/camera` and
microphone frame-events to `ws://192.168.1.66:8794/eve/mic`. The first transport
uses JSON plus base64 payloads because it is inspectable and already matches
Mimir's frame-event source. Replace it with binary framing only after the
sample contract is proven on device.
EveCanvas sends binary `mimir.eve_media_observation.v1` camera observations to
`ws://192.168.1.66:8793/eve/camera` and microphone observations to
`ws://192.168.1.66:8794/eve/mic`. Periwinkle sends camera, microphone, motion,
and touch observations through `ws://192.168.1.66:8796/eve/periwinkle`.
## Native Mimir Dashboard

View file

@ -6,6 +6,7 @@
- (void)connect;
- (void)disconnect;
- (void)sendJSONObject:(NSDictionary *)payload;
- (void)sendData:(NSData *)payload;
@property(nonatomic, copy, readonly) NSString *status;

View file

@ -88,6 +88,37 @@
}];
}
- (void)sendData:(NSData *)payload {
NSURLSessionWebSocketTask *task = self.task;
if (!task) {
[self connect];
return;
}
if (!payload) {
self.status = [NSString stringWithFormat:@"%@ binary missing", self.label];
return;
}
NSURLSessionWebSocketMessage *message = [[NSURLSessionWebSocketMessage alloc] initWithData:payload];
__weak typeof(self) weakSelf = self;
[task sendMessage:message completionHandler:^(NSError *error) {
__strong typeof(weakSelf) self = weakSelf;
if (!self) {
return;
}
if (error) {
self.status = [NSString stringWithFormat:@"%@ send failed", self.label];
NSLog(@"EveCanvas %@ uplink binary send failed: %@", self.label, error);
self.task = nil;
[self reconnectSoon];
} else {
self.status = [NSString stringWithFormat:@"%@ live", self.label];
}
}];
}
- (void)reconnectSoon {
if (!self.shouldReconnect) {
return;

View file

@ -15,6 +15,117 @@ static int64_t EVEHostTimeNowNs(void) {
return (int64_t)(CACurrentMediaTime() * 1000000000.0);
}
static void EVEAppendByte(NSMutableData *data, uint8_t value) {
[data appendBytes:&value length:1];
}
static void EVEAppendUInt16(NSMutableData *data, uint16_t value) {
uint8_t bytes[2] = { (uint8_t)((value >> 8) & 0xff), (uint8_t)(value & 0xff) };
[data appendBytes:bytes length:2];
}
static void EVEAppendUInt32(NSMutableData *data, uint32_t value) {
uint8_t bytes[4] = {
(uint8_t)((value >> 24) & 0xff),
(uint8_t)((value >> 16) & 0xff),
(uint8_t)((value >> 8) & 0xff),
(uint8_t)(value & 0xff),
};
[data appendBytes:bytes length:4];
}
static void EVEAppendInt64(NSMutableData *data, int64_t value) {
EVEAppendByte(data, 0xd3);
uint64_t raw = (uint64_t)value;
uint8_t bytes[8] = {
(uint8_t)((raw >> 56) & 0xff),
(uint8_t)((raw >> 48) & 0xff),
(uint8_t)((raw >> 40) & 0xff),
(uint8_t)((raw >> 32) & 0xff),
(uint8_t)((raw >> 24) & 0xff),
(uint8_t)((raw >> 16) & 0xff),
(uint8_t)((raw >> 8) & 0xff),
(uint8_t)(raw & 0xff),
};
[data appendBytes:bytes length:8];
}
static void EVEAppendNullableInt64(NSMutableData *data, NSNumber *value) {
if (!value) {
EVEAppendByte(data, 0xc0);
return;
}
EVEAppendInt64(data, value.longLongValue);
}
static void EVEAppendString(NSMutableData *data, NSString *value) {
NSData *bytes = [(value ?: @"") dataUsingEncoding:NSUTF8StringEncoding] ?: [NSData data];
NSUInteger length = bytes.length;
if (length < 32) {
EVEAppendByte(data, (uint8_t)(0xa0 | length));
} else if (length < 256) {
EVEAppendByte(data, 0xd9);
EVEAppendByte(data, (uint8_t)length);
} else {
EVEAppendByte(data, 0xda);
EVEAppendUInt16(data, (uint16_t)MIN(length, 65535));
}
[data appendData:bytes];
}
static void EVEAppendBinary(NSMutableData *data, NSData *payload) {
NSUInteger length = payload.length;
if (length < 256) {
EVEAppendByte(data, 0xc4);
EVEAppendByte(data, (uint8_t)length);
} else if (length <= 65535) {
EVEAppendByte(data, 0xc5);
EVEAppendUInt16(data, (uint16_t)length);
} else {
EVEAppendByte(data, 0xc6);
EVEAppendUInt32(data, (uint32_t)length);
}
[data appendData:payload ?: [NSData data]];
}
static NSData *EVEMediaObservation(NSString *observationId,
NSString *deviceId,
NSString *streamId,
NSString *kind,
uint64_t sequence,
int64_t sensorTimestampNs,
int64_t elapsedRealtimeNs,
NSString *format,
NSNumber *width,
NSNumber *height,
NSNumber *sampleRate,
NSNumber *channels,
NSNumber *frameCount,
NSData *payload) {
NSMutableData *data = [NSMutableData data];
EVEAppendByte(data, 0xdc);
EVEAppendUInt16(data, 17);
EVEAppendString(data, observationId);
EVEAppendString(data, deviceId);
EVEAppendString(data, streamId);
EVEAppendString(data, kind);
EVEAppendInt64(data, (int64_t)sequence);
EVEAppendInt64(data, sensorTimestampNs);
EVEAppendInt64(data, elapsedRealtimeNs);
EVEAppendString(data, [[NSDate date] descriptionWithLocale:nil]);
EVEAppendString(data, @"eve-host-time");
EVEAppendString(data, format);
EVEAppendNullableInt64(data, width);
EVEAppendNullableInt64(data, height);
EVEAppendNullableInt64(data, sampleRate);
EVEAppendNullableInt64(data, channels);
EVEAppendNullableInt64(data, frameCount);
EVEAppendString(data, @"raw");
EVEAppendBinary(data, payload ?: [NSData data]);
return data;
}
@interface EVEViewController () <EVEFrameStreamClientDelegate, EVEDashboardClientDelegate, AVCaptureVideoDataOutputSampleBufferDelegate, UIGestureRecognizerDelegate>
@property(nonatomic, strong) EVEGLView *glView;
@ -418,20 +529,24 @@ static int64_t EVEHostTimeNowNs(void) {
}
}
NSString *base64 = [samples base64EncodedStringWithOptions:0];
NSDictionary *payload = @{
@"type": @"audio-block",
@"sourceId": @"eve-mic",
@"timestampNs": @(EVEHostTimeNowNs()),
@"sequence": @(self.micSequence++),
@"sampleRate": @((int)format.sampleRate),
@"channels": @(channels),
@"sampleFormat": @"Float32",
@"frameCount": @(frameCount),
@"byteLength": @(samples.length),
@"samplesBase64": base64,
};
[self.micUplink sendJSONObject:payload];
uint64_t sequence = self.micSequence++;
int64_t timestampNs = EVEHostTimeNowNs();
NSData *payload = EVEMediaObservation(
[NSString stringWithFormat:@"eve:microphone-float32-block:%llu", sequence],
@"eve",
@"eve-mic",
@"microphone-float32-block",
sequence,
timestampNs,
timestampNs,
@"float32le",
nil,
nil,
@((int)format.sampleRate),
@(channels),
@(frameCount),
samples);
[self.micUplink sendData:payload];
}];
NSError *error = nil;
@ -489,19 +604,24 @@ static int64_t EVEHostTimeNowNs(void) {
return;
}
NSString *base64 = [jpeg base64EncodedStringWithOptions:0];
NSDictionary *payload = @{
@"type": @"video-frame",
@"sourceId": @"eve-camera",
@"timestampNs": @(EVEHostTimeNowNs()),
@"sequence": @(self.cameraSequence++),
@"width": @(width),
@"height": @(height),
@"pixelFormat": @"MJPG",
@"byteLength": @(jpeg.length),
@"samplesBase64": base64,
};
[self.cameraUplink sendJSONObject:payload];
uint64_t sequence = self.cameraSequence++;
int64_t timestampNs = EVEHostTimeNowNs();
NSData *payload = EVEMediaObservation(
[NSString stringWithFormat:@"eve:camera-mjpeg-frame:%llu", sequence],
@"eve",
@"eve-camera",
@"camera-mjpeg-frame",
sequence,
timestampNs,
timestampNs,
@"mjpeg",
@(width),
@(height),
nil,
nil,
@1,
jpeg);
[self.cameraUplink sendData:payload];
}
- (CGPoint)streamPointForTouch:(UITouch *)touch {

View file

@ -1,6 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.gamecult.eve">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:theme="@style/EveTheme"

View file

@ -1,11 +1,23 @@
package org.gamecult.eve
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.graphics.Color
import android.graphics.ImageFormat
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.hardware.camera2.CameraCaptureSession
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CameraManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.Image
import android.media.ImageReader
import android.media.MediaRecorder
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@ -25,6 +37,7 @@ import org.gamecult.cultmesh.eve.EveDashboardCommandDocument
import org.gamecult.cultmesh.eve.EveDashboardNodeSnapshot
import org.gamecult.cultmesh.eve.EveDashboardStateDocument
import org.gamecult.cultmesh.eve.EveDashboardUiElement
import org.gamecult.cultmesh.eve.EveMediaObservationDocument
import org.gamecult.cultmesh.eve.EveSensorObservationDocument
import java.net.URI
import java.text.SimpleDateFormat
@ -49,11 +62,19 @@ class MainActivity : Activity(), SensorEventListener {
private var commandSequence = 0L
private var sensorSequence = 0L
private var touchSequence = 0L
private var mediaSequence = 0L
@Volatile private var lastCameraSentElapsedNs = 0L
@Volatile private var mediaRunning = false
private var audioRecord: AudioRecord? = null
private var cameraDevice: CameraDevice? = null
private var cameraSession: CameraCaptureSession? = null
private var imageReader: ImageReader? = null
private lateinit var brokerText: TextView
private lateinit var selectedText: TextView
private lateinit var sensorText: TextView
private lateinit var touchText: TextView
private lateinit var mediaText: TextView
private lateinit var surfaceList: LinearLayout
override fun onCreate(savedInstanceState: Bundle?) {
@ -63,11 +84,18 @@ class MainActivity : Activity(), SensorEventListener {
startSensors()
connectDashboard()
connectSensorUplink()
requestMediaPermissionsAndStart()
}
override fun onDestroy() {
super.onDestroy()
mediaRunning = false
sensorManager?.unregisterListener(this)
audioRecord?.stop()
audioRecord?.release()
cameraSession?.close()
cameraDevice?.close()
imageReader?.close()
dashboardSocket?.close()
sensorSocket?.close()
workers.shutdownNow()
@ -89,13 +117,15 @@ class MainActivity : Activity(), SensorEventListener {
selectedText = card("selection\nwaiting for dashboard state")
sensorText = card("CultMesh sensors\nconnecting $sensorUri")
touchText = card("touch surface\nwaiting for operator input")
mediaText = card("media sensors\nwaiting for camera/mic permissions")
surfaceList = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
root.addView(brokerText)
root.addView(selectedText)
root.addView(surfaceList)
root.addView(sensorText)
root.addView(mediaText)
root.addView(touchText)
root.addView(card("contract\nPeriwinkle consumes mimir.eve_dashboard_state.v1, sends mimir.eve_dashboard_command.v1, and publishes mimir.eve_sensor_observation.v1. Mimir accepts meaning; Android renders and observes."))
root.addView(card("contract\nPeriwinkle consumes mimir.eve_dashboard_state.v1, sends mimir.eve_dashboard_command.v1, and publishes mimir.eve_sensor_observation.v1 plus mimir.eve_media_observation.v1. Mimir accepts meaning; Android renders and observes."))
return scroll
}
@ -323,6 +353,11 @@ class MainActivity : Activity(), SensorEventListener {
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 42) startLocalMedia()
}
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
val sequence = ++touchSequence
val elapsedNs = SystemClock.elapsedRealtimeNanos()
@ -372,6 +407,204 @@ class MainActivity : Activity(), SensorEventListener {
}
}
private fun requestMediaPermissionsAndStart() {
val missing = arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)
.filter { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }
.toTypedArray()
if (missing.isNotEmpty()) {
requestPermissions(missing, 42)
return
}
startLocalMedia()
}
private fun startLocalMedia() {
if (mediaRunning) return
mediaRunning = true
startMicrophone()
startCamera()
}
@Suppress("MissingPermission")
private fun startMicrophone() {
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
main.post { mediaText.text = "media sensors\nmicrophone permission missing" }
return
}
workers.execute {
val sampleRate = 16_000
val minBuffer = AudioRecord.getMinBufferSize(sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
if (minBuffer <= 0) {
main.post { mediaText.text = "media sensors\nAudioRecord unavailable" }
return@execute
}
val blockBytes = 3_200
val record = AudioRecord(MediaRecorder.AudioSource.MIC, sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, minBuffer.coerceAtLeast(blockBytes * 2))
audioRecord = record
val buffer = ByteArray(blockBytes)
record.startRecording()
while (mediaRunning && !Thread.currentThread().isInterrupted) {
val read = record.read(buffer, 0, buffer.size)
if (read > 0) {
val payload = buffer.copyOf(read)
val elapsedNs = SystemClock.elapsedRealtimeNanos()
val sequence = ++mediaSequence
main.post { mediaText.text = "media sensors\nmicrophone pcm16le seq=$sequence bytes=$read rate=$sampleRate" }
sendMediaObservation(
kind = "microphone-pcm16-block",
streamId = "periwinkle-mic",
sequence = sequence,
sensorTimestampNs = elapsedNs,
elapsedNs = elapsedNs,
format = "pcm16le",
sampleRate = sampleRate,
channels = 1,
frameCount = read / 2,
payload = payload)
}
}
}
}
@Suppress("MissingPermission", "DEPRECATION")
private fun startCamera() {
if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
main.post { mediaText.text = "media sensors\ncamera permission missing" }
return
}
val manager = getSystemService(CAMERA_SERVICE) as CameraManager
val cameraId = manager.cameraIdList.firstOrNull { id ->
manager.getCameraCharacteristics(id).get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK
} ?: manager.cameraIdList.firstOrNull()
if (cameraId == null) {
main.post { mediaText.text = "media sensors\ncamera unavailable" }
return
}
imageReader = ImageReader.newInstance(160, 120, ImageFormat.YUV_420_888, 2).apply {
setOnImageAvailableListener({ reader ->
val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener
handleCameraImage(image)
}, main)
}
manager.openCamera(cameraId, object : CameraDevice.StateCallback() {
override fun onOpened(camera: CameraDevice) {
cameraDevice = camera
val reader = imageReader ?: return
val request = camera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply {
addTarget(reader.surface)
}
camera.createCaptureSession(listOf(reader.surface), object : CameraCaptureSession.StateCallback() {
override fun onConfigured(session: CameraCaptureSession) {
cameraSession = session
session.setRepeatingRequest(request.build(), null, main)
}
override fun onConfigureFailed(session: CameraCaptureSession) {
mediaText.text = "media sensors\ncamera session configure failed"
}
}, main)
}
override fun onDisconnected(camera: CameraDevice) {
camera.close()
}
override fun onError(camera: CameraDevice, error: Int) {
mediaText.text = "media sensors\ncamera error=$error"
camera.close()
}
}, main)
}
private fun handleCameraImage(image: Image) {
image.use {
val elapsedNs = SystemClock.elapsedRealtimeNanos()
if (elapsedNs - lastCameraSentElapsedNs < 200_000_000L) return
lastCameraSentElapsedNs = elapsedNs
val payload = copyLumaPlane(image)
val sequence = ++mediaSequence
mediaText.text = "media sensors\ncamera y8 seq=$sequence ${image.width}x${image.height} bytes=${payload.size}"
sendMediaObservation(
kind = "camera-luma-frame",
streamId = "periwinkle-camera",
sequence = sequence,
sensorTimestampNs = image.timestamp,
elapsedNs = elapsedNs,
format = "y8",
width = image.width,
height = image.height,
frameCount = 1,
payload = payload)
}
}
private fun copyLumaPlane(image: Image): ByteArray {
val plane = image.planes[0]
val buffer = plane.buffer
val rowStride = plane.rowStride
val pixelStride = plane.pixelStride
val data = ByteArray(image.width * image.height)
var out = 0
for (y in 0 until image.height) {
val rowStart = y * rowStride
for (x in 0 until image.width) {
data[out++] = buffer.get(rowStart + x * pixelStride)
}
}
return data
}
private fun sendMediaObservation(
kind: String,
streamId: String,
sequence: Long,
sensorTimestampNs: Long,
elapsedNs: Long,
format: String,
width: Int? = null,
height: Int? = null,
sampleRate: Int? = null,
channels: Int? = null,
frameCount: Int? = null,
payload: ByteArray,
) {
val socket = sensorSocket ?: return
val observation = EveMediaObservationDocument(
observationId = "$deviceId:$kind:$sequence",
deviceId = deviceId,
streamId = streamId,
kind = kind,
sequence = sequence,
sensorTimestampNs = sensorTimestampNs,
elapsedRealtimeNs = elapsedNs,
wallClockUtc = utcNow(),
clockDomainId = "periwinkle-elapsed-realtime",
format = format,
width = width,
height = height,
sampleRate = sampleRate,
channels = channels,
frameCount = frameCount,
payloadEncoding = "raw",
payload = payload,
)
mesh.remember(EveMediaObservationDocument, observation.observationId, observation)
workers.execute {
runCatching { socket.sendBinary(EveMediaObservationDocument.encode(observation)) }
.onFailure {
sensorSocket?.close()
sensorSocket = null
connectSensorUplink()
}
}
}
private fun postBroker(text: String) = main.post { brokerText.text = text }
private fun postSensor(text: String) = main.post { sensorText.text = text }
private fun dp(value: Int): Int = (value * resources.displayMetrics.density + 0.5f).toInt()