#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using NAudio.Midi;
using Rug.Osc;
using T3.Core.Animation;
using T3.Core.DataTypes.DataSet;
using T3.Core.IO;
using T3.Core.Logging;
using T3.Core.Settings;
namespace T3.IoServices;
///
/// Session-scoped MIDI / OSC recorder. A single static façade starts a session, captures
/// incoming MIDI and OSC events into a fresh with timestamps
/// relative to record-start, and serialises the result to a .data file on stop.
/// Mirrors on the data side.
///
///
///
/// Coexists with the always-on /
/// singletons that feed the IO window's live view — events go to both. The always-on path
/// stamps events with absolute for a continuously
/// scrolling visualisation; this session path uses elapsed seconds since record-start, so
/// the saved file's events start at t = 0.
///
///
/// Allocation behaviour: per-event allocations (, occasionally a
/// new channel on first encounter) are unavoidable; registration / un-registration only
/// happens at / , not per frame.
///
///
public sealed class IoDataSetRecorder : MidiConnectionManager.IMidiConsumer, OscConnectionManager.IOscConsumer, IDisposable
{
///
/// Starts a new IO recording session. Returns the destination file path that will be
/// written when is called, or null if a session is
/// already active or the destination cannot be prepared.
///
///
/// Optional filename suffix. Audio sessions use suffixes per source (e.g. mic1);
/// data sessions merge all selected MIDI / OSC sources into a single file so this is
/// normally null. Kept for symmetry with
/// .
///
public static string? BeginRecording(int sessionIndex = -1, string? suffix = null, bool captureMidi = true, bool captureOsc = true)
{
if (_active != null)
{
Log.Warning($"IO data recording already active at '{_active.Path}'. Call EndRecording first.");
return null;
}
if (!captureMidi && !captureOsc)
{
Log.Warning("IoDataSetRecorder.BeginRecording called with both MIDI and OSC disabled — nothing to capture.");
return null;
}
var directory = T3.Core.Audio.RecordingPaths.TempRecordingsDirectory;
try
{
Directory.CreateDirectory(directory);
}
catch (Exception e)
{
Log.Warning($"Cannot create recordings directory '{directory}': {e.Message}");
return null;
}
// -1 = compute independently; the recording session passes a shared index so the audio + data
// files of one session line up (AudioRec-007 / DataRec-007).
if (sessionIndex < 0)
sessionIndex = T3.Core.Audio.RecordingPaths.NextSessionIndex(directory);
var fileName = T3.Core.Audio.RecordingPaths.BuildFileName(T3.Core.Audio.RecordingPaths.DataRecordingPrefix, sessionIndex, ".data", suffix);
var path = System.IO.Path.Combine(directory, fileName);
try
{
// Pass -1 for the OSC port when OSC is disabled — the constructor's existing
// out-of-range branch already skips OSC registration on negative ports, so we
// don't need a separate flag here.
var oscPort = captureOsc ? CoreSettings.Config.DefaultOscPort : -1;
_active = new IoDataSetRecorder(path, oscPort, captureMidi);
Log.Debug($"IO data recording started: {path} (midi={captureMidi}, osc={captureOsc})");
return path;
}
catch (Exception e)
{
Log.Warning($"Failed to start IO data recording at '{path}': {e.Message}");
return null;
}
}
///
/// Stops the active recording started by , writes the
/// captured to disk, and returns the file path. Returns
/// null if no session was active or the write failed.
///
public static string? EndRecording()
{
var active = _active;
if (active == null)
{
Log.Warning("EndRecording called but no IO data recording is active.");
return null;
}
_active = null;
try
{
active.Dispose();
StampRecordingMetadata(active.DataSet, active.SessionStartUtc, active.RecordStartBpm);
active.DataSet.WriteToFile(active.Path);
Log.Debug($"IO data recording stopped: {active.Path} ({active.DataSet.Channels.Count} channel(s))");
return active.Path;
}
catch (Exception e)
{
Log.Warning($"Failed to finalise IO data recording '{active.Path}': {e.Message}");
return null;
}
}
public static bool IsRecording => _active != null;
public static string? ActiveRecordingPath => _active?.Path;
///
/// The live being written during an active session, or
/// null when no session is in progress. Consumers iterating the channels'
/// event lists must be tolerant of mutation: events arrive on MIDI / OSC callback
/// threads and the list may be appended to mid-read.
///
public static DataSet? ActiveDataSet => _active?.DataSet;
public string Path { get; }
public DataSet DataSet { get; } = new();
/// Wall-clock UTC moment the session started. Stamped into file metadata on stop.
public DateTime SessionStartUtc { get; }
///
/// Playback BPM at record-start. Event times are stored in absolute seconds, but a clip's
/// TimeRange / SourceRange are in bars — so the bars↔seconds conversion needs the tempo
/// the recording was made at. Captured here (rather than read from live playback at replay
/// time) so a later project-tempo change can't silently re-time the recording.
///
public double RecordStartBpm { get; }
private IoDataSetRecorder(string path, int oscPort, bool registerMidi = true)
{
Path = path;
_recordStartRunSecs = Playback.RunTimeInSecs;
SessionStartUtc = DateTime.UtcNow;
RecordStartBpm = Playback.Current?.Bpm ?? CompositionSettings.Defaults.Playback.Bpm;
if (registerMidi)
MidiConnectionManager.RegisterConsumer(this);
if (oscPort >= 0 && oscPort <= 65535)
{
OscConnectionManager.RegisterConsumer(this, oscPort);
_oscPort = oscPort;
}
else
{
// Match OscDataRecording's behaviour: silently skip if no port is configured.
_oscPort = -1;
}
}
public void Dispose()
{
MidiConnectionManager.UnregisterConsumer(this);
if (_oscPort >= 0)
OscConnectionManager.UnregisterConsumer(this);
}
// ---------------------------------------------------------------------------
// MIDI capture — mirrors MidiDataRecording but with relative timestamps. Note-on /
// note-off pair up as DataIntervalEvents so playback can render note durations cleanly.
// ---------------------------------------------------------------------------
void MidiConnectionManager.IMidiConsumer.MessageReceivedHandler(object sender, MidiInMessageEventArgs msg)
{
if (sender is not MidiIn midiIn || msg.MidiEvent == null)
return;
if (msg.MidiEvent.CommandCode == MidiCommandCode.AutoSensing)
return;
var device = MidiConnectionManager.GetDescriptionForMidiIn(midiIn);
// Use ProductName verbatim so it matches what MidiInput.Device sees during live
// capture. SimulateIoData hands this same name to MidiInput on replay; any
// divergence here breaks the round-trip. MidiDataRecording (always-on, IO Window
// display only) appends ProductId for visual disambiguation; we don't, because
// MidiInput itself can't distinguish same-name devices either.
var deviceName = device.ProductName;
var t = Playback.RunTimeInSecs - _recordStartRunSecs;
switch (msg.MidiEvent)
{
case NoteEvent noteEvent:
{
var channel = FindOrCreateNoteChannel(deviceName, noteEvent);
var lastNote = channel.GetLastEvent() as DataIntervalEvent;
switch (msg.MidiEvent.CommandCode)
{
case MidiCommandCode.NoteOff:
lastNote?.Finish(t);
break;
case MidiCommandCode.NoteOn:
if (lastNote != null && lastNote.IsUnfinished)
{
lastNote.Finish(t);
if (noteEvent.Velocity == 0)
break;
}
channel.Events.Add(new DataIntervalEvent
{
Time = t,
EndTime = double.PositiveInfinity,
Value = (float)noteEvent.Velocity,
});
break;
}
break;
}
case ControlChangeEvent cc:
FindOrCreateControlChangeChannel(deviceName, cc).Events.Add(new DataEvent
{
Time = t,
Value = (float)cc.ControllerValue,
});
break;
case PitchWheelChangeEvent pb:
FindOrCreatePitchWheelChannel(deviceName, pb).Events.Add(new DataEvent
{
Time = t,
Value = (float)pb.Pitch,
});
break;
case ChannelAfterTouchEvent cat:
FindOrCreateChannelPressureChannel(deviceName, cat).Events.Add(new DataEvent
{
Time = t,
Value = (float)cat.AfterTouchPressure,
});
break;
}
}
void MidiConnectionManager.IMidiConsumer.ErrorReceivedHandler(object sender, MidiInMessageEventArgs msg) { }
void MidiConnectionManager.IMidiConsumer.OnSettingsChanged() { }
// ---------------------------------------------------------------------------
// OSC capture — one channel per address+arg-index, float-valued. Non-numeric arg
// types are skipped (consistent with OscDataRecording's existing behaviour).
// ---------------------------------------------------------------------------
void OscConnectionManager.IOscConsumer.ProcessMessage(OscMessage msg)
{
if (msg.Count == 0)
return;
var t = Playback.RunTimeInSecs - _recordStartRunSecs;
for (var i = 0; i < msg.Count; i++)
{
if (!OscConnectionManager.TryGetFloatFromMessagePart(msg[i], out var value))
continue;
var pathWithIndex = msg.Count == 1
? OscConnectionManager.BuildMessageComponentPath(msg)
: OscConnectionManager.BuildMessageComponentPath(msg, i);
var channel = FindOrCreateOscChannel(pathWithIndex);
channel.Events.Add(new DataEvent
{
Time = t,
Value = value,
});
}
}
// ---------------------------------------------------------------------------
// Channel factories. Hash + dictionary lookup matches MidiDataRecording style;
// first event with a given (device, channel, controller) tuple lazily creates a
// DataChannel and appends it to the recorder's DataSet.
// ---------------------------------------------------------------------------
private DataChannel FindOrCreateNoteChannel(string deviceName, NoteEvent noteEvent)
{
var hash = HashCode.Combine(deviceName, "note", noteEvent.Channel, noteEvent.NoteNumber);
return GetOrAddChannel(hash, () => new DataChannel(typeof(float), ChannelDurationTypes.Interval)
{
Path = new List
{
ChannelPaths.MidiNamespacePrefix,
deviceName,
ChannelPaths.ChannelPathPrefix + noteEvent.Channel,
ChannelPaths.MidiNoteTag + noteEvent.NoteNumber,
},
});
}
private DataChannel FindOrCreateControlChangeChannel(string deviceName, ControlChangeEvent cc)
{
var hash = HashCode.Combine(deviceName, "cc", cc.Channel, (int)cc.Controller);
return GetOrAddChannel(hash, () => new DataChannel(typeof(float), ChannelDurationTypes.Tick)
{
Path = new List
{
ChannelPaths.MidiNamespacePrefix,
deviceName,
ChannelPaths.ChannelPathPrefix + cc.Channel,
ChannelPaths.MidiControlChangeTag + (int)cc.Controller,
},
});
}
private DataChannel FindOrCreatePitchWheelChannel(string deviceName, PitchWheelChangeEvent pb)
{
var hash = HashCode.Combine(deviceName, "pb", pb.Channel);
return GetOrAddChannel(hash, () => new DataChannel(typeof(float), ChannelDurationTypes.Tick)
{
Path = new List
{
ChannelPaths.MidiNamespacePrefix,
deviceName,
ChannelPaths.ChannelPathPrefix + pb.Channel,
ChannelPaths.MidiPitchBendTag,
},
});
}
private DataChannel FindOrCreateChannelPressureChannel(string deviceName, ChannelAfterTouchEvent cat)
{
var hash = HashCode.Combine(deviceName, "cp", cat.Channel);
return GetOrAddChannel(hash, () => new DataChannel(typeof(float), ChannelDurationTypes.Tick)
{
Path = new List
{
ChannelPaths.MidiNamespacePrefix,
deviceName,
ChannelPaths.ChannelPathPrefix + cat.Channel,
ChannelPaths.MidiChannelPressureTag,
},
});
}
private DataChannel FindOrCreateOscChannel(string path)
{
var hash = HashCode.Combine("osc", _oscPort, path);
return GetOrAddChannel(hash, () =>
{
var segments = string.IsNullOrEmpty(path)
? new List { "/" }
: new List(path.Split('/'));
segments[0] = $"{ChannelPaths.OscNamespacePrefix}:{_oscPort}";
return new DataChannel(typeof(float), ChannelDurationTypes.Tick) { Path = segments };
});
}
private DataChannel GetOrAddChannel(int hash, Func factory)
{
if (_channelsByHash.TryGetValue(hash, out var channel))
return channel;
var newChannel = factory();
_channelsByHash[hash] = newChannel;
DataSet.Channels.Add(newChannel);
return newChannel;
}
///
/// Stamps session-level provenance onto right before
/// the file is written: the TiXL build that recorded it, the wall-clock UTC moment the
/// session started, the playback BPM at record-start (the tempo anchor for the clip's
/// bars↔seconds mapping), and the event time unit (always seconds today). Lets the user
/// tell at a glance which build / when a clip is from when re-opening it months later.
///
private static void StampRecordingMetadata(DataSet dataSet, DateTime sessionStartUtc, double recordStartBpm)
{
var metadata = dataSet.Metadata ?? new Newtonsoft.Json.Linq.JObject();
metadata[DataSet.MetadataKeys.TixlVersion] = FileLocations.TixlVersion;
metadata[DataSet.MetadataKeys.RecordedAtUtc] = sessionStartUtc.ToString("o"); // ISO 8601 round-trip
metadata[DataSet.MetadataKeys.Bpm] = recordStartBpm;
metadata[DataSet.MetadataKeys.TimeUnits] = DataSet.MetadataKeys.TimeUnitSeconds;
dataSet.Metadata = metadata;
}
///
/// Wire-format constants for segments produced by this
/// recorder. Forms the read / write contract between the recorder (here, in
/// T3.Core) and the SimulateIoData operator (in Operators/Lib)
/// that decodes the segments back into MIDI / OSC events for replay.
///
///
///
/// Centralised here so renaming a segment is safe-by-construction — both writer and
/// reader pick up the change atomically. Adding a new MIDI event family (e.g.
/// program change) means adding a new tag here, a writer call site below, and a
/// matching decoder branch in SimulateIoData.DispatchMidiChannel.
///
///
/// Layouts:
///
/// - MIDI: ["Midi", <device>, "Ch<n>", "<tag><param?>"]
/// where tag is one of , ,
/// , .
/// - OSC: ["<OscNamespacePrefix>:<port>", ...address segments].
///
///
///
/// Reserved top-level prefixes — the first Path[0] segment identifies
/// the source family. Decoders early-return on unknown prefixes, so a new family can
/// be added freely without breaking existing consumers. Currently reserved:
///
/// - "Midi" — MIDI capture (this recorder).
/// - "OSC:<port>" — OSC capture (this recorder).
/// - "Text" — reserved for transcription / SRT subtitle channels.
/// - "Audio" — reserved for audio-analysis channels (FFT bands, RMS, etc.).
/// - "Animation" — reserved for keyframe-driven streams.
/// - "Annotation" — reserved for markers, regions, user comments.
/// - "Debug" — debug telemetry from DebugDataRecording.
///
/// Picking a fresh prefix and writing the decoder is all that's needed for a new
/// source type; existing files and ops keep working unchanged.
///
///
public static class ChannelPaths
{
/// First segment for every recorded MIDI channel.
public const string MidiNamespacePrefix = "Midi";
/// First-segment prefix for OSC channels — followed by :<port>.
public const string OscNamespacePrefix = "OSC";
/// Prefix for the channel-number segment, e.g. "Ch1".
public const string ChannelPathPrefix = "Ch";
/// MIDI note channel tag, followed by note number, e.g. "N60".
public const string MidiNoteTag = "N";
/// MIDI control-change channel tag, followed by controller number, e.g. "CC74".
public const string MidiControlChangeTag = "CC";
/// MIDI pitch-bend channel tag — no parameter; one channel per (device, midiChannel).
public const string MidiPitchBendTag = "PB";
/// MIDI channel-pressure (aftertouch) tag — no parameter.
public const string MidiChannelPressureTag = "CP";
}
private readonly Dictionary _channelsByHash = new();
private readonly double _recordStartRunSecs;
private readonly int _oscPort;
private static IoDataSetRecorder? _active;
}