mirror of
https://github.com/stride3d/stride.git
synced 2026-10-12 02:23:23 +00:00
Stride.Video: AVFoundation backend skeleton, opt into net10.0-macos for AVFoundation/Metal bindings
This commit is contained in:
@@ -8,6 +8,16 @@
|
||||
<StrideGraphicsRegression>true</StrideGraphicsRegression>
|
||||
<!-- Force msbuild to check to rebuild this assembly instead of letting VS IDE guess -->
|
||||
<DisableFastUpToDateCheck>true</DisableFastUpToDateCheck>
|
||||
</PropertyGroup>
|
||||
<!-- Mirror Stride.Video.csproj's per-TFM STRIDE_VIDEO_* defines -->
|
||||
<PropertyGroup Condition="'$(TargetFramework)' == '$(StrideFramework)'">
|
||||
<DefineConstants>STRIDE_VIDEO_FFMPEG;$(DefineConstants)</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(StrideTargetFramework)' == '$(StrideFrameworkAndroid)'">
|
||||
<DefineConstants>STRIDE_VIDEO_MEDIACODEC;$(DefineConstants)</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(StrideTargetFramework)' == '$(StrideFrameworkiOS)' Or '$(StrideTargetFramework)' == '$(StrideFrameworkmacOS)'">
|
||||
<DefineConstants>STRIDE_VIDEO_AVFOUNDATION;$(DefineConstants)</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\tests\xunit.runner.stride\xunit.runner.stride.csproj" />
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
|
||||
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
|
||||
|
||||
// No video backend on iOS (FFmpeg not built for iOS, no AVFoundation backend yet).
|
||||
#if STRIDE_VIDEO_FFMPEG || STRIDE_VIDEO_MEDIACODEC
|
||||
#if STRIDE_VIDEO_FFMPEG || STRIDE_VIDEO_MEDIACODEC || STRIDE_VIDEO_AVFOUNDATION
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
|
||||
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
|
||||
|
||||
#if STRIDE_VIDEO_AVFOUNDATION
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace Stride.Audio;
|
||||
|
||||
// Copies the offset+length bundle slice to a temp file so AVAsset can take a file URL.
|
||||
internal static class AVFoundationAssetSliceHelper
|
||||
{
|
||||
public static string ExtractAssetSliceToTempFile(string sourcePath, long startPosition, long length, string suffix)
|
||||
{
|
||||
var temp = Path.Combine(Path.GetTempPath(), $"stride-{suffix}-{Guid.NewGuid():N}.bin");
|
||||
using var src = File.OpenRead(sourcePath);
|
||||
using var dst = File.Create(temp);
|
||||
|
||||
src.Seek(startPosition, SeekOrigin.Begin);
|
||||
var buffer = new byte[64 * 1024];
|
||||
long remaining = length;
|
||||
while (remaining > 0)
|
||||
{
|
||||
int toRead = (int)Math.Min(buffer.Length, remaining);
|
||||
int read = src.Read(buffer, 0, toRead);
|
||||
if (read <= 0)
|
||||
break;
|
||||
dst.Write(buffer, 0, read);
|
||||
remaining -= read;
|
||||
}
|
||||
return temp;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,455 @@
|
||||
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
|
||||
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
|
||||
#if STRIDE_PLATFORM_IOS || STRIDE_PLATFORM_MACOS
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using AVFoundation;
|
||||
using Foundation;
|
||||
using Stride.Core.Mathematics;
|
||||
|
||||
namespace Stride.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// AVAudioEngine-backed managed implementation of <see cref="AudioLayer"/>. Replaces
|
||||
/// libstrideaudio's OpenAL backend on Apple platforms. Handles flow through GCHandle: each
|
||||
/// public Device/Listener/Source/Buffer.Ptr wraps a GCHandle to a <see cref="ManagedDevice"/>,
|
||||
/// <see cref="ManagedListener"/>, etc., resolved with <see cref="ResolveHandle"/>.
|
||||
/// </summary>
|
||||
public partial class AudioLayer
|
||||
{
|
||||
// -- Handle plumbing -----------------------------------------------------
|
||||
|
||||
private static IntPtr AllocHandle<T>(T obj) where T : class
|
||||
{
|
||||
return GCHandle.ToIntPtr(GCHandle.Alloc(obj, GCHandleType.Normal));
|
||||
}
|
||||
|
||||
private static T ResolveHandle<T>(IntPtr ptr) where T : class
|
||||
{
|
||||
if (ptr == IntPtr.Zero) return null;
|
||||
var handle = GCHandle.FromIntPtr(ptr);
|
||||
return handle.Target as T;
|
||||
}
|
||||
|
||||
private static void FreeHandle(IntPtr ptr)
|
||||
{
|
||||
if (ptr == IntPtr.Zero) return;
|
||||
GCHandle.FromIntPtr(ptr).Free();
|
||||
}
|
||||
|
||||
// -- Managed wrappers ----------------------------------------------------
|
||||
|
||||
private sealed class ManagedDevice
|
||||
{
|
||||
public AVAudioEngine Engine;
|
||||
public AVAudioMixerNode Mixer; // == Engine.MainMixerNode; cached for master volume
|
||||
public AVAudioEnvironmentNode Env; // 3D mixer; spatialized sources connect here
|
||||
public List<ManagedSource> Sources = new();
|
||||
}
|
||||
|
||||
private sealed class ManagedListener
|
||||
{
|
||||
public ManagedDevice Device;
|
||||
public bool Enabled;
|
||||
}
|
||||
|
||||
private sealed class ManagedBuffer
|
||||
{
|
||||
public int CapacityBytes; // max bytes BufferFill can write
|
||||
public AVAudioPcmBuffer Pcm; // null until BufferFill is called the first time
|
||||
public AudioLayer.BufferType StreamType;
|
||||
// Owner's current schedule state — used by the streaming path to know when a buffer
|
||||
// becomes free again after a Source consumed it.
|
||||
public bool Scheduled;
|
||||
}
|
||||
|
||||
private sealed class ManagedSource
|
||||
{
|
||||
public ManagedDevice Device;
|
||||
public ManagedListener Listener;
|
||||
public AVAudioPlayerNode Player;
|
||||
public AVAudioFormat Format; // declared input format (int16 mono/stereo)
|
||||
public int SampleRate;
|
||||
public bool Mono;
|
||||
public bool Spatialized;
|
||||
public bool Streamed;
|
||||
public bool Looping;
|
||||
public float Gain = 1f;
|
||||
public ManagedBuffer StaticBuffer; // SourceSetBuffer target; replays on Play
|
||||
public Queue<ManagedBuffer> StreamingFree = new(); // pre-allocated, returned via SourceGetFreeBuffer
|
||||
public Queue<ManagedBuffer> StreamingPending = new(); // queued, not yet consumed
|
||||
public double PlayRangeStart, PlayRangeStop;
|
||||
}
|
||||
|
||||
// -- Init / Device -------------------------------------------------------
|
||||
|
||||
public static bool Init()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public static Device Create(string deviceName, DeviceFlags flags)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dev = new ManagedDevice
|
||||
{
|
||||
Engine = new AVAudioEngine(),
|
||||
};
|
||||
dev.Mixer = dev.Engine.MainMixerNode;
|
||||
// EnvironmentNode is set up lazily on first spatialized SourceCreate. AVAudioEngine
|
||||
// on macOS deadlocks during Start if AttachNode/Connect happen before MainMixerNode
|
||||
// has been touched and the output node format is fully resolved.
|
||||
if (!dev.Engine.StartAndReturnError(out var error) || error != null)
|
||||
{
|
||||
AudioEngine.Logger?.Warning($"AVAudioEngine.Start failed: {error?.LocalizedDescription}");
|
||||
return default;
|
||||
}
|
||||
return new Device { Ptr = AllocHandle(dev) };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AudioEngine.Logger?.Error($"AudioLayer.Create threw: {ex}");
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureEnv(ManagedDevice dev)
|
||||
{
|
||||
if (dev.Env != null) return;
|
||||
dev.Env = new AVAudioEnvironmentNode();
|
||||
dev.Engine.AttachNode(dev.Env);
|
||||
dev.Engine.Connect(dev.Env, dev.Mixer, null);
|
||||
}
|
||||
|
||||
public static void Destroy(Device device)
|
||||
{
|
||||
var dev = ResolveHandle<ManagedDevice>(device.Ptr);
|
||||
if (dev == null) return;
|
||||
|
||||
// Stop and tear down player nodes still attached.
|
||||
foreach (var src in dev.Sources)
|
||||
{
|
||||
try { src.Player?.Stop(); } catch { }
|
||||
try { if (src.Player != null) dev.Engine.DetachNode(src.Player); } catch { }
|
||||
}
|
||||
dev.Sources.Clear();
|
||||
|
||||
try { dev.Engine.Stop(); } catch { }
|
||||
try { if (dev.Env != null) dev.Engine.DetachNode(dev.Env); } catch { }
|
||||
dev.Engine.Dispose();
|
||||
FreeHandle(device.Ptr);
|
||||
}
|
||||
|
||||
public static void Update(Device device)
|
||||
{
|
||||
// AVAudioEngine is push-driven; no per-frame work required.
|
||||
}
|
||||
|
||||
public static void SetMasterVolume(Device device, float volume)
|
||||
{
|
||||
var dev = ResolveHandle<ManagedDevice>(device.Ptr);
|
||||
if (dev?.Mixer == null) return;
|
||||
dev.Mixer.OutputVolume = volume;
|
||||
}
|
||||
|
||||
// -- Listener ------------------------------------------------------------
|
||||
|
||||
public static Listener ListenerCreate(Device device)
|
||||
{
|
||||
var dev = ResolveHandle<ManagedDevice>(device.Ptr);
|
||||
if (dev == null) return default;
|
||||
var listener = new ManagedListener { Device = dev };
|
||||
return new Listener { Ptr = AllocHandle(listener) };
|
||||
}
|
||||
|
||||
public static void ListenerDestroy(Listener listener) => FreeHandle(listener.Ptr);
|
||||
|
||||
public static bool ListenerEnable(Listener listener)
|
||||
{
|
||||
var l = ResolveHandle<ManagedListener>(listener.Ptr);
|
||||
if (l == null) return false;
|
||||
l.Enabled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void ListenerDisable(Listener listener)
|
||||
{
|
||||
var l = ResolveHandle<ManagedListener>(listener.Ptr);
|
||||
if (l != null) l.Enabled = false;
|
||||
}
|
||||
|
||||
public static void ListenerPush3D(Listener listener, ref Vector3 pos, ref Vector3 forward, ref Vector3 up, ref Vector3 vel, ref Matrix worldTransform)
|
||||
{
|
||||
var l = ResolveHandle<ManagedListener>(listener.Ptr);
|
||||
if (l?.Device?.Env == null) return;
|
||||
l.Device.Env.ListenerPosition = new System.Numerics.Vector3(pos.X, pos.Y, pos.Z);
|
||||
l.Device.Env.ListenerVectorOrientation = new AVAudio3DVectorOrientation(
|
||||
new System.Numerics.Vector3(forward.X, forward.Y, forward.Z),
|
||||
new System.Numerics.Vector3(up.X, up.Y, up.Z));
|
||||
}
|
||||
|
||||
// -- Buffer --------------------------------------------------------------
|
||||
|
||||
public static Buffer BufferCreate(int maxBufferSizeBytes)
|
||||
{
|
||||
var buf = new ManagedBuffer { CapacityBytes = maxBufferSizeBytes };
|
||||
return new Buffer { Ptr = AllocHandle(buf) };
|
||||
}
|
||||
|
||||
public static void BufferDestroy(Buffer buffer)
|
||||
{
|
||||
var b = ResolveHandle<ManagedBuffer>(buffer.Ptr);
|
||||
if (b != null)
|
||||
{
|
||||
b.Pcm?.Dispose();
|
||||
b.Pcm = null;
|
||||
}
|
||||
FreeHandle(buffer.Ptr);
|
||||
}
|
||||
|
||||
public static unsafe void BufferFill(Buffer buffer, IntPtr pcm, int bufferSize, int sampleRate, bool mono)
|
||||
{
|
||||
var b = ResolveHandle<ManagedBuffer>(buffer.Ptr);
|
||||
if (b == null || pcm == IntPtr.Zero || bufferSize <= 0) return;
|
||||
|
||||
uint channels = mono ? 1u : 2u;
|
||||
// Stride writes int16 interleaved PCM into the buffer.
|
||||
using var format = new AVAudioFormat(AVAudioCommonFormat.PCMInt16, sampleRate, channels, interleaved: true);
|
||||
uint frameCapacity = (uint)(bufferSize / (2 * channels));
|
||||
if (b.Pcm == null || b.Pcm.FrameCapacity < frameCapacity || b.Pcm.Format == null || (int)b.Pcm.Format.SampleRate != sampleRate || b.Pcm.Format.ChannelCount != channels)
|
||||
{
|
||||
b.Pcm?.Dispose();
|
||||
b.Pcm = new AVAudioPcmBuffer(format, frameCapacity);
|
||||
}
|
||||
// AVAudioPcmBuffer.Int16ChannelData is one pointer per channel; interleaved formats expose
|
||||
// a single channel pointer holding all samples interleaved.
|
||||
var dst = b.Pcm.Int16ChannelData;
|
||||
if (dst != IntPtr.Zero)
|
||||
{
|
||||
System.Buffer.MemoryCopy((void*)pcm, ((short**)dst)[0], frameCapacity * 2 * channels, bufferSize);
|
||||
}
|
||||
b.Pcm.FrameLength = frameCapacity;
|
||||
}
|
||||
|
||||
// -- Source --------------------------------------------------------------
|
||||
|
||||
public static Source SourceCreate(Listener listener, int sampleRate, int maxNumberOfBuffers, bool mono, bool spatialized, bool streamed, bool hrtf, float hrtfDirectionFactor, HrtfEnvironment environment)
|
||||
{
|
||||
var l = ResolveHandle<ManagedListener>(listener.Ptr);
|
||||
if (l?.Device == null) return default;
|
||||
var dev = l.Device;
|
||||
|
||||
var src = new ManagedSource
|
||||
{
|
||||
Device = dev,
|
||||
Listener = l,
|
||||
SampleRate = sampleRate,
|
||||
Mono = mono,
|
||||
Spatialized = spatialized,
|
||||
Streamed = streamed,
|
||||
};
|
||||
uint channels = mono ? 1u : 2u;
|
||||
src.Format = new AVAudioFormat(AVAudioCommonFormat.PCMInt16, sampleRate, channels, interleaved: true);
|
||||
src.Player = new AVAudioPlayerNode();
|
||||
dev.Engine.AttachNode(src.Player);
|
||||
|
||||
// 3D-spatialized mono sources route through the environment node (lazy-created);
|
||||
// everything else goes straight to the main mixer.
|
||||
if (spatialized && mono)
|
||||
{
|
||||
EnsureEnv(dev);
|
||||
dev.Engine.Connect(src.Player, dev.Env, src.Format);
|
||||
}
|
||||
else
|
||||
{
|
||||
dev.Engine.Connect(src.Player, dev.Mixer, src.Format);
|
||||
}
|
||||
|
||||
// Streaming sources pre-allocate a pool of empty buffers that SourceGetFreeBuffer
|
||||
// hands back to the caller; BufferFill (via the buffer the caller filled) + QueueBuffer
|
||||
// recycle them as the player consumes them.
|
||||
if (streamed && maxNumberOfBuffers > 0)
|
||||
{
|
||||
for (int i = 0; i < maxNumberOfBuffers; i++)
|
||||
src.StreamingFree.Enqueue(new ManagedBuffer { CapacityBytes = 0 });
|
||||
}
|
||||
|
||||
lock (dev.Sources) dev.Sources.Add(src);
|
||||
return new Source { Ptr = AllocHandle(src) };
|
||||
}
|
||||
|
||||
public static void SourceDestroy(Source source)
|
||||
{
|
||||
var src = ResolveHandle<ManagedSource>(source.Ptr);
|
||||
if (src != null)
|
||||
{
|
||||
try { src.Player?.Stop(); } catch { }
|
||||
try { if (src.Player != null && src.Device != null) src.Device.Engine.DetachNode(src.Player); } catch { }
|
||||
if (src.Device != null)
|
||||
lock (src.Device.Sources) src.Device.Sources.Remove(src);
|
||||
}
|
||||
FreeHandle(source.Ptr);
|
||||
}
|
||||
|
||||
public static double SourceGetPosition(Source source)
|
||||
{
|
||||
var src = ResolveHandle<ManagedSource>(source.Ptr);
|
||||
if (src?.Player?.LastRenderTime is { } rt && src.SampleRate > 0)
|
||||
{
|
||||
var playerTime = src.Player.GetPlayerTimeFromNodeTime(rt);
|
||||
if (playerTime != null) return (double)playerTime.SampleTime / src.SampleRate;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static void SourceSetPan(Source source, float pan)
|
||||
{
|
||||
var src = ResolveHandle<ManagedSource>(source.Ptr);
|
||||
if (src?.Player != null) src.Player.Pan = pan;
|
||||
}
|
||||
|
||||
public static void SourceSetBuffer(Source source, Buffer buffer)
|
||||
{
|
||||
var src = ResolveHandle<ManagedSource>(source.Ptr);
|
||||
var buf = ResolveHandle<ManagedBuffer>(buffer.Ptr);
|
||||
if (src == null) return;
|
||||
src.StaticBuffer = buf;
|
||||
}
|
||||
|
||||
public static void SourceFlushBuffers(Source source)
|
||||
{
|
||||
var src = ResolveHandle<ManagedSource>(source.Ptr);
|
||||
if (src?.Player == null) return;
|
||||
try { src.Player.Stop(); } catch { }
|
||||
// Recycle pending streaming buffers back to the free pool.
|
||||
while (src.StreamingPending.Count > 0)
|
||||
{
|
||||
var b = src.StreamingPending.Dequeue();
|
||||
b.Scheduled = false;
|
||||
src.StreamingFree.Enqueue(b);
|
||||
}
|
||||
}
|
||||
|
||||
public static void SourceQueueBuffer(Source source, Buffer buffer, IntPtr pcm, int bufferSize, BufferType streamType)
|
||||
{
|
||||
var src = ResolveHandle<ManagedSource>(source.Ptr);
|
||||
var buf = ResolveHandle<ManagedBuffer>(buffer.Ptr);
|
||||
if (src?.Player == null || buf == null) return;
|
||||
// Caller already wrote PCM into the buffer's AVAudioPcmBuffer via BufferFill. Just schedule.
|
||||
buf.StreamType = streamType;
|
||||
buf.Scheduled = true;
|
||||
src.StreamingPending.Enqueue(buf);
|
||||
if (buf.Pcm != null)
|
||||
{
|
||||
src.Player.ScheduleBuffer(buf.Pcm, () =>
|
||||
{
|
||||
// Completion runs on an AV worker thread; bounce back onto a free pool the
|
||||
// game thread polls via SourceGetFreeBuffer.
|
||||
lock (src.StreamingFree)
|
||||
{
|
||||
buf.Scheduled = false;
|
||||
src.StreamingFree.Enqueue(buf);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public static Buffer SourceGetFreeBuffer(Source source)
|
||||
{
|
||||
var src = ResolveHandle<ManagedSource>(source.Ptr);
|
||||
if (src == null) return default;
|
||||
lock (src.StreamingFree)
|
||||
{
|
||||
if (src.StreamingFree.Count == 0) return default;
|
||||
var b = src.StreamingFree.Dequeue();
|
||||
return new Buffer { Ptr = AllocHandle(b) };
|
||||
}
|
||||
}
|
||||
|
||||
public static void SourcePlay(Source source)
|
||||
{
|
||||
var src = ResolveHandle<ManagedSource>(source.Ptr);
|
||||
if (src?.Player == null) return;
|
||||
// Static (non-streamed) sources schedule the single attached buffer here. The streaming
|
||||
// path schedules via SourceQueueBuffer; SourcePlay just resumes the player node.
|
||||
if (!src.Streamed && src.StaticBuffer?.Pcm != null && !src.Player.Playing)
|
||||
{
|
||||
var options = src.Looping ? AVAudioPlayerNodeBufferOptions.Loops : AVAudioPlayerNodeBufferOptions.Interrupts;
|
||||
src.Player.ScheduleBuffer(src.StaticBuffer.Pcm, when: null, options, completionHandler: () => { });
|
||||
}
|
||||
src.Player.Play();
|
||||
}
|
||||
|
||||
public static void SourcePause(Source source)
|
||||
{
|
||||
var src = ResolveHandle<ManagedSource>(source.Ptr);
|
||||
try { src?.Player?.Pause(); } catch { }
|
||||
}
|
||||
|
||||
public static void SourceStop(Source source)
|
||||
{
|
||||
var src = ResolveHandle<ManagedSource>(source.Ptr);
|
||||
try { src?.Player?.Stop(); } catch { }
|
||||
if (src != null)
|
||||
{
|
||||
while (src.StreamingPending.Count > 0)
|
||||
{
|
||||
var b = src.StreamingPending.Dequeue();
|
||||
b.Scheduled = false;
|
||||
src.StreamingFree.Enqueue(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void SourceSetLooping(Source source, bool looped)
|
||||
{
|
||||
var src = ResolveHandle<ManagedSource>(source.Ptr);
|
||||
if (src != null) src.Looping = looped;
|
||||
}
|
||||
|
||||
public static void SourceSetRange(Source source, double startTime, double stopTime)
|
||||
{
|
||||
var src = ResolveHandle<ManagedSource>(source.Ptr);
|
||||
if (src != null)
|
||||
{
|
||||
src.PlayRangeStart = startTime;
|
||||
src.PlayRangeStop = stopTime;
|
||||
}
|
||||
}
|
||||
|
||||
public static void SourceSetGain(Source source, float gain)
|
||||
{
|
||||
var src = ResolveHandle<ManagedSource>(source.Ptr);
|
||||
if (src != null)
|
||||
{
|
||||
src.Gain = gain;
|
||||
if (src.Player != null) src.Player.Volume = gain;
|
||||
}
|
||||
}
|
||||
|
||||
public static void SourceSetPitch(Source source, float pitch)
|
||||
{
|
||||
// AVAudioPlayerNode has no rate property; an AVAudioUnitVarispeed node between player
|
||||
// and mixer would handle this. Skipped until a real consumer needs it.
|
||||
}
|
||||
|
||||
public static void SourcePush3D(Source source, ref Vector3 pos, ref Vector3 forward, ref Vector3 up, ref Vector3 vel, ref Matrix worldTransform)
|
||||
{
|
||||
var src = ResolveHandle<ManagedSource>(source.Ptr);
|
||||
if (src?.Player == null) return;
|
||||
if (src.Spatialized)
|
||||
{
|
||||
src.Player.Position = new System.Numerics.Vector3(pos.X, pos.Y, pos.Z);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool SourceIsPlaying(Source source)
|
||||
{
|
||||
var src = ResolveHandle<ManagedSource>(source.Ptr);
|
||||
return src?.Player?.Playing ?? false;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp)
|
||||
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
|
||||
#if !(STRIDE_PLATFORM_IOS || STRIDE_PLATFORM_MACOS)
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security;
|
||||
using Stride.Core.Mathematics;
|
||||
|
||||
namespace Stride.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// libstrideaudio (OpenAL on desktop / OpenSL ES on Android / XAudio2 on Windows / static-linked OpenAL on iOS) backend.
|
||||
/// </summary>
|
||||
public partial class AudioLayer
|
||||
{
|
||||
static AudioLayer()
|
||||
{
|
||||
NativeInvoke.PreLoad();
|
||||
}
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioInit", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern bool Init();
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioCreate", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
|
||||
public static extern Device Create(string deviceName, DeviceFlags flags);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioDestroy", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void Destroy(Device device);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioUpdate", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void Update(Device device);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSetMasterVolume", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SetMasterVolume(Device device, float volume);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioListenerCreate", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern Listener ListenerCreate(Device device);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioListenerDestroy", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void ListenerDestroy(Listener listener);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioListenerEnable", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern bool ListenerEnable(Listener listener);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioListenerDisable", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void ListenerDisable(Listener listener);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceCreate", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern Source SourceCreate(Listener listener, int sampleRate, int maxNumberOfBuffers, bool mono, bool spatialized, bool streamed, bool hrtf, float hrtfDirectionFactor, HrtfEnvironment environment);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceDestroy", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceDestroy(Source source);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceGetPosition", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern double SourceGetPosition(Source source);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceSetPan", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceSetPan(Source source, float pan);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioBufferCreate", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern Buffer BufferCreate(int maxBufferSizeBytes);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioBufferDestroy", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void BufferDestroy(Buffer buffer);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioBufferFill", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void BufferFill(Buffer buffer, IntPtr pcm, int bufferSize, int sampleRate, bool mono);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceSetBuffer", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceSetBuffer(Source source, Buffer buffer);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceFlushBuffers", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceFlushBuffers(Source source);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceQueueBuffer", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceQueueBuffer(Source source, Buffer buffer, IntPtr pcm, int bufferSize, BufferType streamType);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceGetFreeBuffer", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern Buffer SourceGetFreeBuffer(Source source);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourcePlay", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourcePlay(Source source);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourcePause", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourcePause(Source source);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceStop", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceStop(Source source);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceSetLooping", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceSetLooping(Source source, bool looped);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceSetRange", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceSetRange(Source source, double startTime, double stopTime);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceSetGain", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceSetGain(Source source, float gain);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceSetPitch", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceSetPitch(Source source, float pitch);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioListenerPush3D", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void ListenerPush3D(Listener listener, ref Vector3 pos, ref Vector3 forward, ref Vector3 up, ref Vector3 vel, ref Matrix worldTransform);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourcePush3D", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourcePush3D(Source source, ref Vector3 pos, ref Vector3 forward, ref Vector3 up, ref Vector3 vel, ref Matrix worldTransform);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceIsPlaying", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern bool SourceIsPlaying(Source source);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,16 +1,15 @@
|
||||
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp)
|
||||
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security;
|
||||
using Stride.Core.Mathematics;
|
||||
|
||||
namespace Stride.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// Wrapper around OpenAL
|
||||
/// Cross-platform audio device abstraction. Static method bodies live in platform-specific partials:
|
||||
/// AudioLayer.OpenAL.cs (Windows/Linux/Android/iOS — DllImport into libstrideaudio),
|
||||
/// AudioLayer.AVFoundation.cs (iOS/macOS — managed AVAudioEngine implementation).
|
||||
/// </summary>
|
||||
public class AudioLayer
|
||||
public partial class AudioLayer
|
||||
{
|
||||
public struct Device
|
||||
{
|
||||
@@ -32,89 +31,12 @@ namespace Stride.Audio
|
||||
public IntPtr Ptr;
|
||||
}
|
||||
|
||||
static AudioLayer()
|
||||
{
|
||||
NativeInvoke.PreLoad();
|
||||
}
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioInit", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern bool Init();
|
||||
|
||||
public enum DeviceFlags
|
||||
{
|
||||
None,
|
||||
Hrtf,
|
||||
}
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioCreate", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
|
||||
public static extern Device Create(string deviceName, DeviceFlags flags);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioDestroy", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void Destroy(Device device);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioUpdate", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void Update(Device device);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSetMasterVolume", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SetMasterVolume(Device device, float volume);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioListenerCreate", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern Listener ListenerCreate(Device device);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioListenerDestroy", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void ListenerDestroy(Listener listener);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioListenerEnable", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern bool ListenerEnable(Listener listener);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioListenerDisable", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void ListenerDisable(Listener listener);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceCreate", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern Source SourceCreate(Listener listener, int sampleRate, int maxNumberOfBuffers, bool mono, bool spatialized, bool streamed, bool hrtf, float hrtfDirectionFactor, HrtfEnvironment environment);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceDestroy", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceDestroy(Source source);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceGetPosition", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern double SourceGetPosition(Source source);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceSetPan", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceSetPan(Source source, float pan);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioBufferCreate", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern Buffer BufferCreate(int maxBufferSizeBytes);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioBufferDestroy", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void BufferDestroy(Buffer buffer);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioBufferFill", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void BufferFill(Buffer buffer, IntPtr pcm, int bufferSize, int sampleRate, bool mono);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceSetBuffer", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceSetBuffer(Source source, Buffer buffer);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceFlushBuffers", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceFlushBuffers(Source source);
|
||||
|
||||
public enum BufferType
|
||||
{
|
||||
None,
|
||||
@@ -122,53 +44,5 @@ namespace Stride.Audio
|
||||
EndOfStream,
|
||||
EndOfLoop,
|
||||
}
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceQueueBuffer", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceQueueBuffer(Source source, Buffer buffer, IntPtr pcm, int bufferSize, BufferType streamType);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceGetFreeBuffer", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern Buffer SourceGetFreeBuffer(Source source);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourcePlay", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourcePlay(Source source);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourcePause", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourcePause(Source source);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceStop", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceStop(Source source);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceSetLooping", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceSetLooping(Source source, bool looped);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceSetRange", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceSetRange(Source source, double startTime, double stopTime);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceSetGain", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceSetGain(Source source, float gain);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceSetPitch", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourceSetPitch(Source source, float pitch);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioListenerPush3D", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void ListenerPush3D(Listener listener, ref Vector3 pos, ref Vector3 forward, ref Vector3 up, ref Vector3 vel, ref Matrix worldTransform);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourcePush3D", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void SourcePush3D(Source source, ref Vector3 pos, ref Vector3 forward, ref Vector3 up, ref Vector3 vel, ref Matrix worldTransform);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity]
|
||||
[DllImport(NativeInvoke.Library, EntryPoint = "xnAudioSourceIsPlaying", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern bool SourceIsPlaying(Source source);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
|
||||
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
|
||||
#if STRIDE_PLATFORM_IOS || STRIDE_PLATFORM_MACOS
|
||||
#pragma warning disable SA1300
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security;
|
||||
|
||||
namespace Stride.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// Managed Celt wrapper that calls libCelt's <c>opus_custom_*</c> symbols directly.
|
||||
/// Replaces the libstrideaudio xnCelt* shim on Apple platforms.
|
||||
/// </summary>
|
||||
internal class Celt : IDisposable
|
||||
{
|
||||
// iOS: static-only linkage, libCelt.a is force-loaded into the app binary, __Internal hits dlsym(RTLD_DEFAULT).
|
||||
// macOS: libstrideaudio.dylib statically embeds libCelt at engine build time, exposing opus_custom_* as
|
||||
// dynamic exports — DllImport("strideaudio") resolves them at runtime via the loaded dylib.
|
||||
#if STRIDE_PLATFORM_IOS
|
||||
private const string LibCelt = "__Internal";
|
||||
#else
|
||||
private const string LibCelt = "strideaudio";
|
||||
#endif
|
||||
|
||||
// OPUS control request codes, copied from deps/Celt/include/opus_defines.h.
|
||||
private const int OPUS_RESET_STATE = 4028;
|
||||
private const int OPUS_GET_LOOKAHEAD_REQUEST = 4027;
|
||||
|
||||
public int SampleRate { get; set; }
|
||||
public int BufferSize { get; set; }
|
||||
public int Channels { get; set; }
|
||||
|
||||
private IntPtr mode;
|
||||
private IntPtr decoder;
|
||||
private IntPtr encoder;
|
||||
|
||||
public Celt(int sampleRate, int bufferSize, int channels, bool decoderOnly)
|
||||
{
|
||||
#if STRIDE_PLATFORM_MACOS
|
||||
// Decode crashes inside libCelt.a's decode_pulses on osx-arm64 after a handful of
|
||||
// packets — investigation pending. Bail cleanly so Sound loading raises a managed
|
||||
// exception instead of taking down the whole process.
|
||||
throw new NotSupportedException("Celt decoder not yet supported on macOS (osx-arm64 libCelt.a needs investigation).");
|
||||
#else
|
||||
SampleRate = sampleRate;
|
||||
BufferSize = bufferSize;
|
||||
Channels = channels;
|
||||
|
||||
mode = opus_custom_mode_create(sampleRate, bufferSize, IntPtr.Zero);
|
||||
if (mode == IntPtr.Zero)
|
||||
throw new Exception("Failed to create Celt custom mode.");
|
||||
|
||||
decoder = opus_custom_decoder_create(mode, channels, IntPtr.Zero);
|
||||
if (decoder == IntPtr.Zero)
|
||||
throw new Exception("Failed to create Celt decoder.");
|
||||
|
||||
if (!decoderOnly)
|
||||
{
|
||||
encoder = opus_custom_encoder_create(mode, channels, IntPtr.Zero);
|
||||
if (encoder == IntPtr.Zero)
|
||||
throw new Exception("Failed to create Celt encoder.");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (encoder != IntPtr.Zero) { opus_custom_encoder_destroy(encoder); encoder = IntPtr.Zero; }
|
||||
if (decoder != IntPtr.Zero) { opus_custom_decoder_destroy(decoder); decoder = IntPtr.Zero; }
|
||||
if (mode != IntPtr.Zero) { opus_custom_mode_destroy(mode); mode = IntPtr.Zero; }
|
||||
}
|
||||
|
||||
public unsafe int Decode(byte[] inputBuffer, int inputBufferSize, short[] outputSamples)
|
||||
{
|
||||
Debug.Assert((uint)inputBufferSize <= (uint)inputBuffer.Length);
|
||||
fixed (short* samplesPtr = outputSamples)
|
||||
fixed (byte* bufferPtr = inputBuffer)
|
||||
{
|
||||
return opus_custom_decode(decoder, bufferPtr, inputBufferSize, samplesPtr, outputSamples.Length / Channels);
|
||||
}
|
||||
}
|
||||
|
||||
public unsafe int Decode(byte[] inputBuffer, int inputBufferSize, short* outputSamples)
|
||||
{
|
||||
Debug.Assert((uint)inputBufferSize <= (uint)inputBuffer.Length);
|
||||
fixed (byte* bufferPtr = inputBuffer)
|
||||
{
|
||||
return opus_custom_decode(decoder, bufferPtr, inputBufferSize, outputSamples, BufferSize);
|
||||
}
|
||||
}
|
||||
|
||||
public unsafe int Decode(byte[] inputBuffer, int inputBufferSize, float[] outputSamples)
|
||||
{
|
||||
fixed (float* samplesPtr = outputSamples)
|
||||
fixed (byte* bufferPtr = inputBuffer)
|
||||
{
|
||||
return opus_custom_decode_float(decoder, bufferPtr, inputBufferSize, samplesPtr, outputSamples.Length / Channels);
|
||||
}
|
||||
}
|
||||
|
||||
public unsafe int Encode(short[] audioSamples, byte[] outputBuffer)
|
||||
{
|
||||
fixed (short* samplesPtr = audioSamples)
|
||||
fixed (byte* bufferPtr = outputBuffer)
|
||||
{
|
||||
return opus_custom_encode(encoder, samplesPtr, audioSamples.Length / Channels, bufferPtr, outputBuffer.Length);
|
||||
}
|
||||
}
|
||||
|
||||
public unsafe int Encode(float[] audioSamples, byte[] outputBuffer)
|
||||
{
|
||||
fixed (float* samplesPtr = audioSamples)
|
||||
fixed (byte* bufferPtr = outputBuffer)
|
||||
{
|
||||
return opus_custom_encode_float(encoder, samplesPtr, audioSamples.Length / Channels, bufferPtr, outputBuffer.Length);
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetDecoder()
|
||||
{
|
||||
opus_custom_decoder_ctl_reset(decoder, OPUS_RESET_STATE);
|
||||
}
|
||||
|
||||
public unsafe int GetDecoderSampleDelay()
|
||||
{
|
||||
int delay = 0;
|
||||
int err = opus_custom_decoder_ctl_get_int(decoder, OPUS_GET_LOOKAHEAD_REQUEST, &delay);
|
||||
return err == 0 ? delay : 0;
|
||||
}
|
||||
|
||||
// libCelt opus_custom_* exports — see LibCelt constant above for per-platform resolution.
|
||||
[SuppressUnmanagedCodeSecurity, DllImport(LibCelt, CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern IntPtr opus_custom_mode_create(int sampleRate, int frameSize, IntPtr error);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity, DllImport(LibCelt, CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern void opus_custom_mode_destroy(IntPtr mode);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity, DllImport(LibCelt, CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern IntPtr opus_custom_decoder_create(IntPtr mode, int channels, IntPtr error);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity, DllImport(LibCelt, CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern void opus_custom_decoder_destroy(IntPtr decoder);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity, DllImport(LibCelt, CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern unsafe int opus_custom_decode(IntPtr decoder, byte* data, int len, short* pcm, int frameSize);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity, DllImport(LibCelt, CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern unsafe int opus_custom_decode_float(IntPtr decoder, byte* data, int len, float* pcm, int frameSize);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity, DllImport(LibCelt, EntryPoint = "opus_custom_decoder_ctl", CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern int opus_custom_decoder_ctl_reset(IntPtr decoder, int request);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity, DllImport(LibCelt, EntryPoint = "opus_custom_decoder_ctl", CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern unsafe int opus_custom_decoder_ctl_get_int(IntPtr decoder, int request, int* value);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity, DllImport(LibCelt, CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern IntPtr opus_custom_encoder_create(IntPtr mode, int channels, IntPtr error);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity, DllImport(LibCelt, CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern void opus_custom_encoder_destroy(IntPtr encoder);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity, DllImport(LibCelt, CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern unsafe int opus_custom_encode(IntPtr encoder, short* pcm, int frameSize, byte* data, int maxDataBytes);
|
||||
|
||||
[SuppressUnmanagedCodeSecurity, DllImport(LibCelt, CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern unsafe int opus_custom_encode_float(IntPtr encoder, float* pcm, int frameSize, byte* data, int maxDataBytes);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp)
|
||||
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
|
||||
#if !(STRIDE_PLATFORM_IOS || STRIDE_PLATFORM_MACOS)
|
||||
#pragma warning disable SA1300 // Element must begin with upper-case letter
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
@@ -190,3 +191,4 @@ namespace Stride.Audio
|
||||
private static extern unsafe int xnCeltDecodeShort(IntPtr celt, byte* inputBuffer, int inputBufferSize, short* outputBuffer, int numberOfOutputSamples);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
|
||||
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
|
||||
|
||||
#if STRIDE_VIDEO_AVFOUNDATION
|
||||
using System;
|
||||
using System.IO;
|
||||
using AVFoundation;
|
||||
using AudioToolbox;
|
||||
using CoreMedia;
|
||||
using Foundation;
|
||||
using Stride.Media;
|
||||
|
||||
namespace Stride.Audio;
|
||||
|
||||
public partial class StreamedBufferSound : SoundBase, IMediaExtractor
|
||||
{
|
||||
partial void InitializeImpl()
|
||||
{
|
||||
if (mediaDataUrl == null)
|
||||
throw new ArgumentNullException(nameof(mediaDataUrl));
|
||||
|
||||
var tempPath = AVFoundationAssetSliceHelper.ExtractAssetSliceToTempFile(mediaDataUrl, startPosition, length, "audio-probe");
|
||||
try
|
||||
{
|
||||
using var url = NSUrl.FromFilename(tempPath);
|
||||
using var asset = AVAsset.FromUrl(url);
|
||||
|
||||
var audioTracks = asset.TracksWithMediaType(AVMediaTypes.Audio.GetConstant());
|
||||
if (audioTracks == null || audioTracks.Length == 0)
|
||||
return;
|
||||
|
||||
var audioTrack = audioTracks[0];
|
||||
MediaDuration = TimeSpan.FromSeconds(asset.Duration.Seconds);
|
||||
|
||||
var formatDescriptions = audioTrack.FormatDescriptions;
|
||||
if (formatDescriptions == null || formatDescriptions.Length == 0)
|
||||
return;
|
||||
|
||||
if (formatDescriptions[0] is CMAudioFormatDescription formatDesc
|
||||
&& formatDesc.AudioStreamBasicDescription is { } asbd)
|
||||
{
|
||||
Channels = (int)asbd.ChannelsPerFrame;
|
||||
SampleRate = (int)asbd.SampleRate;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(tempPath); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,188 @@
|
||||
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
|
||||
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
|
||||
|
||||
#if STRIDE_VIDEO_AVFOUNDATION
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using AVFoundation;
|
||||
using AudioToolbox;
|
||||
using CoreMedia;
|
||||
using Foundation;
|
||||
using Stride.Core.Extensions;
|
||||
using Stride.Media;
|
||||
|
||||
namespace Stride.Audio;
|
||||
|
||||
public partial class StreamedBufferSoundSource : DynamicSoundSource
|
||||
{
|
||||
private string audioTempFilePath;
|
||||
private AVAsset audioAsset;
|
||||
private AVAssetReader audioReader;
|
||||
private AVAssetReaderAudioMixOutput audioOutput;
|
||||
private AVAssetTrack audioTrack;
|
||||
|
||||
private bool audioExtractionDone;
|
||||
|
||||
partial void InitializeMediaExtractor(string mediaDataUrl, long startPosition, long length)
|
||||
{
|
||||
if (mediaDataUrl == null)
|
||||
throw new ArgumentNullException(nameof(mediaDataUrl));
|
||||
|
||||
ReleaseMediaInternal();
|
||||
|
||||
audioTempFilePath = AVFoundationAssetSliceHelper.ExtractAssetSliceToTempFile(
|
||||
mediaDataUrl, startPosition, length, "audio-stream");
|
||||
|
||||
using var url = NSUrl.FromFilename(audioTempFilePath);
|
||||
audioAsset = AVAsset.FromUrl(url);
|
||||
|
||||
var audioTracks = audioAsset.TracksWithMediaType(AVMediaTypes.Audio.GetConstant());
|
||||
if (audioTracks == null || audioTracks.Length == 0)
|
||||
{
|
||||
Logger.Error($"The input file '{mediaDataUrl}' does not contain any audio track.");
|
||||
ReleaseMediaInternal();
|
||||
return;
|
||||
}
|
||||
audioTrack = audioTracks[0];
|
||||
|
||||
int sourceChannels = 2;
|
||||
int sourceSampleRate = 44100;
|
||||
var formatDescriptions = audioTrack.FormatDescriptions;
|
||||
if (formatDescriptions != null && formatDescriptions.Length > 0
|
||||
&& formatDescriptions[0] is CMAudioFormatDescription formatDesc
|
||||
&& formatDesc.AudioStreamBasicDescription is { } asbd)
|
||||
{
|
||||
sourceChannels = (int)asbd.ChannelsPerFrame;
|
||||
sourceSampleRate = (int)asbd.SampleRate;
|
||||
}
|
||||
Channels = sourceChannels;
|
||||
SampleRate = sourceSampleRate;
|
||||
MediaDuration = TimeSpan.FromSeconds(audioAsset.Duration.Seconds);
|
||||
|
||||
CreateAudioReader(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
partial void SeekInternalImpl(TimeSpan seekTime)
|
||||
{
|
||||
CreateAudioReader(seekTime);
|
||||
audioExtractionDone = false;
|
||||
}
|
||||
|
||||
partial void ReleaseMediaInternal()
|
||||
{
|
||||
DisposeReader();
|
||||
|
||||
audioAsset?.Dispose();
|
||||
audioAsset = null;
|
||||
audioTrack = null;
|
||||
|
||||
if (audioTempFilePath != null && File.Exists(audioTempFilePath))
|
||||
{
|
||||
try { File.Delete(audioTempFilePath); } catch { /* best-effort */ }
|
||||
}
|
||||
audioTempFilePath = null;
|
||||
}
|
||||
|
||||
private void CreateAudioReader(TimeSpan startTime)
|
||||
{
|
||||
DisposeReader();
|
||||
if (audioAsset == null || audioTrack == null)
|
||||
return;
|
||||
|
||||
audioReader = AVAssetReader.FromAsset(audioAsset, out var error);
|
||||
if (error != null)
|
||||
throw new InvalidOperationException($"AVAssetReader (audio) create failed: {error.LocalizedDescription}");
|
||||
|
||||
if (startTime > TimeSpan.Zero)
|
||||
{
|
||||
var startCMTime = new CMTime((long)(startTime.TotalMilliseconds), 1000);
|
||||
audioReader.TimeRange = new CMTimeRange { Start = startCMTime, Duration = CMTime.PositiveInfinity };
|
||||
}
|
||||
|
||||
// 16-bit signed LE interleaved PCM — the format Stride.Audio consumes downstream.
|
||||
var settings = new NSMutableDictionary
|
||||
{
|
||||
[AVAudioSettings.AVFormatIDKey] = NSNumber.FromUInt32((uint)AudioFormatType.LinearPCM),
|
||||
[AVAudioSettings.AVLinearPCMBitDepthKey] = NSNumber.FromInt32(16),
|
||||
[AVAudioSettings.AVLinearPCMIsBigEndianKey] = NSNumber.FromBoolean(false),
|
||||
[AVAudioSettings.AVLinearPCMIsFloatKey] = NSNumber.FromBoolean(false),
|
||||
[AVAudioSettings.AVLinearPCMIsNonInterleaved] = NSNumber.FromBoolean(false),
|
||||
};
|
||||
|
||||
audioOutput = new AVAssetReaderAudioMixOutput(new[] { audioTrack }, settings);
|
||||
audioReader.AddOutput(audioOutput);
|
||||
if (!audioReader.StartReading())
|
||||
{
|
||||
var err = audioReader.Error;
|
||||
throw new InvalidOperationException(
|
||||
$"AVAssetReader.StartReading (audio) failed: {(err != null ? err.LocalizedDescription : "unknown")}");
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposeReader()
|
||||
{
|
||||
audioOutput?.Dispose();
|
||||
audioOutput = null;
|
||||
audioReader?.Dispose();
|
||||
audioReader = null;
|
||||
}
|
||||
|
||||
private bool ExtractSomeAudioData(out bool endOfFile)
|
||||
{
|
||||
endOfFile = audioExtractionDone;
|
||||
if (audioExtractionDone || audioOutput == null)
|
||||
return false;
|
||||
|
||||
var sampleBuffer = audioOutput.CopyNextSampleBuffer();
|
||||
if (sampleBuffer == null)
|
||||
{
|
||||
audioExtractionDone = true;
|
||||
endOfFile = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var blockBuffer = sampleBuffer.GetDataBuffer();
|
||||
if (blockBuffer == null)
|
||||
return false;
|
||||
|
||||
int totalLen = (int)blockBuffer.DataLength;
|
||||
if (totalLen <= 0)
|
||||
return false;
|
||||
|
||||
var presentationTime = TimeSpan.FromSeconds(sampleBuffer.PresentationTimeStamp.Seconds);
|
||||
|
||||
int available = storageBuffer.Data.Length - storageBuffer.CountDataBytes;
|
||||
if (totalLen > available)
|
||||
{
|
||||
Logger.Error("The storage buffer has reached full capacity. Current data will be dropped");
|
||||
return false;
|
||||
}
|
||||
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* dst = &storageBuffer.Data[storageBuffer.CountDataBytes])
|
||||
{
|
||||
var status = blockBuffer.CopyDataBytes(0, (nuint)totalLen, (IntPtr)dst);
|
||||
if (status != CMBlockBufferError.None)
|
||||
{
|
||||
Logger.Error($"CMBlockBuffer.CopyDataBytes failed: {status}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (storageBuffer.CountDataBytes == 0)
|
||||
storageBuffer.PresentationTime = presentationTime;
|
||||
storageBuffer.CountDataBytes += totalLen;
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sampleBuffer.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,7 +1,8 @@
|
||||
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp)
|
||||
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
|
||||
|
||||
#if !STRIDE_PLATFORM_ANDROID || !STRIDE_VIDEO_MEDIACODEC
|
||||
// Stub for platforms with no real audio extractor; suppressed when MediaCodec or AVFoundation is set.
|
||||
#if !STRIDE_VIDEO_MEDIACODEC && !STRIDE_VIDEO_AVFOUNDATION
|
||||
|
||||
using System;
|
||||
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<StrideNativeOutputName>libstrideaudio</StrideNativeOutputName>
|
||||
<StrideRuntime>true</StrideRuntime>
|
||||
<!-- net10.0-macos for AVFoundation audio extraction (gated by STRIDE_VIDEO_AVFOUNDATION). -->
|
||||
<StrideExplicitMacOSRuntime>true</StrideExplicitMacOSRuntime>
|
||||
<StrideAssemblyProcessor>true</StrideAssemblyProcessor>
|
||||
<StrideCodeAnalysis>true</StrideCodeAnalysis>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(StrideTargetFramework)' == '$(StrideFrameworkiOS)' Or '$(StrideTargetFramework)' == '$(StrideFrameworkmacOS)'">
|
||||
<DefineConstants>STRIDE_VIDEO_AVFOUNDATION;$(DefineConstants)</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\shared\SharedAssemblyInfo.cs">
|
||||
<Link>Properties\SharedAssemblyInfo.cs</Link>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<StrideGraphicsApiDependent>true</StrideGraphicsApiDependent>
|
||||
<StrideAssemblyProcessor>true</StrideAssemblyProcessor>
|
||||
<StridePackAssets>true</StridePackAssets>
|
||||
<StrideExplicitMacOSRuntime>true</StrideExplicitMacOSRuntime>
|
||||
<DefineConstants>$(DefineConstants);STRIDE_GRAPHICS_NO_DESCRIPTOR_COPIES</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<ItemGroup Condition=" '$(StrideGraphicsApi)' == 'Vulkan' ">
|
||||
|
||||
@@ -177,6 +177,11 @@ namespace Stride.Graphics
|
||||
internal bool HasAndroidHardwareBufferSupport { get; private set; }
|
||||
#endif
|
||||
|
||||
#if STRIDE_PLATFORM_IOS || STRIDE_PLATFORM_MACOS
|
||||
// True when VK_EXT_metal_objects was enabled — lets us import CVPixelBuffer IOSurfaces as VkImage.
|
||||
internal bool HasMetalObjectsSupport { get; private set; }
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Marks context as active on the current thread.
|
||||
/// </summary>
|
||||
@@ -427,6 +432,10 @@ namespace Stride.Graphics
|
||||
{
|
||||
VK_KHR_SWAPCHAIN_EXTENSION_NAME,
|
||||
VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME,
|
||||
#if STRIDE_PLATFORM_IOS || STRIDE_PLATFORM_MACOS
|
||||
// IOSurface-backed VkImage import (zero-copy CVPixelBuffer upload on Apple platforms).
|
||||
VK_EXT_METAL_OBJECTS_EXTENSION_NAME,
|
||||
#endif
|
||||
#if STRIDE_PLATFORM_ANDROID
|
||||
// AHardwareBuffer import (zero-copy buffers from camera / MediaCodec / etc.).
|
||||
VK_ANDROID_EXTERNAL_MEMORY_ANDROID_HARDWARE_BUFFER_EXTENSION_NAME,
|
||||
@@ -453,6 +462,14 @@ namespace Stride.Graphics
|
||||
if (isPortabilitySubsetDevice)
|
||||
desiredExtensionProperties.Add(VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME);
|
||||
|
||||
#if STRIDE_PLATFORM_IOS || STRIDE_PLATFORM_MACOS
|
||||
// VK_EXT_metal_objects lets us import IOSurface-backed CVPixelBuffers as VkImages for
|
||||
// zero-copy video upload. MoltenVK 1.2+ supports it; no extra dependency extensions needed.
|
||||
HasMetalObjectsSupport = availableExtensionProperties.Contains(VK_EXT_METAL_OBJECTS_EXTENSION_NAME);
|
||||
if (HasMetalObjectsSupport)
|
||||
desiredExtensionProperties.Add(VK_EXT_METAL_OBJECTS_EXTENSION_NAME);
|
||||
#endif
|
||||
|
||||
#if STRIDE_PLATFORM_ANDROID
|
||||
// All extensions in the AHardwareBuffer → VkImage chain must be present together.
|
||||
HasAndroidHardwareBufferSupport =
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
|
||||
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
|
||||
#if STRIDE_GRAPHICS_API_VULKAN && (STRIDE_PLATFORM_IOS || STRIDE_PLATFORM_MACOS)
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using Vortice.Vulkan;
|
||||
|
||||
namespace Stride.Graphics
|
||||
{
|
||||
public partial class Texture
|
||||
{
|
||||
// Set when this Texture wraps an externally-owned IOSurface imported via VK_EXT_metal_objects.
|
||||
private IntPtr ioSurfaceRef;
|
||||
private bool isImportedImage;
|
||||
|
||||
partial void TryInitializeImportedImage()
|
||||
{
|
||||
if (!isImportedImage)
|
||||
return;
|
||||
|
||||
HasStencil = false;
|
||||
NativeImageAspect = VkImageAspectFlags.Color;
|
||||
|
||||
var arraySlice = ArraySlice;
|
||||
var mipLevel = MipLevel;
|
||||
GetViewSliceBounds(ViewType, ref arraySlice, ref mipLevel, out var arrayOrDepthCount, out var mipCount);
|
||||
NativeResourceRange = new VkImageSubresourceRange(NativeImageAspect, (uint)mipLevel, (uint)mipCount, (uint)arraySlice, (uint)arrayOrDepthCount);
|
||||
|
||||
// Image starts Undefined per vkCreateImage; Stride will emit the transition to
|
||||
// ShaderReadOnlyOptimal on first sampler bind (CommandList barrier path).
|
||||
NativeLayout = VkImageLayout.Undefined;
|
||||
NativeAccessMask = VkAccessFlags.None;
|
||||
NativePipelineStageMask = VkPipelineStageFlags.TopOfPipe;
|
||||
LayoutTracker.Initialize(BarrierLayout.Undefined, ArraySize * MipLevelCount);
|
||||
IsInitialized = true;
|
||||
importedImageHandled = true;
|
||||
}
|
||||
|
||||
partial void ReleaseImportedImageResources()
|
||||
{
|
||||
if (ioSurfaceRef != IntPtr.Zero)
|
||||
{
|
||||
CFRelease(ioSurfaceRef);
|
||||
ioSurfaceRef = IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports a CoreVideo IOSurface as a sampleable BGRA8_sRGB <see cref="Texture"/> via VK_EXT_metal_objects.
|
||||
/// Retains the IOSurface for the lifetime of the returned Texture; the caller is free to dispose its
|
||||
/// CVPixelBuffer once this call returns.
|
||||
/// </summary>
|
||||
public static unsafe Texture NewFromIOSurface(GraphicsDevice device, IntPtr ioSurface, int width, int height)
|
||||
{
|
||||
if (device == null) throw new ArgumentNullException(nameof(device));
|
||||
if (ioSurface == IntPtr.Zero) throw new ArgumentNullException(nameof(ioSurface));
|
||||
if (!device.HasMetalObjectsSupport)
|
||||
throw new InvalidOperationException("IOSurface import requires VK_EXT_metal_objects.");
|
||||
|
||||
CFRetain(ioSurface);
|
||||
|
||||
var ioSurfaceInfo = new VkImportMetalIOSurfaceInfoEXT
|
||||
{
|
||||
sType = VkStructureType.ImportMetalIOSurfaceInfoEXT,
|
||||
ioSurface = ioSurface,
|
||||
};
|
||||
|
||||
var imageCreateInfo = new VkImageCreateInfo
|
||||
{
|
||||
sType = VkStructureType.ImageCreateInfo,
|
||||
pNext = &ioSurfaceInfo,
|
||||
imageType = VkImageType.Image2D,
|
||||
format = VkFormat.B8G8R8A8Srgb,
|
||||
extent = new VkExtent3D(width, height, 1),
|
||||
mipLevels = 1,
|
||||
arrayLayers = 1,
|
||||
samples = VkSampleCountFlags.Count1,
|
||||
tiling = VkImageTiling.Optimal,
|
||||
usage = VkImageUsageFlags.Sampled,
|
||||
sharingMode = VkSharingMode.Exclusive,
|
||||
initialLayout = VkImageLayout.Undefined,
|
||||
};
|
||||
VkImage image;
|
||||
var imageResult = device.NativeDeviceApi.vkCreateImage(device.NativeDevice, &imageCreateInfo, null, &image);
|
||||
if (imageResult != VkResult.Success)
|
||||
{
|
||||
CFRelease(ioSurface);
|
||||
device.CheckResult(imageResult);
|
||||
}
|
||||
|
||||
// MoltenVK actually backs the image with the IOSurface, but the Vulkan spec still
|
||||
// requires vkBindImageMemory for the validation layer to be happy. Allocate a normal
|
||||
// VkDeviceMemory block sized to GetImageMemoryRequirements; MoltenVK ignores it as
|
||||
// soon as the IOSurface-backed MTLTexture is created on first use.
|
||||
device.NativeDeviceApi.vkGetImageMemoryRequirements(device.NativeDevice, image, out var memReq);
|
||||
var allocateInfo = new VkMemoryAllocateInfo
|
||||
{
|
||||
sType = VkStructureType.MemoryAllocateInfo,
|
||||
allocationSize = memReq.size,
|
||||
memoryTypeIndex = FindMemoryTypeIndex(device, memReq.memoryTypeBits),
|
||||
};
|
||||
VkDeviceMemory memory;
|
||||
var allocResult = device.NativeDeviceApi.vkAllocateMemory(device.NativeDevice, &allocateInfo, null, &memory);
|
||||
if (allocResult != VkResult.Success)
|
||||
{
|
||||
device.NativeDeviceApi.vkDestroyImage(device.NativeDevice, image, null);
|
||||
CFRelease(ioSurface);
|
||||
device.CheckResult(allocResult);
|
||||
}
|
||||
device.CheckResult(device.NativeDeviceApi.vkBindImageMemory(device.NativeDevice, image, memory, 0));
|
||||
|
||||
var viewCreateInfo = new VkImageViewCreateInfo
|
||||
{
|
||||
sType = VkStructureType.ImageViewCreateInfo,
|
||||
image = image,
|
||||
viewType = VkImageViewType.Image2D,
|
||||
format = VkFormat.B8G8R8A8Srgb,
|
||||
components = VkComponentMapping.Identity,
|
||||
subresourceRange = new VkImageSubresourceRange(VkImageAspectFlags.Color, baseMipLevel: 0, levelCount: 1, baseArrayLayer: 0, layerCount: 1),
|
||||
};
|
||||
VkImageView imageView;
|
||||
var viewResult = device.NativeDeviceApi.vkCreateImageView(device.NativeDevice, &viewCreateInfo, null, &imageView);
|
||||
if (viewResult != VkResult.Success)
|
||||
{
|
||||
device.NativeDeviceApi.vkFreeMemory(device.NativeDevice, memory, null);
|
||||
device.NativeDeviceApi.vkDestroyImage(device.NativeDevice, image, null);
|
||||
CFRelease(ioSurface);
|
||||
device.CheckResult(viewResult);
|
||||
}
|
||||
|
||||
var description = TextureDescription.New2D(width, height, mipCount: 1, PixelFormat.B8G8R8A8_UNorm_SRgb, TextureFlags.ShaderResource);
|
||||
|
||||
var texture = new Texture(device);
|
||||
texture.NativeImage = image;
|
||||
texture.NativeMemory = memory;
|
||||
texture.NativeImageView = imageView;
|
||||
texture.NativeFormat = VkFormat.B8G8R8A8Srgb;
|
||||
texture.ioSurfaceRef = ioSurface;
|
||||
texture.isImportedImage = true;
|
||||
texture.InitializeFrom(description);
|
||||
return texture;
|
||||
}
|
||||
|
||||
private static unsafe uint FindMemoryTypeIndex(GraphicsDevice device, uint memoryTypeBits)
|
||||
{
|
||||
device.NativeInstanceApi.vkGetPhysicalDeviceMemoryProperties(device.NativePhysicalDevice, out var memoryProperties);
|
||||
for (uint i = 0; i < memoryProperties.memoryTypeCount; i++)
|
||||
{
|
||||
if ((memoryTypeBits & (1u << (int)i)) != 0)
|
||||
return i;
|
||||
}
|
||||
throw new InvalidOperationException("Vulkan: no memory type satisfies the IOSurface memoryTypeBits mask.");
|
||||
}
|
||||
|
||||
[DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
|
||||
private static extern IntPtr CFRetain(IntPtr cf);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
|
||||
private static extern void CFRelease(IntPtr cf);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,426 @@
|
||||
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
|
||||
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
|
||||
|
||||
#if STRIDE_VIDEO_AVFOUNDATION
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AVFoundation;
|
||||
using CoreMedia;
|
||||
using CoreVideo;
|
||||
using Foundation;
|
||||
using Stride.Audio;
|
||||
using Stride.Core;
|
||||
using Stride.Graphics;
|
||||
using Stride.Media;
|
||||
|
||||
namespace Stride.Video.Backends;
|
||||
|
||||
/// <summary>
|
||||
/// Video backend for iOS and macOS using AVFoundation. AVAssetReader does the demux and dispatches
|
||||
/// to VideoToolbox under the hood for hardware decode; we pull <see cref="CMSampleBuffer"/>s on
|
||||
/// demand and import the resulting <see cref="CVPixelBuffer"/>'s IOSurface as a Vulkan VkImage
|
||||
/// via VK_EXT_metal_objects, then shader-blit to the user's RGBA target. Zero-copy GPU path.
|
||||
/// </summary>
|
||||
internal sealed class AVFoundationVideoBackend : VideoBackend
|
||||
{
|
||||
// Stride bundles compiled assets as offset+length slices inside a single archive file. AVAsset
|
||||
// wants a file URL pointing to the start of a self-contained container, so we extract the slice
|
||||
// to a temp file at Initialize time. AVAssetResourceLoader is the right long-term answer but
|
||||
// adds delegate plumbing we don't need to validate the decode pipeline first.
|
||||
private string tempFilePath;
|
||||
private AVAsset asset;
|
||||
private AVAssetReader reader;
|
||||
private AVAssetReaderTrackOutput videoOutput;
|
||||
// Last delivered frame, held until the next one replaces it: the blit reading its IOSurface
|
||||
// runs after Update, and releasing sooner lets the decoder pool overwrite the surface mid-read.
|
||||
private CMSampleBuffer presentedSampleBuffer;
|
||||
private AVAssetTrack videoTrack;
|
||||
|
||||
private int videoWidth;
|
||||
private int videoHeight;
|
||||
private TimeSpan frameDuration;
|
||||
private long adjustedTicksSinceLastFrame;
|
||||
|
||||
// Audio runs on the StreamedBufferSoundSource worker thread; video clock forwards to the synchronizer.
|
||||
private MediaSynchronizer audioSynchronizer;
|
||||
private StreamedBufferSound audioSound;
|
||||
private SoundInstanceStreamedBuffer audioSoundInstance;
|
||||
private readonly System.Collections.Generic.List<AudioEmitterSoundController> audioControllers = new();
|
||||
|
||||
public AVFoundationVideoBackend(VideoInstance instance) : base(instance) { }
|
||||
|
||||
public override bool UsesHardwareDecode => true; // AVAssetReader uses VideoToolbox
|
||||
|
||||
public override bool Initialize(string url, long startPosition, long length)
|
||||
{
|
||||
if (reader != null)
|
||||
throw new InvalidOperationException("AVFoundationVideoBackend already initialized.");
|
||||
|
||||
try
|
||||
{
|
||||
tempFilePath = ExtractAssetSliceToTempFile(url, startPosition, length);
|
||||
|
||||
using var assetUrl = NSUrl.FromFilename(tempFilePath);
|
||||
asset = AVAsset.FromUrl(assetUrl);
|
||||
|
||||
var videoTracks = asset.TracksWithMediaType(AVMediaTypes.Video.GetConstant());
|
||||
if (videoTracks == null || videoTracks.Length == 0)
|
||||
{
|
||||
VideoInstance.Logger.Warning("AVFoundationVideoBackend: media has no video track.");
|
||||
ReleaseMedia();
|
||||
return false;
|
||||
}
|
||||
videoTrack = videoTracks[0];
|
||||
|
||||
var size = videoTrack.NaturalSize;
|
||||
videoWidth = (int)size.Width;
|
||||
videoHeight = (int)size.Height;
|
||||
|
||||
// NominalFrameRate is 0 for tracks where the muxer didn't write it (rare for H.264).
|
||||
// Default to 30 fps as a conservative fallback — used only for the per-frame advance
|
||||
// accumulator, not for sample-buffer timing (which comes from the CMSampleBuffer PTS).
|
||||
var fps = videoTrack.NominalFrameRate > 0 ? videoTrack.NominalFrameRate : 30f;
|
||||
frameDuration = TimeSpan.FromSeconds(1.0 / fps);
|
||||
|
||||
Instance.SetDuration(TimeSpan.FromSeconds(asset.Duration.Seconds));
|
||||
Instance.AllocateVideoTexture(videoWidth, videoHeight);
|
||||
|
||||
CreateReader(TimeSpan.Zero);
|
||||
|
||||
InitializeAudio(url, startPosition, length);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
VideoInstance.Logger.Error($"AVFoundationVideoBackend.Initialize failed: {ex}");
|
||||
ReleaseMedia();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public override void ReleaseMedia()
|
||||
{
|
||||
ReleaseAudio();
|
||||
|
||||
DisposeReader();
|
||||
presentedSampleBuffer?.Dispose();
|
||||
presentedSampleBuffer = null;
|
||||
asset?.Dispose();
|
||||
asset = null;
|
||||
videoTrack = null;
|
||||
|
||||
if (tempFilePath != null && File.Exists(tempFilePath))
|
||||
{
|
||||
try { File.Delete(tempFilePath); }
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
tempFilePath = null;
|
||||
}
|
||||
|
||||
public override void Play()
|
||||
{
|
||||
if (Instance.PlayRange.Start > Instance.CurrentTime)
|
||||
{
|
||||
Instance.SetCurrentTime(Instance.PlayRange.Start);
|
||||
Instance.Seek(Instance.PlayRange.Start);
|
||||
}
|
||||
else if (Instance.PlayState == PlayState.Stopped && reader != null)
|
||||
{
|
||||
// Prime the accumulator so the first frame extracts on the very next Update, matching
|
||||
// the FFmpeg backend's behavior (otherwise the caller waits frameDuration/60Hz_tick
|
||||
// game frames before any frame surfaces).
|
||||
adjustedTicksSinceLastFrame = frameDuration.Ticks;
|
||||
}
|
||||
audioSynchronizer?.Play();
|
||||
}
|
||||
|
||||
public override void Pause()
|
||||
{
|
||||
audioSynchronizer?.Pause();
|
||||
}
|
||||
|
||||
public override void Stop()
|
||||
{
|
||||
// FFmpeg backend pattern: seeks back to the play range start. Keeps the reader alive in
|
||||
// case Play() resumes; the next Seek/Play just recreates the reader at the new position.
|
||||
Instance.Seek(Instance.PlayRange.Start);
|
||||
audioSynchronizer?.Stop();
|
||||
}
|
||||
|
||||
public override void Seek(TimeSpan time)
|
||||
{
|
||||
// AVAssetReader has no in-place seek — recreate it with a new timeRange. Cheap enough
|
||||
// for the smoke test's seek-per-capture pattern (~5-50 ms per seek).
|
||||
CreateReader(time);
|
||||
adjustedTicksSinceLastFrame = frameDuration.Ticks; // make the first frame extract immediately
|
||||
audioSynchronizer?.Seek(time);
|
||||
}
|
||||
|
||||
public override void SetAudioVolume(float volume)
|
||||
{
|
||||
if (audioSoundInstance != null)
|
||||
audioSoundInstance.Volume = volume;
|
||||
foreach (var controller in audioControllers)
|
||||
controller.Volume = volume;
|
||||
}
|
||||
|
||||
public override void UpdatePlayRange()
|
||||
{
|
||||
if (audioSynchronizer != null)
|
||||
audioSynchronizer.PlayRange = Instance.PlayRange;
|
||||
}
|
||||
|
||||
public override void UpdateLoopRange()
|
||||
{
|
||||
if (audioSynchronizer != null)
|
||||
{
|
||||
audioSynchronizer.IsLooping = Instance.IsLooping;
|
||||
audioSynchronizer.LoopRange = Instance.LoopRange;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Update(TimeSpan elapsed)
|
||||
{
|
||||
audioSynchronizer?.Update(elapsed);
|
||||
|
||||
if (reader == null)
|
||||
return;
|
||||
|
||||
// Mirror the FFmpeg accumulator semantics: speedFactor=0 when not playing freezes auto-advance,
|
||||
// but a Seek() that just primed the accumulator still gets to decode this tick (= delivers
|
||||
// a frame for seek-only captures with no Play()).
|
||||
var speedFactor = Instance.SpeedFactor;
|
||||
if (Instance.PlayState != PlayState.Playing)
|
||||
speedFactor = 0;
|
||||
|
||||
var frameDurationTicks = frameDuration.Ticks;
|
||||
adjustedTicksSinceLastFrame += (long)(elapsed.Ticks * speedFactor);
|
||||
if (adjustedTicksSinceLastFrame < frameDurationTicks)
|
||||
return;
|
||||
|
||||
var frameCount = (int)(adjustedTicksSinceLastFrame / frameDurationTicks);
|
||||
if (frameCount == 0)
|
||||
return;
|
||||
|
||||
// Skip-ahead heuristic copied from FFmpeg backend: if we're more than 4 frames behind,
|
||||
// seek instead of stepping (cheaper than decoding the gap, and tests don't depend on
|
||||
// intermediate frames anyway).
|
||||
if (frameCount > 4)
|
||||
{
|
||||
Instance.Seek(Instance.CurrentTime + TimeSpan.FromTicks(frameDurationTicks * frameCount));
|
||||
frameCount = 1;
|
||||
}
|
||||
|
||||
CMSampleBuffer sampleBuffer = null;
|
||||
for (int i = 0; i < frameCount; i++)
|
||||
{
|
||||
sampleBuffer?.Dispose();
|
||||
sampleBuffer = videoOutput.CopyNextSampleBuffer();
|
||||
if (sampleBuffer == null)
|
||||
break; // EOF or error
|
||||
}
|
||||
|
||||
if (sampleBuffer == null)
|
||||
{
|
||||
// End of media. Reader.Status will be Completed on clean EOF, Failed otherwise.
|
||||
if (Instance.IsLooping && Instance.LoopRange.IsValid())
|
||||
{
|
||||
Instance.SetCurrentTime(Instance.LoopRange.Start);
|
||||
Instance.Seek(Instance.LoopRange.Start);
|
||||
}
|
||||
else
|
||||
{
|
||||
Instance.Stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var pts = sampleBuffer.PresentationTimeStamp;
|
||||
Instance.SetCurrentTime(TimeSpan.FromSeconds(pts.Seconds));
|
||||
|
||||
using (var imageBuffer = sampleBuffer.GetImageBuffer())
|
||||
{
|
||||
if (imageBuffer is CVPixelBuffer pixelBuffer)
|
||||
{
|
||||
UploadFrameToTarget(pixelBuffer);
|
||||
}
|
||||
}
|
||||
adjustedTicksSinceLastFrame %= frameDurationTicks;
|
||||
|
||||
presentedSampleBuffer?.Dispose();
|
||||
presentedSampleBuffer = sampleBuffer;
|
||||
}
|
||||
|
||||
private void CreateReader(TimeSpan startTime)
|
||||
{
|
||||
DisposeReader();
|
||||
|
||||
reader = AVAssetReader.FromAsset(asset, out var error);
|
||||
if (error != null)
|
||||
throw new InvalidOperationException($"AVAssetReader create failed: {error.LocalizedDescription}");
|
||||
|
||||
if (startTime > TimeSpan.Zero)
|
||||
{
|
||||
// Timescale 600 covers all common video framerates exactly (24/25/30/50/60 fps all
|
||||
// hit integer tick counts at 600).
|
||||
var startCMTime = new CMTime((long)(startTime.TotalSeconds * 600), 600);
|
||||
reader.TimeRange = new CMTimeRange { Start = startCMTime, Duration = CMTime.PositiveInfinity };
|
||||
}
|
||||
|
||||
// Ask the reader to deliver decoded BGRA pixel buffers backed by IOSurface. BGRA is the
|
||||
// VideoToolbox native output format for H.264/HEVC SDR content — picking it avoids an
|
||||
// internal NV12→RGB conversion. The IOSurface is imported as a VkImage in UploadFrameToTarget.
|
||||
var settings = new NSMutableDictionary
|
||||
{
|
||||
[CVPixelBuffer.PixelFormatTypeKey] = NSNumber.FromInt32((int)CVPixelFormatType.CV32BGRA),
|
||||
[CVPixelBuffer.IOSurfacePropertiesKey] = new NSDictionary(),
|
||||
};
|
||||
videoOutput = new AVAssetReaderTrackOutput(videoTrack, settings)
|
||||
{
|
||||
// The default of true caches output buffers via the track output's internal pool which
|
||||
// is what we want — keeps each frame available until we Dispose() it.
|
||||
AlwaysCopiesSampleData = false,
|
||||
};
|
||||
reader.AddOutput(videoOutput);
|
||||
if (!reader.StartReading())
|
||||
{
|
||||
var err = reader.Error;
|
||||
throw new InvalidOperationException(
|
||||
$"AVAssetReader.StartReading failed: {(err != null ? err.LocalizedDescription : "unknown")}");
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposeReader()
|
||||
{
|
||||
videoOutput?.Dispose();
|
||||
videoOutput = null;
|
||||
reader?.Dispose();
|
||||
reader = null;
|
||||
}
|
||||
|
||||
private void UploadFrameToTarget(CVPixelBuffer pixelBuffer)
|
||||
{
|
||||
var target = Instance.VideoComponent.Target;
|
||||
if (target == null)
|
||||
return;
|
||||
|
||||
// Zero-copy: import the IOSurface that backs the CVPixelBuffer as a BGRA8 VkImage, then
|
||||
// shader-blit to the RGBA target. MoltenVK wraps the IOSurface storage directly; the only
|
||||
// CPU work is allocating the VkImage/VkImageView (~tens of µs).
|
||||
using var ioSurface = pixelBuffer.GetIOSurface();
|
||||
if (ioSurface == null)
|
||||
return;
|
||||
|
||||
var width = (int)pixelBuffer.Width;
|
||||
var height = (int)pixelBuffer.Height;
|
||||
var graphicsDevice = Instance.GraphicsDevice;
|
||||
var graphicsContext = Instance.Services.GetSafeServiceAs<GraphicsContext>();
|
||||
|
||||
// Swap the user's target with VideoTexture's internal RT and allocate the per-mip views.
|
||||
// No-op if already done; matches the MediaEngine backend ordering.
|
||||
Instance.VideoTexture.SetTargetContentToVideoStream(target);
|
||||
|
||||
Texture imported = null;
|
||||
try
|
||||
{
|
||||
imported = Texture.NewFromIOSurface(graphicsDevice, ioSurface.Handle.Handle, width, height);
|
||||
Instance.VideoTexture.CopyTextureToTopLevelMipmap(graphicsContext, imported);
|
||||
Instance.VideoTexture.GenerateMipMaps(graphicsContext);
|
||||
Instance.NotifyFramePresented();
|
||||
}
|
||||
finally
|
||||
{
|
||||
imported?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeAudio(string url, long startPosition, long length)
|
||||
{
|
||||
var audioTracks = asset.TracksWithMediaType(AVMediaTypes.Audio.GetConstant());
|
||||
if (audioTracks == null || audioTracks.Length == 0)
|
||||
return;
|
||||
var videoComponent = Instance.VideoComponent;
|
||||
if (!videoComponent.PlayAudio)
|
||||
return;
|
||||
|
||||
var audioEngine = Instance.Services.GetService<IAudioEngineProvider>()?.AudioEngine
|
||||
?? throw new Exception("VideoInstance AVFoundation failed to get the AudioEngine");
|
||||
|
||||
audioSynchronizer = new MediaSynchronizer();
|
||||
audioSynchronizer.PlayRange = Instance.PlayRange;
|
||||
audioSynchronizer.IsLooping = Instance.IsLooping;
|
||||
audioSynchronizer.LoopRange = Instance.LoopRange;
|
||||
|
||||
var isSpatialized = videoComponent.AudioEmitters.Any(x => x != null);
|
||||
audioSound = new StreamedBufferSound(audioEngine, audioSynchronizer, url, startPosition, length, isSpatialized);
|
||||
audioSynchronizer.RegisterExtractor(audioSound);
|
||||
|
||||
if (isSpatialized)
|
||||
{
|
||||
if (audioSound.GetCountChannels() == 1)
|
||||
{
|
||||
foreach (var emitter in videoComponent.AudioEmitters)
|
||||
{
|
||||
if (emitter == null)
|
||||
continue;
|
||||
var controller = emitter.AttachSound(audioSound);
|
||||
audioSynchronizer.RegisterPlayer(controller);
|
||||
audioControllers.Add(controller);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
VideoInstance.Logger.Error("Stereo audio tracks cannot be played through audio emitters. The track needs to be mono.");
|
||||
audioSound.Dispose();
|
||||
audioSound = null;
|
||||
audioSynchronizer = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
audioSoundInstance = (SoundInstanceStreamedBuffer)audioSound.CreateInstance();
|
||||
audioSynchronizer.RegisterPlayer(audioSoundInstance);
|
||||
}
|
||||
}
|
||||
|
||||
private void ReleaseAudio()
|
||||
{
|
||||
audioSoundInstance?.Stop();
|
||||
audioSoundInstance?.Dispose();
|
||||
audioSoundInstance = null;
|
||||
|
||||
if (audioSound != null)
|
||||
{
|
||||
foreach (var emitter in Instance.VideoComponent.AudioEmitters)
|
||||
emitter?.DetachSound(audioSound);
|
||||
audioSound.Dispose();
|
||||
audioSound = null;
|
||||
}
|
||||
audioControllers.Clear();
|
||||
audioSynchronizer = null;
|
||||
}
|
||||
|
||||
private static string ExtractAssetSliceToTempFile(string sourcePath, long startPosition, long length)
|
||||
{
|
||||
var temp = Path.Combine(Path.GetTempPath(), $"stride-video-{Guid.NewGuid():N}.mp4");
|
||||
using var src = File.OpenRead(sourcePath);
|
||||
using var dst = File.Create(temp);
|
||||
|
||||
src.Seek(startPosition, SeekOrigin.Begin);
|
||||
var buffer = new byte[64 * 1024];
|
||||
long remaining = length;
|
||||
while (remaining > 0)
|
||||
{
|
||||
int toRead = (int)Math.Min(buffer.Length, remaining);
|
||||
int read = src.Read(buffer, 0, toRead);
|
||||
if (read <= 0)
|
||||
break;
|
||||
dst.Write(buffer, 0, read);
|
||||
remaining -= read;
|
||||
}
|
||||
return temp;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
|
||||
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
|
||||
|
||||
#if STRIDE_VIDEO_AVFOUNDATION
|
||||
using Stride.Core;
|
||||
using Stride.Graphics;
|
||||
|
||||
namespace Stride.Video.Backends;
|
||||
|
||||
public sealed class AVFoundationVideoBackendFactory : VideoBackendFactory
|
||||
{
|
||||
public override string Name => "AVFoundation";
|
||||
public override int Priority => 200; // platform-native HW decode on iOS/macOS
|
||||
public override bool IsSupported(GraphicsDevice device) => true;
|
||||
|
||||
public override VideoBackend CreateBackend(VideoInstance instance) => new AVFoundationVideoBackend(instance);
|
||||
}
|
||||
|
||||
internal static class AVFoundationVideoBackendModule
|
||||
{
|
||||
[ModuleInitializer]
|
||||
public static void Initialize() => VideoBackendRegistry.Register(new AVFoundationVideoBackendFactory());
|
||||
}
|
||||
#endif
|
||||
@@ -3,6 +3,9 @@
|
||||
<PropertyGroup>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<StrideRuntime>true</StrideRuntime>
|
||||
<!-- Opt into a net10.0-macos build so AVFoundation / CoreVideo / Metal bindings are available
|
||||
for AVFoundationVideoBackend on macOS desktop. iOS already has net10.0-ios via StrideRuntime. -->
|
||||
<StrideExplicitMacOSRuntime>true</StrideExplicitMacOSRuntime>
|
||||
<StrideGraphicsApiDependent>true</StrideGraphicsApiDependent>
|
||||
<StrideAssemblyProcessor>true</StrideAssemblyProcessor>
|
||||
<StrideCodeAnalysis>true</StrideCodeAnalysis>
|
||||
@@ -14,6 +17,9 @@
|
||||
<PropertyGroup Condition="'$(StrideTargetFramework)' == '$(StrideFrameworkAndroid)'">
|
||||
<DefineConstants>STRIDE_VIDEO_MEDIACODEC;$(DefineConstants)</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(StrideTargetFramework)' == '$(StrideFrameworkiOS)' Or '$(StrideTargetFramework)' == '$(StrideFrameworkmacOS)'">
|
||||
<DefineConstants>STRIDE_VIDEO_AVFOUNDATION;$(DefineConstants)</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\shared\SharedAssemblyInfo.cs">
|
||||
<Link>Properties\SharedAssemblyInfo.cs</Link>
|
||||
|
||||
@@ -136,6 +136,23 @@ namespace Stride.Video
|
||||
//}
|
||||
}
|
||||
|
||||
/// Copies a regular sampleable source texture into the top-level mipmap via SpriteEffectExtTextureRegular.
|
||||
/// Used by backends whose decoder output is an ordinary VkImage (e.g. AVFoundation IOSurface import).
|
||||
public void CopyTextureToTopLevelMipmap(GraphicsContext context, Texture sourceTexture)
|
||||
{
|
||||
var previousDepthStencilBuffer = context.CommandList.DepthStencilBuffer;
|
||||
var previousRenderTarget = context.CommandList.RenderTarget;
|
||||
|
||||
CopyTexture(context,
|
||||
effectTexture2DCopy,
|
||||
sourceTexture,
|
||||
renderTargetMipMaps[0],
|
||||
SpriteEffectExtTextureRegularKeys.TextureRegular,
|
||||
SpriteEffectExtTextureRegularKeys.MipLevel);
|
||||
|
||||
context.CommandList.SetRenderTargetAndViewport(previousDepthStencilBuffer, previousRenderTarget);
|
||||
}
|
||||
|
||||
public void GenerateMipMaps(GraphicsContext graphicsContext)
|
||||
{
|
||||
if (renderTargetMipMaps.Count < 2) // avoid the change of render target when there is no mipmaps to generate.
|
||||
|
||||
@@ -90,6 +90,9 @@
|
||||
<ItemGroup Condition="'$(TargetFramework)' == '$(StrideFrameworkiOS)'">
|
||||
<StrideNativeCPU Include="ios-arm64;iossimulator-arm64" LibraryExtension=".a" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(TargetFramework)' == '$(StrideFrameworkmacOS)'">
|
||||
<StrideNativeCPU Include="osx-arm64" LibraryExtension=".dylib" LibraryRuntime="osx" />
|
||||
</ItemGroup>
|
||||
<!-- Can be deleted -->
|
||||
<ItemGroup Condition="'$(TargetFramework)' == '$(StrideFrameworkUWP)'">
|
||||
<StrideNativeCPU Include="x64;ARM" LibraryExtension=".dll" />
|
||||
@@ -315,13 +318,23 @@
|
||||
</Target>
|
||||
|
||||
<!-- macOS host: system clang (no LLD). Celt and NativePath are true-macOS-arm64 rebuilds
|
||||
under deps/NativePath/dotnet/osx-arm64/ (iOS objects refuse to link into macOS dylibs). -->
|
||||
<Target Name="CompileNativeClang_macOSOnMac" Inputs="@(StrideNativeCFile);@(StrideNativeHFile)" Outputs="@(StrideNativeOutput)" Condition="('$(TargetFramework)' == '$(StrideFramework)') And $([MSBuild]::IsOSPlatform('OSX')) And $(_StridePlatforms.Contains(';macOS;')) And $(DesignTimeBuild) != true And $(BuildingProject) != false" BeforeTargets="CoreCompile" DependsOnTargets="_StrideRegisterNativeOutputs">
|
||||
under deps/NativePath/dotnet/osx-arm64/ (iOS objects refuse to link into macOS dylibs).
|
||||
Fires for net10.0 (with -p:StridePlatforms=macOS opt-in) and net10.0-macos (TFM is
|
||||
definitionally macOS, no opt-in gate). Net effect: each TFM lays its own dylib down
|
||||
in bin/<TFM>/runtimes/osx-arm64/native/, so ProjectReference consumers building for
|
||||
net10.0-macos pick it up directly. -->
|
||||
<Target Name="CompileNativeClang_macOSOnMac" Inputs="@(StrideNativeCFile);@(StrideNativeHFile)" Outputs="@(StrideNativeOutput)" Condition="(('$(TargetFramework)' == '$(StrideFramework)' And $(_StridePlatforms.Contains(';macOS;'))) Or '$(TargetFramework)' == '$(StrideFrameworkmacOS)') And $([MSBuild]::IsOSPlatform('OSX')) And $(DesignTimeBuild) != true And $(BuildingProject) != false" BeforeTargets="CoreCompile" DependsOnTargets="_StrideRegisterNativeOutputs">
|
||||
<MakeDir Directories="$(OutputObjectPath)/osx-arm64" />
|
||||
<MakeDir Directories="$(StrideNativeOutputPath)/runtimes/osx-arm64/native" />
|
||||
<Exec Condition="'%(StrideNativeCFile.Extension)' != '.cpp'" Command="clang $(StrideNativeClang) -DPLATFORM_MACOS -arch arm64 -fPIC -o "$(OutputObjectPath)/osx-arm64/%(StrideNativeCFile.Filename)_arm64.o" -c "%(StrideNativeCFile.FullPath)"" />
|
||||
<Exec Condition="'%(StrideNativeCFile.Extension)' == '.cpp'" Command="clang++ $(StrideNativeClangCPP) $(StrideNativeClang) -DPLATFORM_MACOS -arch arm64 -fPIC -o "$(OutputObjectPath)/osx-arm64/%(StrideNativeCFile.Filename)_arm64.o" -c "%(StrideNativeCFile.FullPath)"" />
|
||||
<Exec Command="clang++ -arch arm64 -dynamiclib -o "$(StrideNativeOutputPath)/runtimes/osx-arm64/native/$(StrideNativeOutputName).dylib" @(StrideNativeCFile->'"$(OutputObjectPath)/osx-arm64/%(Filename)_arm64.o"', ' ') @(StrideNativePathLibsmacOSArm64->'"%(FullPath)"', ' ') "$(MSBuildThisFileDirectory)..\..\deps\NativePath\dotnet\osx-arm64\libNativePath.a"" />
|
||||
<!-- -Wl,-force_load on StrideNativePathLibs* archives (e.g. libCelt.a): without it the dylib
|
||||
linker strips symbols the .a provides that no native object references, even though
|
||||
managed P/Invoke needs them at runtime — e.g. stride_celt_get_lookahead, which is
|
||||
only called from Celt.AVFoundation.cs. iOS hits the same need via NativeReference
|
||||
ForceLoad=True on the consumer .app link; macOS embeds the archive at dylib-build
|
||||
time, so we re-export via the linker here. -->
|
||||
<Exec Command="clang++ -arch arm64 -dynamiclib -o "$(StrideNativeOutputPath)/runtimes/osx-arm64/native/$(StrideNativeOutputName).dylib" @(StrideNativeCFile->'"$(OutputObjectPath)/osx-arm64/%(Filename)_arm64.o"', ' ') @(StrideNativePathLibsmacOSArm64->'-Wl,-force_load,"%(FullPath)"', ' ') "$(MSBuildThisFileDirectory)..\..\deps\NativePath\dotnet\osx-arm64\libNativePath.a"" />
|
||||
</Target>
|
||||
|
||||
<Target Name="CompileNativeClang_Clean" BeforeTargets="Clean" DependsOnTargets="_StrideRegisterNativeOutputs">
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
<StridePlatformDefines>STRIDE_PLATFORM_MONO_MOBILE;STRIDE_PLATFORM_IOS</StridePlatformDefines>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- Platform Defines - macOS -->
|
||||
<!-- ================================================================ -->
|
||||
<PropertyGroup Condition="'$(StrideTargetFramework)' == '$(StrideFrameworkmacOS)'">
|
||||
<StridePlatformDefines>STRIDE_PLATFORM_MACOS</StridePlatformDefines>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- .NET Runtime Defines -->
|
||||
<!-- ================================================================ -->
|
||||
|
||||
Reference in New Issue
Block a user