Files
2026-07-13 13:13:17 +08:00

328 lines
13 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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;
/// <summary>
/// Converts a standard MIDI file (<c>.mid</c> / <c>.midi</c>) into a <see cref="DataSet"/>
/// using the same channel conventions as live MIDI recording, so a loaded file can be
/// replayed through <c>SimulateIoData</c> exactly like a recorded <c>.data</c> clip.
/// </summary>
/// <remarks>
/// Channel paths follow <see cref="IoDataSetRecorder.ChannelPaths"/> with the file name in
/// the device-name segment: <c>["Midi", "MyTrack.mid", "Ch1", "N60"]</c>. Notes become
/// interval events carrying the raw NoteOn velocity (0127); 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).
/// </remarks>
public static class MidiFileToDataSet
{
/// <summary>
/// Returns a shared <see cref="DataSet"/> for the MIDI file at <paramref name="absolutePath"/>.
/// Parses once per (path, last-write timestamp) and serves subsequent callers from the cache,
/// mirroring <see cref="DataSetCache"/> for <c>.data</c> files.
/// </summary>
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;
}
/// <summary>
/// Parses and converts a MIDI file into a fresh <see cref="DataSet"/> without touching
/// the cache. Prefer <see cref="TryGet"/> for normal use.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<string>
{
IoDataSetRecorder.ChannelPaths.MidiNamespacePrefix,
deviceName,
IoDataSetRecorder.ChannelPaths.ChannelPathPrefix + midiChannel,
tag,
},
};
channelsByKey[key] = newChannel;
dataSet.Channels.Add(newChannel);
return newChannel;
}
private static List<MidiEvent> CollectEventsSorted(MidiFile midiFile)
{
var all = new List<MidiEvent>();
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;
}
/// <summary>
/// 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.
/// </summary>
private sealed class TempoMap
{
public TempoMap(List<MidiEvent> 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<Segment> _segments = new();
private readonly int _deltaTicksPerQuarterNote;
private int _cursor;
}
private readonly record struct CacheEntry(DateTime LastWrite, DataSet DataSet);
private static readonly Dictionary<string, CacheEntry> _cache = new();
}