diff --git a/.gitignore b/.gitignore index 319b659f..7167dd90 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ docfx_project/log.txt log.txt +src/Native/* linguist-vendored + # Build results [Dd]ebug/ [Dd]ebugPublic/ diff --git a/src/Maui/Addons/DrawnUi.Maui.Camera/Apple/SkiaCamera.Apple.cs b/src/Maui/Addons/DrawnUi.Maui.Camera/Apple/SkiaCamera.Apple.cs index e73f28e8..678ecc6c 100644 --- a/src/Maui/Addons/DrawnUi.Maui.Camera/Apple/SkiaCamera.Apple.cs +++ b/src/Maui/Addons/DrawnUi.Maui.Camera/Apple/SkiaCamera.Apple.cs @@ -308,16 +308,9 @@ private async Task CaptureFrameCore() } } - if (imageToDraw == null) - { - var raw = nativeCam.GetRawFullImage(); - if (raw.Image != null) - { - imageToDraw = raw.Image; - imageRotation = raw.Rotation; - imageFlip = raw.Flip; - } - } + // No fallback to GetRawFullImage - it reads _latestRecordingFrame which was + // populated earlier and can be older than what zero-copy previously encoded, + // causing out-of-order frames in the video. Better to drop a frame than glitch. } // Fallback to standard preview image (slower, already rotated) diff --git a/src/Maui/Samples/Camera/CameraTestPage.AppCamera.cs b/src/Maui/Samples/Camera/CameraTestPage.AppCamera.cs index 4bbe355d..63964978 100644 --- a/src/Maui/Samples/Camera/CameraTestPage.AppCamera.cs +++ b/src/Maui/Samples/Camera/CameraTestPage.AppCamera.cs @@ -7,14 +7,14 @@ public partial class CameraTestPage public class AppCamera : SkiaCamera { // Audio visualizer (switch between AudioOscillograph and AudioLevels) - private IAudioVisualizer _audioVisualizer = new AudioLevelsVU(); + private IAudioVisualizer _audioVisualizer = null; private int _visualizerIndex = 0; public static readonly BindableProperty VisualizerNameProperty = BindableProperty.Create( nameof(VisualizerName), typeof(string), typeof(AppCamera), - "VU Meter"); + "None"); public string VisualizerName { @@ -22,28 +22,40 @@ public string VisualizerName set => SetValue(VisualizerNameProperty, value); } - public void SwitchVisualizer() + public void SwitchVisualizer(int index = -1) { - _visualizerIndex++; - if (_visualizerIndex > 6) _visualizerIndex = 0; + if (index >= 0) + { + _visualizerIndex = index; + } + else + { + _visualizerIndex++; + } + if (_visualizerIndex > 8) _visualizerIndex = 0; var old = _audioVisualizer; - bool useGain = true; + bool useGain = true; switch (_visualizerIndex) { case 0: + _audioVisualizer = new AudioSoundBars(); + useGain = true; + VisualizerName = "Sound Bars"; + break; + case 1: _audioVisualizer = new AudioLevelsVU(); VisualizerName = "VU Meter"; break; - case 1: + case 2: _audioVisualizer = new AudioLevelsPeak(); VisualizerName = "Peak Monitor"; break; - case 2: - _audioVisualizer = new AudioLevels(); - VisualizerName = "Spectrum"; - break; + //case 2: + // _audioVisualizer = new AudioLevels(); + // VisualizerName = "Spectrum"; + // break; case 3: _audioVisualizer = new AudioOscillograph(); useGain = true; @@ -59,6 +71,10 @@ public void SwitchVisualizer() VisualizerName = "Tuner"; break; case 6: + _audioVisualizer = new AudioWaveformBars(); + VisualizerName = "Waveform Bars"; + break; + case 8: _audioVisualizer = null; VisualizerName = "None"; break; @@ -89,7 +105,7 @@ public override void OnWillDisposeWithChildren() _paintRec = null; _paintPreview?.Dispose(); _paintPreview = null; - + (_audioVisualizer as IDisposable)?.Dispose(); _audioVisualizer = null; } @@ -158,6 +174,6 @@ public void DrawOverlay(DrawableFrame frame) private SKPaint _paintRec; } - + } } diff --git a/src/Maui/Samples/Camera/CameraTestPage.cs b/src/Maui/Samples/Camera/CameraTestPage.cs index 0159b49d..89d690e4 100644 --- a/src/Maui/Samples/Camera/CameraTestPage.cs +++ b/src/Maui/Samples/Camera/CameraTestPage.cs @@ -263,14 +263,18 @@ private void CreateContent() CornerRadius = 8, UseCache = SkiaCacheType.Image } - .OnTapped(me => { CameraControl.SwitchVisualizer(); }) + .OnTapped(me => + { + CameraControl.SwitchVisualizer(); + }) .ObserveProperty(CameraControl, nameof(CameraControl.VisualizerName), me => { - me.Text = $"Vis: {CameraControl.VisualizerName}"; + me.Text = $"{CameraControl.VisualizerName}"; }) .ObserveProperty(CameraControl, nameof(CameraControl.CaptureMode), me => { - me.IsVisible = CameraControl.CaptureMode == CaptureModeType.Video; + CameraControl.SwitchVisualizer(0); + //me.IsVisible = CameraControl.CaptureMode == CaptureModeType.Video; }), // Take Picture button (only visible in Still mode) diff --git a/src/Maui/Samples/Camera/Visualizers/AudioSoundBars.cs b/src/Maui/Samples/Camera/Visualizers/AudioSoundBars.cs new file mode 100644 index 00000000..27bc92ef --- /dev/null +++ b/src/Maui/Samples/Camera/Visualizers/AudioSoundBars.cs @@ -0,0 +1,322 @@ +using DrawnUi.Camera; + +namespace CameraTests +{ + /// + /// High-resolution frequency spectrum sound bars (music player style). + /// Uses Goertzel algorithm to compute energy at logarithmically-spaced + /// frequency bins. Bars with energy are ON, bars without are fully OFF, + /// creating an organic pattern of active/silent frequency clusters. + /// ZERO render allocations, double-buffered. + /// + public class AudioSoundBars : IAudioVisualizer, IDisposable + { + public bool UseGain { get; set; } = true; + public int Skin { get; set; } = 1; + public bool ShowPeakDots { get; set; } = true; + + private const int BarCount = 48; + private const int AnalysisSize = 1024; // Samples to analyze (power of 2 for cleaner bins) + + // Ballistics + /// + /// Controls how fast a bar falls down when the audio at that frequency drops. + /// Each frame, if the new target is lower than the current value, the bar is set to current * 0.70. + /// So it loses 30% of its height per frame. + /// This prevents bars from snapping instantly to zero - they fade down smoothly over several frames. + /// + private const float ReleaseCoeff = 0.70f; + + /// + /// Controls how fast the floating peak dot falls. + /// The dot marks the highest point a bar reached, + /// then slowly drifts down at current * 0.97 per frame (only 3% loss per frame). + /// Much slower than the bar itself, so the dot lingers near the top + /// while the bar drops away underneath it - classic "peak hold" meter behavior. + /// + private const float PeakDecay = 0.97f; + + // Hard cutoff: bins below this fraction of max energy are completely OFF + private const float CutoffRatio = 0.12f; + + // Absolute noise gate: if max power across all bins is below this, + // all bars stay OFF. Prevents ambient noise from triggering the display. + private const float NoiseGate = 1.00f; + + private float[] _barsFrontBuffer = new float[BarCount]; + private float[] _barsBackBuffer = new float[BarCount]; + private float[] _peakHold = new float[BarCount]; + private float[] _peakHoldFront = new float[BarCount]; + private int _swapRequested = 0; + + // Pre-computed Goertzel coefficients per bin + private float[] _goertzelCoeff = new float[BarCount]; + private float[] _goertzelFreqs = new float[BarCount]; // For debug/tuning + private bool _coeffReady = false; + private int _lastSampleRate = 0; + + + // Per-bin gain compensation (higher freqs need more boost) + private float[] _binGain = new float[BarCount]; + + private SKPaint _paintBar; + private SKPaint _paintDot; + private SKPaint _paintText; + private SKPaint _paintBg; + + private void InitCoefficients(int sampleRate) + { + if (_coeffReady && sampleRate == _lastSampleRate) + return; + + _lastSampleRate = sampleRate; + + // Logarithmic frequency mapping: 60Hz to 8000Hz + float minFreq = 60f; + float maxFreq = 8000f; + float logMin = (float)Math.Log(minFreq); + float logMax = (float)Math.Log(maxFreq); + + for (int i = 0; i < BarCount; i++) + { + float t = i / (float)(BarCount - 1); + float freq = (float)Math.Exp(logMin + t * (logMax - logMin)); + _goertzelFreqs[i] = freq; + + // Goertzel: k = round(N * freq / sampleRate) + // coeff = 2 * cos(2*pi*k/N) + float k = AnalysisSize * freq / sampleRate; + _goertzelCoeff[i] = 2f * (float)Math.Cos(2.0 * Math.PI * k / AnalysisSize); + + // Higher frequency bins get more gain to compensate + // for natural spectral rolloff in music + _binGain[i] = 1.0f + t * 2.5f; + } + + _coeffReady = true; + } + + public void AddSample(AudioSample sample) + { + int sampleRate = sample.SampleRate > 0 ? sample.SampleRate : 44100; + InitCoefficients(sampleRate); + + int sampleCount = sample.Data.Length / 2; + + // Use at most AnalysisSize samples from the end of the buffer (most recent audio) + int analyzeCount = Math.Min(sampleCount, AnalysisSize); + int startOffset = (sampleCount - analyzeCount) * 2; // byte offset + + // Goertzel for each frequency bin + float maxPower = 0f; + + for (int bin = 0; bin < BarCount; bin++) + { + float coeff = _goertzelCoeff[bin]; + float s1 = 0f, s2 = 0f; + + for (int i = 0; i < analyzeCount; i++) + { + int byteIndex = startOffset + i * 2; + if (byteIndex + 1 >= sample.Data.Length) + break; + + short pcm = (short)(sample.Data[byteIndex] | (sample.Data[byteIndex + 1] << 8)); + float x = pcm / 32768f; + + float s0 = x + coeff * s1 - s2; + s2 = s1; + s1 = s0; + } + + // Power = s1^2 + s2^2 - coeff*s1*s2, apply per-bin gain + float power = (s1 * s1 + s2 * s2 - coeff * s1 * s2) * _binGain[bin]; + if (power < 0) power = 0; // Safety clamp + + _barsBackBuffer[bin] = power; + if (power > maxPower) maxPower = power; + } + + // Absolute noise gate + relative cutoff, then progressive boost for ON bars + float cutoff = maxPower * CutoffRatio; + bool gated = maxPower < NoiseGate; // Silence when just ambient noise + + for (int bin = 0; bin < BarCount; bin++) + { + float power = _barsBackBuffer[bin]; + + float target; + if (gated || power < cutoff) + { + target = 0f; // Completely OFF + } + else + { + // Normalize to 0..1 relative to max, remapping from cutoff..max + float normalized = (power - cutoff) / (maxPower - cutoff); + // Square root curve: compresses range so mid-level bins + // appear tall while keeping proportions between ON bars + target = (float)Math.Sqrt(normalized); + } + + // Instant attack, smooth release + float current = _barsFrontBuffer[bin]; + if (target > current) + _barsBackBuffer[bin] = target; + else + _barsBackBuffer[bin] = Math.Max(current * ReleaseCoeff, target); + + // Peak hold + if (_barsBackBuffer[bin] > _peakHold[bin]) + _peakHold[bin] = _barsBackBuffer[bin]; + else + _peakHold[bin] *= PeakDecay; + } + + System.Threading.Interlocked.Exchange(ref _swapRequested, 1); + } + + public void Render(SKCanvas canvas, float width, float height, float scale, string recognizedText = null) + { + if (_paintBar == null) + { + _paintBar = new SKPaint + { + Style = SKPaintStyle.Fill, + IsAntialias = false + }; + } + + if (_paintDot == null) + { + _paintDot = new SKPaint + { + Style = SKPaintStyle.Fill, + IsAntialias = false + }; + } + + if (_paintText == null) + { + _paintText = new SKPaint + { + Color = SKColors.Yellow, + IsAntialias = true, + TextAlign = SKTextAlign.Center + }; + } + + if (_paintBg == null) + { + _paintBg = new SKPaint + { + Color = SKColors.Black.WithAlpha(128), + Style = SKPaintStyle.Fill + }; + } + + // Swap buffers + if (System.Threading.Interlocked.CompareExchange(ref _swapRequested, 0, 1) == 1) + { + Array.Copy(_barsBackBuffer, _barsFrontBuffer, BarCount); + Array.Copy(_peakHold, _peakHoldFront, BarCount); + } + + var areaWidth = width * 0.85f; + var maxBarHeight = 150 * scale; + var startX = (width - areaWidth) / 2; + var bottomY = height - 40 * scale; + var topY = bottomY - maxBarHeight; + + if (!string.IsNullOrEmpty(recognizedText)) + { + _paintText.TextSize = 32 * scale; + canvas.DrawText(recognizedText, width / 2, topY - 20 * scale, _paintText); + } + + // Background + canvas.DrawRoundRect( + startX - 8 * scale, topY - 8 * scale, + areaWidth + 16 * scale, maxBarHeight + 16 * scale, + 6 * scale, 6 * scale, + _paintBg); + + var totalSlot = areaWidth / BarCount; + var barWidth = Math.Max(1f, totalSlot * 0.55f); + + // Clip all bar drawing to the background area + canvas.Save(); + canvas.ClipRect(new SKRect(startX, topY, startX + areaWidth, bottomY)); + + if (Skin == 0) + { + // Skin 0: White/gray bars with floating peak dots + for (int i = 0; i < BarCount; i++) + { + var level = _barsFrontBuffer[i]; + var peakLevel = _peakHoldFront[i]; + var x = startX + i * totalSlot + (totalSlot - barWidth) / 2; + + // Only draw bars that are actually ON + if (level > 0.01f) + { + var barH = level * maxBarHeight; + byte barAlpha = (byte)(140 + Math.Min(115, level * 200)); + _paintBar.Color = new SKColor(220, 225, 235, barAlpha); + canvas.DrawRect(x, bottomY - barH, barWidth, barH, _paintBar); + } + + // Floating peak dot + if (ShowPeakDots && peakLevel > 0.03f) + { + var dotY = bottomY - peakLevel * maxBarHeight; + var dotH = 2f * scale; + _paintDot.Color = SKColors.White; + canvas.DrawRect(x, dotY - dotH, barWidth, dotH, _paintDot); + } + } + } + else + { + // Skin 1: Colored bars, hue based on frequency position + for (int i = 0; i < BarCount; i++) + { + var level = _barsFrontBuffer[i]; + var peakLevel = _peakHoldFront[i]; + var x = startX + i * totalSlot + (totalSlot - barWidth) / 2; + + if (level > 0.01f) + { + var barH = level * maxBarHeight; + float hue = (i / (float)(BarCount - 1)) * 200 + 160; // blue->cyan->green + if (hue >= 360) hue -= 360; + _paintBar.Color = SKColor.FromHsv(hue, 80, 100); + canvas.DrawRect(x, bottomY - barH, barWidth, barH, _paintBar); + } + + if (ShowPeakDots && peakLevel > 0.03f) + { + var dotY = bottomY - peakLevel * maxBarHeight; + var dotH = 2f * scale; + _paintDot.Color = SKColors.White; + canvas.DrawRect(x, dotY - dotH, barWidth, dotH, _paintDot); + } + } + } + + canvas.Restore(); + } + + public void Dispose() + { + _paintBar?.Dispose(); + _paintBar = null; + _paintDot?.Dispose(); + _paintDot = null; + _paintText?.Dispose(); + _paintText = null; + _paintBg?.Dispose(); + _paintBg = null; + } + } +} diff --git a/src/Maui/Samples/Camera/Visualizers/AudioWaveformBars.cs b/src/Maui/Samples/Camera/Visualizers/AudioWaveformBars.cs new file mode 100644 index 00000000..a8de17ec --- /dev/null +++ b/src/Maui/Samples/Camera/Visualizers/AudioWaveformBars.cs @@ -0,0 +1,192 @@ +using DrawnUi.Camera; + +namespace CameraTests +{ + /// + /// Scrolling waveform bars visualizer (music player style, like SoundCloud/Spotify). + /// Displays many thin vertical bars growing symmetrically from center. + /// New audio scrolls in from the right, older bars fade on the left. + /// ZERO allocations during render, double-buffered. + /// + public class AudioWaveformBars : IAudioVisualizer, IDisposable + { + private const int BarCount = 64; + + private float[] _barsFrontBuffer = new float[BarCount]; + private float[] _barsBackBuffer = new float[BarCount]; + private int _swapRequested = 0; + + // Rolling accumulator for incoming samples + private int _samplesAccumulated = 0; + private float _currentPeak = 0f; + + // How many raw PCM samples per bar (controls scroll speed) + // At 44100Hz with ~30fps render, each frame ~1470 samples. + // We want roughly 2-4 bars per frame for smooth scrolling. + private int _samplesPerBar = 512; + + public bool UseGain { get; set; } = true; + public int Skin { get; set; } = 0; + + private SKPaint _paintBar; + private SKPaint _paintText; + private SKPaint _paintBg; + + public void AddSample(AudioSample sample) + { + int sampleCount = sample.Data.Length / 2; + float gain = UseGain ? 3.5f : 1.0f; + + for (int i = 0; i < sampleCount; i++) + { + int byteIndex = i * 2; + if (byteIndex + 1 >= sample.Data.Length) + break; + + short pcm = (short)(sample.Data[byteIndex] | (sample.Data[byteIndex + 1] << 8)); + float val = Math.Abs(pcm / 32768f) * gain; + + if (val > _currentPeak) + _currentPeak = val; + + _samplesAccumulated++; + + if (_samplesAccumulated >= _samplesPerBar) + { + // Shift all bars left by one + Array.Copy(_barsBackBuffer, 1, _barsBackBuffer, 0, BarCount - 1); + + // Push new peak value at the right end + _barsBackBuffer[BarCount - 1] = Math.Clamp(_currentPeak, 0f, 1f); + + _currentPeak = 0f; + _samplesAccumulated = 0; + + System.Threading.Interlocked.Exchange(ref _swapRequested, 1); + } + } + } + + public void Render(SKCanvas canvas, float width, float height, float scale, string recognizedText = null) + { + if (_paintBar == null) + { + _paintBar = new SKPaint + { + Style = SKPaintStyle.Fill, + IsAntialias = false + }; + } + + if (_paintText == null) + { + _paintText = new SKPaint + { + Color = SKColors.Yellow, + IsAntialias = true, + TextAlign = SKTextAlign.Center + }; + } + + if (_paintBg == null) + { + _paintBg = new SKPaint + { + Color = SKColors.Black.WithAlpha(128), + Style = SKPaintStyle.Fill + }; + } + + // Swap buffers + if (System.Threading.Interlocked.CompareExchange(ref _swapRequested, 0, 1) == 1) + { + var temp = _barsFrontBuffer; + _barsFrontBuffer = _barsBackBuffer; + _barsBackBuffer = temp; + } + + var areaWidth = width * 0.85f; + var areaHeight = 120 * scale; + var startX = (width - areaWidth) / 2; + var centerY = height - 40 * scale - areaHeight / 2; + + if (!string.IsNullOrEmpty(recognizedText)) + { + _paintText.TextSize = 32 * scale; + canvas.DrawText(recognizedText, width / 2, centerY - areaHeight / 2 - 20 * scale, _paintText); + } + + // Background + canvas.DrawRoundRect( + startX - 8 * scale, + centerY - areaHeight / 2 - 8 * scale, + areaWidth + 16 * scale, + areaHeight + 16 * scale, + 8 * scale, 8 * scale, + _paintBg); + + // Bar dimensions + var totalBarSlot = areaWidth / BarCount; + var barWidth = Math.Max(1f, totalBarSlot * 0.6f); + var barGap = totalBarSlot - barWidth; + var halfHeight = areaHeight / 2; + var minBarHeight = 2f * scale; // Minimum dot size for silence + + if (Skin == 0) + { + // Skin 0: Symmetrical bars from center, white with opacity fade + for (int i = 0; i < BarCount; i++) + { + var level = _barsFrontBuffer[i]; + var x = startX + i * totalBarSlot + barGap / 2; + + // Opacity: older bars (left) fade out + byte alpha = (byte)(100 + (155f * i / (BarCount - 1))); + + var barH = Math.Max(minBarHeight, level * halfHeight); + + _paintBar.Color = SKColors.White.WithAlpha(alpha); + + // Top half (grows upward from center) + canvas.DrawRect(x, centerY - barH, barWidth, barH, _paintBar); + + // Bottom half (mirror, grows downward from center) + canvas.DrawRect(x, centerY, barWidth, barH, _paintBar); + } + } + else + { + // Skin 1: Single-sided bars from bottom, colored gradient + var bottomY = centerY + halfHeight; + + for (int i = 0; i < BarCount; i++) + { + var level = _barsFrontBuffer[i]; + var x = startX + i * totalBarSlot + barGap / 2; + + byte alpha = (byte)(100 + (155f * i / (BarCount - 1))); + + var barH = Math.Max(minBarHeight, level * areaHeight); + + // Hue shifts from purple (left/old) to cyan (right/new) + float hue = 220 + (140f * i / (BarCount - 1)); // 220=blue -> 360=red wrap + if (hue >= 360) hue -= 360; + + _paintBar.Color = SKColor.FromHsv(hue, 70, 100).WithAlpha(alpha); + + canvas.DrawRect(x, bottomY - barH, barWidth, barH, _paintBar); + } + } + } + + public void Dispose() + { + _paintBar?.Dispose(); + _paintBar = null; + _paintText?.Dispose(); + _paintText = null; + _paintBg?.Dispose(); + _paintBg = null; + } + } +} diff --git a/src/Maui/Samples/Sandbox/Game/SpaceShooter.xaml b/src/Maui/Samples/Sandbox/Game/SpaceShooter.xaml index e4d99542..5cd8ca49 100644 --- a/src/Maui/Samples/Sandbox/Game/SpaceShooter.xaml +++ b/src/Maui/Samples/Sandbox/Game/SpaceShooter.xaml @@ -1,11 +1,11 @@  - - +