diff --git a/.gitignore b/.gitignore index 13d7896..02401c4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .vs/ packages/ /TODO +mybuild.bat diff --git a/BNA/BNA.csproj b/BNA/BNA.csproj index f1935ed..6998a08 100644 --- a/BNA/BNA.csproj +++ b/BNA/BNA.csproj @@ -14,6 +14,7 @@ + diff --git a/BNA/FNA.filter b/BNA/FNA.filter index 35438da..0cf5226 100644 --- a/BNA/FNA.filter +++ b/BNA/FNA.filter @@ -102,3 +102,11 @@ *.Content.* *.Storage.* + +*.Audio.AudioChannels +*.Audio.SoundState +*.Audio.*Exception + +*.Media.MediaQueue +*.Media.MediaState +*.Media.SongCollection diff --git a/BNA/lzxdecoder.license b/BNA/lzxdecoder.license new file mode 100644 index 0000000..c570d11 --- /dev/null +++ b/BNA/lzxdecoder.license @@ -0,0 +1,29 @@ +LzxDecoder.cs was derived from libmspack +Copyright 2003-2004 Stuart Caie +Copyright 2011 Ali Scissons + +The LZX method was created by Jonathan Forbes and Tomi Poutanen, adapted +by Microsoft Corporation. + +This source file is Dual licensed; meaning the end-user of this source file +may redistribute/modify it under the LGPL 2.1 or MS-PL licenses. + +About +----- +This derived work is recognized by Stuart Caie and is authorized to adapt +any changes made to lzxd.c in his libmspack library and will still retain +this dual licensing scheme. Big thanks to Stuart Caie! + +This file is a pure C# port of the lzxd.c file from libmspack, with minor +changes towards the decompression of XNB files. The original decompression +software of LZX encoded data was written by Suart Caie in his +libmspack/cabextract projects, which can be located at +http://http://www.cabextract.org.uk/ + +GNU Lesser General Public License, Version 2.1 +---------------------------------------------- +https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html + +Microsoft Public License +------------------------ +http://www.opensource.org/licenses/ms-pl.html diff --git a/BNA/monoxna.license b/BNA/monoxna.license new file mode 100644 index 0000000..185aa38 --- /dev/null +++ b/BNA/monoxna.license @@ -0,0 +1,22 @@ +MIT License +Copyright 2006 The Mono.Xna Team + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/BNA/src/Effect.cs b/BNA/src/Effect.cs index d661568..276abf0 100644 --- a/BNA/src/Effect.cs +++ b/BNA/src/Effect.cs @@ -22,7 +22,6 @@ namespace Microsoft.Xna.Framework.Graphics } - // // CreateProgram // diff --git a/BNA/src/FNA3D_Rt.cs b/BNA/src/FNA3D_Rt.cs index 959cc58..dc07b0b 100644 --- a/BNA/src/FNA3D_Rt.cs +++ b/BNA/src/FNA3D_Rt.cs @@ -37,7 +37,8 @@ namespace Microsoft.Xna.Framework.Graphics if ((state.TargetFramebuffer = id[0]) == 0) return; } - GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, state.TargetFramebuffer); + GLES20.glBindFramebuffer(GLES30.GL_DRAW_FRAMEBUFFER, + state.TargetFramebuffer); int attachmentIndex = GLES20.GL_COLOR_ATTACHMENT0; foreach (var renderTarget in renderTargetsCopy) @@ -48,7 +49,7 @@ namespace Microsoft.Xna.Framework.Graphics // from FNA3D_GetMaxMultiSampleCount, which we never do throw new PlatformNotSupportedException(); /*GLES20.glFramebufferRenderbuffer( - GLES20.GL_FRAMEBUFFER, attachmentIndex, + GLES20.GL_DRAW_FRAMEBUFFER, attachmentIndex, GLES20.GL_RENDERBUFFER, (int) renderTarget.colorBuffer);*/ } else @@ -60,7 +61,7 @@ namespace Microsoft.Xna.Framework.Graphics + renderTarget.data2; } GLES20.glFramebufferTexture2D( - GLES20.GL_FRAMEBUFFER, attachmentIndex, + GLES30.GL_DRAW_FRAMEBUFFER, attachmentIndex, attachmentType, (int) renderTarget.texture, 0); } attachmentIndex++; @@ -71,12 +72,12 @@ namespace Microsoft.Xna.Framework.Graphics while (attachmentIndex < lastAttachmentPlusOne) { GLES20.glFramebufferRenderbuffer( - GLES20.GL_FRAMEBUFFER, attachmentIndex++, + GLES30.GL_DRAW_FRAMEBUFFER, attachmentIndex++, GLES20.GL_RENDERBUFFER, 0); } GLES20.glFramebufferRenderbuffer( - GLES20.GL_FRAMEBUFFER, GLES30.GL_DEPTH_STENCIL_ATTACHMENT, + GLES30.GL_DRAW_FRAMEBUFFER, GLES30.GL_DEPTH_STENCIL_ATTACHMENT, GLES20.GL_RENDERBUFFER, (int) depthStencilBuffer); state.RenderToTexture = true; @@ -100,7 +101,7 @@ namespace Microsoft.Xna.Framework.Graphics var renderer = Renderer.Get(device); renderer.Send( () => { - GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0); + GLES20.glBindFramebuffer(GLES30.GL_DRAW_FRAMEBUFFER, 0); var state = (State) renderer.UserData; state.RenderToTexture = false; @@ -138,7 +139,10 @@ namespace Microsoft.Xna.Framework.Graphics GLES20.glBindTexture(attachmentType, texture); GLES20.glGenerateMipmap(attachmentType); - GLES20.glBindTexture(attachmentType, 0); + + var state = (State) renderer.UserData; + if (state.TextureOnLastUnit != 0) + GLES20.glBindTexture(attachmentType, state.TextureOnLastUnit); }); } } @@ -204,6 +208,75 @@ namespace Microsoft.Xna.Framework.Graphics return ((State) Renderer.Get(graphicsDevice.GLDevice).UserData).RenderToTexture; } + // + // Get Texture Data + // + + private static void GetTextureData(Renderer renderer, int textureId, + int x, int y, int w, int h, int level, + object dataObject, int dataOffset, int dataLength) + { + java.nio.Buffer buffer = dataObject switch + { + sbyte[] byteArray => + java.nio.ByteBuffer.wrap(byteArray, dataOffset, dataLength), + + int[] intArray => + java.nio.IntBuffer.wrap(intArray, dataOffset / 4, dataLength / 4), + + _ => throw new ArgumentException(dataObject?.GetType().ToString()), + }; + + renderer.Send( () => + { + var state = (State) renderer.UserData; + if (state.SourceFramebuffer == 0) + { + var id = new int[1]; + GLES20.glGenFramebuffers(1, id, 0); + if ((state.SourceFramebuffer = id[0]) == 0) + return; + } + + var config = state.TextureConfigs[textureId]; + if (config[1] != (int) SurfaceFormat.Color) + { + throw new NotSupportedException( + ((SurfaceFormat) config[1]).ToString()); + } + + GLES20.glBindFramebuffer(GLES30.GL_READ_FRAMEBUFFER, + state.SourceFramebuffer); + + GLES20.glFramebufferTexture2D( + GLES30.GL_READ_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0, + GLES20.GL_TEXTURE_2D, textureId, level); + + GLES20.glReadPixels(x, y, w, h, + GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, buffer); + + GLES20.glBindFramebuffer(GLES30.GL_READ_FRAMEBUFFER, 0); + }); + } + + // + // FNA3D_GetTextureData2D + // + + public static void FNA3D_GetTextureData2D(IntPtr device, IntPtr texture, + int x, int y, int w, int h, int level, + IntPtr data, int dataLength) + { + // FNA Texture2D uses GCHandle::Alloc and GCHandle::AddrOfPinnedObject. + // we use GCHandle::FromIntPtr to convert that address to an object reference. + // see also: system.runtime.interopservices.GCHandle struct in baselib. + int dataOffset = (int) data; + var dataObject = System.Runtime.InteropServices.GCHandle.FromIntPtr(data).Target; + + GetTextureData(Renderer.Get(device), (int) texture, + x, y, w, h, level, dataObject, dataOffset, dataLength); + } + // // DepthFormatToDepthStorage // @@ -238,6 +311,7 @@ namespace Microsoft.Xna.Framework.Graphics private partial class State { public bool RenderToTexture; + public int SourceFramebuffer; public int TargetFramebuffer; public int ActiveAttachments; } diff --git a/BNA/src/FNA3D_Tex.cs b/BNA/src/FNA3D_Tex.cs index 4db1ca1..c225483 100644 --- a/BNA/src/FNA3D_Tex.cs +++ b/BNA/src/FNA3D_Tex.cs @@ -257,7 +257,7 @@ namespace Microsoft.Xna.Framework.Graphics { string reason = (bitmap == null) ? "unspecified error" : $"unsupported config '{bitmap.getConfig()}'"; - throw new System.BadImageFormatException( + throw new BadImageFormatException( $"Load failed for bitmap image '{titleStream.Name}': {reason}"); } @@ -341,15 +341,22 @@ namespace Microsoft.Xna.Framework.Graphics ref FNA3D_SamplerState sampler) { var samplerCopy = sampler; + int textureId = (int) texture; var renderer = Renderer.Get(device); renderer.Send( () => { var state = (State) renderer.UserData; - var config = state.TextureConfigs[(int) texture]; + var config = state.TextureConfigs[textureId]; GLES20.glActiveTexture(GLES20.GL_TEXTURE0 + index); - GLES20.glBindTexture(config[0], (int) texture); + GLES20.glBindTexture(config[0], textureId); + + if (index == renderer.TextureUnits - 1) + state.TextureOnLastUnit = textureId; + + if (textureId == 0) + return; GLES20.glTexParameteri(config[0], GLES30.GL_TEXTURE_MAX_LEVEL, config[2] - 1); @@ -571,6 +578,8 @@ namespace Microsoft.Xna.Framework.Graphics // #1 - SurfaceFormat // #2 - levels count public Dictionary TextureConfigs = new Dictionary(); + + public int TextureOnLastUnit; } } diff --git a/BNA/src/Import.cs b/BNA/src/Import.cs index 64aac7e..71db5ce 100644 --- a/BNA/src/Import.cs +++ b/BNA/src/Import.cs @@ -226,3 +226,24 @@ namespace Microsoft.Xna.Framework.Input.Touch } } + + + +namespace Microsoft.Xna.Framework.Media +{ + + // + // MediaQueue + // + + [java.attr.Discard] // discard in output + public class MediaQueue + { + public MediaQueue() { } + public Song ActiveSong { get; } + public int ActiveSongIndex { get; set; } + public void Add(Song song) { } + public void Clear() { } + } + +} diff --git a/BNA/src/MediaPlayer.cs b/BNA/src/MediaPlayer.cs new file mode 100644 index 0000000..24da1fe --- /dev/null +++ b/BNA/src/MediaPlayer.cs @@ -0,0 +1,331 @@ + +using System; +using System.Diagnostics; +#pragma warning disable 0436 + +namespace Microsoft.Xna.Framework.Media +{ + + public static class MediaPlayer + { + + [java.attr.RetainType] private static android.media.MediaPlayer player; + [java.attr.RetainType] private static MediaQueue queue; + [java.attr.RetainType] private static java.util.concurrent.atomic.AtomicInteger state; + [java.attr.RetainType] private static float volume; + [java.attr.RetainType] private static bool muted; + [java.attr.RetainType] private static bool looping; + + // + // static constructor + // + + static MediaPlayer() + { + volume = 1f; + state = new java.util.concurrent.atomic.AtomicInteger(0); + queue = new MediaQueue(); + var watcher = new Watcher(); + player = new android.media.MediaPlayer(); + player.setOnPreparedListener(watcher); + player.setOnCompletionListener(watcher); + } + + // + // PrepareAndStart + // + + private static void PrepareAndStart() + { + int oldState = state.getAndSet((int) MediaState.Playing); + player.prepareAsync(); + if (oldState != (int) MediaState.Playing && MediaStateChanged != null) + { + MediaStateChanged(null, EventArgs.Empty); + } + } + + // + // Play + // + + public static void Play(Song song) + { + if (ActiveSongChanged != null) + throw new PlatformNotSupportedException("ActiveSongChanged"); + + if (song.isAsset) + { + var asset = GameRunner.Singleton.Activity.getAssets().openFd(song.path); + player.setDataSource(asset.getFileDescriptor(), + asset.getStartOffset(), asset.getLength()); + } + else + player.setDataSource(song.path); + + Queue.Clear(); + Queue.Add(song); + Queue.ActiveSongIndex = 0; + + PrepareAndStart(); + } + + // + // Play (SongCollection) + // + + public static void Play(SongCollection songs, int index) + { + if (songs.Count == 0) + { + Queue.Clear(); + Stop(); + } + else if (songs.Count == 1) + { + Play((Song) (object) songs[0]); + } + else + throw new PlatformNotSupportedException(); + } + + public static void Play(SongCollection songs) => Play(songs, 0); + + // + // Pause + // + + public static void Pause() + { + if (State == MediaState.Playing) + { + if (player.isPlaying()) + player.pause(); + State = MediaState.Paused; + } + } + + // + // Resume + // + + public static void Resume() + { + if (State == MediaState.Paused) + { + player.start(); + State = MediaState.Playing; + } + } + + // + // Stop + // + + public static void Stop() + { + if (State != MediaState.Stopped) + { + player.stop(); + State = MediaState.Stopped; + } + } + + // + // MoveNext, MovePrevious + // + + public static void MoveNext() + { + Stop(); + if (looping) + PrepareAndStart(); + } + + public static void MovePrevious() => MoveNext(); + + // + // IsMuted, Volume + // + + public static bool IsMuted + { + get => muted; + set + { + muted = value; + Volume = volume; + } + } + + public static float Volume + { + get => volume; + set + { + if (value < 0f) + value = 0f; + else if (value > 1f) + value = 1f; + volume = value; + + if (muted) + value = 0f; + player.setVolume(value, value); + } + } + + public static bool GameHasControl => true; + public static bool IsShuffled { get; set; } + + // + // IsRepeating + // + + public static bool IsRepeating + { + get => looping; + set + { + if (value != looping) + { + looping = value; + player.setLooping(value); + } + } + } + + // + // State + // + + public static MediaState State + { + get => (MediaState) state.get(); + set + { + if ( state.getAndSet((int) value) != (int) value + && MediaStateChanged != null) + { + MediaStateChanged(null, EventArgs.Empty); + } + } + } + + // + // PlayPosition + // + + public static TimeSpan PlayPosition + => TimeSpan.FromMilliseconds(player.getCurrentPosition()); + + // + // Properties + // + + public static event EventHandler ActiveSongChanged; + public static event EventHandler MediaStateChanged; + + public static MediaQueue Queue => queue; + + // + // IsVisualizationEnabled + // + + public static bool IsVisualizationEnabled + { + get => false; + set => throw new PlatformNotSupportedException(); + } + + // + // Watcher + // + + private class Watcher : android.media.MediaPlayer.OnPreparedListener, + android.media.MediaPlayer.OnCompletionListener + { + + // + // onPrepared + // + + [java.attr.RetainName] + public void onPrepared(android.media.MediaPlayer player) + { + if (MediaPlayer.State == MediaState.Playing) + { + try + { + var duration = player.getDuration(); + if (duration != -1) + { + MediaPlayer.Queue.ActiveSong.Duration = + TimeSpan.FromMilliseconds(duration); + } + } + catch (Exception) + { + } + + player.start(); + } + } + + // + // onCompletion + // + + [java.attr.RetainName] + public void onCompletion(android.media.MediaPlayer player) + { + MediaPlayer.Stop(); + } + + } + } + + // + // Song + // + + public sealed class Song : IEquatable, IDisposable + { + [java.attr.RetainType] public string path; + [java.attr.RetainType] public bool isAsset; + + public bool IsDisposed { get; private set; } + public string Name { get; private set; } + public TimeSpan Duration { get; set; } + public bool IsProtected => false; + public bool IsRated => false; + public int PlayCount => 0; + public int Rating => 0; + public int TrackNumber => 0; + + public static Song FromUri(string name, Uri uri) + { + var song = new Song() { Name = name }; + if (uri.IsAbsoluteUri && uri.IsFile) + song.path = uri.LocalPath; + else + { + song.path = uri.ToString(); + song.isAsset = true; + } + return song; + } + + ~Song() => Dispose(); + public void Dispose() => IsDisposed = true; + + public override int GetHashCode() => base.GetHashCode(); + public bool Equals(Song other) => (((object) other) != null) && (path == other.path); + public override bool Equals(object other) => Equals(other as Song); + public static bool operator ==(Song song1, Song song2) + => (song1 == null) ? (song2 == null) : song1.Equals(song2); + public static bool operator !=(Song song1, Song song2) => ! (song1 == song2); + + } + +} diff --git a/BNA/src/SoundEffect.cs b/BNA/src/SoundEffect.cs new file mode 100644 index 0000000..f5e4f38 --- /dev/null +++ b/BNA/src/SoundEffect.cs @@ -0,0 +1,631 @@ + +using System; +using System.IO; +#pragma warning disable 0436 + +namespace Microsoft.Xna.Framework.Audio +{ + + public sealed class SoundEffect : IDisposable + { + + [java.attr.RetainType] public object dataArray; + [java.attr.RetainType] public int dataCount; + [java.attr.RetainType] public int sampleRate; + [java.attr.RetainType] public int channelConfig; + [java.attr.RetainType] public int markerFrame; + + [java.attr.RetainType] public static java.util.ArrayList instancesList = new java.util.ArrayList(); + [java.attr.RetainType] public static java.util.concurrent.locks.ReentrantLock instancesLock = new java.util.concurrent.locks.ReentrantLock(); + + // + // Constructor (for ContentReader) + // + + public SoundEffect(string name, byte[] buffer, int offset, int count, + ushort wFormatTag, ushort nChannels, + uint nSamplesPerSec, uint nAvgBytesPerSec, + ushort nBlockAlign, ushort wBitsPerSample, + int loopStart, int loopLength) + { + if (wFormatTag != 1 /* WAVE_FORMAT_PCM */) + throw new ArgumentException("bad wFormatTag"); + if (offset != 0) + throw new ArgumentException("bad offset"); + if (nBlockAlign != nChannels * wBitsPerSample / 8) + throw new ArgumentException("bad nBlockAlign"); + if (nAvgBytesPerSec != nSamplesPerSec * nBlockAlign) + throw new ArgumentException("bad nAvgBytesPerSec"); + + sampleRate = (int) nSamplesPerSec; + channelConfig = (nChannels == 1) ? android.media.AudioFormat.CHANNEL_OUT_MONO + : (nChannels == 2) ? android.media.AudioFormat.CHANNEL_OUT_STEREO + : throw new ArgumentException("bad nChannels"); + + if (wBitsPerSample == 8) + { + dataArray = buffer; + dataCount = count; + } + else if (wBitsPerSample == 16) + { + int shortCount = count / 2; + var shortBuffer = new short[shortCount]; + java.nio.ByteBuffer.wrap((sbyte[]) (object) buffer) + .order(java.nio.ByteOrder.LITTLE_ENDIAN).asShortBuffer() + .get(shortBuffer); + dataArray = shortBuffer; + dataCount = shortCount; + } + else + throw new ArgumentException("bad wBitsPerSample"); + + markerFrame = dataCount / nChannels; + + Name = name; + Duration = TimeSpan.FromSeconds(count / (double) nAvgBytesPerSec); + } + + // + // Constructor + // + + public SoundEffect(byte[] buffer, int sampleRate, AudioChannels channels) + : this(null, buffer, 0, buffer.Length, + 1 /* WAVE_FORMAT_PCM */, (ushort) channels, (uint) sampleRate, + (uint) (sampleRate * ((ushort) channels * 2)), + (ushort) ((ushort) channels * 2), 16, 0, 0) + { + } + + // + // Constructor + // + + public SoundEffect(byte[] buffer, int offset, int count, int sampleRate, + AudioChannels channels, int loopStart, int loopLength) + : this(null, buffer, offset, count, + 1 /* WAVE_FORMAT_PCM */, (ushort) channels, (uint) sampleRate, + (uint) (sampleRate * ((ushort) channels * 2)), + (ushort) ((ushort) channels * 2), 16, loopStart, loopLength) + { + } + + // + // FromStream + // + + public static SoundEffect FromStream(Stream stream) + { + using (BinaryReader reader = new BinaryReader(stream)) + { + for (;;) + { + if (new string(reader.ReadChars(4)) != "RIFF") + break; + reader.ReadUInt32(); // skip chunk size + if (new string(reader.ReadChars(4)) != "WAVE") + break; + + if (new string(reader.ReadChars(4)) != "fmt ") + break; + if (reader.ReadInt32() != 16) // fmt chunk size always 16 + break; + + var wFormatTag = reader.ReadUInt16(); + var nChannels = reader.ReadUInt16(); + var nSamplesPerSec = reader.ReadUInt32(); + var nAvgBytesPerSec = reader.ReadUInt32(); + var nBlockAlign = reader.ReadUInt16(); + var wBitsPerSample = reader.ReadUInt16(); + + if (new string(reader.ReadChars(4)) != "data") + break; + + var count = reader.ReadInt32(); + var buffer = reader.ReadBytes(count); + + return new SoundEffect(null, buffer, 0, count, wFormatTag, nChannels, + nSamplesPerSec, nAvgBytesPerSec, + nBlockAlign, wBitsPerSample, 0, 0); + } + } + + throw new BadImageFormatException("invalid wave data for sound effect"); + } + + // + // Destructor + // + + ~SoundEffect() => Dispose(); + + // + // Dispose + // + + public void Dispose() + { + if (! IsDisposed) + { + IsDisposed = true; + DiscardInstance(null, this); + } + } + + // + // Properties + // + + public string Name { get; set; } + public TimeSpan Duration { get; private set; } + public bool IsDisposed { get; private set; } + + // + // Play + // + + public bool Play() => Play(1f, 0f, 0f); + + public bool Play(float volume, float pitch, float pan) + => CreateInstance().Play(volume, pitch, pan); + + // + // CreateInstance + // + + public SoundEffectInstance CreateInstance() + { + var inst = new SoundEffectInstance(this); + try + { + instancesLock.@lock(); + instancesList.add(new java.lang.@ref.WeakReference(inst)); + } + finally + { + instancesLock.unlock(); + } + return inst; + } + + // + // DiscardInstance + // + + public static void DiscardInstance(SoundEffectInstance discardInstance, SoundEffect discardEffect) + { + if (instancesLock.isHeldByCurrentThread()) + return; + try + { + instancesLock.@lock(); + for (int idx = instancesList.size(); idx-- > 0;) + { + var instRef = (java.lang.@ref.WeakReference) instancesList.get(idx); + var inst = (SoundEffectInstance) instRef.get(); + if (inst == discardInstance || inst == null || inst.ShouldDiscard(discardEffect)) + instancesList.remove(idx); + } + } + finally + { + instancesLock.unlock(); + } + } + + // + // ReleaseInstance + // + + public static bool ReleaseInstance() + { + bool found = false; + try + { + instancesLock.@lock(); + int num = instancesList.size(); + for (int idx = 0; idx < num; idx++) + { + var instRef = (java.lang.@ref.WeakReference) instancesList.get(idx); + var inst = (SoundEffectInstance) instRef.get(); + if (inst != null && inst.ReleaseTrack(false)) + { + found = true; + break; + } + } + } + finally + { + instancesLock.unlock(); + } + return found; + } + + // + // GetSampleDuration, GetSampleSizeInBytes + // + + public static TimeSpan GetSampleDuration(int sizeInBytes, int sampleRate, + AudioChannels channels) + => TimeSpan.FromSeconds( + ((sizeInBytes / (2 * (int) channels)) / (float) sampleRate)); + + public static int GetSampleSizeInBytes(TimeSpan duration, int sampleRate, + AudioChannels channels) + => (int) (duration.TotalSeconds * sampleRate * 2 * (int) channels); + + // + // MasterVolume, DistanceScale, DopplerScale, SpeedOfSound (no-op) + // + + public static float MasterVolume { get; set; } + public static float DistanceScale { get; set; } + public static float DopplerScale { get; set; } + public static float SpeedOfSound { get; set; } + } + + + + // + // SoundEffectInstance + // + + public class SoundEffectInstance : IDisposable + { + [java.attr.RetainType] private SoundEffect effect; + [java.attr.RetainType] private SoundEffectInstanceWatcher watcher; + [java.attr.RetainType] private android.media.AudioTrack track; + [java.attr.RetainType] private float pitch, pan, volume; + [java.attr.RetainType] private bool isLooped; + + // + // Constructor (for SoundEffect.CreateInstance) + // + + public SoundEffectInstance(SoundEffect fromEffect) + { + effect = fromEffect; + volume = 1f; + } + + // + // CreateTrack + // + + private void CreateTrack(bool tryRelease) + { + int numToWrite, numWritten; + + if (effect.dataArray is sbyte[] byteData) + { + numToWrite = byteData.Length; + track = new android.media.AudioTrack( + android.media.AudioManager.STREAM_MUSIC, + effect.sampleRate, effect.channelConfig, + android.media.AudioFormat.ENCODING_PCM_8BIT, + numToWrite, android.media.AudioTrack.MODE_STATIC); + numWritten = track.write(byteData, 0, numToWrite); + } + + else if (effect.dataArray is short[] shortData) + { + numToWrite = shortData.Length; + track = new android.media.AudioTrack( + android.media.AudioManager.STREAM_MUSIC, + effect.sampleRate, effect.channelConfig, + android.media.AudioFormat.ENCODING_PCM_16BIT, + numToWrite * 2, android.media.AudioTrack.MODE_STATIC); + numWritten = track.write(shortData, 0, numToWrite); + } + else + { + numToWrite = 0; + numWritten = android.media.AudioTrack.ERROR_INVALID_OPERATION; + } + + if (numWritten != numToWrite) + { + track = null; + if (numWritten < 0) + { + if (SoundEffect.ReleaseInstance()) + CreateTrack(false); + } + if (track == null) + { + GameRunner.Log($"SoundEffectInstance '{effect.Name}' error {numWritten}/{numToWrite}"); + } + return; + } + + track.setNotificationMarkerPosition(effect.markerFrame); + track.setPlaybackPositionUpdateListener(watcher = new SoundEffectInstanceWatcher()); + } + + // + // ReleaseTrack + // + + public bool ReleaseTrack(bool disposing) + { + var track = this.track; + if (track != null) + { + if (disposing || track.getPlayState() == 1 /* android.media.AudioTrack.PLAYSTATE_STOPPED */) + { + this.track = null; + track.setPlaybackPositionUpdateListener(null); + track.stop(); + track.release(); + return true; + } + } + return false; + } + + // + // Destructor + // + + ~SoundEffectInstance() => Dispose(true); + + // + // Dispose + // + + protected virtual void Dispose(bool disposing) + { + if (! IsDisposed) + { + ReleaseTrack(true); + SoundEffect.DiscardInstance(this, null); + effect = null; + IsDisposed = true; + } + } + + public void Dispose() => Dispose(true); + + // + // ShouldDiscard + // + + public bool ShouldDiscard(SoundEffect fromEffect) + { + if (effect == fromEffect) + Dispose(true); + return IsDisposed; + } + + // + // Properties + // + + public SoundState State + { + get + { + var track = this.track; + if (track == null) + return SoundState.Stopped; + + return track.getPlayState() switch + { + 1 /* android.media.AudioTrack.PLAYSTATE_STOPPED */ => SoundState.Stopped, + 2 /* android.media.AudioTrack.PLAYSTATE_PAUSED */ => SoundState.Paused, + 3 /* android.media.AudioTrack.PLAYSTATE_PLAYING */ => SoundState.Playing, + _ => throw new InvalidOperationException() + }; + } + } + + public bool IsDisposed { get; protected set; } + public float Pitch + { + get => pitch; + set => SetPlaybackRate(value, State == SoundState.Playing); + } + public float Pan + { + get => pan; + set => SetStereoVolume(volume, value, State == SoundState.Playing); + } + public float Volume + { + get => volume; + set => SetStereoVolume(value, pan, State == SoundState.Playing); + } + + // + // IsLooped + // + // note that looping does not respect any custom loop points, + // and always occurs on the entire effect + // + + public virtual bool IsLooped + { + get => isLooped; + set + { + if (State == SoundState.Playing) + throw new InvalidOperationException(); + isLooped = value; + } + } + + // + // SetPlaybackRate + // + + private void SetPlaybackRate(float pitch, bool playing) + { + if (pitch < -1f) + pitch = -1f; + else if (pitch > 1f) + pitch = 1f; + this.pitch = pitch; + + if (playing) + { + // convert pitch from range 0 .. 1 to range 0.5 .. 2 + // (which represents half to twice the sample rate) + if (pitch < 0f) + pitch = 1f + pitch * 0.5f; + else if (pitch > 0f) + pitch = 1f + pitch; + int r = (int) (effect.sampleRate * pitch); + var track = this.track; + if (track != null) + track.setPlaybackRate(r); + } + } + + // + // SetStereoVolume + // + + private void SetStereoVolume(float volume, float pan, bool playing) + { + if (volume < 0f) + volume = 0f; + else if (volume > 1f) + volume = 1f; + this.volume = volume; + + if (pan < -1f) + pan = -1f; + else if (pan > 1f) + pan = 1f; + this.pan = pan; + + if (playing) + { + float leftGain = 1f; + float rightGain = 1f; + if (pan < 0f) + { + leftGain *= 0f - pan; + rightGain *= pan + 1f; + } + else if (pan > 0f) + { + rightGain *= pan; + leftGain *= 1f - pan; + } + var track = this.track; + if (track != null) + track.setStereoVolume(leftGain * volume, rightGain * volume); + } + } + + // + // Play, Pause, Resume, Stop + // + + public virtual void Play() + { + if (State != SoundState.Playing) + { + var track = this.track; + if (track == null) + { + CreateTrack(true); + track = this.track; + } + + if (track != null) + { + SetPlaybackRate(pitch, true); + SetStereoVolume(volume, pan, true); + + watcher.instance = this; + track.play(); + } + } + } + + public void Pause() + { + var track = this.track; + if (track != null && State == SoundState.Playing) + track.pause(); + } + + public void Resume() => Play(); + + public void Stop() => Stop(true); + + public void Stop(bool immediate) + { + if (immediate) + { + var track = this.track; + if (track != null && State != SoundState.Stopped) + track.stop(); + } + if (watcher != null) + watcher.instance = null; + } + + // + // Play (for SoundEffect.Play) + // + + public bool Play(float volume, float pitch, float pan) + { + this.volume = volume; + this.pitch = pitch; + this.pan = pan; + Play(); + if (track == null) + { + Dispose(true); + return false; + } + return true; + } + + // + // Apply3D (no-op) + // + + public void Apply3D(AudioListener listener, AudioEmitter emitter) { } + public void Apply3D(AudioListener[] listeners, AudioEmitter emitter) { } + + } + + + + // + // SoundEffectInstanceWatcher + // + + public class SoundEffectInstanceWatcher : + android.media.AudioTrack.OnPlaybackPositionUpdateListener + { + [java.attr.RetainType] public SoundEffectInstance instance; + + [java.attr.RetainName] + public void onMarkerReached(android.media.AudioTrack track) + { + // release the strong reference to the SoundEffectInstance, + // so it can be garbage collected if not otherwise referenced + track.stop(); + var instance = this.instance; + if (instance != null) + { + if (instance.IsLooped && (! instance.IsDisposed)) + track.play(); + else + this.instance = null; + } + } + + [java.attr.RetainName] + public void onPeriodicNotification(android.media.AudioTrack track) { } + } + +} diff --git a/BNA/src/TitleContainer.cs b/BNA/src/TitleContainer.cs index 20e438e..0b67268 100644 --- a/BNA/src/TitleContainer.cs +++ b/BNA/src/TitleContainer.cs @@ -59,4 +59,10 @@ namespace Microsoft.Xna.Framework } + /*internal static class TitleLocation + { + // there is no file path for asset files which are part of the APK + public static string Path => throw new System.PlatformNotSupportedException(); + }*/ + } diff --git a/Demo1/Demo1/Demo1.csproj b/Demo1/Demo1/Demo1.csproj index 2c947b0..c7b0104 100644 --- a/Demo1/Demo1/Demo1.csproj +++ b/Demo1/Demo1/Demo1.csproj @@ -60,6 +60,7 @@ + diff --git a/Demo1/Demo1/Game1.cs b/Demo1/Demo1/Game1.cs index 260bf0e..080e089 100644 --- a/Demo1/Demo1/Game1.cs +++ b/Demo1/Demo1/Game1.cs @@ -3,6 +3,7 @@ using System; using System.IO; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Audio; namespace Demo1 { @@ -14,6 +15,8 @@ namespace Demo1 public Texture2D white; private SpriteBatch spriteBatch; private Font myFont; + private SoundEffectInstance effect; + private bool playEffect; private int pageNumber, pageNumberOld; private bool paused; private bool anyDrawText; @@ -58,8 +61,33 @@ namespace Demo1 // Texture2D.FromStream(GraphicsDevice, stream); // // to disable processing, open Properties on the image in the - // Content project, set "Build Action: None", and - // "Copy to Output Directory: Copy if newer". + // Content project, and change the following in the Advanced tab: + // "Build Action: None" and "Copy to Output Directory: Copy if newer". + + effect = Content.Load("effect").CreateInstance(); + effect.Pitch = 0.2f; + + // the XNA content processor for Song converts music files to WMA + // format, which Android does not support playing. use MP3 instead. + // + // to disable processing, open Properties on the image in the + // Content project, and change the following in the Advanced tab: + // "Build Action: None" and "Copy to Output Directory: Copy if newer". + // + // note that XNA on Windows may not play MP3 files which contain ID3 + // tags. use one of the many free utilities to remove such tags. + // + // unsupported BNA MediaPlayer: queueing more than one song at a time; + // the ActiveSongChanged event; visualization data. + + Microsoft.Xna.Framework.Media.MediaPlayer.Volume = 0.05f; + Microsoft.Xna.Framework.Media.MediaPlayer.Play( + Microsoft.Xna.Framework.Media.Song.FromUri("Song1", + new System.Uri(Content.RootDirectory + "/music.mp3", UriKind.Relative))); + Microsoft.Xna.Framework.Media.MediaPlayer.IsRepeating = true; + // song duration is populated during the call to Play() + // Console.WriteLine(Microsoft.Xna.Framework.Media.MediaPlayer.Queue.ActiveSong.Duration); + } @@ -80,12 +108,26 @@ namespace Demo1 if (paused) return; + if (playEffect) + { + effect.Play(); + playEffect = false; + } + // basic scene management for the purpose of this demo: // after either arrow at the top of the screen is clicked, and // the current page number has changed, create the new 'page'. - if (pageNumber != pageNumberOld) + if (pageNumber != pageNumberOld) { + if (pageComponent != null) + { + if (effect.State == SoundState.Playing) + effect.Stop(); + effect.Pan = (pageNumber > pageNumberOld) ? 1f : -1f; + playEffect = true; + } + const int LAST_PAGE = 4; if (pageNumber <= 0) pageNumber = LAST_PAGE; diff --git a/Demo1/Demo1/RenderDemo.cs b/Demo1/Demo1/RenderDemo.cs index eac63e1..9fe6120 100644 --- a/Demo1/Demo1/RenderDemo.cs +++ b/Demo1/Demo1/RenderDemo.cs @@ -42,7 +42,7 @@ namespace Demo1 { if (renderToTexture) { - ((Game1)Game).DrawFlushBatch(); + ((Game1) Game).DrawFlushBatch(); renderTargetWidth = Config.ClientWidth; renderTargetHeight = Config.ClientHeight; if (renderTarget != null) diff --git a/Demo1/Demo1/Storage.cs b/Demo1/Demo1/Storage.cs index 0f74a5b..b381138 100644 --- a/Demo1/Demo1/Storage.cs +++ b/Demo1/Demo1/Storage.cs @@ -48,6 +48,10 @@ namespace Demo1 file = container.OpenFile("state-v1.bin", FileMode.OpenOrCreate); if (file.Length > 4) Read(file); + + // reset file in case we crash + file.SetLength(0); + file.Flush(); } catch (Exception e) { diff --git a/Demo1/Demo1Content/Demo1Content.contentproj b/Demo1/Demo1Content/Demo1Content.contentproj index cee89a8..2a9f94d 100644 --- a/Demo1/Demo1Content/Demo1Content.contentproj +++ b/Demo1/Demo1Content/Demo1Content.contentproj @@ -1,4 +1,11 @@  + {E50B6FE1-C91C-4226-BA4B-75DEFDEF4CA3} {96E2B04D-8817-42c6-938A-82C39BA4D311};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} @@ -64,6 +71,21 @@ TextureProcessor + + + effect + Mp3Importer + SoundEffectProcessor + + + + + music + Mp3Importer + SongProcessor + PreserveNewest + + + Library net461 @@ -14,6 +20,9 @@ + + + C:\Program Files (x86)\Microsoft XNA\XNA Game Studio\v4.0\References\Windows\x86\Microsoft.Xna.Framework.dll diff --git a/Demo1/Demo1FSharp/Library1.fs b/Demo1/Demo1FSharp/Library1.fs index 30f1236..9ee1a16 100644 --- a/Demo1/Demo1FSharp/Library1.fs +++ b/Demo1/Demo1FSharp/Library1.fs @@ -7,8 +7,10 @@ open Microsoft.Xna.Framework.Graphics type StencilDemo (game : Game) = inherit DrawableGameComponent(game) - let mutable spriteBatch = null - let mutable texture = null + let mutable spriteBatch = new SpriteBatch (game.GraphicsDevice) + let mutable logoTexture = game.Content.Load("fsharp256") + let mutable backTexture = new Texture2D(game.GraphicsDevice, 16, 16) + let mutable counter = 0 // using option here solely to force a dependency on FSharp.Core.dll let mutable rectFunc : (Game -> Rectangle) option = None @@ -26,12 +28,36 @@ type StencilDemo (game : Game) = (float32) (Math.Cos(gameTime.TotalGameTime.TotalMilliseconds * 0.001))) override Game.Initialize() = - spriteBatch <- new SpriteBatch (game.GraphicsDevice) - texture <- game.Content.Load("fsharp256") rectFunc <- Some logoRect colorFunc <- Some logoColor override Game.Draw gameTime = + + let backArray = Array.zeroCreate (16 * 16) + backTexture.GetData backArray + + if counter = 0 + then + backArray.[12 * 16] <- 0xFF0000FF + counter <- 1 + else + for y = 4 to 11 do + for x = 0 to 15 do + let idx = y * 16 + x + backArray.[idx] <- backArray.[idx + 1] + backArray.[idx + 1] <- 0 + counter <- match counter with + | 60 -> 0 + | n -> n + 1 + backTexture.SetData backArray + + spriteBatch.Begin (SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, DepthStencilState.None, new RasterizerState()) + spriteBatch.Draw (backTexture, + Rectangle(0, 0, game.Window.ClientBounds.Width, game.Window.ClientBounds.Height), + Nullable (Rectangle(0, 0, 16, 16)), + Color.White) + spriteBatch.End () + spriteBatch.Begin () - spriteBatch.Draw (texture, rectFunc.Value game, colorFunc.Value gameTime) + spriteBatch.Draw (logoTexture, rectFunc.Value game, colorFunc.Value gameTime) spriteBatch.End () \ No newline at end of file diff --git a/MakeAPK.project b/MakeAPK.project index df42009..06411e4 100644 --- a/MakeAPK.project +++ b/MakeAPK.project @@ -17,6 +17,8 @@ CONTENT_DIR = path to Content directory e.g. $(OutputDir)/MyGame/Debug/Content + ICON_PNG = path to a PNG file to use as the app icon + APK_OUTPUT = path to copy the final APK e.g. $(OutputDir)/MyGame.apk diff --git a/build_demo.bat b/build_demo.bat index cc555c4..6eeec36 100644 --- a/build_demo.bat +++ b/build_demo.bat @@ -30,6 +30,7 @@ echo Building BNA. Command: echo MSBuild BNA -p:Configuration=Release echo ======================================== MSBuild BNA -p:Configuration=Release +if errorlevel 1 goto :EOF pause echo ======================================== @@ -38,14 +39,17 @@ echo nuget restore Demo1 echo msbuild Demo1 -p:Configuration=Release -p:Platform="x86" echo ======================================== nuget restore Demo1 +if errorlevel 1 goto :EOF msbuild Demo1 -p:Configuration=Release -p:Platform="x86" +if errorlevel 1 goto :EOF pause echo ======================================== echo Converting Demo1 to APK. Command: echo MSBuild MakeAPK.project -p:INPUT_DLL=.obj\Demo1\Release\Demo1.exe -p:INPUT_DLL_2=.obj\Demo1\Release\Demo1FSharp.dll -p:INPUT_DLL_3=.obj\Demo1\Release\FSharp.Core.dll -p:CONTENT_DIR=.obj\Demo1\Release\Content -p:ICON_PNG=Demo1\Demo1\GameThumbnail.png -p:ANDROID_MANIFEST=Demo1\AndroidManifest.xml -p:KEYSTORE_FILE=.\my.keystore -p:KEYSTORE_PWD=123456 -p:APK_OUTPUT=.obj\Demo1.apk -p:APK_TEMP_DIR=.obj\Demo1\Release\TempApk -p:EXTRA_JAR_1=.obj\BNA.jar -p:EXTRA_JAR_2=%BLUEBONNET_LIB% echo ======================================== -MSBuild MakeAPK.project -p:INPUT_DLL=.obj\Demo1\Release\Demo1.exe -p:INPUT_DLL_2=.obj\Demo1\Release\Demo1FSharp.dll -p:INPUT_DLL_3=.obj\Demo1\Release\FSharp.Core.dll -p:CONTENT_DIR=.obj\Demo1\Release\Content -p:ICON_PNG=Demo1\Demo1\GameThumbnail.png -p:ANDROID_MANIFEST=Demo1\AndroidManifest.xml -p:KEYSTORE_FILE=.\my.keystore -p:KEYSTORE_PWD=123456 -p:APK_OUTPUT=.obj\Demo1.apk -p:APK_TEMP_DIR=.obj\Demo1\Release\TempApk -p:EXTRA_JAR_1=.obj\BNA.jar -p:EXTRA_JAR_2=%BLUEBONNET_LIB% + MSBuild MakeAPK.project -p:INPUT_DLL=.obj\Demo1\Release\Demo1.exe -p:INPUT_DLL_2=.obj\Demo1\Release\Demo1FSharp.dll -p:INPUT_DLL_3=.obj\Demo1\Release\FSharp.Core.dll -p:CONTENT_DIR=.obj\Demo1\Release\Content -p:ICON_PNG=Demo1\Demo1\GameThumbnail.png -p:ANDROID_MANIFEST=Demo1\AndroidManifest.xml -p:KEYSTORE_FILE=.\my.keystore -p:KEYSTORE_PWD=123456 -p:APK_OUTPUT=.obj\Demo1.apk -p:APK_TEMP_DIR=.obj\Demo1\Release\TempApk -p:EXTRA_JAR_1=.obj\BNA.jar -p:EXTRA_JAR_2=%BLUEBONNET_LIB% +if errorlevel 1 goto :EOF echo ======================================== echo All done