diff --git a/src/TeamsISO.App/Services/OscBridge.cs b/src/TeamsISO.App/Services/OscBridge.cs
new file mode 100644
index 0000000..818abb1
--- /dev/null
+++ b/src/TeamsISO.App/Services/OscBridge.cs
@@ -0,0 +1,393 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using Microsoft.Extensions.Logging;
+using TeamsISO.App.ViewModels;
+using TeamsISO.Engine.Controller;
+
+namespace TeamsISO.App.Services;
+
+///
+/// OSC over UDP. Companion, TouchOSC, Bome, and most lighting consoles speak
+/// OSC natively, so wrapping the same command surface in OSC opens the
+/// product to the broader live-show ecosystem without a Companion bridge.
+///
+/// Protocol — minimal OSC 1.0:
+/// - Address pattern (null-terminated string, padded to 4-byte boundary)
+/// - Type tag (",iiisf" etc., null-terminated, padded to 4)
+/// - Args in order
+///
+/// We don't implement bundles, time tags, blob args, or pattern matching
+/// — none are needed for the verbs we support. If a sender uses bundles
+/// we ignore them; if a sender uses a wildcard address ("/teamsiso/*") we
+/// ignore it. Operators get a clear log line in either case.
+///
+/// Routes:
+/// /teamsiso/iso "DisplayName" {0|1} — toggle/set ISO by display name
+/// /teamsiso/iso/by-id "guid" {0|1} — toggle/set by Id
+/// /teamsiso/preset "Name" — apply preset
+/// /teamsiso/teams/mute — UIA toggle mute
+/// /teamsiso/teams/camera — UIA toggle camera
+/// /teamsiso/teams/leave — UIA leave
+/// /teamsiso/teams/share — UIA share tray
+/// /teamsiso/teams/raise-hand — UIA raise hand
+/// /teamsiso/refresh-discovery — rebuild NDI finder
+/// /teamsiso/stop-all — disable every ISO
+/// /teamsiso/recording {0|1} — recording on/off (default dir)
+///
+public sealed class OscBridge : IAsyncDisposable
+{
+ public const int DefaultPort = 9000;
+
+ private readonly IIsoController _controller;
+ private readonly Func _viewModel;
+ private readonly ILogger? _logger;
+ private UdpClient? _udp;
+ private CancellationTokenSource? _cts;
+ private Task? _receiveTask;
+
+ public bool IsRunning { get; private set; }
+ public int Port { get; private set; } = DefaultPort;
+
+ public OscBridge(
+ IIsoController controller,
+ Func viewModel,
+ ILogger? logger = null)
+ {
+ _controller = controller;
+ _viewModel = viewModel;
+ _logger = logger;
+ }
+
+ public void Start(int port)
+ {
+ if (IsRunning && Port == port) return;
+ Stop();
+
+ Port = port;
+ try
+ {
+ // Bind to loopback only — same threat model as the REST surface.
+ _udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, port));
+ }
+ catch (SocketException ex)
+ {
+ _logger?.LogWarning(ex, "Could not bind OSC bridge to udp://127.0.0.1:{Port}.", port);
+ _udp = null;
+ return;
+ }
+ _cts = new CancellationTokenSource();
+ _receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token));
+ IsRunning = true;
+ _logger?.LogInformation("OSC bridge listening on udp://127.0.0.1:{Port}/", port);
+ }
+
+ public void Stop()
+ {
+ if (!IsRunning) return;
+ try { _cts?.Cancel(); } catch { /* ignore */ }
+ try { _udp?.Close(); } catch { /* ignore */ }
+ try { _receiveTask?.Wait(TimeSpan.FromSeconds(2)); } catch { /* ignore */ }
+ _udp?.Dispose();
+ _udp = null;
+ _cts?.Dispose();
+ _cts = null;
+ _receiveTask = null;
+ IsRunning = false;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ Stop();
+ await Task.CompletedTask;
+ }
+
+ private async Task ReceiveLoopAsync(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested && _udp is not null)
+ {
+ UdpReceiveResult result;
+ try { result = await _udp.ReceiveAsync(ct); }
+ catch (OperationCanceledException) { break; }
+ catch (ObjectDisposedException) { break; }
+ catch (SocketException ex)
+ {
+ _logger?.LogWarning(ex, "OSC receive failed; continuing.");
+ continue;
+ }
+
+ try
+ {
+ var msg = OscMessage.TryParse(result.Buffer);
+ if (msg is null) continue;
+ await DispatchAsync(msg);
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogWarning(ex, "OSC dispatch failed for packet from {Endpoint}.", result.RemoteEndPoint);
+ }
+ }
+ }
+
+ private async Task DispatchAsync(OscMessage msg)
+ {
+ var addr = msg.Address;
+ switch (addr)
+ {
+ case "/teamsiso/teams/mute": InvokeTeams(TeamsControlBridge.ToggleMute); return;
+ case "/teamsiso/teams/camera": InvokeTeams(TeamsControlBridge.ToggleCamera); return;
+ case "/teamsiso/teams/leave": InvokeTeams(TeamsControlBridge.LeaveCall); return;
+ case "/teamsiso/teams/share": InvokeTeams(TeamsControlBridge.OpenShareTray); return;
+ case "/teamsiso/teams/raise-hand":InvokeTeams(TeamsControlBridge.ToggleRaiseHand); return;
+ case "/teamsiso/refresh-discovery":_controller.RefreshDiscovery(); return;
+ case "/teamsiso/stop-all": await StopAllAsync(); return;
+ case "/teamsiso/recording": SetRecording(msg); return;
+ case "/teamsiso/recording/marker": DropMarker(msg); return;
+ case "/teamsiso/recording/roll": await RollRecordingAsync(); return;
+ case "/teamsiso/notes": AppendNote(msg); return;
+ case "/teamsiso/iso": await ToggleByNameAsync(msg); return;
+ case "/teamsiso/iso/by-id": await ToggleByIdAsync(msg); return;
+ case "/teamsiso/preset": await ApplyPresetAsync(msg); return;
+ default:
+ _logger?.LogDebug("Ignoring unknown OSC address {Addr}", addr);
+ return;
+ }
+ }
+
+ // ─── handler helpers ────────────────────────────────────────────────
+
+ private static void InvokeTeams(Func action) => _ = action();
+
+ private async Task StopAllAsync()
+ {
+ var vm = _viewModel();
+ if (vm is null) return;
+ var dispatcher = System.Windows.Application.Current?.Dispatcher;
+ if (dispatcher is null) return;
+ var enabled = await dispatcher.InvokeAsync(() =>
+ vm.Participants.Where(p => p.IsEnabled).ToArray());
+ foreach (var p in enabled)
+ {
+ try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
+ catch { /* defensive */ }
+ await dispatcher.InvokeAsync(() => p.IsEnabled = false);
+ }
+ }
+
+ private void SetRecording(OscMessage msg)
+ {
+ var enabled = msg.GetBoolArg(0) ?? false;
+ // OSC doesn't carry a directory string in this minimal protocol; let the
+ // recording directory remain whatever the UI / REST surface set last.
+ _controller.SetRecording(enabled, _controller.RecordingDirectory);
+ }
+
+ private void DropMarker(OscMessage msg)
+ {
+ var label = msg.GetStringArg(0) ?? "Marker @ " + DateTimeOffset.Now.ToString("HH:mm:ss");
+ _controller.AddRecordingMarker(label);
+ }
+
+ private static void AppendNote(OscMessage msg)
+ {
+ var text = msg.GetStringArg(0);
+ if (!string.IsNullOrWhiteSpace(text)) NotesService.Append(text);
+ }
+
+ /// Roll every active recording into a new chunk. Same path as REST /recording/roll.
+ private async Task RollRecordingAsync()
+ {
+ var vm = _viewModel();
+ var dispatcher = System.Windows.Application.Current?.Dispatcher;
+ if (vm is null || dispatcher is null) return;
+
+ var enabled = await dispatcher.InvokeAsync(() =>
+ vm.Participants.Where(p => p.IsEnabled).ToArray());
+ foreach (var p in enabled)
+ {
+ try
+ {
+ await _controller.DisableIsoAsync(p.Id, CancellationToken.None);
+ await Task.Delay(150);
+ var nameToUse = string.IsNullOrWhiteSpace(p.CustomName)
+ ? OutputNameTemplate.Render(OutputNameTemplate.Get(), p.Id, p.DisplayName)
+ : p.CustomName;
+ bool? recordOverride = p.RecordToDisk ? null : false;
+ await _controller.EnableIsoAsync(p.Id, nameToUse, recordOverride, CancellationToken.None);
+ }
+ catch { /* per-pipeline best-effort */ }
+ }
+ }
+
+ private async Task ToggleByNameAsync(OscMessage msg)
+ {
+ var name = msg.GetStringArg(0);
+ if (string.IsNullOrEmpty(name)) return;
+ var enabled = msg.GetBoolArg(1);
+
+ var vm = _viewModel();
+ var dispatcher = System.Windows.Application.Current?.Dispatcher;
+ if (vm is null || dispatcher is null) return;
+ var p = await dispatcher.InvokeAsync(() =>
+ vm.Participants.FirstOrDefault(x =>
+ string.Equals(x.DisplayName, name, StringComparison.OrdinalIgnoreCase)));
+ if (p is null) return;
+ await ApplyToggleAsync(p, enabled, dispatcher);
+ }
+
+ private async Task ToggleByIdAsync(OscMessage msg)
+ {
+ var idStr = msg.GetStringArg(0);
+ if (!Guid.TryParse(idStr, out var id)) return;
+ var enabled = msg.GetBoolArg(1);
+
+ var vm = _viewModel();
+ var dispatcher = System.Windows.Application.Current?.Dispatcher;
+ if (vm is null || dispatcher is null) return;
+ var p = await dispatcher.InvokeAsync(() =>
+ vm.Participants.FirstOrDefault(x => x.Id == id));
+ if (p is null) return;
+ await ApplyToggleAsync(p, enabled, dispatcher);
+ }
+
+ private async Task ApplyToggleAsync(ParticipantViewModel p, bool? enabled, System.Windows.Threading.Dispatcher dispatcher)
+ {
+ var target = enabled ?? !p.IsEnabled;
+ if (target == p.IsEnabled) return;
+ try
+ {
+ if (target)
+ {
+ await _controller.EnableIsoAsync(p.Id,
+ string.IsNullOrWhiteSpace(p.CustomName) ? null : p.CustomName,
+ CancellationToken.None);
+ await dispatcher.InvokeAsync(() => p.IsEnabled = true);
+ }
+ else
+ {
+ await _controller.DisableIsoAsync(p.Id, CancellationToken.None);
+ await dispatcher.InvokeAsync(() => p.IsEnabled = false);
+ }
+ }
+ catch
+ {
+ /* defensive: OSC senders are typically fire-and-forget */
+ }
+ }
+
+ private async Task ApplyPresetAsync(OscMessage msg)
+ {
+ var name = msg.GetStringArg(0);
+ if (string.IsNullOrEmpty(name)) return;
+ var preset = OperatorPresetStore.Find(name);
+ if (preset is null) return;
+
+ var vm = _viewModel();
+ var dispatcher = System.Windows.Application.Current?.Dispatcher;
+ if (vm is null || dispatcher is null) return;
+ var snapshot = await dispatcher.InvokeAsync(() => vm.Participants.ToList());
+ await PresetApplier.ApplyAsync(preset, snapshot, _controller, dispatcher);
+ }
+}
+
+// ─── OSC message parser ─────────────────────────────────────────────────
+
+///
+/// Minimal OSC 1.0 message parser. Supports the subset we care about:
+/// integer (i), float (f), string (s) args. Bundles / time tags / blobs are
+/// not implemented — incoming packets that look like bundles return null
+/// and the caller logs + skips them.
+///
+internal sealed class OscMessage
+{
+ public string Address { get; init; } = "";
+ public string TypeTag { get; init; } = "";
+ public IReadOnlyList