#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using NAudio.Midi;
using Newtonsoft.Json.Linq;
using T3.Core.DataTypes.DataSet;
namespace T3.IoServices;
///
/// Converts a standard MIDI file (.mid / .midi) into a
/// using the same channel conventions as live MIDI recording, so a loaded file can be
/// replayed through SimulateIoData exactly like a recorded .data clip.
///
///
/// Channel paths follow with the file name in
/// the device-name segment: ["Midi", "MyTrack.mid", "Ch1", "N60"]. Notes become
/// interval events carrying the raw NoteOn velocity (0–127); control changes, pitch bend
/// and channel pressure become tick events with raw MIDI values. Values stay in MIDI range
/// because replay casts them straight back to MIDI bytes — normalization is a consumer-side
/// concern.
///
/// Event times are absolute seconds from file start, computed by walking the file's tempo
/// map. Tracks of a format-1 file are merged into one time-ordered stream; the source
/// track number is not preserved (the recording convention groups by MIDI channel only).
///
public static class MidiFileToDataSet
{
///
/// Returns a shared for the MIDI file at .
/// Parses once per (path, last-write timestamp) and serves subsequent callers from the cache,
/// mirroring for .data files.
///
public static bool TryGet(string absolutePath, [NotNullWhen(true)] out DataSet? dataSet, out string failureReason)
{
dataSet = null;
failureReason = string.Empty;
if (string.IsNullOrEmpty(absolutePath))
{
failureReason = "Empty path";
return false;
}
DateTime lastWrite;
try
{
lastWrite = File.GetLastWriteTimeUtc(absolutePath);
}
catch (Exception e)
{
failureReason = $"Cannot stat file: {e.Message}";
return false;
}
lock (_cache)
{
if (_cache.TryGetValue(absolutePath, out var cached) && cached.LastWrite == lastWrite)
{
dataSet = cached.DataSet;
return true;
}
}
if (!TryConvertFile(absolutePath, out var parsed, out failureReason))
return false;
lock (_cache)
{
_cache[absolutePath] = new CacheEntry(lastWrite, parsed);
}
dataSet = parsed;
return true;
}
///
/// Parses and converts a MIDI file into a fresh without touching
/// the cache. Prefer for normal use.
///
public static bool TryConvertFile(string absolutePath, [NotNullWhen(true)] out DataSet? dataSet, out string failureReason)
{
dataSet = null;
failureReason = string.Empty;
if (!File.Exists(absolutePath))
{
failureReason = $"File not found: {absolutePath}";
return false;
}
MidiFile midiFile;
try
{
midiFile = new MidiFile(absolutePath, strictChecking: false);
}
catch (Exception e)
{
failureReason = $"Failed to parse {absolutePath}: {e.Message}";
return false;
}
try
{
dataSet = Convert(midiFile, Path.GetFileName(absolutePath));
return true;
}
catch (Exception e)
{
failureReason = $"Failed to convert {absolutePath}: {e.Message}";
return false;
}
}
private static DataSet Convert(MidiFile midiFile, string fileName)
{
// Same sanitization as the recorder's device names — path segments must not
// contain the '/' the UI uses when joining segments for display.
var deviceName = fileName.Replace("/", "_");
var sortedEvents = CollectEventsSorted(midiFile);
var tempoMap = new TempoMap(sortedEvents, midiFile.DeltaTicksPerQuarterNote);
var dataSet = new DataSet();
var channelsByKey = new Dictionary<(int MidiChannel, string Tag), DataChannel>();
var lastEventSecs = 0.0;
foreach (var midiEvent in sortedEvents)
{
var time = tempoMap.TicksToSeconds(midiEvent.AbsoluteTime);
switch (midiEvent)
{
case NoteOnEvent noteOn when noteOn.Velocity > 0:
{
var channel = FindOrCreateChannel(dataSet, channelsByKey, deviceName, noteOn.Channel,
IoDataSetRecorder.ChannelPaths.MidiNoteTag + noteOn.NoteNumber,
ChannelDurationTypes.Interval);
// A retriggered note ends the previous interval — same as the recorder.
if (channel.GetLastEvent() is DataIntervalEvent { IsUnfinished: true } unfinished)
unfinished.Finish(time);
channel.Events.Add(new DataIntervalEvent
{
Time = time,
EndTime = double.PositiveInfinity,
Value = (float)noteOn.Velocity,
});
lastEventSecs = Math.Max(lastEventSecs, time);
break;
}
// NoteOff, or the NoteOn-with-velocity-0 shorthand for it (velocity > 0 was
// handled above, so any NoteOn reaching this arm is a release).
case NoteEvent noteOff when noteOff.CommandCode is MidiCommandCode.NoteOff or MidiCommandCode.NoteOn:
{
var key = (noteOff.Channel, IoDataSetRecorder.ChannelPaths.MidiNoteTag + noteOff.NoteNumber);
if (channelsByKey.TryGetValue(key, out var channel)
&& channel.GetLastEvent() is DataIntervalEvent { IsUnfinished: true } unfinished)
{
unfinished.Finish(time);
lastEventSecs = Math.Max(lastEventSecs, time);
}
break;
}
case ControlChangeEvent cc:
AddTickEvent(dataSet, channelsByKey, deviceName, cc.Channel,
IoDataSetRecorder.ChannelPaths.MidiControlChangeTag + (int)cc.Controller,
(float)cc.ControllerValue, time);
lastEventSecs = Math.Max(lastEventSecs, time);
break;
case PitchWheelChangeEvent pb:
AddTickEvent(dataSet, channelsByKey, deviceName, pb.Channel,
IoDataSetRecorder.ChannelPaths.MidiPitchBendTag,
(float)pb.Pitch, time);
lastEventSecs = Math.Max(lastEventSecs, time);
break;
case ChannelAfterTouchEvent cp:
AddTickEvent(dataSet, channelsByKey, deviceName, cp.Channel,
IoDataSetRecorder.ChannelPaths.MidiChannelPressureTag,
(float)cp.AfterTouchPressure, time);
lastEventSecs = Math.Max(lastEventSecs, time);
break;
}
}
// Notes without a matching NoteOff (truncated files) end at the last event time.
foreach (var channel in dataSet.Channels)
{
if (channel.GetLastEvent() is DataIntervalEvent { IsUnfinished: true } unfinished)
unfinished.Finish(lastEventSecs);
}
dataSet.Metadata = new JObject
{
[DataSet.MetadataKeys.Bpm] = tempoMap.InitialBpm,
[DataSet.MetadataKeys.TimeUnits] = DataSet.MetadataKeys.TimeUnitSeconds,
[SourceDurationSecsKey] = lastEventSecs,
};
return dataSet;
}
///
/// Metadata key for the source duration in seconds (time of the last event). Written on
/// import so consumers (e.g. the timeline drop probe) don't have to rescan all channels.
///
public const string SourceDurationSecsKey = "SourceDurationSecs";
private static void AddTickEvent(DataSet dataSet, Dictionary<(int, string), DataChannel> channelsByKey,
string deviceName, int midiChannel, string tag, float value, double time)
{
FindOrCreateChannel(dataSet, channelsByKey, deviceName, midiChannel, tag, ChannelDurationTypes.Tick)
.Events
.Add(new DataEvent
{
Time = time,
Value = value,
});
}
private static DataChannel FindOrCreateChannel(DataSet dataSet, Dictionary<(int, string), DataChannel> channelsByKey,
string deviceName, int midiChannel, string tag, string durationType)
{
var key = (midiChannel, tag);
if (channelsByKey.TryGetValue(key, out var channel))
return channel;
var newChannel = new DataChannel(typeof(float), durationType)
{
Path = new List
{
IoDataSetRecorder.ChannelPaths.MidiNamespacePrefix,
deviceName,
IoDataSetRecorder.ChannelPaths.ChannelPathPrefix + midiChannel,
tag,
},
};
channelsByKey[key] = newChannel;
dataSet.Channels.Add(newChannel);
return newChannel;
}
private static List CollectEventsSorted(MidiFile midiFile)
{
var all = new List();
for (var trackIndex = 0; trackIndex < midiFile.Tracks; trackIndex++)
{
all.AddRange(midiFile.Events[trackIndex]);
}
// Stable sort keeps same-tick events in track order, so a NoteOff never
// overtakes the NoteOn it belongs to.
all.Sort(static (a, b) => a.AbsoluteTime.CompareTo(b.AbsoluteTime));
return all;
}
///
/// Piecewise tick→seconds conversion over the file's tempo changes. MIDI stores delta
/// ticks whose wall-clock length depends on the tempo in effect, so each segment between
/// tempo events accumulates its own seconds span.
///
private sealed class TempoMap
{
public TempoMap(List sortedEvents, int deltaTicksPerQuarterNote)
{
_deltaTicksPerQuarterNote = Math.Max(deltaTicksPerQuarterNote, 1);
var tick = 0L;
var seconds = 0.0;
var microsecondsPerQuarter = DefaultMicrosecondsPerQuarter;
foreach (var midiEvent in sortedEvents)
{
if (midiEvent is not TempoEvent tempoEvent)
continue;
seconds += TicksToSecondsAtTempo(tempoEvent.AbsoluteTime - tick, microsecondsPerQuarter);
tick = tempoEvent.AbsoluteTime;
microsecondsPerQuarter = tempoEvent.MicrosecondsPerQuarterNote;
_segments.Add(new Segment(tick, seconds, microsecondsPerQuarter));
}
if (_segments.Count == 0 || _segments[0].StartTick > 0)
{
_segments.Insert(0, new Segment(0, 0, DefaultMicrosecondsPerQuarter));
}
InitialBpm = 60_000_000.0 / _segments[0].MicrosecondsPerQuarter;
}
public double InitialBpm { get; }
public double TicksToSeconds(long tick)
{
// Events are converted in ascending tick order; advance the cursor instead of
// binary-searching per call.
while (_cursor + 1 < _segments.Count && _segments[_cursor + 1].StartTick <= tick)
_cursor++;
var segment = _segments[_cursor];
return segment.StartSeconds + TicksToSecondsAtTempo(tick - segment.StartTick, segment.MicrosecondsPerQuarter);
}
private double TicksToSecondsAtTempo(long ticks, int microsecondsPerQuarter)
{
return ticks * (microsecondsPerQuarter / 1_000_000.0) / _deltaTicksPerQuarterNote;
}
private readonly record struct Segment(long StartTick, double StartSeconds, int MicrosecondsPerQuarter);
private const int DefaultMicrosecondsPerQuarter = 500_000; // 120 BPM, the MIDI spec default
private readonly List _segments = new();
private readonly int _deltaTicksPerQuarterNote;
private int _cursor;
}
private readonly record struct CacheEntry(DateTime LastWrite, DataSet DataSet);
private static readonly Dictionary _cache = new();
}