Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/core-engine/src/bus/audience-inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* batching, and publishing to the parameter bus.
*/

import { v4 as uuidv4 } from 'crypto';
import { randomUUID } from 'crypto';
import {
type AudienceInput,
AudienceInputSchema,
Expand Down Expand Up @@ -139,7 +139,7 @@ export class AudienceInputsHandler {

// Create input record
const input: AudienceInput = {
id: uuidv4(),
id: randomUUID(),
clientId,
sessionId: this.sessionId,
timestamp: now,
Expand Down
77 changes: 66 additions & 11 deletions packages/core-engine/src/consensus/weighted-voting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,39 @@ export function calculateWeight(
return Math.max(0.001, Math.min(1, weight)); // Clamp to avoid zero weights
}

function calculateConsensusWeights(
inputs: AudienceInput[],
config: WeightingConfig
): number[] {
if (inputs.length <= 1) return inputs.map(() => 1.0);

const sorted = inputs
.map((input, index) => ({ index, value: input.value }))
.sort((a, b) => a.value - b.value);
const weights = new Array<number>(inputs.length);
let left = 0;
let right = 0;

for (let i = 0; i < sorted.length; i++) {
const value = sorted[i].value;

while (value - sorted[left].value > config.clusterThreshold) {
left++;
}

while (
right + 1 < sorted.length &&
sorted[right + 1].value - value <= config.clusterThreshold
) {
right++;
}

weights[sorted[i].index] = (right - left) / (inputs.length - 1);
}

return weights;
}

// =============================================================================
// WEIGHTED INPUT GENERATION
// =============================================================================
Expand All @@ -125,11 +158,12 @@ export function weightInputs(
config: WeightingConfig = DEFAULT_WEIGHTING_CONFIG
): WeightedInput[] {
const currentTime = Date.now();
const consensusWeights = calculateConsensusWeights(inputs, config);

return inputs.map(input => {
return inputs.map((input, index) => {
const spatialWeight = calculateSpatialWeight(input.location, stagePosition, config);
const temporalWeight = calculateTemporalWeight(input.timestamp, currentTime, config);
const consensusWeight = calculateConsensusWeight(input, inputs, config);
const consensusWeight = consensusWeights[index];

const weight =
config.spatialAlpha * spatialWeight +
Expand Down Expand Up @@ -186,25 +220,46 @@ export function standardDeviation(inputs: WeightedInput[]): number {
}

/**
* Remove outliers using z-score method.
* Remove outliers using robust modified z-score.
*/
export function removeOutliers(
inputs: WeightedInput[],
threshold: number = 2.5
): WeightedInput[] {
if (inputs.length < 4) return inputs;

const mean = weightedMean(inputs);
const std = standardDeviation(inputs);

if (std < 0.001) return inputs; // No variance


const medianValue = median(inputs.map(input => input.value));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve input weights during outlier filtering

This median/MAD calculation ignores the weight field even though computeConsensus passes in inputs that were already weighted by spatial proximity, recency, and agreement. In a live window where several stale or far-away low-weight values outnumber a smaller set of recent/front-row high-weight values, the unweighted median can classify the high-weight values as outliers and remove them before weightedMean, effectively overriding the weighting model that is supposed to give those inputs more influence.

Useful? React with 👍 / 👎.

const deviations = inputs.map(input => Math.abs(input.value - medianValue));
const medianAbsoluteDeviation = median(deviations);

if (medianAbsoluteDeviation < 0.001) {
const hasVariance = deviations.some(deviation => deviation > 0.001);
if (!hasVariance) return inputs;

return inputs.filter(input => Math.abs(input.value - medianValue) <= 0.15);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor the configured outlier threshold

When the median absolute deviation collapses to zero, this branch ignores the caller-provided threshold/config.outlierThreshold and hard-codes a 0.15 cutoff. In any deployment that raises the threshold to relax or disable outlier filtering, e.g. mostly identical inputs like [0.5, 0.5, 0.5, 0.7] with outlierThreshold: 100, computeConsensus still discards 0.7, so the documented configuration no longer controls consensus behavior.

Useful? React with 👍 / 👎.

}

return inputs.filter(input => {
const zScore = Math.abs((input.value - mean) / std);
return zScore <= threshold;
const modifiedZScore = (
0.6745 * Math.abs(input.value - medianValue)
) / medianAbsoluteDeviation;
return modifiedZScore <= threshold;
});
}

function median(values: number[]): number {
if (values.length === 0) return 0;

const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);

if (sorted.length % 2 === 1) {
return sorted[middle];
}

return (sorted[middle - 1] + sorted[middle]) / 2;
}

/**
* Apply exponential smoothing to reduce jitter.
*/
Expand Down
269 changes: 269 additions & 0 deletions packages/core-engine/tests/osc-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
/**
* OSC Bridge Tests for Omni-Dromenon-Engine
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createBus } from '../src/bus/parameter-bus.js';
import { createOSCBridge } from '../src/osc/osc-bridge.js';
import { ConsensusMode, type ConsensusResult } from '../src/types/index.js';

const oscMock = vi.hoisted(() => {
class MockUDPPort {
options: Record<string, unknown>;
sent: unknown[] = [];
handlers = new Map<string, Array<(...args: unknown[]) => void>>();
open = vi.fn(() => {
this.emit('ready');
});
close = vi.fn();
send = vi.fn((packet: unknown) => {
this.sent.push(packet);
});

constructor(options: Record<string, unknown>) {
this.options = options;
}

on(event: string, handler: (...args: unknown[]) => void): this {
const handlers = this.handlers.get(event) ?? [];
handlers.push(handler);
this.handlers.set(event, handlers);
return this;
}

emit(event: string, ...args: unknown[]): void {
for (const handler of this.handlers.get(event) ?? []) {
handler(...args);
}
}
}

const instances: MockUDPPort[] = [];
const UDPPort = vi.fn(function MockUDPPortConstructor(
this: unknown,
options: Record<string, unknown>
) {
const port = new MockUDPPort(options);
instances.push(port);
return port;
});
const timeTag = vi.fn((time: number) => ({ time }));

return { instances, UDPPort, timeTag };
});

vi.mock('osc', () => ({
default: {
UDPPort: oscMock.UDPPort,
timeTag: oscMock.timeTag,
},
}));

function consensus(parameter: string, value: number): ConsensusResult {
return {
parameter,
value,
confidence: 0.9,
inputCount: 4,
timestamp: 123,
mode: ConsensusMode.WEIGHTED_AVERAGE,
rawMean: value,
weightedMean: value,
standardDeviation: 0.05,
participationRate: 0.8,
};
}

function latestPort() {
return oscMock.instances.at(-1);
}

describe('OSCBridge', () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
oscMock.instances.length = 0;
oscMock.UDPPort.mockClear();
oscMock.timeTag.mockClear();
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
});

it('opens a configured UDP port and tracks connection state', async () => {
const bridge = createOSCBridge({
localPort: 9101,
remoteHost: '127.0.0.2',
remotePort: 9102,
addressPrefix: '/test',
enabled: true,
});
const connected = vi.fn();
bridge.on('connected', connected);

await bridge.connect();

expect(oscMock.UDPPort).toHaveBeenCalledWith({
localAddress: '0.0.0.0',
localPort: 9101,
remoteAddress: '127.0.0.2',
remotePort: 9102,
metadata: true,
});
expect(bridge.isConnected()).toBe(true);
expect(connected).toHaveBeenCalledOnce();

const port = latestPort();
bridge.disconnect();

expect(port?.close).toHaveBeenCalledOnce();
expect(bridge.isConnected()).toBe(false);
});

it('does not create a UDP port when disabled', async () => {
const bridge = createOSCBridge({ enabled: false });

await bridge.connect();

expect(oscMock.UDPPort).not.toHaveBeenCalled();
expect(bridge.isConnected()).toBe(false);
});

it('sends parameter updates with the configured address prefix', async () => {
const bridge = createOSCBridge({ addressPrefix: '/test' });

await bridge.connect();
bridge.sendParameter('mood', 0.75);

const port = latestPort();
expect(port?.send).toHaveBeenCalledWith({
address: '/test/mood',
args: [{ type: 'f', value: 0.75 }],
});
expect(bridge.getStats()).toEqual({ connected: true, messageCount: 1 });
});

it('does not send or count messages before connecting', () => {
const bridge = createOSCBridge({ addressPrefix: '/test' });

bridge.sendParameter('mood', 0.75);

expect(latestPort()).toBeUndefined();
expect(bridge.getStats()).toEqual({ connected: false, messageCount: 0 });
});

it('formats OSC bundles with typed arguments and timetags', async () => {
const bridge = createOSCBridge({ addressPrefix: '/test' });
const blob = Buffer.from([1, 2, 3]);

await bridge.connect();
bridge.sendBundle(
[
{
address: '/test/mood',
args: [0.6, 'bright', true, false, blob],
},
],
456
);

expect(oscMock.timeTag).toHaveBeenCalledWith(456);
expect(latestPort()?.send).toHaveBeenCalledWith({
timeTag: { time: 456 },
packets: [
{
address: '/test/mood',
args: [
{ type: 'f', value: 0.6 },
{ type: 's', value: 'bright' },
{ type: 'T' },
{ type: 'F' },
{ type: 'b', value: blob },
],
},
],
});
expect(bridge.getStats().messageCount).toBe(1);
});

it('forwards bus consensus updates and snapshots to OSC', async () => {
const bus = createBus();
const bridge = createOSCBridge({ addressPrefix: '/test' });

bridge.attachToBus(bus);
await bridge.connect();
bus.publishConsensus(consensus('tempo', 0.42));
bus.publishSnapshot({
sessionId: 'session-1',
timestamp: 124,
totalParticipants: 5,
activeParticipants: 4,
results: new Map([
['mood', consensus('mood', 0.7)],
['density', consensus('density', 0.2)],
]),
});

const port = latestPort();
expect(port?.send).toHaveBeenNthCalledWith(1, {
address: '/test/tempo',
args: [{ type: 'f', value: 0.42 }],
});
expect(port?.send).toHaveBeenNthCalledWith(2, {
timeTag: { time: 0 },
packets: [
{
address: '/test/mood',
args: [{ type: 'f', value: 0.7 }],
},
{
address: '/test/density',
args: [{ type: 'f', value: 0.2 }],
},
],
});
expect(bridge.getStats().messageCount).toBe(3);
});

it('emits parsed incoming messages and answers ping', async () => {
const bridge = createOSCBridge({ addressPrefix: '/test' });
const received = vi.fn();
bridge.on('message', received);

await bridge.connect();
const port = latestPort();
port?.emit('message', {
address: '/test/tempo',
args: [{ type: 'f', value: 0.33 }],
});
port?.emit('message', {
address: '/ignored/tempo',
args: [{ type: 'f', value: 0.99 }],
});
port?.emit('message', {
address: '/test/ping',
args: [],
});

expect(received).toHaveBeenCalledTimes(2);
expect(received).toHaveBeenNthCalledWith(1, {
address: '/test/tempo',
parameter: 'tempo',
args: [0.33],
});
expect(received).toHaveBeenNthCalledWith(2, {
address: '/test/ping',
parameter: 'ping',
args: [],
});
expect(port?.send).toHaveBeenCalledWith({
address: '/test/pong',
args: [{ type: 'i', value: expect.any(Number) }],
});
});
});
Loading