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

306 lines
13 KiB
C#

#nullable enable
using ManagedBass;
using T3.Core.Audio;
// ReSharper disable MemberCanBePrivate.Global
// ReSharper disable UnusedMember.Local
namespace Lib.io.audio
{
/// <summary>
/// A spatial audio player operator that provides 3D positional audio playback capabilities.
/// Supports sound positioning, listener orientation, distance-based attenuation, and directional sound cones.
/// </summary>
[Guid("8a3c9f2e-4b7d-4e1a-9c5f-7d2e8b1a6c3f")]
internal sealed class SpatialAudioPlayer : Instance<SpatialAudioPlayer>, ITransformable, ISpatialAudioPropertiesProvider
{
#region ITransformable Implementation
IInputSlot ITransformable.TranslationInput => SourcePosition;
IInputSlot ITransformable.RotationInput => SourceRotation;
IInputSlot? ITransformable.ScaleInput => null;
public Action<Instance, EvaluationContext>? TransformCallback { get; set; }
#endregion
#region ISpatialAudioPropertiesProvider Implementation
Vector3 ISpatialAudioPropertiesProvider.SourcePosition => SourcePosition.Value;
Vector3 ISpatialAudioPropertiesProvider.SourceRotation => SourceRotation.Value;
Vector3 ISpatialAudioPropertiesProvider.ListenerPosition => ListenerPosition.Value;
Vector3 ISpatialAudioPropertiesProvider.ListenerRotation => ListenerRotation.Value;
float ISpatialAudioPropertiesProvider.MinDistance => MinDistance.Value;
float ISpatialAudioPropertiesProvider.MaxDistance => MaxDistance.Value;
float ISpatialAudioPropertiesProvider.InnerConeAngle => InnerConeAngle.Value;
float ISpatialAudioPropertiesProvider.OuterConeAngle => OuterConeAngle.Value;
GizmoVisibility ISpatialAudioPropertiesProvider.GizmoVisibility => Visibility.Value;
#endregion
[Input(Guid = "2aa976e3-a63c-4c7e-80a5-a459b3240388")]
public readonly InputSlot<string> AudioFile = new();
[Input(Guid = "454a65fa-c0a3-4055-85ec-ac3771b2524c")]
public readonly InputSlot<bool> PlayAudio = new();
[Input(Guid = "1800cdcb-8863-423e-a2ec-901cf54e014c")]
public readonly InputSlot<bool> StopAudio = new();
[Input(Guid = "d0c660d7-f90b-4e61-95ff-0ce7c76d9d67")]
public readonly InputSlot<bool> PauseAudio = new();
[Input(Guid = "7110399e-fd85-4053-9a49-cc09760dceb6")]
public readonly InputSlot<float> Volume = new();
[Input(Guid = "ad8822bc-939b-4152-b253-bf01c5aa4d73")]
public readonly InputSlot<bool> Mute = new();
[Input(Guid = "9c0b519b-45b1-4b28-8c0e-2eba7035a999")]
public readonly InputSlot<float> Speed = new();
[Input(Guid = "b921cbd4-4803-48e8-9eb9-f05901cc5802")]
public readonly InputSlot<float> Seek = new();
[Input(Guid = "f3730e3b-a335-4a13-9b2a-87b8e75cddeb")]
public readonly InputSlot<Vector3> SourcePosition = new();
[Input(Guid = "3cf1b579-a1f2-4e9f-a54a-0e9e3580a6bf")]
public readonly InputSlot<Vector3> SourceRotation = new();
[Input(Guid = "732be517-a58e-4f21-8395-7761a95905ea")]
public readonly InputSlot<float> MinDistance = new();
[Input(Guid = "7eb612cb-1dbf-4336-b91d-1042573bd7ff")]
public readonly InputSlot<float> MaxDistance = new();
[Input(Guid = "159c7342-a8c8-4ffb-8a69-da3ffa0d3b71")]
public readonly InputSlot<Vector3> ListenerPosition = new();
[Input(Guid = "db9a0aa1-95db-4da4-99fc-31c385773ca8")]
public readonly InputSlot<Vector3> ListenerRotation = new();
[Input(Guid = "785bb240-2020-4937-b132-b46f542e8986")]
public readonly InputSlot<float> InnerConeAngle = new();
[Input(Guid = "73971807-98d8-4b8b-b90f-41d673398b2b")]
public readonly InputSlot<float> OuterConeAngle = new();
[Input(Guid = "b4d639d4-d96b-42e1-a250-efe57d7e9c5e")]
public readonly InputSlot<float> OuterConeVolume = new();
[Input(Guid = "42d8337a-d419-4038-a132-0e3934d25a2a", MappedType = typeof(Audio3DModes))]
public readonly InputSlot<int> Audio3DMode = new();
/// <summary>
/// Controls the visibility of the transform gizmo.
/// </summary>
[Input(Guid = "77d9309e-0c9d-407f-b9b1-dd4b734fa51e")]
public readonly InputSlot<GizmoVisibility> Visibility = new();
// Outputs
/// <summary>
/// Command output for chaining with other operators.
/// </summary>
[Output(Guid = "117c0d2e-c14b-471a-8a6d-a9f983e48908")]
public readonly TransformCallbackSlot<Command> Result = new();
/// <summary>
/// Indicates whether the audio is currently playing.
/// </summary>
[Output(Guid = "022b0246-73d7-4a99-9c89-3e199171fd6c")]
public readonly Slot<bool> IsPlaying = new();
/// <summary>
/// Indicates whether the audio is currently paused.
/// </summary>
[Output(Guid = "52e624ca-508c-44c1-8583-946260e9f660")]
public readonly Slot<bool> IsPaused = new();
/// <summary>
/// Returns the current audio level/amplitude.
/// </summary>
[Output(Guid = "ccbff254-1090-4ba2-8341-08fa3cb1540c")]
public readonly Slot<float> GetLevel = new();
private Guid _operatorId;
private bool _wasPausedLastFrame;
/// <summary>
/// Gets the current audio file path being played.
/// </summary>
public string CurrentFilePath { get; private set; } = string.Empty;
/// <summary>
/// Initializes a new instance of the <see cref="SpatialAudioPlayer"/> class.
/// </summary>
public SpatialAudioPlayer()
{
Result.TransformableOp = this;
Result.UpdateAction += UpdatePlayback;
IsPlaying.UpdateAction += UpdateStatus;
IsPaused.UpdateAction += UpdateStatus;
GetLevel.UpdateAction += UpdateStatus;
}
private void EnsureOperatorId()
{
if (_operatorId != Guid.Empty)
return;
_operatorId = AudioPlayerUtils.ComputeInstanceGuid(InstancePath);
Log.Gated.Audio($"[SpatialAudioPlayer] Initialized: {_operatorId}");
}
private void UpdateStatus(EvaluationContext context)
{
EnsureOperatorId();
IsPlaying.Value = AudioEngine.IsSpatialOperatorStreamPlaying(_operatorId);
IsPaused.Value = AudioEngine.IsSpatialOperatorPaused(_operatorId);
GetLevel.Value = AudioEngine.GetSpatialOperatorLevel(_operatorId);
}
/// <summary>
/// Updates the spatial audio player state and processes playback.
/// </summary>
/// <param name="context">The evaluation context for the current frame.</param>
private void UpdatePlayback(EvaluationContext context)
{
EnsureOperatorId();
string? filePath = AudioFile.GetValue(context);
bool shouldPlay = PlayAudio.GetValue(context);
var shouldStop = StopAudio.GetValue(context) || !shouldPlay;
var shouldPause = PauseAudio.GetValue(context);
var volume = Volume.GetValue(context);
var mute = Mute.GetValue(context);
var sourcePosition = SourcePosition.GetValue(context);
var listenerPosition = ListenerPosition.GetValue(context);
var listenerRotation = ListenerRotation.GetValue(context);
// Convert Euler angles (in degrees) to forward and up vectors
var rotationMatrix = Matrix4x4.CreateFromYawPitchRoll(
listenerRotation.Y * (MathF.PI / 180f),
listenerRotation.X * (MathF.PI / 180f),
listenerRotation.Z * (MathF.PI / 180f));
var listenerForward = Vector3.Transform(new Vector3(0, 0, 1), rotationMatrix);
var listenerUp = Vector3.Transform(new Vector3(0, 1, 0), rotationMatrix);
var minDistance = MinDistance.GetValue(context);
if (minDistance <= 0) minDistance = 1.0f;
var maxDistance = MaxDistance.GetValue(context);
if (maxDistance <= minDistance) maxDistance = minDistance + 10.0f;
var speed = Speed.GetValue(context);
var seek = Seek.GetValue(context);
var sourceRotation = SourceRotation.GetValue(context);
// Convert source rotation (Euler angles in degrees) to orientation vector
var sourceRotationMatrix = Matrix4x4.CreateFromYawPitchRoll(
sourceRotation.Y * (MathF.PI / 180f),
sourceRotation.X * (MathF.PI / 180f),
sourceRotation.Z * (MathF.PI / 180f));
var sourceOrientation = Vector3.Transform(new Vector3(0, 0, 1), sourceRotationMatrix);
var innerConeAngle = Math.Clamp(InnerConeAngle.GetValue(context), 0f, 360f);
var outerConeAngle = Math.Clamp(OuterConeAngle.GetValue(context), 0f, 360f);
var outerConeVolume = Math.Clamp(OuterConeVolume.GetValue(context), 0f, 1f);
var audio3DMode = Math.Clamp(Audio3DMode.GetValue(context), 0, 2);
AudioEngine.Set3DListenerPosition(listenerPosition, listenerForward, listenerUp);
// Handle pause/resume transitions
if (shouldPause != _wasPausedLastFrame)
{
if (shouldPause)
AudioEngine.PauseSpatialOperator(_operatorId);
else
AudioEngine.ResumeSpatialOperator(_operatorId);
}
_wasPausedLastFrame = shouldPause;
AudioEngine.UpdateSpatialOperatorPlayback(
operatorId: _operatorId,
filePath: filePath,
shouldPlay: shouldPlay,
shouldStop: shouldStop,
volume: volume,
mute: mute,
position: sourcePosition,
minDistance: minDistance,
maxDistance: maxDistance,
speed: speed,
seek: seek,
orientation: sourceOrientation,
innerConeAngle: innerConeAngle,
outerConeAngle: outerConeAngle,
outerConeVolume: outerConeVolume,
mode3D: audio3DMode);
IsPlaying.Value = AudioEngine.IsSpatialOperatorStreamPlaying(_operatorId);
IsPaused.Value = AudioEngine.IsSpatialOperatorPaused(_operatorId);
GetLevel.Value = AudioEngine.GetSpatialOperatorLevel(_operatorId);
}
/// <summary>
/// Render audio for export. This is called by AudioRendering during export.
/// </summary>
/// <param name="startTime">The start time in seconds.</param>
/// <param name="duration">The duration to render in seconds.</param>
/// <param name="buffer">The buffer to write audio samples to.</param>
/// <returns>The number of samples written to the buffer.</returns>
public int RenderAudio(double startTime, double duration, float[] buffer)
{
if (AudioEngine.TryGetSpatialOperatorStream(_operatorId, out var stream) && stream != null)
return stream.RenderAudio(startTime, duration, buffer, AudioConfig.MixerFrequency, 2);
Array.Clear(buffer, 0, buffer.Length);
return buffer.Length;
}
/// <summary>
/// Restores the volume level after an audio export operation.
/// </summary>
public void RestoreVolumeAfterExport()
{
if (AudioEngine.TryGetSpatialOperatorStream(_operatorId, out var stream) && stream != null)
Bass.ChannelSetAttribute(stream.StreamHandle, ChannelAttribute.Volume, Volume.Value);
}
/// <summary>
/// Finalizer that unregisters the operator from the audio engine.
/// </summary>
~SpatialAudioPlayer()
{
if (_operatorId != Guid.Empty)
AudioEngine.UnregisterOperator(_operatorId);
}
/// <summary>
/// Defines the 3D audio processing modes.
/// </summary>
private enum Audio3DModes
{
/// <summary>
/// Normal 3D audio processing with absolute positioning.
/// </summary>
Normal = 0,
/// <summary>
/// Relative 3D audio processing where the source position is relative to the listener.
/// Example: footsteps, breathing, or equipment sounds attached to the listener's position.
/// </summary>
Relative = 1,
/// <summary>
/// Disables 3D audio processing.
/// </summary>
Off = 2
}
}
}