Spectrum Visualizer for player & Layout

This commit is contained in:
daniel-c-harvey
2025-12-07 11:18:32 -05:00
parent c5fdf12ef4
commit 75456a59ce
16 changed files with 712 additions and 110 deletions
@@ -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">
+2 -2
View File
@@ -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();
}
+41
View File
@@ -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;
}
}
+46
View File
@@ -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 {