#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; }