#nullable enable
using T3.Core.Animation;
using T3.Core.Audio;
using T3.Core.IO;
using T3.IoServices;
using T3.Core.Logging;
using T3.Core.Operator;
using T3.Core.Operator.Slots;
using T3.Core.Resource.Assets;
using T3.Editor.Gui.Interaction;
using T3.Editor.UiModel;
using T3.Editor.UiModel.Commands;
using T3.Editor.Gui.UiHelpers;
using T3.Editor.UiModel.Commands.Animation;
using T3.Editor.UiModel.Commands.Graph;
using T3.Editor.UiModel.Helpers;
using T3.Editor.UiModel.ProjectHandling;
namespace T3.Editor.Gui.Windows.TimeLine;
///
/// Coordinates a paired audio + IO recording session. Owns the "is something being
/// captured right now" state, dispatches start / stop to the underlying
/// and , and spawns the
/// destination clips on the timeline at start so the user sees the recording grow in
/// real time. The whole session lands as one on stop so it
/// undoes as a unit.
///
///
///
/// Clip growth is wall-clock driven: each frame, extends the
/// TimeRange.End on both the LoadDataClip and AudioClip ops by the
/// elapsed seconds since record-start (converted to
/// bars at the current BPM). The recordings themselves measure wall-clock time
/// regardless of playback scrubbing, so the clip's visual duration matches the file's
/// real duration.
///
///
/// The composition reference is captured by symbol Guid (not ),
/// so a graph hot-reload mid-session doesn't strand a stale pointer — every resolve
/// roundtrips through .
///
///
internal static class RecordingSession
{
public static bool IsActive { get; private set; }
///
/// During an active session, returns the in-progress being
/// written by if the given SymbolChild is the one
/// the session is targeting. Lets timeline UI (the clip-body renderer) show events
/// streaming in live before the file is finalised on stop.
///
public static bool TryGetLiveDataSet(Guid clipChildId, out T3.Core.DataTypes.DataSet.DataSet? dataSet)
{
dataSet = null;
if (!IsActive || clipChildId != _activeDataClipChildId)
return false;
dataSet = IoDataSetRecorder.ActiveDataSet;
return dataSet != null;
}
public static void Start(Instance compositionOp)
{
if (IsActive)
{
Log.Warning("RecordingSession.Start called while another session is already active.");
return;
}
if (Playback.Current == null)
{
Log.Warning("RecordingSession.Start: no active Playback; can't anchor the recording.");
return;
}
_compositionSymbolId = compositionOp.Symbol.Id;
_recordStartBars = Playback.Current.TimeInBars;
_recordStartRunSecs = Playback.RunTimeInSecs;
// Capture what to record per the composition's recording settings (lives on the
// composition's SymbolUi — see RecordingSettings.cs). Permissive defaults — a
// composition that never touched the panel still grabs everything (RecordingSettings
// has a Defaults instance with all flags on). CaptureMidi / CaptureOsc only have
// an effect when CaptureIo is on; the IoDataSetRecorder no-ops the session if
// both sub-flags would land it with nothing to subscribe to.
var recording = compositionOp.GetSymbolUi().RecordingSettings ?? RecordingSettings.Defaults;
var captureAudio = recording.CaptureAudio;
var captureIo = recording.CaptureIo && (recording.CaptureMidi || recording.CaptureOsc);
var captureMidi = recording.CaptureMidi;
var captureOsc = recording.CaptureOsc;
if (!captureAudio && !captureIo)
{
Log.Warning("RecordingSession.Start: nothing to record — both Audio and IO are disabled in this project's recording settings.");
return;
}
var startBars = (float)_recordStartBars;
var baseLayer = FindNextLayerIndex(compositionOp, startBars);
var dataLayer = baseLayer;
var audioLayer = baseLayer + 1;
_lastBaseLayerIndex = baseLayer;
_activeMacro = new MacroCommand("Live recording session");
// LoadDataClip op — only created when the user actually wants IO recorded.
// Same rationale: avoid an empty clip + op pair just because the user opted out.
if (captureIo)
{
var addCmd = new AddSymbolChildCommand(compositionOp.Symbol, _loadDataClipSymbolId)
{
PosOnCanvas = FindFreeCanvasPositionForClip(compositionOp.Symbol, _loadDataClipSymbolId),
};
_activeMacro.AddAndExecCommand(addCmd);
_activeDataClipChildId = addCmd.AddedChildId;
InitClipTimeClip(compositionOp.Symbol, _activeDataClipChildId, startBars, dataLayer);
}
// AudioClip op — only created when audio capture is enabled. Symmetric with the
// LoadDataClip op above: both are TimeClip-backed ops that grow during capture and
// get their file path committed on stop.
if (captureAudio)
{
// When IO is off and audio is the only capture, stash the audio on the
// base layer instead of layer+1 — there's no data clip to pair with.
var rowForAudio = captureIo ? audioLayer : dataLayer;
var addAudioCmd = new AddSymbolChildCommand(compositionOp.Symbol, _audioClipSymbolId)
{
PosOnCanvas = FindFreeCanvasPositionForClip(compositionOp.Symbol, _audioClipSymbolId),
};
_activeMacro.AddAndExecCommand(addAudioCmd);
_activeAudioClipChildId = addAudioCmd.AddedChildId;
InitClipTimeClip(compositionOp.Symbol, _activeAudioClipChildId, startBars, rowForAudio);
}
// Recorders last — if a Begin fails, the clips already exist but stay zero-width
// until Stop. The user sees something went wrong; undo cleanly removes both clips.
_audioCaptureActive = captureAudio;
_ioCaptureActive = captureIo;
// One shared session index so a session's audio + data files line up (AudioRec-007 / DataRec-007).
// Computed once here (before either recorder writes) from the persistent imported recordings in the
// project's Assets — not the temp dir, whose copies are deleted after import (which would reset the
// index to 001 every session).
var sessionIndex = RecordingPaths.NextSessionIndex(NextIndexScanDirs(compositionOp));
if (captureAudio)
WasapiAudioInput.BeginRecording(sessionIndex);
if (captureIo)
IoDataSetRecorder.BeginRecording(sessionIndex, captureMidi: captureMidi, captureOsc: captureOsc);
// Without this, the graph canvas keeps drawing its cached child list until the
// user clicks on it — the new LoadDataClip op isn't visible until then.
// FlagAsModified bumps the symbol-UI revision; FlagChanges notifies the open
// ProjectView so it rebuilds its child renderable set.
compositionOp.GetSymbolUi().FlagAsModified();
ProjectView.Focused?.FlagChanges(ProjectView.ChangeTypes.Children);
IsActive = true;
}
///
/// Called once per frame while a session is active. Extends both clips' TimeRange.End
/// based on wall-clock elapsed time since Start, converted to bars at the current BPM.
/// No-op when no session is active.
///
///
/// Auto-stops the session when the user pauses playback (Spacebar → PlaybackSpeed = 0).
/// The mental model the user works with is "record while playing": running the recorders
/// past a pause produces a clip whose TimeRange end doesn't match the audible content,
/// and the audio buffer keeps growing in the background. Stopping at pause matches the
/// "Start recording also starts playback" affordance on the Record button.
///
public static void OnFrame()
{
if (!IsActive)
return;
var playback = Playback.Current;
if (playback == null)
return;
// Pause-stops-recording: if playback is paused, finalise the session here so the
// user doesn't have to remember to click Record off. Same Stop() the toolbar runs,
// so the file finalisation, asset import, and undo macro all flow normally.
if (playback.PlaybackSpeed == 0)
{
Stop();
return;
}
var elapsedSecs = Playback.RunTimeInSecs - _recordStartRunSecs;
var elapsedBars = elapsedSecs * playback.Bpm / 240.0;
var timelineEnd = (float)(_recordStartBars + elapsedBars);
var sourceEnd = (float)elapsedBars;
// Grow both clip ops' TimeClip output data in place. Animated dirty flag on each op's
// output ensures the next Update picks up the new range. TimeRange extends in
// timeline-bar space (startBars + elapsed); SourceRange extends in file-time-bar space
// (elapsed since record-start). Keeping the two separate lets cut / start-handle trim
// work correctly — see InitClipTimeClip.
if (SymbolUiRegistry.TryGetSymbolUi(_compositionSymbolId, out var compositionUi))
{
GrowClipTimeRange(compositionUi.Symbol, _activeDataClipChildId, timelineEnd, sourceEnd);
GrowClipTimeRange(compositionUi.Symbol, _activeAudioClipChildId, timelineEnd, sourceEnd);
}
}
private static void GrowClipTimeRange(Symbol composition, Guid childId, float timelineEnd, float sourceEnd)
{
if (childId == Guid.Empty || !composition.Children.TryGetValue(childId, out var symbolChild))
return;
foreach (var output in symbolChild.Outputs.Values)
{
if (output.OutputData is TimeClip tc)
{
tc.TimeRange.End = timelineEnd;
tc.SourceRange.End = sourceEnd;
break;
}
}
}
public static void Stop()
{
if (!IsActive)
return;
IsActive = false;
// Skip EndRecording on whichever side never started — the underlying recorders
// log a warning when called without an active session, which would otherwise spam
// the Console every time the user records with only one source selected.
var audioPath = _audioCaptureActive ? WasapiAudioInput.EndRecording() : null;
var dataPath = _ioCaptureActive ? IoDataSetRecorder.EndRecording() : null;
_audioCaptureActive = false;
_ioCaptureActive = false;
// Final extend with one last frame's worth so the clip ends precisely where the
// recorders did. The recorders' EndRecording closes the files synchronously, so
// the elapsed time at this exact moment is the real duration.
OnFrame();
if (!SymbolUiRegistry.TryGetSymbolUi(_compositionSymbolId, out var compositionUi))
{
Log.Warning($"RecordingSession.Stop: composition symbol {_compositionSymbolId} no longer registered; clips left with empty paths.");
ResetSessionState();
return;
}
// Import the just-finalised files into the project's Assets folder so they land
// under the active package, register with AssetRegistry, and show up in the
// AssetLib UI. The temp-staging copy in the recordings dir is removed on success
// (see TryImportRecording) so each session doesn't leave a duplicate behind.
var package = compositionUi.Symbol.SymbolPackage;
var dataAddress = TryImportRecording(dataPath, package) ?? dataPath;
var audioAddress = TryImportRecording(audioPath, package) ?? audioPath;
// Apply FilePath to the LoadDataClip via the standard input-value command so
// undo restores the empty default. AddSymbolChildCommand.Undo removes the child
// entirely, so this is only meaningful when the user redoes after an undo.
if (!string.IsNullOrEmpty(dataAddress)
&& compositionUi.Symbol.Children.TryGetValue(_activeDataClipChildId, out var symbolChild)
&& symbolChild.Inputs.TryGetValue(_loadDataClipFilePathInputId, out var filePathInput))
{
var newValue = new InputValue(dataAddress);
_activeMacro?.AddAndExecCommand(new ChangeInputValueCommand(compositionUi.Symbol,
_activeDataClipChildId,
filePathInput,
newValue));
}
// Apply the recorded file to the AudioClip op's Path input via the standard
// input-value command (same pattern as the data clip above), so undo restores the
// empty default.
if (!string.IsNullOrEmpty(audioAddress)
&& compositionUi.Symbol.Children.TryGetValue(_activeAudioClipChildId, out var audioChild)
&& audioChild.Inputs.TryGetValue(_audioClipPathInputId, out var audioPathInput))
{
var newAudioValue = new InputValue(audioAddress);
_activeMacro?.AddAndExecCommand(new ChangeInputValueCommand(compositionUi.Symbol,
_activeAudioClipChildId,
audioPathInput,
newAudioValue));
}
if (_activeMacro != null)
UndoRedoStack.Add(_activeMacro);
// Mirror the Begin-side flagging so the file-path commit on Stop (which makes the
// clip's label switch from "rec…" to the real filename) and any other late mutations
// propagate to the graph view without a click.
compositionUi.FlagAsModified();
ProjectView.Focused?.FlagChanges(ProjectView.ChangeTypes.Children);
ResetSessionState();
}
private static void ResetSessionState()
{
_activeMacro = null;
_activeDataClipChildId = Guid.Empty;
_activeAudioClipChildId = Guid.Empty;
_compositionSymbolId = Guid.Empty;
_recordStartBars = 0;
_recordStartRunSecs = 0;
}
///
/// Copies a just-finalised recording into the active project's Assets folder via
/// , registering it with
/// and triggering an AssetLib refresh. Returns the
/// resulting Asset.Address (e.g. "project:Assets/audio/rec-001.wav") so
/// the clip's FilePath / AssetPath resolves through the registry afterwards. Returns
/// null on failure — caller falls back to the original absolute path.
///
private static string? TryImportRecording(string? absolutePath, T3.Core.Resource.IResourcePackage? package)
{
if (string.IsNullOrEmpty(absolutePath) || package == null)
return null;
// Import into the type's RecordingFolder (audio / dataclips) — the same folder NextIndexScanDirs
// reads, so storage + index-scan stay driven by the one AssetType field.
var subfolder = AssetType.TryGetForFilePath(absolutePath, out var assetType, out _) ? assetType.RecordingFolder : null;
if (!FileImport.TryImportDroppedFile(absolutePath, package, subfolder, out var asset))
{
Log.Warning($"RecordingSession: failed to import {absolutePath} into project Assets; clip will reference the original absolute path.");
return null;
}
// The recording now lives in the project's Assets folder (the canonical location the clip
// references). Remove the temp-staging copy so each session doesn't leave a duplicate behind.
// Best-effort — only runs after a successful import, so the data is already safe in Assets.
try
{
System.IO.File.Delete(absolutePath);
}
catch (Exception e)
{
Log.Debug($"RecordingSession: could not remove temp recording {absolutePath}: {e.Message}");
}
return asset.Address;
}
///
/// Directories scanned to pick the next session index: the project's imported-recording folders (the
/// persistent record) plus the temp staging dir (covers a leftover from a failed import). Deleting the
/// temp copies on import is why the persistent Assets folders must be the source of truth — scanning only
/// the temp dir would reset the index to 001 every session.
///
private static string[] NextIndexScanDirs(Instance compositionOp)
{
var dirs = new List { RecordingPaths.TempRecordingsDirectory };
var assetsFolder = compositionOp.Symbol.SymbolPackage?.AssetsFolder;
if (!string.IsNullOrEmpty(assetsFolder))
{
// Each recordable asset type declares its import subfolder via AssetType.RecordingFolder (set in
// AssetHandling). Scanning those persistent folders is what keeps the session index climbing after
// the temp copies are deleted on import.
foreach (var assetType in AssetType.AvailableTypes)
{
if (!string.IsNullOrEmpty(assetType.RecordingFolder))
dirs.Add(System.IO.Path.Combine(assetsFolder, assetType.RecordingFolder));
}
}
return dirs.ToArray();
}
private static void InitClipTimeClip(Symbol composition, Guid childId, float startBars, int layer)
{
if (!composition.Children.TryGetValue(childId, out var symbolChild))
{
Log.Warning("RecordingSession: newly added recording clip not found in composition; TimeRange not initialised.");
return;
}
foreach (var output in symbolChild.Outputs.Values)
{
if (output.OutputData is TimeClip tc)
{
// TimeRange is in timeline bars (placement); SourceRange is in file-time
// bars (anchored at 0). Keeping the two in different spaces is what makes
// cut / drag-start trim mathematically consistent — splitting a clip just
// narrows both ranges in their own coordinate space, and LoadDataClip
// maps file events without rebasing.
tc.TimeRange = new TimeRange(startBars, startBars);
tc.SourceRange = new TimeRange(0f, 0f);
tc.LayerIndex = layer;
break;
}
}
}
///
/// Canvas position for a fresh recording-clip op of type .
/// Anchors below the lowest existing op of the same type (so successive recordings stay in a
/// tidy column), re-centres on the viewport when that anchor is off-screen, then hands the
/// preferred spot to for collision-avoidance —
/// without that last step, repeated recordings that all re-centre to the same viewport pile
/// up on top of each other.
///
private static Vector2 FindFreeCanvasPositionForClip(Symbol compositionSymbol, Guid anchorSymbolId)
{
const float spacingY = 80f;
const float defaultX = 0f;
const float defaultY = 200f;
var preferred = new Vector2(defaultX, defaultY);
if (!SymbolUiRegistry.TryGetSymbolUi(compositionSymbol.Id, out var compositionUi))
return preferred;
var maxY = float.NegativeInfinity;
var anchorX = defaultX;
foreach (var (childId, childUi) in compositionUi.ChildUis)
{
if (!compositionSymbol.Children.TryGetValue(childId, out var symbolChild))
continue;
if (symbolChild.Symbol.Id != anchorSymbolId)
continue;
if (childUi.PosOnCanvas.Y > maxY)
{
maxY = childUi.PosOnCanvas.Y;
anchorX = childUi.PosOnCanvas.X;
}
}
if (!float.IsNegativeInfinity(maxY))
preferred = new Vector2(anchorX, maxY + spacingY);
// Re-centre on the visible canvas when the stack-anchor candidate is off-screen,
// so a new clip doesn't land far outside the viewport where it looks like nothing
// happened.
if (ProjectView.Focused?.GraphView is ScalableCanvas canvas)
{
var visible = canvas.GetVisibleCanvasArea();
if (visible.GetWidth() > 0 && visible.GetHeight() > 0 && !visible.Contains(preferred))
preferred = visible.GetCenter();
}
return GraphUtils.FindFreePosition(compositionUi, preferred, SymbolUi.Child.DefaultOpSize);
}
///
/// Picks the base layer for a new recording's (data, audio) pair. Occupancy is
/// evaluated at the recording's start position only — a clip parked far down the
/// timeline doesn't reserve its row for a recording happening earlier. Preference order:
/// (1) the previous session's base layer if both rows there are free at start — keeps
/// consecutive recordings on the same lane the user already cleared / dedicated;
/// (2) the lowest pair (i, i+1) that's free at start;
/// (3) one layer above the highest occupied row anywhere in the composition.
/// Step (3) only triggers when (1) and (2) both fail, i.e. the timeline is densely
/// packed at this position — same fallback the original behaviour produced.
///
private static int FindNextLayerIndex(Instance compositionOp, float startBars)
{
_occupiedAtStartScratch.Clear();
var maxLayerAnywhere = -1;
// Both data and audio recording clips are now TimeClip-backed ops, so a single
// GetAllTimeClips pass covers layer occupancy (the main soundtrack lives outside the
// clip grid as the background image and never reserved a row).
foreach (var clip in Structure.GetAllTimeClips(compositionOp))
{
if (clip.LayerIndex > maxLayerAnywhere)
maxLayerAnywhere = clip.LayerIndex;
if (clip.TimeRange.Start <= startBars && startBars < clip.TimeRange.End)
_occupiedAtStartScratch.Add(clip.LayerIndex);
}
// Re-use the previous session's lane when nothing's parked across it at this start
// position. Makes "record, listen back, record again at a different point" stay on
// the same dedicated row instead of marching downward each take.
if (_lastBaseLayerIndex >= 0
&& !_occupiedAtStartScratch.Contains(_lastBaseLayerIndex)
&& !_occupiedAtStartScratch.Contains(_lastBaseLayerIndex + 1))
{
return _lastBaseLayerIndex;
}
// Otherwise scan from 0 up for the first free pair at this start position.
for (var i = 0; i <= maxLayerAnywhere + 1; i++)
{
if (!_occupiedAtStartScratch.Contains(i) && !_occupiedAtStartScratch.Contains(i + 1))
return i;
}
return maxLayerAnywhere + 1;
}
// Reused across calls to keep FindNextLayerIndex allocation-free — Begin is user-
// triggered (one click) but the helper is cheap to call and we already follow the
// editor's "no per-frame allocations" habit.
private static readonly HashSet _occupiedAtStartScratch = new();
// Last base layer the user actually recorded onto. Persists across sessions so the
// next recording prefers the same lane when it's free at the new start position.
// -1 means "no preference yet" — fresh editor launch falls back to the scan path.
private static int _lastBaseLayerIndex = -1;
private static readonly Guid _loadDataClipSymbolId = new("4d1c0e80-7b2a-4f6d-9c1b-12d3e4f50607");
private static readonly Guid _loadDataClipFilePathInputId = new("70419103-ae5d-4ca0-cf4e-456071829304");
private static readonly Guid _audioClipSymbolId = new("f0008b50-091d-4e9f-91eb-baa212acfa20");
private static readonly Guid _audioClipPathInputId = new("625951af-5f99-4171-b5b0-c97413121f56");
private static Guid _compositionSymbolId;
private static Guid _activeDataClipChildId;
private static Guid _activeAudioClipChildId;
private static MacroCommand? _activeMacro;
private static double _recordStartBars;
private static double _recordStartRunSecs;
// Track which underlying recorders the active session actually started so Stop()
// only calls EndRecording on the ones with a running session — avoids the underlying
// recorder logging "no session active" warnings when capture was opted out.
private static bool _audioCaptureActive;
private static bool _ioCaptureActive;
}