Spectrum Visualizer for player & Layout
This commit is contained in:
@@ -15,81 +15,92 @@ else
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="player-inner-container">
|
||||
<div class="player-backdrop pa-3">
|
||||
|
||||
@* Desktop Layout *@
|
||||
<div class="d-none d-md-flex align-center gap-3">
|
||||
<div class="controls-left d-flex flex-column align-center gap-2">
|
||||
<div class="d-flex align-center gap-1">
|
||||
<PlayerControls IsPlaying="IsPlaying"
|
||||
IsLoaded="IsLoaded"
|
||||
TogglePlayPause="@TogglePlayPause"
|
||||
Stop="@Stop"/>
|
||||
@if (IsLoading && !IsStreaming)
|
||||
{
|
||||
<MudProgressCircular Color="Color.Tertiary"
|
||||
Size="Size.Small"
|
||||
Max="1D"
|
||||
Value="@LoadProgress"
|
||||
Indeterminate="@(LoadProgress == 0)"/>
|
||||
}
|
||||
@if (_isDesktop)
|
||||
{
|
||||
@* Desktop Layout *@
|
||||
<div class="d-flex align-center gap-3">
|
||||
<div class="controls-left d-flex flex-column align-center gap-2">
|
||||
<div class="d-flex align-center gap-1">
|
||||
<PlayerControls IsPlaying="IsPlaying"
|
||||
IsLoaded="IsLoaded"
|
||||
TogglePlayPause="@TogglePlayPause"
|
||||
Stop="@Stop"/>
|
||||
@if (IsLoading && !IsStreaming)
|
||||
{
|
||||
<MudProgressCircular Color="Color.Tertiary"
|
||||
Size="Size.Small"
|
||||
Max="1D"
|
||||
Value="@LoadProgress"
|
||||
Indeterminate="@(LoadProgress == 0)"/>
|
||||
}
|
||||
</div>
|
||||
<TimestampLabel CurrentTime="DisplayTime" Duration="Duration"/>
|
||||
</div>
|
||||
<TimestampLabel CurrentTime="DisplayTime" Duration="Duration"/>
|
||||
</div>
|
||||
|
||||
<div class="seekbar-flex mx-3"
|
||||
@onpointerdown="OnSeekStart"
|
||||
@onpointerup="@(() => OnSeekEnd(_seekPosition))"
|
||||
@onpointerleave="@(() => { if (_isSeeking) OnSeekEnd(_seekPosition); })">
|
||||
<MudSlider T="double"
|
||||
Min="0"
|
||||
Max="@(Duration ?? 0D)"
|
||||
Step="0.1"
|
||||
Value="@DisplayTime"
|
||||
ValueChanged="@OnSeekChange"
|
||||
Immediate="true"
|
||||
Disabled="@(!CanSeek)"/>
|
||||
</div>
|
||||
|
||||
<div class="volume-right">
|
||||
<VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Mobile Layout *@
|
||||
<div class="d-md-none">
|
||||
<div class="d-flex align-center justify-space-between mb-3">
|
||||
<div class="d-flex align-center gap-2">
|
||||
<PlayerControls IsPlaying="IsPlaying"
|
||||
IsLoaded="IsLoaded"
|
||||
TogglePlayPause="@TogglePlayPause"
|
||||
Stop="@Stop"/>
|
||||
@if (IsLoading && !IsStreaming)
|
||||
{
|
||||
<MudProgressCircular Color="Color.Tertiary"
|
||||
Size="Size.Small"
|
||||
Max="1D"
|
||||
Value="@LoadProgress"
|
||||
Indeterminate="@(LoadProgress == 0)"/>
|
||||
}
|
||||
<div class="d-flex flex-column flex-grow-1">
|
||||
<div class="seekbar-flex mx-3"
|
||||
@onpointerdown="OnSeekStart"
|
||||
@onpointerup="@(() => OnSeekEnd(_seekPosition))"
|
||||
@onpointerleave="@(() => { if (_isSeeking) OnSeekEnd(_seekPosition); })">
|
||||
<MudSlider T="double"
|
||||
Min="0"
|
||||
Max="@(Duration ?? 0D)"
|
||||
Step="0.1"
|
||||
Value="@DisplayTime"
|
||||
ValueChanged="@OnSeekChange"
|
||||
Immediate="true"
|
||||
Disabled="@(!CanSeek)"/>
|
||||
</div>
|
||||
<SpectrumVisualizer />
|
||||
</div>
|
||||
<TimestampLabel CurrentTime="DisplayTime" Duration="Duration"/>
|
||||
<VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
|
||||
</div>
|
||||
|
||||
<div @onpointerdown="OnSeekStart"
|
||||
@onpointerup="@(() => OnSeekEnd(_seekPosition))"
|
||||
@onpointerleave="@(() => { if (_isSeeking) OnSeekEnd(_seekPosition); })">
|
||||
<MudSlider T="double"
|
||||
Min="0"
|
||||
Max="@(Duration ?? 0D)"
|
||||
Step="0.1"
|
||||
Value="@DisplayTime"
|
||||
ValueChanged="@OnSeekChange"
|
||||
Immediate="true"
|
||||
Disabled="@(!CanSeek)"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Control Buttons - positioned absolutely like original *@
|
||||
<div class="volume-right">
|
||||
<VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Mobile Layout *@
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between mb-3">
|
||||
<div class="d-flex align-center gap-2">
|
||||
<PlayerControls IsPlaying="IsPlaying"
|
||||
IsLoaded="IsLoaded"
|
||||
TogglePlayPause="@TogglePlayPause"
|
||||
Stop="@Stop"/>
|
||||
@if (IsLoading && !IsStreaming)
|
||||
{
|
||||
<MudProgressCircular Color="Color.Tertiary"
|
||||
Size="Size.Small"
|
||||
Max="1D"
|
||||
Value="@LoadProgress"
|
||||
Indeterminate="@(LoadProgress == 0)"/>
|
||||
}
|
||||
</div>
|
||||
<TimestampLabel CurrentTime="DisplayTime" Duration="Duration"/>
|
||||
<VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column flex-grow-1">
|
||||
<div @onpointerdown="OnSeekStart"
|
||||
@onpointerup="@(() => OnSeekEnd(_seekPosition))"
|
||||
@onpointerleave="@(() => { if (_isSeeking) OnSeekEnd(_seekPosition); })">
|
||||
<MudSlider T="double"
|
||||
Min="0"
|
||||
Max="@(Duration ?? 0D)"
|
||||
Step="0.1"
|
||||
Value="@DisplayTime"
|
||||
ValueChanged="@OnSeekChange"
|
||||
Immediate="true"
|
||||
Disabled="@(!CanSeek)"/>
|
||||
</div>
|
||||
<SpectrumVisualizer />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Control Buttons - positioned absolutely like original *@
|
||||
<div class="player-controls d-flex align-center justify-center gap-1">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Minimize"
|
||||
Color="Color.Secondary"
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
using DeepDrftWeb.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using MudBlazor;
|
||||
using MudBlazor.Services;
|
||||
|
||||
namespace DeepDrftWeb.Client.Controls.AudioPlayerBar;
|
||||
|
||||
public partial class AudioPlayerBar : ComponentBase
|
||||
public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
{
|
||||
[CascadingParameter] public required IStreamingPlayerService PlayerService { get; set; }
|
||||
[Inject] private IBrowserViewportService BrowserViewportService { get; set; } = default!;
|
||||
|
||||
private bool _isMinimized = true;
|
||||
private bool _isSeeking = false;
|
||||
private double _seekPosition = 0;
|
||||
private bool _isDesktop = true;
|
||||
private Guid _viewportSubscriptionId;
|
||||
|
||||
private bool IsLoaded => PlayerService.IsLoaded;
|
||||
private bool IsLoading => PlayerService.IsLoading;
|
||||
@@ -132,4 +136,31 @@ public partial class AudioPlayerBar : ComponentBase
|
||||
{
|
||||
return IsPlaying ? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
var breakpoint = await BrowserViewportService.GetCurrentBreakpointAsync();
|
||||
_isDesktop = breakpoint >= Breakpoint.Sm;
|
||||
|
||||
_viewportSubscriptionId = Guid.NewGuid();
|
||||
await BrowserViewportService.SubscribeAsync(
|
||||
_viewportSubscriptionId,
|
||||
args =>
|
||||
{
|
||||
_isDesktop = args.Breakpoint >= Breakpoint.Sm;
|
||||
InvokeAsync(StateHasChanged);
|
||||
},
|
||||
new ResizeOptions { NotifyOnBreakpointOnly = true },
|
||||
fireImmediately: true);
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await BrowserViewportService.UnsubscribeAsync(_viewportSubscriptionId);
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,12 @@
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.seekbar-visualizer-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.seekbar-flex {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
@namespace DeepDrftWeb.Client.Controls.AudioPlayerBar
|
||||
|
||||
<div class="spectrum-container @(IsVisible ? "" : "hidden")">
|
||||
<div class="spectrum-bars">
|
||||
@for (int i = 0; i < BucketCount; i++)
|
||||
{
|
||||
var index = i;
|
||||
var height = GetBarHeight(index);
|
||||
<div class="spectrum-bar" style="--bar-height: @(height.ToString("F1"))%;"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,114 @@
|
||||
using DeepDrftWeb.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftWeb.Client.Controls.AudioPlayerBar;
|
||||
|
||||
public partial class SpectrumVisualizer : ComponentBase, IAsyncDisposable
|
||||
{
|
||||
[Inject] public required AudioInteropService AudioInterop { get; set; }
|
||||
|
||||
[CascadingParameter] public required IStreamingPlayerService PlayerService { get; set; }
|
||||
|
||||
[Parameter] public int BucketCount { get; set; } = 32;
|
||||
|
||||
private readonly string _instanceId = Guid.NewGuid().ToString();
|
||||
private double[] _spectrumData = Array.Empty<double>();
|
||||
private bool _isAnimating = false;
|
||||
private string? _playerId;
|
||||
private EventCallback? _originalOnStateChanged;
|
||||
|
||||
private bool IsVisible => PlayerService.IsPlaying || PlayerService.IsPaused || _isAnimating;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_spectrumData = new double[BucketCount];
|
||||
|
||||
// Get the player ID from the service
|
||||
if (PlayerService is AudioPlayerService baseService)
|
||||
{
|
||||
_playerId = baseService.PlayerId;
|
||||
}
|
||||
|
||||
// Chain into the existing OnStateChanged callback to detect play/pause
|
||||
_originalOnStateChanged = PlayerService.OnStateChanged;
|
||||
PlayerService.OnStateChanged = new EventCallback(this, async () =>
|
||||
{
|
||||
// Call original callback first
|
||||
if (_originalOnStateChanged.HasValue)
|
||||
{
|
||||
await _originalOnStateChanged.Value.InvokeAsync();
|
||||
}
|
||||
// Then update our animation state
|
||||
await UpdateAnimationState();
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
// Initial check in case already playing
|
||||
await UpdateAnimationState();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateAnimationState()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_playerId)) return;
|
||||
|
||||
var shouldAnimate = PlayerService.IsPlaying;
|
||||
|
||||
if (shouldAnimate && !_isAnimating)
|
||||
{
|
||||
await StartAnimation();
|
||||
}
|
||||
else if (!shouldAnimate && _isAnimating)
|
||||
{
|
||||
await StopAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartAnimation()
|
||||
{
|
||||
if (_isAnimating || string.IsNullOrEmpty(_playerId)) return;
|
||||
|
||||
_isAnimating = true;
|
||||
await AudioInterop.StartSpectrumAnimationAsync(_playerId, _instanceId, OnSpectrumData);
|
||||
}
|
||||
|
||||
private async Task StopAnimation()
|
||||
{
|
||||
if (!_isAnimating || string.IsNullOrEmpty(_playerId)) return;
|
||||
|
||||
_isAnimating = false;
|
||||
await AudioInterop.StopSpectrumAnimationAsync(_playerId, _instanceId);
|
||||
|
||||
// Clear the display
|
||||
Array.Clear(_spectrumData);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private Task OnSpectrumData(double[] data)
|
||||
{
|
||||
if (data.Length > 0)
|
||||
{
|
||||
_spectrumData = data;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private double GetBarHeight(int index)
|
||||
{
|
||||
if (index >= _spectrumData.Length) return 0;
|
||||
|
||||
// Scale to 0-100 percentage, with minimum height for visual appeal
|
||||
var value = _spectrumData[index];
|
||||
return Math.Max(2, value * 100);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopAnimation();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.spectrum-container {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spectrum-container.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.spectrum-bars {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
height: 100%;
|
||||
gap: 2px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.spectrum-bar {
|
||||
flex: 1;
|
||||
margin: 0 auto;
|
||||
max-width: 8px;
|
||||
min-width: 4px;
|
||||
height: var(--bar-height, 2%);
|
||||
min-height: 2px;
|
||||
background: var(--deepdrft-theme-secondary, #8A2BE2);
|
||||
border-radius: 2px 2px 0 0;
|
||||
transition: height 0.05s ease-out;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.spectrum-container {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.spectrum-bars {
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.spectrum-bar {
|
||||
max-width: 8px;
|
||||
min-width: 3px;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="tracks-gallery-container">
|
||||
<MudGrid Spacing="3" Justify="Justify.Center">
|
||||
<MudGrid Spacing="6" Justify="Justify.Center">
|
||||
@foreach (var track in Tracks)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
<MudLayout>
|
||||
<MudLayout Style="display: flex; flex-direction: column; min-height: 100vh">
|
||||
<AudioPlayerProvider>
|
||||
<MudAppBar Elevation="_themeManager.AppBarElevation">
|
||||
<MudAvatar Class="mr-2">
|
||||
@@ -16,7 +16,7 @@
|
||||
<MudSpacer/>
|
||||
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
|
||||
</MudAppBar>
|
||||
<MudMainContent Class="pt-16 deepdrft-layout-with-overlay-player">
|
||||
<MudMainContent Class="flex-grow-1 pt-16 pb-8">
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
|
||||
@Body
|
||||
</MudContainer>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
@@ -1,28 +1,23 @@
|
||||
.tracks-page-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100dvh - 80px); /* Subtract app bar height (pt-16 = 4rem = 64px) */
|
||||
/*margin: -16px; !* Counteract MudMainContent padding *!*/
|
||||
padding-top: 16px; /* Restore top padding for spacing */
|
||||
}
|
||||
|
||||
.tracks-view-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
padding: 0 16px; /* Horizontal padding only */
|
||||
}
|
||||
|
||||
.tracks-content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.tracks-footer {
|
||||
flex: 0 0 auto;
|
||||
flex: 0 0;
|
||||
padding: 8px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace DeepDrftWeb.Client.Services;
|
||||
public class AudioInteropService : IAsyncDisposable
|
||||
{
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
private readonly Dictionary<string, DotNetObjectReference<AudioPlayerCallback>> _callbacks = new();
|
||||
private readonly Dictionary<string, IDisposable> _callbacks = new();
|
||||
|
||||
public AudioInteropService(IJSRuntime jsRuntime)
|
||||
{
|
||||
@@ -153,10 +153,66 @@ public class AudioInteropService : IAsyncDisposable
|
||||
|
||||
public async Task<AudioOperationResult> SetOnEndCallbackAsync(string playerId, Func<Task> callback)
|
||||
{
|
||||
return await SetCallbackAsync(playerId, "_end", "setOnEndCallback", "OnEndCallback",
|
||||
return await SetCallbackAsync(playerId, "_end", "setOnEndCallback", "OnEndCallback",
|
||||
wrapper => wrapper.OnEnd = callback);
|
||||
}
|
||||
|
||||
// Spectrum analyzer methods
|
||||
public async Task<double[]?> GetSpectrumDataAsync(string playerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _jsRuntime.InvokeAsync<double[]>("DeepDrftAudio.getSpectrumData", playerId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> SetSpectrumHighPassAsync(string playerId, double freq)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setSpectrumHighPass", playerId, freq);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> SetSpectrumLowPassAsync(string playerId, double freq)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setSpectrumLowPass", playerId, freq);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> SetSpectrumSlopeAsync(string playerId, double dbPerDecade)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setSpectrumSlope", playerId, dbPerDecade);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> StartSpectrumAnimationAsync(string playerId, string callbackId, Func<double[], Task> callback)
|
||||
{
|
||||
try
|
||||
{
|
||||
var callbackWrapper = new SpectrumCallback { OnData = callback };
|
||||
var dotNetObjectRef = DotNetObjectReference.Create(callbackWrapper);
|
||||
_callbacks[playerId + "_spectrum_" + callbackId] = dotNetObjectRef;
|
||||
|
||||
return await _jsRuntime.InvokeAsync<AudioOperationResult>(
|
||||
"DeepDrftAudio.startSpectrumAnimation",
|
||||
playerId, callbackId, dotNetObjectRef, "OnSpectrumDataCallback");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AudioOperationResult { Success = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> StopSpectrumAnimationAsync(string playerId, string callbackId)
|
||||
{
|
||||
var key = playerId + "_spectrum_" + callbackId;
|
||||
if (_callbacks.TryGetValue(key, out var callback))
|
||||
{
|
||||
callback?.Dispose();
|
||||
_callbacks.Remove(key);
|
||||
}
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.stopSpectrumAnimation", playerId, callbackId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> DisposePlayerAsync(string playerId)
|
||||
{
|
||||
@@ -243,6 +299,18 @@ public class AudioPlayerCallback
|
||||
}
|
||||
}
|
||||
|
||||
public class SpectrumCallback
|
||||
{
|
||||
public Func<double[], Task>? OnData { get; set; }
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnSpectrumDataCallback(double[] data)
|
||||
{
|
||||
if (OnData != null)
|
||||
await OnData(data);
|
||||
}
|
||||
}
|
||||
|
||||
public class AudioOperationResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
|
||||
@@ -2,10 +2,20 @@
|
||||
* AudioContextManager - Manages the Web Audio API AudioContext and GainNode.
|
||||
*
|
||||
* Single Responsibility: AudioContext lifecycle and audio routing.
|
||||
*
|
||||
* Audio chain: Source → GainNode → AnalyserNode → destination
|
||||
*/
|
||||
|
||||
import { SpectrumAnalyzer } from './SpectrumAnalyzer.js';
|
||||
|
||||
export class AudioContextManager {
|
||||
private audioContext: AudioContext | null = null;
|
||||
private gainNode: GainNode | null = null;
|
||||
private spectrumAnalyzer: SpectrumAnalyzer;
|
||||
|
||||
constructor() {
|
||||
this.spectrumAnalyzer = new SpectrumAnalyzer();
|
||||
}
|
||||
|
||||
async initialize(sampleRate: number = 44100): Promise<void> {
|
||||
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
|
||||
@@ -15,7 +25,12 @@ export class AudioContextManager {
|
||||
|
||||
this.audioContext = new AudioContextClass({ sampleRate });
|
||||
this.gainNode = this.audioContext.createGain();
|
||||
this.gainNode.connect(this.audioContext.destination);
|
||||
|
||||
// Initialize spectrum analyzer and insert into chain
|
||||
// Chain: Source → GainNode → AnalyserNode → destination
|
||||
const analyserNode = this.spectrumAnalyzer.initialize(this.audioContext);
|
||||
this.gainNode.connect(analyserNode);
|
||||
analyserNode.connect(this.audioContext.destination);
|
||||
|
||||
console.log(`AudioContext initialized: sampleRate=${this.audioContext.sampleRate}Hz, state=${this.audioContext.state}`);
|
||||
}
|
||||
@@ -88,7 +103,12 @@ export class AudioContextManager {
|
||||
return this.audioContext.decodeAudioData(buffer);
|
||||
}
|
||||
|
||||
getSpectrumAnalyzer(): SpectrumAnalyzer {
|
||||
return this.spectrumAnalyzer;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.spectrumAnalyzer.dispose();
|
||||
if (this.audioContext && this.audioContext.state !== 'closed') {
|
||||
this.audioContext.close();
|
||||
}
|
||||
|
||||
@@ -389,6 +389,47 @@ export class AudioPlayer {
|
||||
this.onEndCallback = callback;
|
||||
}
|
||||
|
||||
// ==================== Spectrum Analysis ====================
|
||||
|
||||
getSpectrumData(): number[] {
|
||||
return this.contextManager.getSpectrumAnalyzer().getFrequencyData();
|
||||
}
|
||||
|
||||
setSpectrumHighPass(freq: number): AudioResult {
|
||||
try {
|
||||
this.contextManager.getSpectrumAnalyzer().setHighPass(freq);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
setSpectrumLowPass(freq: number): AudioResult {
|
||||
try {
|
||||
this.contextManager.getSpectrumAnalyzer().setLowPass(freq);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
setSpectrumSlope(dbPerDecade: number): AudioResult {
|
||||
try {
|
||||
this.contextManager.getSpectrumAnalyzer().setSlopeCorrection(dbPerDecade);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
startSpectrumAnimation(callbackId: string, callback: (data: number[]) => void): void {
|
||||
this.contextManager.getSpectrumAnalyzer().addCallback(callbackId, callback);
|
||||
}
|
||||
|
||||
stopSpectrumAnimation(callbackId: string): void {
|
||||
this.contextManager.getSpectrumAnalyzer().removeCallback(callbackId);
|
||||
}
|
||||
|
||||
// ==================== Private Methods ====================
|
||||
|
||||
private resetState(): void {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* SpectrumAnalyzer - Manages FFT analysis with filtering and slope correction.
|
||||
*
|
||||
* Single Responsibility: FFT analysis, frequency bucketing, and visual processing filters.
|
||||
*/
|
||||
|
||||
export interface SpectrumConfig {
|
||||
bucketCount: number;
|
||||
highPassFreq: number; // Hz, 0 = disabled
|
||||
lowPassFreq: number; // Hz
|
||||
slopeDb: number; // dB/decade correction
|
||||
}
|
||||
|
||||
export class SpectrumAnalyzer {
|
||||
private analyser: AnalyserNode | null = null;
|
||||
private audioContext: AudioContext | null = null;
|
||||
private fftSize: number = 2048;
|
||||
private dataArray: Float32Array<ArrayBuffer> | null = null;
|
||||
|
||||
// Configuration
|
||||
private bucketCount: number = 32;
|
||||
private highPassFreq: number = 0;
|
||||
private lowPassFreq: number = 20000;
|
||||
private slopeDb: number = 0;
|
||||
|
||||
// Animation state - supports multiple callbacks per player
|
||||
private animationId: number | null = null;
|
||||
private callbacks = new Map<string, (data: number[]) => void>();
|
||||
private lastFrameTime: number = 0;
|
||||
private targetFrameInterval: number = 1000 / 30; // ~30fps for smooth visuals without excessive interop
|
||||
|
||||
initialize(context: AudioContext): AnalyserNode {
|
||||
this.audioContext = context;
|
||||
this.analyser = context.createAnalyser();
|
||||
this.analyser.fftSize = this.fftSize;
|
||||
this.analyser.smoothingTimeConstant = 0.8;
|
||||
this.dataArray = new Float32Array(this.analyser.frequencyBinCount);
|
||||
|
||||
console.log(`SpectrumAnalyzer initialized: fftSize=${this.fftSize}, bins=${this.analyser.frequencyBinCount}`);
|
||||
return this.analyser;
|
||||
}
|
||||
|
||||
getAnalyserNode(): AnalyserNode | null {
|
||||
return this.analyser;
|
||||
}
|
||||
|
||||
setConfig(config: Partial<SpectrumConfig>): void {
|
||||
if (config.bucketCount !== undefined) this.bucketCount = config.bucketCount;
|
||||
if (config.highPassFreq !== undefined) this.highPassFreq = config.highPassFreq;
|
||||
if (config.lowPassFreq !== undefined) this.lowPassFreq = config.lowPassFreq;
|
||||
if (config.slopeDb !== undefined) this.slopeDb = config.slopeDb;
|
||||
}
|
||||
|
||||
setHighPass(freq: number): void {
|
||||
this.highPassFreq = Math.max(0, freq);
|
||||
}
|
||||
|
||||
setLowPass(freq: number): void {
|
||||
this.lowPassFreq = Math.max(20, freq);
|
||||
}
|
||||
|
||||
setSlopeCorrection(dbPerDecade: number): void {
|
||||
this.slopeDb = dbPerDecade;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get frequency data as normalized values (0-1) for each bucket
|
||||
*/
|
||||
getFrequencyData(): number[] {
|
||||
if (!this.analyser || !this.dataArray || !this.audioContext) {
|
||||
return new Array(this.bucketCount).fill(0);
|
||||
}
|
||||
|
||||
// Get raw FFT data (in dB, typically -100 to 0)
|
||||
this.analyser.getFloatFrequencyData(this.dataArray);
|
||||
|
||||
const nyquist = this.audioContext.sampleRate / 2;
|
||||
const binCount = this.dataArray.length;
|
||||
const buckets: number[] = new Array(this.bucketCount).fill(0);
|
||||
|
||||
// Logarithmic frequency mapping for perceptual balance
|
||||
// Map 20Hz - 20kHz to buckets using log scale
|
||||
const minFreq = 20;
|
||||
const maxFreq = Math.min(20000, nyquist);
|
||||
const logMin = Math.log10(minFreq);
|
||||
const logMax = Math.log10(maxFreq);
|
||||
const logRange = logMax - logMin;
|
||||
|
||||
for (let bucket = 0; bucket < this.bucketCount; bucket++) {
|
||||
// Calculate frequency range for this bucket
|
||||
const logFreqLow = logMin + (bucket / this.bucketCount) * logRange;
|
||||
const logFreqHigh = logMin + ((bucket + 1) / this.bucketCount) * logRange;
|
||||
const freqLow = Math.pow(10, logFreqLow);
|
||||
const freqHigh = Math.pow(10, logFreqHigh);
|
||||
|
||||
// Map frequencies to FFT bins
|
||||
const binLow = Math.floor((freqLow / nyquist) * binCount);
|
||||
const binHigh = Math.ceil((freqHigh / nyquist) * binCount);
|
||||
|
||||
// Average the bins in this range
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (let bin = binLow; bin < binHigh && bin < binCount; bin++) {
|
||||
const freq = (bin / binCount) * nyquist;
|
||||
let value = this.dataArray[bin];
|
||||
|
||||
// Apply filters
|
||||
value = this.applyFilters(value, freq);
|
||||
|
||||
sum += value;
|
||||
count++;
|
||||
}
|
||||
|
||||
const avgDb = count > 0 ? sum / count : -100;
|
||||
|
||||
// Normalize from dB (-100 to 0) to 0-1 range
|
||||
// Clamp to reasonable range and scale
|
||||
const normalizedDb = Math.max(-80, Math.min(0, avgDb));
|
||||
buckets[bucket] = (normalizedDb + 80) / 80;
|
||||
}
|
||||
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply high-pass, low-pass, and slope correction filters
|
||||
*/
|
||||
private applyFilters(valueDb: number, freq: number): number {
|
||||
// Convert dB to linear for filter math
|
||||
let linear = Math.pow(10, valueDb / 20);
|
||||
|
||||
// High-pass filter (6dB/octave)
|
||||
if (this.highPassFreq > 0 && freq < this.highPassFreq && freq > 0) {
|
||||
const octaves = Math.log2(this.highPassFreq / freq);
|
||||
const attenuation = Math.pow(10, (-6 * octaves) / 20);
|
||||
linear *= attenuation;
|
||||
}
|
||||
|
||||
// Low-pass filter (6dB/octave)
|
||||
if (freq > this.lowPassFreq && this.lowPassFreq > 0) {
|
||||
const octaves = Math.log2(freq / this.lowPassFreq);
|
||||
const attenuation = Math.pow(10, (-6 * octaves) / 20);
|
||||
linear *= attenuation;
|
||||
}
|
||||
|
||||
// Slope correction (dB/decade, referenced to 1kHz)
|
||||
if (this.slopeDb !== 0 && freq > 0) {
|
||||
const decades = Math.log10(freq / 1000);
|
||||
const correction = Math.pow(10, (this.slopeDb * decades) / 20);
|
||||
linear *= correction;
|
||||
}
|
||||
|
||||
// Convert back to dB
|
||||
return linear > 0 ? 20 * Math.log10(linear) : -100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a callback for spectrum data. Starts animation loop on first subscriber.
|
||||
*/
|
||||
addCallback(id: string, callback: (data: number[]) => void): void {
|
||||
const wasEmpty = this.callbacks.size === 0;
|
||||
this.callbacks.set(id, callback);
|
||||
if (wasEmpty) {
|
||||
this.lastFrameTime = 0;
|
||||
this.animationId = requestAnimationFrame(this.animate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a callback by ID. Stops animation loop when no subscribers remain.
|
||||
*/
|
||||
removeCallback(id: string): void {
|
||||
this.callbacks.delete(id);
|
||||
if (this.callbacks.size === 0) {
|
||||
this.stopAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop animation loop
|
||||
*/
|
||||
stopAnimation(): void {
|
||||
if (this.animationId !== null) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
this.animationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private animate = (timestamp: number): void => {
|
||||
if (this.callbacks.size === 0) return;
|
||||
|
||||
// Throttle to target frame rate
|
||||
const elapsed = timestamp - this.lastFrameTime;
|
||||
if (elapsed >= this.targetFrameInterval) {
|
||||
this.lastFrameTime = timestamp - (elapsed % this.targetFrameInterval);
|
||||
const data = this.getFrequencyData();
|
||||
// Broadcast to all callbacks
|
||||
for (const cb of this.callbacks.values()) {
|
||||
cb(data);
|
||||
}
|
||||
}
|
||||
|
||||
this.animationId = requestAnimationFrame(this.animate);
|
||||
};
|
||||
|
||||
dispose(): void {
|
||||
this.stopAnimation();
|
||||
this.callbacks.clear();
|
||||
this.analyser = null;
|
||||
this.audioContext = null;
|
||||
this.dataArray = null;
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,52 @@ const DeepDrftAudio = {
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
// Spectrum analyzer methods
|
||||
getSpectrumData: (playerId: string): number[] | null => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
return player?.getSpectrumData() ?? null;
|
||||
},
|
||||
|
||||
setSpectrumHighPass: (playerId: string, freq: number): AudioResult => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
if (!player) return { success: false, error: 'Player not found' };
|
||||
return player.setSpectrumHighPass(freq);
|
||||
},
|
||||
|
||||
setSpectrumLowPass: (playerId: string, freq: number): AudioResult => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
if (!player) return { success: false, error: 'Player not found' };
|
||||
return player.setSpectrumLowPass(freq);
|
||||
},
|
||||
|
||||
setSpectrumSlope: (playerId: string, dbPerDecade: number): AudioResult => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
if (!player) return { success: false, error: 'Player not found' };
|
||||
return player.setSpectrumSlope(dbPerDecade);
|
||||
},
|
||||
|
||||
startSpectrumAnimation: (
|
||||
playerId: string,
|
||||
callbackId: string,
|
||||
dotNetRef: DotNetObjectReference,
|
||||
methodName: string
|
||||
): AudioResult => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
if (!player) return { success: false, error: 'Player not found' };
|
||||
|
||||
player.startSpectrumAnimation(callbackId, (data: number[]) => {
|
||||
dotNetRef.invokeMethodAsync(methodName, data);
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
stopSpectrumAnimation: (playerId: string, callbackId: string): AudioResult => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
if (!player) return { success: false, error: 'Player not found' };
|
||||
player.stopSpectrumAnimation(callbackId);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
disposePlayer: (playerId: string): AudioResult => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
if (player) {
|
||||
|
||||
@@ -294,29 +294,6 @@ body, p, span, div,
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
||||
/* Layout with overlay audio player - Global layout class */
|
||||
/*.deepdrft-layout-with-overlay-player {*/
|
||||
/* position: relative;*/
|
||||
/* min-height: calc(100vh - 64px);*/
|
||||
/* padding-bottom: 160px; !* Increased space for overlay player *!*/
|
||||
/*}*/
|
||||
|
||||
/*!* Audio player overlay positioning - Global positioning *!*/
|
||||
/*.deepdrft-layout-with-overlay-player > .AudioPlayerBar,*/
|
||||
/*.deepdrft-layout-with-overlay-player > *:last-child {*/
|
||||
/* position: fixed;*/
|
||||
/* bottom: 0;*/
|
||||
/* left: 0;*/
|
||||
/* right: 0;*/
|
||||
/* z-index: 1000;*/
|
||||
/* pointer-events: none;*/
|
||||
/*}*/
|
||||
|
||||
/*.deepdrft-layout-with-overlay-player > *:last-child > * {*/
|
||||
/* pointer-events: auto;*/
|
||||
/*}*/
|
||||
|
||||
/* Responsive Utilities */
|
||||
@media (max-width: 768px) {
|
||||
.deepdrft-hero-text {
|
||||
|
||||
Reference in New Issue
Block a user