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