#nullable enable
using System;
using System.Diagnostics.CodeAnalysis;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using T3.Core.Animation;
using T3.Core.Logging;
using T3.Core.Operator;
using T3.Core.Resource;
using T3.Core.Resource.Assets;
using T3.Serialization;
namespace T3.Core.Audio;
///
/// A wrapper that provides access to file resources of and their
/// filepath. This is then used by the to generate
/// s for playback.
///
public sealed record AudioClipResourceHandle(TimelineAudioClip Clip, IResourceConsumer? Owner)
{
public bool TryGetFileResource([NotNullWhen(true)] out FileResource? file)
{
if (string.IsNullOrEmpty(Clip.AssetPath))
{
file = null;
LoadingAttemptFailed = false;
return false;
}
if (FileResource.TryGetFileResource(Clip.AssetPath, Owner, out file))
{
LoadingAttemptFailed = false;
return true;
}
file = null;
LoadingAttemptFailed = true;
return false;
}
///
/// Only applies a newly entered filepath if it can be loaded.
/// Otherwise, keeps original value and return false.
///
public bool TryToApplyFilePath(string newFilePath, Instance composition)
{
if (!AssetRegistry.TryResolveAddress(newFilePath, composition, out _, out _))
{
Clip.AssetPath = string.Empty;
return false;
}
LoadingAttemptFailed = false;
Clip.AssetPath = newFilePath;
return true;
}
///
/// Keep flag to prevent multiple error messages
///
public bool LoadingAttemptFailed;
}
///
/// A single audio clip placed on a composition's timeline. Owned by the symbol's
/// CompositionSettings.Playback.AudioClips list and rendered directly in
/// LayersArea — no operator instance is required.
///
/// Audio plays at native rate; is the timeline window in bars,
/// while and describe
/// what part of the source file is audible (in seconds). BPM changes never pitch-shift
/// the audio — they only shift the bar-to-seconds mapping for the timeline.
///
public sealed class TimelineAudioClip
{
#region serialized attributes
public Guid Id = Guid.NewGuid();
public string? AssetPath;
///
/// Where on the timeline the clip is audible, in bars.
/// less than or equal to means "no explicit end on
/// the timeline; play until the source content runs out."
///
public TimeRange TimeRange;
///
/// Offset into the source file where playback starts, in seconds.
///
public double SourceOffsetSecs;
///
/// How much of the source file (starting at ) is
/// audible, in seconds. 0 means "until the end of the file."
///
public double SourceDurationSecs;
public int LayerIndex;
public float Volume = 1.0f;
///
/// Per-clip mute. Silences playback (the audio engine multiplies the channel volume
/// by 0) and the clip body renders faded so the user can tell at a glance that the
/// clip won't be heard.
///
public bool IsMuted;
///
/// Marks the project's "main soundtrack" clip. This single flag currently bundles three concerns:
/// (1) the clip is drawn as the timeline-background waveform, (2) it gets FFT routing for
/// audio-reactive effects, and (3) it's wired into the export pipeline. Only one clip per
/// project should set this — see AudioEngine.ProcessSoundtrackClips's
/// handledMainSoundtrack guard.
///
/// The overload is acknowledged technical debt and may be split in a future change.
///
public bool IsMainSoundtrack;
#endregion
///
/// Initialized by after BASS reports it.
///
public double LengthInSeconds;
///
/// Returns a resource handle for this clip, cached per owner. Per-frame callers must reuse the
/// same handle so the engine's load/fail state ()
/// and stream-dictionary key stay stable — a fresh handle each frame would re-attempt a missing
/// file and re-log the failure on every frame, and allocate in the hot path.
///
public AudioClipResourceHandle GetResourceHandle(IResourceConsumer? owner)
{
if (_handle == null || !ReferenceEquals(_handle.Owner, owner))
_handle = new AudioClipResourceHandle(this, owner);
return _handle;
}
private AudioClipResourceHandle? _handle;
///
/// Read-only mirror of a pre-rewrite project's per-clip Bpm field. Populated only by
/// for back-compat with old JSON. Consumed by
/// CompositionSettings to copy into Playback.Bpm once on load, then ignored.
/// Not serialized. New code should not read or write this — BPM lives on Playback now.
///
[JsonIgnore]
internal float LegacyBpmForMigration;
#region serialization
internal static bool TryFromJson(JToken jToken, [NotNullWhen(true)] out TimelineAudioClip? newClip)
{
if (!JsonUtils.TryGetGuid(jToken[nameof(Id)], out var clipId))
{
Log.Warning("Missing or malformed id in TimelineAudioClip.");
newClip = null;
return false;
}
// Heal old data that was saved with Guid.Empty (the "Add soundtrack" button used
// to construct clips without initializing Id). Silent — the assignment is fully
// backward-compatible and gets persisted on the next save; logging once per clip
// floods the console on project load when many legacy clips are present.
if (clipId == Guid.Empty)
{
clipId = Guid.NewGuid();
}
// Back-compat: pre-rewrite projects use FilePath / StartTime / EndTime / Bpm /
// DiscardAfterUse / IsSoundtrack. Read the new names first; fall through to the old.
var assetPath = jToken[nameof(AssetPath)]?.Value()
?? jToken["FilePath"]?.Value();
TimeRange timeRange;
var timeRangeToken = jToken[nameof(TimeRange)];
if (timeRangeToken != null)
{
timeRange = new TimeRange(
timeRangeToken["Start"]?.Value() ?? 0f,
timeRangeToken["End"]?.Value() ?? 0f);
}
else
{
// Legacy: StartTime / EndTime (both in bars).
var legacyStart = (float)(jToken["StartTime"]?.Value() ?? 0);
var legacyEnd = (float)(jToken["EndTime"]?.Value() ?? 0);
timeRange = new TimeRange(legacyStart, legacyEnd);
}
var legacyBpm = jToken["Bpm"]?.Value() ?? 0f;
var isMainSoundtrack = jToken[nameof(IsMainSoundtrack)]?.Value()
?? jToken["IsSoundtrack"]?.Value()
?? true;
newClip = new TimelineAudioClip
{
Id = clipId,
AssetPath = assetPath,
TimeRange = timeRange,
SourceOffsetSecs = jToken[nameof(SourceOffsetSecs)]?.Value() ?? 0,
SourceDurationSecs = jToken[nameof(SourceDurationSecs)]?.Value() ?? 0,
LayerIndex = jToken[nameof(LayerIndex)]?.Value() ?? 0,
Volume = jToken[nameof(Volume)]?.Value() ?? 1f,
IsMuted = jToken[nameof(IsMuted)]?.Value() ?? false,
IsMainSoundtrack = isMainSoundtrack,
LegacyBpmForMigration = legacyBpm,
};
return true;
}
internal void ToJson(JsonTextWriter writer)
{
if (string.IsNullOrEmpty(AssetPath))
return;
writer.WriteStartObject();
{
writer.WriteValue(nameof(Id), Id);
writer.WriteObject(nameof(AssetPath), AssetPath);
writer.WritePropertyName(nameof(TimeRange));
writer.WriteStartObject();
writer.WriteValue("Start", TimeRange.Start);
writer.WriteValue("End", TimeRange.End);
writer.WriteEndObject();
if (SourceOffsetSecs > 0)
writer.WriteValue(nameof(SourceOffsetSecs), SourceOffsetSecs);
if (SourceDurationSecs > 0)
writer.WriteValue(nameof(SourceDurationSecs), SourceDurationSecs);
if (LayerIndex != 0)
writer.WriteValue(nameof(LayerIndex), LayerIndex);
if (Math.Abs(Volume - 1.0f) > 0.001f)
writer.WriteValue(nameof(Volume), Volume);
// Only emit the mute flag when set — keeps the JSON lean for the common case.
if (IsMuted)
writer.WriteValue(nameof(IsMuted), IsMuted);
writer.WriteValue(nameof(IsMainSoundtrack), IsMainSoundtrack);
}
writer.WriteEndObject();
}
#endregion
}