@@ -4130,9 +4161,108 @@
- AutoEQ
+ Binaural / Spatial DSP
Precision headphone correction & parametric equalizerMultichannel HRTF rendering for Atmos & 3D Audio, crossfeed for
+ stereo
+
+
+
+
+
+
+ Mode: Stereo
+
+
+
+
+ Auto-enable for Spatial Audio
+ Automatically activate when Atmos or 3D content is detected
+
+
+
+
+
+
+ Crossfeed
+ Simulate speaker presentation on headphones
+
+
+
+
+
+
+ Crossfeed Level
+
+
+
+
+
+
+ HRTF Preset
+ Virtual speaker angle for multichannel rendering
+
+
+
+
+
+
+ Stereo Width
+ Adjust spatial width (0 = mono, 1 = neutral, 2 = wide)
+
+
+
+
+
+
+ Width Amount
+ 1.0
+
+
+
+
+
+
+
+ EQ Studio
+ Multi-mode equalizer with AutoEQ, M/S processing & room
+ correction
diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift
index 88e35b1..7b0e5aa 100644
--- a/ios/App/App/AppDelegate.swift
+++ b/ios/App/App/AppDelegate.swift
@@ -48,10 +48,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
switch type {
case .began:
- // Interruption began — system pauses audio automatically
+ // Interruption began - system pauses audio automatically
break
case .ended:
- // Interruption ended — reactivate session so playback can resume
+ // Interruption ended - reactivate session so playback can resume
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
@@ -75,7 +75,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
if reason == .oldDeviceUnavailable {
- // Headphones/Bluetooth disconnected — reactivate session to keep background alive
+ // Headphones/Bluetooth disconnected - reactivate session to keep background alive
do {
try AVAudioSession.sharedInstance().setActive(true)
} catch {
diff --git a/js/HiFi.ts b/js/HiFi.ts
index df33f49..255968e 100644
--- a/js/HiFi.ts
+++ b/js/HiFi.ts
@@ -108,7 +108,7 @@ export interface TidalArtistProfile {
picture: string | null;
/** Fallback album cover UUID used when no artist picture exists, or `null`. */
selectedAlbumCoverFallback: string | null;
- /** Popularity score (0–100). */
+ /** Popularity score (0-100). */
popularity: number;
/** List of credited roles for this artist. */
artistRoles: TidalArtistRole[];
@@ -150,7 +150,7 @@ export interface TidalTrackAlbumRef {
* Full track object as returned by the `/info` route and embedded in albums, playlists, and mixes.
*
* @remarks
- * Fields `bpm`, `key`, and `keyScale` are nullable — they are absent for some tracks.
+ * Fields `bpm`, `key`, and `keyScale` are nullable - they are absent for some tracks.
* `version` is present in the payload but may be `null`.
*/
export interface TidalTrack {
@@ -162,7 +162,7 @@ export interface TidalTrack {
duration: number;
/** Track replay-gain value in dB. */
replayGain: number;
- /** Track peak amplitude (0–1). */
+ /** Track peak amplitude (0-1). */
peak: number;
/** Whether the track is available for streaming. */
allowStreaming: boolean;
@@ -186,7 +186,7 @@ export interface TidalTrack {
volumeNumber: number;
/** Version suffix (e.g. `"Remastered"`), or `null`. */
version: string | null;
- /** Popularity score (0–100). */
+ /** Popularity score (0-100). */
popularity: number;
/** Copyright notice. */
copyright: string;
@@ -299,7 +299,7 @@ export interface TidalAlbum {
explicit: boolean;
/** UPC barcode. */
upc: string;
- /** Popularity score (0–100). */
+ /** Popularity score (0-100). */
popularity: number;
/** Highest available audio quality. */
audioQuality: string;
@@ -339,7 +339,7 @@ export interface TidalVideoItem {
volumeNumber: number;
/** Track number on the disc. */
trackNumber: number;
- /** Popularity score (0–100). */
+ /** Popularity score (0-100). */
popularity: number;
/** Double-precision popularity score (present in topvideos). */
doublePopularity?: number;
@@ -452,7 +452,7 @@ export interface TidalSimilarAlbum {
releaseDate: string;
/** Copyright information. */
copyright: { text: string };
- /** Popularity score (0–1 float). */
+ /** Popularity score (0-1 float). */
popularity: number;
/** Access type, e.g. `"PUBLIC"`. */
accessType: string;
@@ -533,7 +533,7 @@ export interface SimilarArtist {
url: string;
/** Relation type, e.g. `"SIMILAR_ARTIST"`. */
relationType: string;
- /** Popularity score (0–1 float). */
+ /** Popularity score (0-1 float). */
popularity: number;
/** External link entries (e.g. TIDAL sharing URL). */
externalLinks: Array<{ href: string; meta: { type: string } }>;
@@ -911,7 +911,7 @@ export interface TopVideosResponse extends VersionedResponse {
export interface TidalAudioNormData {
/** Replay gain value in dB. */
replayGain: number;
- /** Peak amplitude (0–1). */
+ /** Peak amplitude (0-1). */
peakAmplitude: number;
}
@@ -961,7 +961,7 @@ export interface TrackManifestAttributes {
export interface TrackManifestResource {
/** Resource identifier (track ID as a string). */
id: string;
- /** JSON:API resource type — always `"trackManifests"`. */
+ /** JSON:API resource type - always `"trackManifests"`. */
type: string;
/** Manifest attributes. */
attributes: TrackManifestAttributes;
diff --git a/js/analytics.js b/js/analytics.js
index 43a5ace..d262fe3 100644
--- a/js/analytics.js
+++ b/js/analytics.js
@@ -34,722 +34,10 @@ export function trackPageView(path) {
trackEvent('pageview', { path });
}
-// Playback Events
-export function trackPlayTrack(track) {
- trackEvent('Play Track', {
- track_id: track?.id || 'unknown',
- track_title: track?.title || 'Unknown',
- artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
- artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
- album_id: track?.album?.id || 'unknown',
- album: track?.album?.title || 'Unknown',
- duration: track?.duration || 0,
- quality: track?.audioQuality || track?.quality || 'Unknown',
- is_local: track?.isLocal || false,
- is_explicit: track?.explicit || false,
- track_number: track?.trackNumber || 0,
- year: track?.album?.releaseYear || track?.album?.releaseDate || 'unknown',
- });
-}
-
-export function trackPauseTrack(track) {
- trackEvent('Pause Track', {
- track_id: track?.id || 'unknown',
- track_title: track?.title || 'Unknown',
- artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
- artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
- album_id: track?.album?.id || 'unknown',
- album: track?.album?.title || 'Unknown',
- });
-}
-
-export function trackSkipTrack(track, direction) {
- trackEvent('Skip Track', {
- track_id: track?.id || 'unknown',
- track_title: track?.title || 'Unknown',
- artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
- artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
- album_id: track?.album?.id || 'unknown',
- album: track?.album?.title || 'Unknown',
- direction: direction,
- });
-}
-
-export function trackToggleShuffle(enabled) {
- trackEvent('Toggle Shuffle', { enabled });
-}
-
-export function trackToggleRepeat(mode) {
- trackEvent('Toggle Repeat', { mode });
-}
-
-export function trackTrackComplete(track, completionPercent) {
- trackEvent('Track Complete', {
- track_id: track?.id || 'unknown',
- track_title: track?.title || 'Unknown',
- artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
- artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
- album_id: track?.album?.id || 'unknown',
- album: track?.album?.title || 'Unknown',
- duration: track?.duration || 0,
- completion_percent: completionPercent || 100,
- });
-}
-
-export function trackSetVolume(level) {
- // Only track volume changes at coarse intervals to avoid spam
- const roundedLevel = Math.round(level * 10) / 10;
- trackEvent('Set Volume', { level: roundedLevel });
-}
-
-export function trackToggleMute(muted) {
- trackEvent('Toggle Mute', { muted });
-}
-
-// Track listening progress milestones (10%, 50%, 90%, 100%)
-export function trackListeningProgress(track, percent) {
- trackEvent('Listening Progress', {
- track_id: track?.id || 'unknown',
- track_title: track?.title || 'Unknown',
- artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
- percent: percent,
- });
-}
-
-// Search Events
-export function trackSearch(query, resultsCount) {
- trackEvent('Search', {
- query_length: query?.length || 0,
- has_results: resultsCount > 0,
- results_count: resultsCount,
- });
-}
-
-export function trackSearchTabChange(tab) {
- trackEvent('Search Tab Change', { tab });
-}
-
-// Navigation Events
-export function trackNavigate(path, pageType) {
- trackEvent('Navigate', {
- path,
- page_type: pageType,
- });
-}
-
-export function trackSidebarNavigation(item) {
- trackEvent('Sidebar Navigation', { item });
-}
-
-// Library Events
-export function trackLikeTrack(track) {
- trackEvent('Like Track', {
- track_id: track?.id || 'unknown',
- track_title: track?.title || 'Unknown',
- artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
- artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
- album_id: track?.album?.id || 'unknown',
- album: track?.album?.title || 'Unknown',
- });
-}
-
-export function trackUnlikeTrack(track) {
- trackEvent('Unlike Track', {
- track_id: track?.id || 'unknown',
- track_title: track?.title || 'Unknown',
- artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
- artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
- album_id: track?.album?.id || 'unknown',
- });
-}
-
-export function trackLikeAlbum(album) {
- trackEvent('Like Album', {
- album_title: album?.title || 'Unknown',
- artist: album?.artist?.name || 'Unknown',
- });
-}
-
-export function trackUnlikeAlbum(album) {
- trackEvent('Unlike Album', {
- album_title: album?.title || 'Unknown',
- });
-}
-
-export function trackLikeArtist(artist) {
- trackEvent('Like Artist', {
- artist_name: artist?.name || 'Unknown',
- });
-}
-
-export function trackUnlikeArtist(artist) {
- trackEvent('Unlike Artist', {
- artist_name: artist?.name || 'Unknown',
- });
-}
-
-export function trackLikePlaylist(playlist) {
- trackEvent('Like Playlist', {
- playlist_name: playlist?.title || playlist?.name || 'Unknown',
- });
-}
-
-export function trackUnlikePlaylist(playlist) {
- trackEvent('Unlike Playlist', {
- playlist_name: playlist?.title || playlist?.name || 'Unknown',
- });
-}
-
-// Playlist Management Events
-export function trackCreatePlaylist(playlist, source) {
- trackEvent('Create Playlist', {
- playlist_name: playlist?.name || 'Unknown',
- track_count: playlist?.tracks?.length || 0,
- is_public: playlist?.isPublic || false,
- source: source || 'manual',
- });
-}
-
-export function trackEditPlaylist(playlist) {
- trackEvent('Edit Playlist', {
- playlist_name: playlist?.name || 'Unknown',
- });
-}
-
-export function trackDeletePlaylist(playlistName) {
- trackEvent('Delete Playlist', { playlist_name: playlistName });
-}
-
-export function trackAddToPlaylist(track, playlist) {
- trackEvent('Add to Playlist', {
- track_title: track?.title || 'Unknown',
- playlist_name: playlist?.name || 'Unknown',
- });
-}
-
-export function trackRemoveFromPlaylist(track, playlist) {
- trackEvent('Remove from Playlist', {
- track_title: track?.title || 'Unknown',
- playlist_name: playlist?.name || 'Unknown',
- });
-}
-
-export function trackCreateFolder(folder) {
- trackEvent('Create Folder', {
- folder_name: folder?.name || 'Unknown',
- });
-}
-
-export function trackDeleteFolder(folderName) {
- trackEvent('Delete Folder', { folder_name: folderName });
-}
-
-// Playback Actions
-export function trackPlayAlbum(album, shuffle) {
- trackEvent('Play Album', {
- album_id: album?.id || 'unknown',
- album_title: album?.title || 'Unknown',
- artist_id: album?.artist?.id || 'unknown',
- artist: album?.artist?.name || 'Unknown',
- shuffle: shuffle || false,
- track_count: album?.numberOfTracks || album?.tracks?.length || 0,
- year: album?.releaseYear || album?.releaseDate || 'unknown',
- });
-}
-
-export function trackPlayPlaylist(playlist, shuffle) {
- trackEvent('Play Playlist', {
- playlist_id: playlist?.id || 'unknown',
- playlist_name: playlist?.title || playlist?.name || 'Unknown',
- shuffle: shuffle || false,
- track_count: playlist?.tracks?.length || 0,
- is_public: playlist?.isPublic || false,
- });
-}
-
-export function trackPlayArtistRadio(artist) {
- trackEvent('Play Artist Radio', {
- artist_id: artist?.id || 'unknown',
- artist_name: artist?.name || 'Unknown',
- });
-}
-
-export function trackShuffleLikedTracks(count) {
- trackEvent('Shuffle Liked Tracks', { track_count: count });
-}
-
-// Download Events
-export function trackDownloadTrack(track, quality) {
- trackEvent('Download Track', {
- track_id: track?.id || 'unknown',
- track_title: track?.title || 'Unknown',
- artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
- artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
- album_id: track?.album?.id || 'unknown',
- quality: quality || 'Unknown',
- });
-}
-
-export function trackDownloadAlbum(album, quality) {
- trackEvent('Download Album', {
- album_id: album?.id || 'unknown',
- album_title: album?.title || 'Unknown',
- artist_id: album?.artist?.id || 'unknown',
- artist: album?.artist?.name || 'Unknown',
- track_count: album?.numberOfTracks || album?.tracks?.length || 0,
- quality: quality || 'Unknown',
- });
-}
-
-export function trackDownloadPlaylist(playlist, quality) {
- trackEvent('Download Playlist', {
- playlist_id: playlist?.id || 'unknown',
- playlist_name: playlist?.title || playlist?.name || 'Unknown',
- track_count: playlist?.tracks?.length || 0,
- quality: quality || 'Unknown',
- });
-}
-
-export function trackDownloadLikedTracks(count, quality) {
- trackEvent('Download Liked Tracks', {
- track_count: count,
- quality: quality || 'Unknown',
- });
-}
-
-export function trackDownloadDiscography(artist, selection) {
- trackEvent('Download Discography', {
- artist_id: artist?.id || 'unknown',
- artist_name: artist?.name || 'Unknown',
- include_albums: selection?.includeAlbums || false,
- include_eps: selection?.includeEPs || false,
- include_singles: selection?.includeSingles || false,
- });
-}
-
-// Queue Management
-export function trackAddToQueue(track, position) {
- trackEvent('Add to Queue', {
- track_id: track?.id || 'unknown',
- track_title: track?.title || 'Unknown',
- artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
- artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
- album_id: track?.album?.id || 'unknown',
- position: position || 'end',
- });
-}
-
-export function trackPlayNext(track) {
- trackEvent('Play Next', {
- track_id: track?.id || 'unknown',
- track_title: track?.title || 'Unknown',
- artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
- artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
- album_id: track?.album?.id || 'unknown',
- });
-}
-
-export function trackClearQueue() {
- trackEvent('Clear Queue');
-}
-
-export function trackShuffleQueue() {
- trackEvent('Shuffle Queue');
-}
-
-// Context Menu Actions
-export function trackContextMenuAction(action, itemType, item) {
- trackEvent('Context Menu Action', {
- action,
- item_type: itemType,
- item_name: item?.title || item?.name || 'Unknown',
- });
-}
-
-export function trackBlockTrack(track) {
- trackEvent('Block Track', {
- track_id: track?.id || 'unknown',
- track_title: track?.title || 'Unknown',
- artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown',
- artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
- album_id: track?.album?.id || 'unknown',
- });
-}
-
-export function trackUnblockTrack(track) {
- trackEvent('Unblock Track', {
- track_id: track?.id || 'unknown',
- track_title: track?.title || 'Unknown',
- });
-}
-
-export function trackBlockAlbum(album) {
- trackEvent('Block Album', {
- album_id: album?.id || 'unknown',
- album_title: album?.title || 'Unknown',
- artist_id: album?.artist?.id || 'unknown',
- });
-}
-
-export function trackUnblockAlbum(album) {
- trackEvent('Unblock Album', {
- album_id: album?.id || 'unknown',
- album_title: album?.title || 'Unknown',
- });
-}
-
-export function trackBlockArtist(artist) {
- trackEvent('Block Artist', {
- artist_id: artist?.id || 'unknown',
- artist_name: artist?.name || 'Unknown',
- });
-}
-
-export function trackUnblockArtist(artist) {
- trackEvent('Unblock Artist', {
- artist_id: artist?.id || 'unknown',
- artist_name: artist?.name || 'Unknown',
- });
-}
-
-export function trackCopyLink(type, id) {
- trackEvent('Copy Link', { type, id });
-}
-
-export function trackOpenInNewTab(type, id) {
- trackEvent('Open in New Tab', { type, id });
-}
-
-// Lyrics Events
-export function trackOpenLyrics(track) {
- trackEvent('Open Lyrics', {
- track_title: track?.title || 'Unknown',
- artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown',
- });
-}
-
-export function trackCloseLyrics(track) {
- trackEvent('Close Lyrics', {
- track_title: track?.title || 'Unknown',
- });
-}
-
-// Fullscreen/Cover View Events
-export function trackOpenFullscreenCover(track) {
- trackEvent('Open Fullscreen Cover', {
- track_title: track?.title || 'Unknown',
- });
-}
-
-export function trackCloseFullscreenCover() {
- trackEvent('Close Fullscreen Cover');
-}
-
-export function trackToggleVisualizer(enabled) {
- trackEvent('Toggle Visualizer', { enabled });
-}
-
-export function trackToggleLyricsFullscreen(enabled) {
- trackEvent('Toggle Lyrics Fullscreen', { enabled });
-}
-
-// Settings Events
-export function trackChangeSetting(setting, value) {
- trackEvent('Change Setting', {
- setting,
- value: typeof value === 'object' ? JSON.stringify(value) : String(value),
- });
-}
-
-export function trackChangeTheme(theme) {
- trackEvent('Change Theme', { theme });
-}
-
-export function trackChangeQuality(type, quality) {
- trackEvent('Change Quality', { type, quality });
-}
-
-export function trackChangeVolume(volume) {
- trackEvent('Change Volume', { volume: Math.round(volume * 100) });
-}
-
-export function trackToggleScrobbler(service, enabled) {
- trackEvent('Toggle Scrobbler', { service, enabled });
-}
-
-export function trackConnectScrobbler(service) {
- trackEvent('Connect Scrobbler', { service });
-}
-
-export function trackDisconnectScrobbler(service) {
- trackEvent('Disconnect Scrobbler', { service });
-}
-
-// Local Files Events
-export function trackSelectLocalFolder(fileCount) {
- trackEvent('Select Local Folder', { file_count: fileCount });
-}
-
-export function trackPlayLocalFile(track) {
- trackEvent('Play Local File', {
- track_title: track?.title || 'Unknown',
- });
-}
-
-export function trackChangeLocalFolder() {
- trackEvent('Change Local Folder');
-}
-
-// Import/Export Events
-export function trackImportCSV(playlistName, trackCount, missingCount) {
- trackEvent('Import CSV', {
- playlist_name: playlistName,
- track_count: trackCount,
- missing_count: missingCount,
- });
-}
-
-export function trackImportJSPF(playlistName, trackCount, missingCount, source) {
- trackEvent('Import JSPF', {
- playlist_name: playlistName,
- track_count: trackCount,
- missing_count: missingCount,
- source: source || 'unknown',
- });
-}
-
-export function trackImportXSPF(playlistName, trackCount, missingCount) {
- trackEvent('Import XSPF', {
- playlist_name: playlistName,
- track_count: trackCount,
- missing_count: missingCount,
- });
-}
-
-export function trackImportXML(playlistName, trackCount, missingCount) {
- trackEvent('Import XML', {
- playlist_name: playlistName,
- track_count: trackCount,
- missing_count: missingCount,
- });
-}
-
-export function trackImportM3U(playlistName, trackCount, missingCount) {
- trackEvent('Import M3U', {
- playlist_name: playlistName,
- track_count: trackCount,
- missing_count: missingCount,
- });
-}
-
-// Sleep Timer Events
-export function trackSetSleepTimer(minutes) {
- trackEvent('Set Sleep Timer', { minutes });
-}
-
-export function trackCancelSleepTimer() {
- trackEvent('Cancel Sleep Timer');
-}
-
-// History Events
-export function trackClearHistory() {
- trackEvent('Clear History');
-}
-
-export function trackClearRecent() {
- trackEvent('Clear Recent');
-}
-
-// Casting Events
-export function trackStartCasting(deviceType) {
- trackEvent('Start Casting', { device_type: deviceType });
-}
-
-export function trackStopCasting() {
- trackEvent('Stop Casting');
-}
-
-// Keyboard Shortcuts
-export function trackKeyboardShortcut(key) {
- trackEvent('Keyboard Shortcut', { key });
-}
-
-// Pinning Events
-export function trackPinItem(type, item) {
- trackEvent('Pin Item', {
- type,
- item_name: item?.title || item?.name || 'Unknown',
- });
-}
-
-export function trackUnpinItem(type, item) {
- trackEvent('Unpin Item', {
- type,
- item_name: item?.title || item?.name || 'Unknown',
- });
-}
-
-// Side Panel Events
-export function trackOpenSidePanel(panelType) {
- trackEvent('Open Side Panel', { panel_type: panelType });
-}
-
-export function trackCloseSidePanel() {
- trackEvent('Close Side Panel');
-}
-
-// Queue Panel Events
-export function trackOpenQueue() {
- trackEvent('Open Queue');
-}
-
-export function trackCloseQueue() {
- trackEvent('Close Queue');
-}
-
-// Mix Events
-export function trackStartMix(sourceType, source) {
- trackEvent('Start Mix', {
- source_type: sourceType,
- source_name: source?.title || source?.name || 'Unknown',
- });
-}
-
-export function trackPlayMix(mixId) {
- trackEvent('Play Mix', { mix_id: mixId });
-}
-
-// Search History Events
-export function trackClearSearchHistory() {
- trackEvent('Clear Search History');
-}
-
-export function trackClickSearchHistory(query) {
- trackEvent('Click Search History', { query_length: query?.length || 0 });
-}
-
-// PWA/Update Events
-export function trackPwaInstall() {
- trackEvent('PWA Install');
-}
-
-export function trackPwaUpdate() {
- trackEvent('PWA Update');
-}
-
-export function trackDismissUpdate() {
- trackEvent('Dismiss Update');
-}
-
-// Sort Events
-export function trackChangeSort(sortType) {
- trackEvent('Change Sort', { sort_type: sortType });
-}
-
-// Modal Events
-export function trackOpenModal(modalName) {
- trackEvent('Open Modal', { modal_name: modalName });
-}
-
-export function trackCloseModal(modalName) {
- trackEvent('Close Modal', { modal_name: modalName });
-}
-
-// Sharing Events
-export function trackSharePlaylist(playlist, isPublic) {
- trackEvent('Share Playlist', {
- playlist_name: playlist?.name || 'Unknown',
- is_public: isPublic,
- });
-}
-
-// Audio Effects Events
-export function trackChangePlaybackSpeed(speed) {
- trackEvent('Change Playback Speed', { speed });
-}
-
-export function trackToggleReplayGain(mode) {
- trackEvent('Toggle ReplayGain', { mode });
-}
-
-export function trackChangeEqualizer(preset) {
- trackEvent('Change Equalizer', { preset });
-}
-
-// Waveform Events
-export function trackToggleWaveform(enabled) {
- trackEvent('Toggle Waveform', { enabled });
-}
-
-// Error Events
-export function trackPlaybackError(errorType, track) {
- trackEvent('Playback Error', {
- error_type: errorType,
- track_title: track?.title || 'Unknown',
- });
-}
-
-export function trackSearchError(query) {
- trackEvent('Search Error', { query_length: query?.length || 0 });
-}
-
-export function trackApiError(endpoint) {
- trackEvent('API Error', { endpoint });
-}
-
-// Feature Discovery Events
-export function trackViewFeature(feature) {
- trackEvent('View Feature', { feature });
-}
-
-export function trackUseFeature(feature) {
- trackEvent('Use Feature', { feature });
-}
-
-// Session Events
-export function trackSessionStart() {
- trackEvent('Session Start', {
- user_agent: navigator.userAgent,
- screen_width: window.screen.width,
- screen_height: window.screen.height,
- language: navigator.language,
- });
-}
-
-export function trackSessionEnd(duration) {
- trackEvent('Session End', { duration });
-}
-
// Initialize analytics on page load
export function initAnalytics() {
if (!isAnalyticsEnabled()) return;
// Track initial page view
trackPageView(window.location.pathname);
-
- // Track session start
- trackSessionStart();
-
- // Track navigation changes
- let lastPath = window.location.pathname;
- setInterval(() => {
- const currentPath = window.location.pathname;
- if (currentPath !== lastPath) {
- trackPageView(currentPath);
- lastPath = currentPath;
- }
- }, 500);
-
- // Track online/offline status
- window.addEventListener('online', () => trackEvent('Go Online'));
- window.addEventListener('offline', () => trackEvent('Go Offline'));
-
- // Track visibility changes (app focus/blur)
- document.addEventListener('visibilitychange', () => {
- if (document.visibilityState === 'hidden') {
- trackEvent('App Background');
- } else {
- trackEvent('App Foreground');
- }
- });
}
diff --git a/js/app.js b/js/app.js
index a61aff2..c60adaf 100644
--- a/js/app.js
+++ b/js/app.js
@@ -35,28 +35,7 @@ import { openEditProfile } from './profile.js';
import { ThemeStore } from './themeStore.js';
import './commandPalette.js';
import { initTracker } from './tracker.js';
-import {
- initAnalytics,
- trackSidebarNavigation,
- trackCreatePlaylist,
- trackCreateFolder,
- trackImportJSPF,
- trackImportCSV,
- trackImportXSPF,
- trackImportXML,
- trackImportM3U,
- trackSelectLocalFolder,
- trackChangeLocalFolder,
- trackOpenModal,
- trackCloseModal,
- trackKeyboardShortcut,
- trackPwaUpdate,
- trackDismissUpdate,
- trackOpenFullscreenCover,
- trackCloseFullscreenCover,
- trackOpenLyrics,
- trackCloseLyrics,
-} from './analytics.js';
+import { initAnalytics } from './analytics.js';
import {
parseCSV,
parseJSPF,
@@ -253,78 +232,68 @@ function initializeCasting(audioPlayer, castBtn) {
function initializeKeyboardShortcuts(player, _audioPlayer) {
const keyActionMap = {
playPause: () => {
- trackKeyboardShortcut('Space');
player.handlePlayPause();
},
seekForward: () => {
- trackKeyboardShortcut('Right');
player.seekForward(10);
},
seekBackward: () => {
- trackKeyboardShortcut('Left');
player.seekBackward(10);
},
nextTrack: () => {
- trackKeyboardShortcut('Shift+Right');
player.playNext();
},
previousTrack: () => {
- trackKeyboardShortcut('Shift+Left');
player.playPrev();
},
volumeUp: () => {
- trackKeyboardShortcut('Up');
player.setVolume(player.userVolume + 0.1);
},
volumeDown: () => {
- trackKeyboardShortcut('Down');
player.setVolume(player.userVolume - 0.1);
},
mute: () => {
- trackKeyboardShortcut('M');
const el = player.activeElement;
el.muted = !el.muted;
},
shuffle: () => {
- trackKeyboardShortcut('S');
document.getElementById('shuffle-btn')?.click();
},
repeat: () => {
- trackKeyboardShortcut('R');
document.getElementById('repeat-btn')?.click();
},
queue: () => {
- trackKeyboardShortcut('Q');
document.getElementById('queue-btn')?.click();
},
lyrics: () => {
- trackKeyboardShortcut('L');
+ const overlay = document.getElementById('fullscreen-cover-overlay');
+ const isFullscreenOpen = overlay && getComputedStyle(overlay).display !== 'none';
+
+ if (isFullscreenOpen && UIRenderer.instance?.toggleFullscreenLyrics(overlay)) {
+ return;
+ }
+
document.getElementById('toggle-lyrics-btn')?.click();
},
search: () => {
- trackKeyboardShortcut('/');
document.getElementById('search-input')?.focus();
},
escape: () => {
- trackKeyboardShortcut('Escape');
document.getElementById('search-input')?.blur();
sidePanelManager.close();
clearLyricsPanelSync(player.activeElement, sidePanelManager.panel);
},
visualizerNext: () => {
- trackKeyboardShortcut('VisualizerNext');
if (UIRenderer.instance.visualizer?.presets?.['butterchurn']) {
UIRenderer.instance.visualizer.presets['butterchurn'].nextPreset();
}
},
visualizerPrev: () => {
- trackKeyboardShortcut('VisualizerPrev');
if (UIRenderer.instance.visualizer?.presets?.['butterchurn']) {
UIRenderer.instance.visualizer.presets['butterchurn'].prevPreset();
}
},
visualizerCycle: () => {
- trackKeyboardShortcut('VisualizerCycle');
if (UIRenderer.instance.visualizer?.presets?.['butterchurn']) {
UIRenderer.instance.visualizer.presets['butterchurn'].toggleCycle();
}
@@ -361,6 +330,19 @@ function initializeKeyboardShortcuts(player, _audioPlayer) {
});
}
+async function closeFullscreenOverlay() {
+ if (UIRenderer.instance?.dismissFullscreenCover) {
+ await UIRenderer.instance.dismissFullscreenCover({ animate: false });
+ return;
+ }
+
+ if (window.location.hash === '#fullscreen') {
+ window.history.back();
+ } else {
+ UIRenderer.instance?.closeFullscreenCover();
+ }
+}
+
function showOfflineNotification() {
const notification = document.createElement('div');
notification.className = 'offline-notification';
@@ -531,7 +513,7 @@ document.addEventListener('DOMContentLoaded', async () => {
* visited the local tab yet).
*/
async function scanLocalMediaFolder(onlyIfAlreadyScanned = false) {
- // Skip the scan if the user has never visited the local tab – they'll
+ // Skip the scan if the user has never visited the local tab - they'll
// get a fresh scan when they navigate there for the first time.
if (onlyIfAlreadyScanned && !window.localFilesCache) return;
@@ -674,7 +656,6 @@ document.addEventListener('DOMContentLoaded', async () => {
const href = link.getAttribute('href');
if (href && !href.startsWith('http')) {
const item = link.querySelector('span')?.textContent || href;
- trackSidebarNavigation(item);
}
});
});
@@ -709,18 +690,10 @@ document.addEventListener('DOMContentLoaded', async () => {
if (mode === 'lyrics') {
const isActive = sidePanelManager.isActive('lyrics');
-
- if (isActive) {
- trackCloseLyrics(Player.instance.currentTrack);
- } else {
- trackOpenLyrics(Player.instance.currentTrack);
- }
} else if (mode === 'cover') {
const overlay = document.getElementById('fullscreen-cover-overlay');
if (overlay && overlay.style.display === 'flex') {
- trackCloseFullscreenCover();
} else {
- trackOpenFullscreenCover(Player.instance.currentTrack);
}
}
@@ -736,11 +709,7 @@ document.addEventListener('DOMContentLoaded', async () => {
} else if (mode === 'cover') {
const overlay = document.getElementById('fullscreen-cover-overlay');
if (overlay && overlay.style.display === 'flex') {
- if (window.location.hash === '#fullscreen') {
- window.history.back();
- } else {
- UIRenderer.instance.closeFullscreenCover();
- }
+ await closeFullscreenOverlay();
} else {
const nextTrack = Player.instance.getNextTrack();
UIRenderer.instance.showFullscreenCover(
@@ -764,13 +733,8 @@ document.addEventListener('DOMContentLoaded', async () => {
if (shareBtn) shareBtn.style.display = e.target.checked ? 'flex' : 'none';
});
- document.getElementById('close-fullscreen-cover-btn')?.addEventListener('click', () => {
- trackCloseFullscreenCover();
- if (window.location.hash === '#fullscreen') {
- window.history.back();
- } else {
- UIRenderer.instance.closeFullscreenCover();
- }
+ document.getElementById('close-fullscreen-cover-btn')?.addEventListener('click', async () => {
+ await closeFullscreenOverlay();
});
document.getElementById('fullscreen-cover-overlay')?.addEventListener('click', (e) => {
@@ -785,11 +749,7 @@ document.addEventListener('DOMContentLoaded', async () => {
switch (action) {
case 'exit':
- if (window.location.hash === '#fullscreen') {
- window.history.back();
- } else {
- UIRenderer.instance.closeFullscreenCover();
- }
+ closeFullscreenOverlay();
break;
case 'hide-ui':
if (overlay) {
@@ -831,11 +791,7 @@ document.addEventListener('DOMContentLoaded', async () => {
case 'nothing':
break;
default:
- if (window.location.hash === '#fullscreen') {
- window.history.back();
- } else {
- UIRenderer.instance.closeFullscreenCover();
- }
+ closeFullscreenOverlay();
}
});
@@ -1328,7 +1284,6 @@ document.addEventListener('DOMContentLoaded', async () => {
}
if (e.target.closest('#create-playlist-btn') || e.target.closest('#library-create-playlist-card')) {
- trackOpenModal('Create Playlist');
const modal = document.getElementById('playlist-modal');
document.getElementById('playlist-modal-title').textContent = 'Create Playlist';
document.getElementById('playlist-name-input').value = '';
@@ -1382,7 +1337,6 @@ document.addEventListener('DOMContentLoaded', async () => {
}
if (e.target.closest('#create-folder-btn') || e.target.closest('#library-create-folder-card')) {
- trackOpenModal('Create Folder');
const modal = document.getElementById('folder-modal');
document.getElementById('folder-name-input').value = '';
document.getElementById('folder-cover-input').value = '';
@@ -1396,11 +1350,9 @@ document.addEventListener('DOMContentLoaded', async () => {
if (name) {
const folder = await db.createFolder(name, cover);
- trackCreateFolder(folder);
await syncManager.syncUserFolder(folder, 'create');
UIRenderer.instance.renderLibraryPage();
document.getElementById('folder-modal').classList.remove('active');
- trackCloseModal('Create Folder');
} else {
showNotification('Please enter a folder name.');
}
@@ -1589,7 +1541,6 @@ document.addEventListener('DOMContentLoaded', async () => {
}
console.log(`Imported ${tracks.length} tracks from YouTube`);
- trackImportCSV(name || 'Untitled', tracks.length, missingTracks.length);
if (missingTracks.length > 0) {
setTimeout(() => {
@@ -1669,12 +1620,6 @@ document.addEventListener('DOMContentLoaded', async () => {
jspfPlaylist?.creator ||
jspfPlaylist?.extension?.['https://musicbrainz.org/doc/jspf#playlist']?.creator ||
'unknown';
- trackImportJSPF(
- name || jspfPlaylist?.title || 'Untitled',
- tracks.length,
- missingTracks.length,
- jspfCreator
- );
if (missingTracks.length > 0) {
setTimeout(() => {
@@ -1788,8 +1733,6 @@ document.addEventListener('DOMContentLoaded', async () => {
}
console.log(`Imported ${tracks.length} tracks from CSV`);
- trackImportCSV(name || 'Untitled', tracks.length, missingTracks.length);
-
if (missingTracks.length > 0) {
setTimeout(() => {
showMissingTracksNotification(missingTracks, name || 'Untitled');
@@ -1847,8 +1790,6 @@ document.addEventListener('DOMContentLoaded', async () => {
}
console.log(`Imported ${tracks.length} tracks from XSPF`);
- trackImportXSPF(name || 'Untitled', tracks.length, missingTracks.length);
-
if (missingTracks.length > 0) {
setTimeout(() => {
showMissingTracksNotification(missingTracks, name || 'Untitled');
@@ -1906,8 +1847,6 @@ document.addEventListener('DOMContentLoaded', async () => {
}
console.log(`Imported ${tracks.length} tracks from XML`);
- trackImportXML(name || 'Untitled', tracks.length, missingTracks.length);
-
if (missingTracks.length > 0) {
setTimeout(() => {
showMissingTracksNotification(missingTracks, name || 'Untitled');
@@ -1965,8 +1904,6 @@ document.addEventListener('DOMContentLoaded', async () => {
}
console.log(`Imported ${tracks.length} tracks from M3U`);
- trackImportM3U(name || 'Untitled', tracks.length, missingTracks.length);
-
if (missingTracks.length > 0) {
setTimeout(() => {
showMissingTracksNotification(missingTracks, name || 'Untitled');
@@ -1998,10 +1935,8 @@ document.addEventListener('DOMContentLoaded', async () => {
// Update DB again with isPublic flag
await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
await syncManager.syncUserPlaylist(playlist, 'create');
- trackCreatePlaylist(playlist, importSource);
UIRenderer.instance.renderLibraryPage();
modal.classList.remove('active');
- trackCloseModal('Create Playlist');
});
}
} else {
@@ -2503,9 +2438,6 @@ document.addEventListener('DOMContentLoaded', async () => {
});
await db.saveSetting('local_folder_handle', handle);
- if (isChange) {
- trackChangeLocalFolder();
- }
const btn = document.getElementById('select-local-folder-btn');
const btnText = document.getElementById('select-local-folder-text');
@@ -2516,7 +2448,6 @@ document.addEventListener('DOMContentLoaded', async () => {
}
const tracks = scanLocalMediaFolder(true);
- trackSelectLocalFolder(tracks?.length ?? 0);
UIRenderer.instance.renderLibraryPage();
} catch (err) {
if (err.name !== 'AbortError') {
@@ -2703,12 +2634,10 @@ document.addEventListener('DOMContentLoaded', async () => {
onNeedRefresh() {
if (pwaUpdateSettings.isAutoUpdateEnabled()) {
// Auto-update: immediately activate the new service worker
- trackPwaUpdate();
updateSW(true);
} else {
// Show notification with Update button and dismiss option
showUpdateNotification(() => {
- trackPwaUpdate();
updateSW(true);
});
}
@@ -2951,7 +2880,6 @@ function showUpdateNotification(updateCallback) {
});
document.getElementById('dismiss-update-btn').addEventListener('click', () => {
- trackDismissUpdate();
notification.remove();
});
}
diff --git a/js/audio-context.js b/js/audio-context.js
index 037c1c1..cc4dce5 100644
--- a/js/audio-context.js
+++ b/js/audio-context.js
@@ -3,7 +3,8 @@
// Supports 3-32 parametric EQ bands
import { isIos } from './platform-detection.js';
-import { equalizerSettings, monoAudioSettings } from './storage.js';
+import { equalizerSettings, monoAudioSettings, binauralDspSettings } from './storage.js';
+import { BinauralDSP } from './binaural-dsp.js';
// Generate frequency array for given number of bands using logarithmic spacing
function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) {
@@ -11,6 +12,12 @@ function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) {
const safeMin = Math.max(10, minFreq);
const safeMax = Math.min(96000, maxFreq);
+ if (bandCount <= 1) {
+ // Single band: use geometric mean of range
+ frequencies.push(Math.round(Math.sqrt(safeMin * safeMax)));
+ return frequencies;
+ }
+
for (let i = 0; i < bandCount; i++) {
// Logarithmic interpolation
const t = i / (bandCount - 1);
@@ -102,6 +109,28 @@ class AudioContextManager {
this.isMonoAudioEnabled = false;
this.monoMergerNode = null;
this.audio = null;
+
+ // M/S (Mid/Side) processing state
+ this.msEnabled = false;
+ this.msSplitter = null;
+ this.msEncoderMidL = null;
+ this.msEncoderMidR = null;
+ this.msEncoderSideL = null;
+ this.msEncoderSideR = null;
+ this.msMidInput = null;
+ this.msSideInput = null;
+ this.midFilters = [];
+ this.sideFilters = [];
+ this.midOutputNode = null;
+ this.sideOutputNode = null;
+ this.msDecoderMidToL = null;
+ this.msDecoderSideToL = null;
+ this.msDecoderMidToR = null;
+ this.msDecoderSideToR = null;
+ this.msLMix = null;
+ this.msRMix = null;
+ this.msMerger = null;
+ this.msOutputNode = null;
this.currentVolume = 1.0;
// Band configuration
@@ -109,17 +138,24 @@ class AudioContextManager {
this.freqRange = equalizerSettings.getFreqRange();
this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
this.currentGains = new Array(this.bandCount).fill(0);
+ this.currentChannels = new Array(this.bandCount).fill('stereo');
+
+ // Binaural DSP state
+ this.binauralDsp = null;
+ this.isBinauralEnabled = binauralDspSettings.isEnabled();
// Callbacks for audio graph changes (for visualizers like Butterchurn)
this._graphChangeCallbacks = [];
- // --- Graphic EQ (16-band, separate chain) ---
+ // --- Graphic EQ (configurable bands, separate chain) ---
this.geqFilters = [];
this.geqPreampNode = null;
this.geqOutputNode = null;
this.isGraphicEQEnabled = equalizerSettings.isGraphicEqEnabled();
- this.geqFrequencies = [25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000, 20000];
- this.geqGains = equalizerSettings.getGraphicEqGains();
+ this.geqBandCount = equalizerSettings.getGraphicEqBandCount();
+ this.geqFreqRange = equalizerSettings.getGraphicEqFreqRange();
+ this.geqFrequencies = generateFrequencies(this.geqBandCount, this.geqFreqRange.min, this.geqFreqRange.max);
+ this.geqGains = equalizerSettings.getGraphicEqGains(this.geqBandCount);
this.geqPreamp = equalizerSettings.getGraphicEqPreamp();
// Load saved settings
@@ -145,15 +181,16 @@ class AudioContextManager {
this.frequencies = generateFrequencies(newCount, this.freqRange.min, this.freqRange.max);
// Interpolate current gains to new band count
- const newGains = equalizerSettings._interpolateGains(this.currentGains, newCount);
+ const newGains = equalizerSettings.interpolateGains(this.currentGains, newCount);
this.currentGains = newGains;
equalizerSettings.setGains(newGains);
// Reinitialize EQ if already initialized
if (this.isInitialized && this.audioContext) {
+ this._destroyMSFilters();
this._destroyEQ();
this._createEQ();
- // Reconnect the audio graph without interrupting playback
+ if (this.msEnabled) this._createMSFilters();
this._connectGraph();
}
@@ -188,9 +225,10 @@ class AudioContextManager {
// Reinitialize EQ if already initialized
if (this.isInitialized && this.audioContext) {
+ this._destroyMSFilters();
this._destroyEQ();
this._createEQ();
- // Reconnect the audio graph without interrupting playback
+ if (this.msEnabled) this._createMSFilters();
this._connectGraph();
}
@@ -230,6 +268,131 @@ class AudioContextManager {
}
}
+ /**
+ * Create M/S matrix nodes (encoder, decoder, merger).
+ * These are cheap static nodes created once in init().
+ */
+ _createMSNodes() {
+ if (!this.audioContext) return;
+
+ this.msSplitter = this.audioContext.createChannelSplitter(2);
+
+ // Encoder: L/R → M/S
+ this.msEncoderMidL = this.audioContext.createGain();
+ this.msEncoderMidL.gain.value = 0.5;
+ this.msEncoderMidR = this.audioContext.createGain();
+ this.msEncoderMidR.gain.value = 0.5;
+ this.msEncoderSideL = this.audioContext.createGain();
+ this.msEncoderSideL.gain.value = 0.5;
+ this.msEncoderSideR = this.audioContext.createGain();
+ this.msEncoderSideR.gain.value = -0.5;
+
+ // Mono mixing points for M and S signals
+ this.msMidInput = this.audioContext.createGain();
+ this.msMidInput.channelCount = 1;
+ this.msMidInput.channelCountMode = 'explicit';
+ this.msSideInput = this.audioContext.createGain();
+ this.msSideInput.channelCount = 1;
+ this.msSideInput.channelCountMode = 'explicit';
+
+ // Chain output nodes
+ this.midOutputNode = this.audioContext.createGain();
+ this.sideOutputNode = this.audioContext.createGain();
+
+ // Decoder: M/S → L/R
+ this.msDecoderMidToL = this.audioContext.createGain();
+ this.msDecoderMidToL.gain.value = 1.0;
+ this.msDecoderSideToL = this.audioContext.createGain();
+ this.msDecoderSideToL.gain.value = 1.0;
+ this.msDecoderMidToR = this.audioContext.createGain();
+ this.msDecoderMidToR.gain.value = 1.0;
+ this.msDecoderSideToR = this.audioContext.createGain();
+ this.msDecoderSideToR.gain.value = -1.0;
+
+ // L/R recombination points (mono)
+ this.msLMix = this.audioContext.createGain();
+ this.msLMix.channelCount = 1;
+ this.msLMix.channelCountMode = 'explicit';
+ this.msRMix = this.audioContext.createGain();
+ this.msRMix.channelCount = 1;
+ this.msRMix.channelCountMode = 'explicit';
+
+ this.msMerger = this.audioContext.createChannelMerger(2);
+ this.msOutputNode = this.audioContext.createGain();
+ }
+
+ /**
+ * Create parallel M/S filter chains based on current band settings.
+ * Mid filters process the center image, Side filters process stereo width.
+ */
+ _createMSFilters() {
+ if (!this.audioContext) return;
+
+ this.midFilters = this.frequencies.map((freq, i) => {
+ const type = (this.currentTypes && this.currentTypes[i]) || 'peaking';
+ const q = this.currentQs && this.currentQs[i] > 0 ? this.currentQs[i] : this._calculateQ(i);
+ const ch = (this.currentChannels && this.currentChannels[i]) || 'stereo';
+ const gain = ch === 'side' ? 0 : this.currentGains[i] || 0;
+ const filter = this.audioContext.createBiquadFilter();
+ filter.type = type;
+ filter.frequency.value = freq;
+ filter.Q.value = q;
+ filter.gain.value = gain;
+ return filter;
+ });
+
+ this.sideFilters = this.frequencies.map((freq, i) => {
+ const type = (this.currentTypes && this.currentTypes[i]) || 'peaking';
+ const q = this.currentQs && this.currentQs[i] > 0 ? this.currentQs[i] : this._calculateQ(i);
+ const ch = (this.currentChannels && this.currentChannels[i]) || 'stereo';
+ const gain = ch === 'mid' ? 0 : this.currentGains[i] || 0;
+ const filter = this.audioContext.createBiquadFilter();
+ filter.type = type;
+ filter.frequency.value = freq;
+ filter.Q.value = q;
+ filter.gain.value = gain;
+ return filter;
+ });
+ }
+
+ /**
+ * Destroy M/S parallel filter chains
+ */
+ _destroyMSFilters() {
+ const sd = (node) => {
+ try {
+ node?.disconnect();
+ } catch {
+ /* */
+ }
+ };
+ this.midFilters.forEach(sd);
+ this.sideFilters.forEach(sd);
+ this.midFilters = [];
+ this.sideFilters = [];
+ }
+
+ /**
+ * Update an existing filter chain in place.
+ * @param {Array} chain - Filter array to update (this.filters, this.midFilters, or this.sideFilters)
+ * @param {Array} freqs - New frequencies
+ * @param {Array} types - New filter types
+ * @param {Array} qs - New Q values
+ * @param {Array} gains - New gain values
+ * @param {number} now - Current audio context time
+ */
+ _updateFilterChain(chain, freqs, types, qs, gains, now) {
+ chain.forEach((filter, i) => {
+ const type = types[i] || 'peaking';
+ const q = qs[i] > 0 ? qs[i] : this._calculateQ(i);
+ const gain = gains[i];
+ filter.type = type;
+ filter.frequency.setTargetAtTime(freqs[i], now, 0.005);
+ filter.gain.setTargetAtTime(gain, now, 0.005);
+ filter.Q.setTargetAtTime(q, now, 0.005);
+ });
+ }
+
/**
* Create EQ filters
*/
@@ -245,14 +408,16 @@ class AudioContextManager {
const gainValue = Math.pow(10, preampValue / 20);
this.preampNode.gain.value = gainValue;
- // Create biquad filters for each frequency band
+ // Create filters for each frequency band
this.filters = this.frequencies.map((freq, index) => {
+ const type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
+ const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
+ const gain = this.currentGains[index] || 0;
const filter = this.audioContext.createBiquadFilter();
- filter.type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
+ filter.type = type;
filter.frequency.value = freq;
- filter.Q.value =
- this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
- filter.gain.value = this.currentGains[index] || 0;
+ filter.Q.value = q;
+ filter.gain.value = gain;
return filter;
});
@@ -326,16 +491,34 @@ class AudioContextManager {
}
if (!this.sources.has(audioElement)) {
- this.sources.set(audioElement, this.audioContext.createMediaElementSource(audioElement));
+ const src = this.audioContext.createMediaElementSource(audioElement);
+ this.sources.set(audioElement, src);
}
this.source = this.sources.get(audioElement);
+ // Enable multichannel passthrough for Atmos/spatial content
+ try {
+ this.audioContext.destination.channelCount = Math.min(this.audioContext.destination.maxChannelCount, 8);
+ this.audioContext.destination.channelCountMode = 'explicit';
+ this.audioContext.destination.channelInterpretation = 'discrete';
+ } catch {
+ // Some browsers may not support changing destination channel count
+ }
+
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 1024;
this.analyser.smoothingTimeConstant = 0.7;
+ // Create binaural DSP processor
+ this.binauralDsp = new BinauralDSP(this.audioContext);
+ void this._loadBinauralSettings();
+
this._createEQ();
this._createGraphicEQ();
+ this._createMSNodes();
+ if (this.msEnabled) {
+ this._createMSFilters();
+ }
this.outputNode = this.audioContext.createGain();
this.outputNode.gain.value = 1;
@@ -447,9 +630,36 @@ class AudioContextManager {
safeDisconnect(this.source);
safeDisconnect(this.monoGainNode);
safeDisconnect(this.monoMergerNode);
+ // Binaural DSP disconnects internally
+ if (this.binauralDsp) {
+ const { input, output } = this.binauralDsp.getNodes();
+ safeDisconnect(input);
+ safeDisconnect(output);
+ }
safeDisconnect(this.preampNode);
this.filters.forEach(safeDisconnect);
safeDisconnect(this.outputNode);
+ // M/S nodes
+ safeDisconnect(this.msSplitter);
+ safeDisconnect(this.msEncoderMidL);
+ safeDisconnect(this.msEncoderMidR);
+ safeDisconnect(this.msEncoderSideL);
+ safeDisconnect(this.msEncoderSideR);
+ safeDisconnect(this.msMidInput);
+ safeDisconnect(this.msSideInput);
+ this.midFilters.forEach(safeDisconnect);
+ this.sideFilters.forEach(safeDisconnect);
+ safeDisconnect(this.midOutputNode);
+ safeDisconnect(this.sideOutputNode);
+ safeDisconnect(this.msDecoderMidToL);
+ safeDisconnect(this.msDecoderSideToL);
+ safeDisconnect(this.msDecoderMidToR);
+ safeDisconnect(this.msDecoderSideToR);
+ safeDisconnect(this.msLMix);
+ safeDisconnect(this.msRMix);
+ safeDisconnect(this.msMerger);
+ safeDisconnect(this.msOutputNode);
+ // Graphic EQ + tail
safeDisconnect(this.geqPreampNode);
this.geqFilters.forEach(safeDisconnect);
safeDisconnect(this.geqOutputNode);
@@ -466,18 +676,77 @@ class AudioContextManager {
lastNode = this.monoMergerNode;
}
+ // Insert binaural DSP before EQ
+ if (this.isBinauralEnabled && this.binauralDsp) {
+ const { input, output } = this.binauralDsp.getNodes();
+ lastNode.connect(input);
+ this.binauralDsp.reconnect();
+ lastNode = output;
+ }
+
if (this.isEQEnabled && this.filters.length > 0) {
- for (let i = 0; i < this.filters.length - 1; i++) {
- this.filters[i].connect(this.filters[i + 1]);
- }
+ const useMS = this.msEnabled && this.midFilters.length > 0 && this.sideFilters.length > 0;
+
+ // Connect preamp
if (this.preampNode) {
lastNode.connect(this.preampNode);
- this.preampNode.connect(this.filters[0]);
- } else {
- lastNode.connect(this.filters[0]);
+ lastNode = this.preampNode;
+ }
+
+ if (useMS) {
+ // === M/S processing path ===
+ // Encode L/R → M/S
+ lastNode.connect(this.msSplitter);
+
+ this.msSplitter.connect(this.msEncoderMidL, 0); // L → Mid
+ this.msSplitter.connect(this.msEncoderMidR, 1); // R → Mid
+ this.msEncoderMidL.connect(this.msMidInput);
+ this.msEncoderMidR.connect(this.msMidInput); // Mid = (L+R)*0.5
+
+ this.msSplitter.connect(this.msEncoderSideL, 0); // L → Side
+ this.msSplitter.connect(this.msEncoderSideR, 1); // R → Side (-0.5)
+ this.msEncoderSideL.connect(this.msSideInput);
+ this.msEncoderSideR.connect(this.msSideInput); // Side = (L-R)*0.5
+
+ // Mid filter chain
+ this.msMidInput.connect(this.midFilters[0]);
+ for (let i = 0; i < this.midFilters.length - 1; i++) {
+ this.midFilters[i].connect(this.midFilters[i + 1]);
+ }
+ this.midFilters[this.midFilters.length - 1].connect(this.midOutputNode);
+
+ // Side filter chain
+ this.msSideInput.connect(this.sideFilters[0]);
+ for (let i = 0; i < this.sideFilters.length - 1; i++) {
+ this.sideFilters[i].connect(this.sideFilters[i + 1]);
+ }
+ this.sideFilters[this.sideFilters.length - 1].connect(this.sideOutputNode);
+
+ // Decode M/S → L/R
+ this.midOutputNode.connect(this.msDecoderMidToL);
+ this.sideOutputNode.connect(this.msDecoderSideToL);
+ this.msDecoderMidToL.connect(this.msLMix);
+ this.msDecoderSideToL.connect(this.msLMix); // L = Mid + Side
+
+ this.midOutputNode.connect(this.msDecoderMidToR);
+ this.sideOutputNode.connect(this.msDecoderSideToR);
+ this.msDecoderMidToR.connect(this.msRMix);
+ this.msDecoderSideToR.connect(this.msRMix); // R = Mid - Side
+
+ this.msLMix.connect(this.msMerger, 0, 0);
+ this.msRMix.connect(this.msMerger, 0, 1);
+ this.msMerger.connect(this.msOutputNode);
+
+ connectTail(this.msOutputNode);
+ } else {
+ // === Normal stereo path ===
+ lastNode.connect(this.filters[0]);
+ for (let i = 0; i < this.filters.length - 1; i++) {
+ this.filters[i].connect(this.filters[i + 1]);
+ }
+ this.filters[this.filters.length - 1].connect(this.outputNode);
+ connectTail(this.outputNode);
}
- this.filters[this.filters.length - 1].connect(this.outputNode);
- connectTail(this.outputNode);
} else {
connectTail(lastNode);
}
@@ -602,6 +871,126 @@ class AudioContextManager {
return this.isInitialized && this.isMonoAudioEnabled;
}
+ // ==========================================
+ // Binaural DSP controls
+ // ==========================================
+
+ /**
+ * Toggle binaural DSP on/off
+ */
+ async toggleBinaural(enabled) {
+ this.isBinauralEnabled = enabled;
+ binauralDspSettings.setEnabled(enabled);
+
+ if (this.binauralDsp) {
+ await this.binauralDsp.setEnabled(enabled);
+ }
+
+ if (this.isInitialized) {
+ this._connectGraph();
+ }
+
+ return this.isBinauralEnabled;
+ }
+
+ /**
+ * Check if binaural DSP is active
+ */
+ isBinauralActive() {
+ return this.isInitialized && this.isBinauralEnabled;
+ }
+
+ /**
+ * Set crossfeed enabled state
+ */
+ async setBinauralCrossfeedEnabled(enabled) {
+ binauralDspSettings.setCrossfeedEnabled(enabled);
+ if (this.binauralDsp) {
+ await this.binauralDsp.setCrossfeedEnabled(enabled);
+ if (this.isInitialized) this._connectGraph();
+ }
+ }
+
+ /**
+ * Set crossfeed level
+ * @param {'low'|'medium'|'high'} level
+ */
+ setBinauralCrossfeedLevel(level) {
+ binauralDspSettings.setCrossfeedLevel(level);
+ if (this.binauralDsp) {
+ this.binauralDsp.setCrossfeedLevel(level);
+ }
+ }
+
+ /**
+ * Set HRTF preset
+ * @param {'intimate'|'studio'|'wide'} preset
+ */
+ async setBinauralHrtfPreset(preset) {
+ binauralDspSettings.setHrtfPreset(preset);
+ if (this.binauralDsp) {
+ await this.binauralDsp.setHrtfPreset(preset);
+ }
+ }
+
+ /**
+ * Set stereo widening enabled state
+ */
+ async setBinauralWideningEnabled(enabled) {
+ binauralDspSettings.setWideningEnabled(enabled);
+ if (this.binauralDsp) {
+ await this.binauralDsp.setWideningEnabled(enabled);
+ if (this.isInitialized) this._connectGraph();
+ }
+ }
+
+ /**
+ * Set stereo widening amount
+ * @param {number} amount - 0.0 to 2.0 (1.0 = neutral)
+ */
+ setBinauralWidening(amount) {
+ binauralDspSettings.setWideningAmount(amount);
+ if (this.binauralDsp) {
+ this.binauralDsp.setWideningAmount(amount);
+ }
+ }
+
+ /**
+ * Notify binaural DSP of channel count change (for multichannel detection)
+ * @param {number} channelCount
+ */
+ async notifyBinauralChannelCount(channelCount) {
+ if (this.binauralDsp && this.isBinauralEnabled) {
+ await this.binauralDsp.detectAndConfigure(channelCount);
+ if (this.isInitialized) this._connectGraph();
+ }
+ }
+
+ /**
+ * Get binaural DSP status
+ */
+ getBinauralStatus() {
+ return this.binauralDsp ? this.binauralDsp.getStatus() : null;
+ }
+
+ /**
+ * Load binaural settings from storage and apply to DSP
+ */
+ async _loadBinauralSettings() {
+ if (!this.binauralDsp) return;
+
+ this.isBinauralEnabled = binauralDspSettings.isEnabled();
+ this.binauralDsp.crossfeedEnabled = binauralDspSettings.getCrossfeedEnabled();
+ this.binauralDsp.crossfeedLevel = binauralDspSettings.getCrossfeedLevel();
+ this.binauralDsp.hrtfPreset = binauralDspSettings.getHrtfPreset();
+ this.binauralDsp.wideningEnabled = binauralDspSettings.getWideningEnabled();
+ this.binauralDsp.wideningAmount = binauralDspSettings.getWideningAmount();
+
+ if (this.isBinauralEnabled) {
+ await this.binauralDsp.setEnabled(true);
+ }
+ }
+
/**
* Get current gain range
*/
@@ -694,7 +1083,7 @@ class AudioContextManager {
// Ensure gains array matches current band count
let adjustedGains = gains;
if (gains.length !== this.bandCount) {
- adjustedGains = equalizerSettings._interpolateGains(gains, this.bandCount);
+ adjustedGains = equalizerSettings.interpolateGains(gains, this.bandCount);
}
const now = this.audioContext?.currentTime || 0;
@@ -757,8 +1146,11 @@ class AudioContextManager {
this.currentGains = equalizerSettings.getGains(this.bandCount);
this.currentTypes = equalizerSettings.getBandTypes(this.bandCount);
this.currentQs = equalizerSettings.getBandQs(this.bandCount);
+ this.currentChannels = equalizerSettings.getBandChannels(this.bandCount);
+ this.msEnabled = this.currentChannels.some((ch) => ch === 'mid' || ch === 'side');
this.isMonoAudioEnabled = monoAudioSettings.isEnabled();
this.preamp = equalizerSettings.getPreamp();
+ this.isBinauralEnabled = binauralDspSettings.isEnabled();
}
/**
@@ -796,6 +1188,7 @@ class AudioContextManager {
if (!bands || bands.length === 0) return '';
const enabledBands = bands.filter((b) => b.enabled);
+ if (enabledBands.length === 0) return '';
const count = Math.max(equalizerSettings.MIN_BANDS, Math.min(equalizerSettings.MAX_BANDS, enabledBands.length));
// Calculate preamp: negative of cumulative peak gain across all bands to prevent clipping
@@ -820,39 +1213,77 @@ class AudioContextManager {
// Sort bands by frequency so index order is deterministic
const sortedBands = [...enabledBands].sort((a, b) => a.freq - b.freq);
- // Build normalized band descriptor arrays
- const newFrequencies = sortedBands
- .slice(0, count)
- .map((b) => Math.round(Math.min(b.freq, (this.audioContext?.sampleRate ?? 48000) / 2 - 1)));
- const newTypes = sortedBands.slice(0, count).map((b) => b.type || 'peaking');
- const newQs = sortedBands.slice(0, count).map((b) => b.q);
- const newGains = sortedBands.slice(0, count).map((b) => this._clampGain(b.gain));
+ // Build normalized band descriptor arrays, pad if fewer enabled bands than minimum
+ const maxFreq = (this.audioContext?.sampleRate ?? 48000) / 2 - 1;
+ const slicedBands = sortedBands.slice(0, count);
+ const newFrequencies = slicedBands.map((b) => Math.round(Math.min(b.freq, maxFreq)));
+ const newTypes = slicedBands.map((b) => b.type || 'peaking');
+ const newQs = slicedBands.map((b) => b.q);
+ const newGains = slicedBands.map((b) => this._clampGain(b.gain));
+ const newChannels = slicedBands.map((b) => b.channel || 'stereo');
+ while (newFrequencies.length < count) {
+ const lastFreq = newFrequencies[newFrequencies.length - 1] || 1000;
+ newFrequencies.push(Math.round(Math.min(lastFreq * 2, maxFreq)));
+ newTypes.push('peaking');
+ newQs.push(1.0);
+ newGains.push(0);
+ newChannels.push('stereo');
+ }
// Update band count via class setter to trigger equalizer-band-count-changed event
if (count !== this.bandCount) {
this.setBandCount(count);
}
- // Override frequencies, types, and Qs with band-specific values
+ // Override frequencies, types, Qs, and channels with band-specific values
this.frequencies = newFrequencies;
this.currentTypes = newTypes;
this.currentQs = newQs;
this.currentGains = newGains;
+ this.currentChannels = newChannels;
+
+ // Determine if M/S processing is needed
+ const needsMS = newChannels.some((ch) => ch === 'mid' || ch === 'side');
+ const msChanged = needsMS !== this.msEnabled;
+ this.msEnabled = needsMS;
if (this.isInitialized && this.audioContext) {
- // If filter count matches, update params in-place (no graph rebuild)
- if (this.filters.length === count) {
- const now = this.audioContext.currentTime;
- this.filters.forEach((filter, i) => {
- filter.type = newTypes[i] || 'peaking';
- filter.frequency.setTargetAtTime(newFrequencies[i], now, 0.005);
- filter.gain.setTargetAtTime(newGains[i], now, 0.005);
- filter.Q.setTargetAtTime(newQs[i] > 0 ? newQs[i] : this._calculateQ(i), now, 0.005);
- });
- } else {
- // Band count changed — must rebuild
+ const needsRebuild =
+ msChanged || this.filters.length !== count || (needsMS && this.midFilters.length !== count);
+
+ if (needsRebuild) {
+ // M/S state changed or band count changed - full rebuild
+ this._destroyMSFilters();
this._destroyEQ();
this._createEQ();
+ if (needsMS) {
+ this._createMSFilters();
+ }
+ this._connectGraph();
+ } else if (needsMS) {
+ // M/S active - update both parallel chains in-place
+ const now = this.audioContext.currentTime;
+
+ // Update main filters (not connected in M/S mode, kept in sync for stereo fallback)
+ this._updateFilterChain(this.filters, newFrequencies, newTypes, newQs, newGains, now);
+
+ // Update mid filters (gain = 0 for side-only bands)
+ const midGains = newGains.map((g, i) => (newChannels[i] === 'side' ? 0 : g));
+ this._updateFilterChain(this.midFilters, newFrequencies, newTypes, newQs, midGains, now);
+
+ // Update side filters (gain = 0 for mid-only bands)
+ const sideGains = newGains.map((g, i) => (newChannels[i] === 'mid' ? 0 : g));
+ this._updateFilterChain(this.sideFilters, newFrequencies, newTypes, newQs, sideGains, now);
+ } else if (this.filters.length === count) {
+ // Normal stereo - update in-place
+ const now = this.audioContext.currentTime;
+ this._updateFilterChain(this.filters, newFrequencies, newTypes, newQs, newGains, now);
+ } else {
+ // Band count changed - must rebuild
+ this._destroyMSFilters();
+ this._destroyEQ();
+ this._createEQ();
+ if (this.msEnabled) this._createMSFilters();
this._connectGraph();
}
}
@@ -867,12 +1298,13 @@ class AudioContextManager {
equalizerSettings.setGains(this.currentGains);
equalizerSettings.setBandTypes(this.currentTypes);
equalizerSettings.setBandQs(this.currentQs);
+ equalizerSettings.setBandChannels(this.currentChannels);
// Generate export text using the actual applied preamp value
const lines = [`Preamp: ${this.preamp.toFixed(1)} dB`];
sortedBands.forEach((band, index) => {
if (index >= count) return;
- const filterType = band.type === 'lowshelf' ? 'LS' : band.type === 'highshelf' ? 'HS' : 'PK';
+ const filterType = band.type === 'lowshelf' ? 'LSC' : band.type === 'highshelf' ? 'HSC' : 'PK';
lines.push(
`Filter ${index + 1}: ON ${filterType} Fc ${newFrequencies[index]} Hz Gain ${newGains[index].toFixed(1)} dB Q ${newQs[index].toFixed(2)}`
);
@@ -893,7 +1325,7 @@ class AudioContextManager {
this.frequencies.forEach((freq, index) => {
const gain = this.currentGains[index] || 0;
const type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
- const filterType = type === 'lowshelf' ? 'LS' : type === 'highshelf' ? 'HS' : 'PK';
+ const filterType = type === 'lowshelf' ? 'LSC' : type === 'highshelf' ? 'HSC' : 'PK';
const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
const filterNum = index + 1;
lines.push(
@@ -928,13 +1360,13 @@ class AudioContextManager {
// Parse filter lines (handle "Filter:" and "Filter X:" formats)
const filterMatch = line.match(
- /^Filter\s*\d*:\s*ON\s+(\w+)\s+Fc\s+(\d+)\s+Hz\s+Gain\s*([+-]?\d+\.?\d*)\s*dB\s+Q\s+(\d+\.?\d*)/i
+ /^Filter\s*\d*:\s*ON\s+(\w+)\s+Fc\s+(\d+)\s+Hz\s+Gain\s*([+-]?\d+\.?\d*)\s*dB(?:\s+Q\s+(\d+\.?\d*))?/i
);
if (filterMatch) {
const type = filterMatch[1].toUpperCase();
const freq = parseInt(filterMatch[2], 10);
const gain = parseFloat(filterMatch[3]);
- const q = parseFloat(filterMatch[4]);
+ const q = filterMatch[4] ? parseFloat(filterMatch[4]) : Math.SQRT1_2;
filters.push({ type, freq, gain, q });
}
}
@@ -967,22 +1399,49 @@ class AudioContextManager {
HSC: 'highshelf',
HSF: 'highshelf',
};
- this.frequencies = sliced.map((f) => f.freq);
- this.currentTypes = sliced.map((f) => typeMap[f.type] || 'peaking');
- this.currentQs = sliced.map((f) => f.q);
- this.currentGains = sliced.map((f) => this._clampGain(f.gain));
+
+ // Pad arrays to bandCount if import has fewer filters than minimum
+ const padCount = this.bandCount - sliced.length;
+ const freqs = sliced.map((f) => f.freq);
+ const types = sliced.map((f) => typeMap[f.type] || 'peaking');
+ const qs = sliced.map((f) => f.q);
+ const gains = sliced.map((f) => this._clampGain(f.gain));
+ if (padCount > 0) {
+ const lastFreq = freqs[freqs.length - 1] || 1000;
+ const maxFreq = (this.audioContext?.sampleRate ?? 48000) / 2 - 1;
+ for (let p = 0; p < padCount; p++) {
+ const padFreq = Math.min(lastFreq * Math.pow(2, p + 1), maxFreq);
+ freqs.push(Math.round(padFreq));
+ types.push('peaking');
+ qs.push(this._calculateQ(freqs.length - 1));
+ gains.push(0);
+ }
+ }
+
+ this.frequencies = freqs;
+ this.currentTypes = types;
+ this.currentQs = qs;
+ this.currentGains = gains;
+
+ // Reset M/S channel assignments - imported config has no channel info
+ this.currentChannels = new Array(this.bandCount).fill('stereo');
+ this.msEnabled = false;
// Rebuild EQ chain to apply new frequencies, types, and Qs
if (this.isInitialized && this.audioContext) {
+ this._destroyMSFilters();
this._destroyEQ();
this._createEQ();
+ if (this.msEnabled) this._createMSFilters();
this._connectGraph();
}
- // Persist all band settings
+ // Persist all band settings including custom frequencies
+ equalizerSettings.setCustomFrequencies(this.frequencies);
equalizerSettings.setGains(this.currentGains);
equalizerSettings.setBandTypes(this.currentTypes);
equalizerSettings.setBandQs(this.currentQs);
+ equalizerSettings.setBandChannels(this.currentChannels);
return true;
} catch (e) {
@@ -1004,11 +1463,12 @@ class AudioContextManager {
this.geqOutputNode = this.audioContext.createGain();
this.geqOutputNode.gain.value = 1;
+ const geqQ = 2.5 * Math.sqrt(16 / this.geqBandCount);
this.geqFilters = this.geqFrequencies.map((freq, i) => {
const filter = this.audioContext.createBiquadFilter();
filter.type = 'peaking';
filter.frequency.value = freq;
- filter.Q.value = 2.5; // constant Q for 16-band
+ filter.Q.value = geqQ;
filter.gain.value = this.geqGains[i] || 0;
return filter;
});
@@ -1050,7 +1510,7 @@ class AudioContextManager {
}
setGraphicEqBandGain(bandIndex, gainDb) {
- if (bandIndex < 0 || bandIndex >= 16) return;
+ if (bandIndex < 0 || bandIndex >= this.geqBandCount) return;
this.geqGains[bandIndex] = Math.max(-30, Math.min(30, gainDb));
if (this.geqFilters[bandIndex] && this.audioContext) {
const now = this.audioContext.currentTime;
@@ -1063,7 +1523,7 @@ class AudioContextManager {
if (!Array.isArray(gains)) return;
const now = this.audioContext?.currentTime || 0;
gains.forEach((g, i) => {
- if (i >= 16) return;
+ if (i >= this.geqBandCount) return;
this.geqGains[i] = Math.max(-30, Math.min(30, g));
if (this.geqFilters[i]) {
this.geqFilters[i].gain.setTargetAtTime(this.geqGains[i], now, 0.01);
@@ -1072,6 +1532,51 @@ class AudioContextManager {
equalizerSettings.setGraphicEqGains([...this.geqGains]);
}
+ setGraphicEqBandCount(count) {
+ const newCount = Math.max(3, Math.min(32, parseInt(count, 10) || 16));
+ if (newCount === this.geqBandCount) return;
+
+ const oldGains = this.geqGains;
+ this.geqBandCount = newCount;
+ this.geqFrequencies = generateFrequencies(newCount, this.geqFreqRange.min, this.geqFreqRange.max);
+ this.geqGains = equalizerSettings.interpolateGains(oldGains, newCount);
+
+ equalizerSettings.setGraphicEqBandCount(newCount);
+ equalizerSettings.setGraphicEqGains(this.geqGains);
+
+ if (this.isInitialized && this.audioContext) {
+ this._destroyGraphicEQ();
+ this._createGraphicEQ();
+ this._connectGraph();
+ }
+ }
+
+ setGraphicEqFreqRange(minFreq, maxFreq) {
+ const newMin = Math.max(10, Math.min(96000, parseInt(minFreq, 10) || 25));
+ const newMax = Math.max(10, Math.min(96000, parseInt(maxFreq, 10) || 20000));
+ if (newMin >= newMax) return;
+ if (newMin === this.geqFreqRange.min && newMax === this.geqFreqRange.max) return;
+
+ this.geqFreqRange = { min: newMin, max: newMax };
+ this.geqFrequencies = generateFrequencies(this.geqBandCount, newMin, newMax);
+
+ equalizerSettings.setGraphicEqFreqRange(newMin, newMax);
+
+ if (this.isInitialized && this.audioContext) {
+ this._destroyGraphicEQ();
+ this._createGraphicEQ();
+ this._connectGraph();
+ }
+ }
+
+ getGraphicEqFrequencies() {
+ return this.geqFrequencies;
+ }
+
+ getGraphicEqBandCount() {
+ return this.geqBandCount;
+ }
+
setGraphicEqPreamp(db) {
this.geqPreamp = Math.max(-20, Math.min(20, parseFloat(db) || 0));
if (this.geqPreampNode && this.audioContext) {
diff --git a/js/autoeq-engine.js b/js/autoeq-engine.js
index 2989313..e6d8d66 100644
--- a/js/autoeq-engine.js
+++ b/js/autoeq-engine.js
@@ -24,8 +24,7 @@ function calculateBiquadResponse(f, band, sr = DEFAULT_SR) {
const w = (2 * PI * band.freq) / sr;
const p = (2 * PI * f) / sr;
const t = band.type[0];
- // WebAudio ignores Q for shelf filters; use 1/√2 (slope = 1) to match
- const effectiveQ = t === 'l' || t === 'h' ? Math.SQRT1_2 : band.q;
+ const effectiveQ = band.q;
const s = Math.sin(w) / (2 * effectiveQ);
const A = Math.pow(DB_BASE, band.gain / DB_DIVISOR);
const c = Math.cos(w);
@@ -244,7 +243,7 @@ function runAutoEqAlgorithm(
if (peakFreq > 5000 && q > 3.0) q = 3.0;
if (gain > 0 && q > 2.0) q = 2.0;
- const newBand = { id: i, type: 'peaking', freq: peakFreq, gain, q, enabled: true };
+ const newBand = { id: i, type: 'peaking', freq: peakFreq, gain, q, enabled: true, channel: 'stereo' };
// Check cumulative gain at the peak frequency across all existing bands + this one
let cumulativeGain = gain;
diff --git a/js/binaural-dsp.js b/js/binaural-dsp.js
new file mode 100644
index 0000000..78239a6
--- /dev/null
+++ b/js/binaural-dsp.js
@@ -0,0 +1,746 @@
+// js/binaural-dsp.js
+// Binaural DSP engine: multichannel HRTF rendering, crossfeed, and stereo widening.
+// Placed before EQ in the audio chain.
+
+import { generateHRTFSet, HRTF_PRESETS, CHANNEL_ANGLES_51 } from './hrtf-generator.js';
+
+/**
+ * Crossfeed presets (Bauer bs2b-style)
+ */
+const CROSSFEED_PRESETS = {
+ low: { cutoff: 500, crossGainDb: -6, delayMs: 0.2 },
+ medium: { cutoff: 700, crossGainDb: -4.5, delayMs: 0.3 },
+ high: { cutoff: 1000, crossGainDb: -3, delayMs: 0.4 },
+};
+
+export class BinauralDSP {
+ /**
+ * @param {AudioContext} audioContext
+ */
+ constructor(audioContext) {
+ this.ctx = audioContext;
+ this.enabled = false;
+ this.mode = 'stereo'; // 'stereo' | 'multichannel'
+ this.channelCount = 2;
+
+ // Sub-feature states
+ this.crossfeedEnabled = true;
+ this.crossfeedLevel = 'medium';
+ this.hrtfPreset = 'studio';
+ this.wideningEnabled = true;
+ this.wideningAmount = 1.0;
+
+ // Graph nodes (created lazily)
+ this.inputNode = this.ctx.createGain();
+ this.outputNode = this.ctx.createGain();
+ this.bypassNode = this.ctx.createGain(); // direct path when disabled
+
+ // Crossfeed nodes
+ this._cfSplitter = null;
+ this._cfMerger = null;
+ this._cfDirectL = null;
+ this._cfDirectR = null;
+ this._cfCrossLR = null; // L → R cross path
+ this._cfCrossRL = null; // R → L cross path
+ this._cfFilterLR = null;
+ this._cfFilterRL = null;
+ this._cfDelayLR = null;
+ this._cfDelayRL = null;
+ this._cfOutputNode = null;
+
+ // Multichannel HRTF nodes
+ this._mcSplitter = null;
+ this._mcMerger = null;
+ this._mcConvolversL = []; // per-channel left-ear convolvers
+ this._mcConvolversR = []; // per-channel right-ear convolvers
+ this._mcLfeGain = null;
+ this._mcOutputNode = null;
+ this._hrtfBuffers = null; // Map from generateHRTFSet
+
+ // Stereo widener nodes
+ this._wSplitter = null;
+ this._wMerger = null;
+ this._wMidL = null;
+ this._wMidR = null;
+ this._wSideL = null;
+ this._wSideR = null;
+ this._wMidGain = null;
+ this._wSideGain = null;
+ this._wMidMix = null;
+ this._wSideMix = null;
+ this._wDecoderMidToL = null;
+ this._wDecoderSideToL = null;
+ this._wDecoderMidToR = null;
+ this._wDecoderSideToR = null;
+ this._wLMix = null;
+ this._wRMix = null;
+ this._wOutputMerger = null;
+ this._wOutputNode = null;
+
+ // Initialize the internal bypass connection
+ this._connectInternal();
+ }
+
+ /**
+ * Get the input/output nodes for graph insertion.
+ */
+ getNodes() {
+ return { input: this.inputNode, output: this.outputNode };
+ }
+
+ /**
+ * Reconnect internal graph (public API for external callers).
+ */
+ reconnect() {
+ this._connectInternal();
+ }
+
+ /**
+ * Connect internal graph based on current state.
+ */
+ _connectInternal() {
+ this._disconnectAll();
+
+ if (!this.enabled) {
+ // Bypass: input → output directly
+ this.inputNode.connect(this.outputNode);
+ return;
+ }
+
+ if (this.mode === 'multichannel' && this._mcOutputNode) {
+ this._connectMultichannelPath();
+ } else {
+ this._connectStereoPath();
+ }
+ }
+
+ /**
+ * Connect the stereo processing path: crossfeed → widener → output
+ */
+ _connectStereoPath() {
+ let lastNode = this.inputNode;
+
+ if (this.crossfeedEnabled && this._cfOutputNode) {
+ lastNode.connect(this._cfSplitter);
+
+ // Direct paths
+ this._cfSplitter.connect(this._cfDirectL, 0);
+ this._cfSplitter.connect(this._cfDirectR, 1);
+
+ // Cross paths: L → R
+ this._cfSplitter.connect(this._cfFilterLR, 0);
+ this._cfFilterLR.connect(this._cfDelayLR);
+ this._cfDelayLR.connect(this._cfCrossLR);
+
+ // Cross paths: R → L
+ this._cfSplitter.connect(this._cfFilterRL, 1);
+ this._cfFilterRL.connect(this._cfDelayRL);
+ this._cfDelayRL.connect(this._cfCrossRL);
+
+ // Merge: L channel = directL + crossRL, R channel = directR + crossLR
+ this._cfDirectL.connect(this._cfMerger, 0, 0);
+ this._cfCrossRL.connect(this._cfMerger, 0, 0);
+ this._cfDirectR.connect(this._cfMerger, 0, 1);
+ this._cfCrossLR.connect(this._cfMerger, 0, 1);
+
+ this._cfMerger.connect(this._cfOutputNode);
+ lastNode = this._cfOutputNode;
+ }
+
+ if (this.wideningEnabled && this._wOutputNode) {
+ this._connectWidener(lastNode);
+ lastNode = this._wOutputNode;
+ }
+
+ lastNode.connect(this.outputNode);
+ }
+
+ /**
+ * Connect the multichannel HRTF rendering path: splitter → per-ch HRTF → merger → widener → output
+ */
+ _connectMultichannelPath() {
+ // Input must pass multichannel through
+ this.inputNode.channelCount = this.channelCount;
+ this.inputNode.channelCountMode = 'max';
+ this.inputNode.channelInterpretation = 'discrete';
+
+ this.inputNode.connect(this._mcSplitter);
+
+ const numChannels = Math.min(this.channelCount, CHANNEL_ANGLES_51.length);
+
+ for (let i = 0; i < numChannels; i++) {
+ const chInfo = CHANNEL_ANGLES_51[i];
+
+ if (chInfo.isLFE) {
+ // LFE: direct mix to both ears at reduced level
+ this._mcSplitter.connect(this._mcLfeGain, i);
+ this._mcLfeGain.connect(this._mcMerger, 0, 0);
+ this._mcLfeGain.connect(this._mcMerger, 0, 1);
+ } else {
+ // HRTF convolution: split to left and right ear convolvers
+ this._mcSplitter.connect(this._mcConvolversL[i], i);
+ this._mcSplitter.connect(this._mcConvolversR[i], i);
+ this._mcConvolversL[i].connect(this._mcMerger, 0, 0); // left ear
+ this._mcConvolversR[i].connect(this._mcMerger, 0, 1); // right ear
+ }
+ }
+
+ this._mcMerger.connect(this._mcOutputNode);
+ let lastNode = this._mcOutputNode;
+
+ if (this.wideningEnabled && this._wOutputNode) {
+ this._connectWidener(lastNode);
+ lastNode = this._wOutputNode;
+ }
+
+ lastNode.connect(this.outputNode);
+ }
+
+ /**
+ * Connect the stereo widener from a source node.
+ */
+ _connectWidener(sourceNode) {
+ sourceNode.connect(this._wSplitter);
+
+ // Encode L/R → M/S
+ this._wSplitter.connect(this._wMidL, 0);
+ this._wSplitter.connect(this._wMidR, 1);
+ this._wMidL.connect(this._wMidMix);
+ this._wMidR.connect(this._wMidMix);
+
+ this._wSplitter.connect(this._wSideL, 0);
+ this._wSplitter.connect(this._wSideR, 1);
+ this._wSideL.connect(this._wSideMix);
+ this._wSideR.connect(this._wSideMix);
+
+ // Apply width gains
+ this._wMidMix.connect(this._wMidGain);
+ this._wSideMix.connect(this._wSideGain);
+
+ // Decode M/S → L/R
+ this._wMidGain.connect(this._wDecoderMidToL);
+ this._wSideGain.connect(this._wDecoderSideToL);
+ this._wDecoderMidToL.connect(this._wLMix);
+ this._wDecoderSideToL.connect(this._wLMix);
+
+ this._wMidGain.connect(this._wDecoderMidToR);
+ this._wSideGain.connect(this._wDecoderSideToR);
+ this._wDecoderMidToR.connect(this._wRMix);
+ this._wDecoderSideToR.connect(this._wRMix);
+
+ // Merge L/R back to stereo
+ this._wLMix.connect(this._wOutputMerger, 0, 0);
+ this._wRMix.connect(this._wOutputMerger, 0, 1);
+ this._wOutputMerger.connect(this._wOutputNode);
+ }
+
+ /**
+ * Disconnect all internal nodes safely.
+ */
+ _disconnectAll() {
+ const sd = (node) => {
+ try {
+ node?.disconnect();
+ } catch {
+ /* */
+ }
+ };
+
+ sd(this.inputNode);
+ sd(this.bypassNode);
+
+ // Crossfeed
+ sd(this._cfSplitter);
+ sd(this._cfMerger);
+ sd(this._cfDirectL);
+ sd(this._cfDirectR);
+ sd(this._cfCrossLR);
+ sd(this._cfCrossRL);
+ sd(this._cfFilterLR);
+ sd(this._cfFilterRL);
+ sd(this._cfDelayLR);
+ sd(this._cfDelayRL);
+ sd(this._cfOutputNode);
+
+ // Multichannel
+ sd(this._mcSplitter);
+ sd(this._mcMerger);
+ sd(this._mcLfeGain);
+ this._mcConvolversL.forEach(sd);
+ this._mcConvolversR.forEach(sd);
+ sd(this._mcOutputNode);
+
+ // Widener
+ sd(this._wSplitter);
+ sd(this._wMerger);
+ sd(this._wMidL);
+ sd(this._wMidR);
+ sd(this._wSideL);
+ sd(this._wSideR);
+ sd(this._wMidGain);
+ sd(this._wSideGain);
+ sd(this._wMidMix);
+ sd(this._wSideMix);
+ sd(this._wDecoderMidToL);
+ sd(this._wDecoderSideToL);
+ sd(this._wDecoderMidToR);
+ sd(this._wDecoderSideToR);
+ sd(this._wLMix);
+ sd(this._wRMix);
+ sd(this._wOutputMerger);
+ sd(this._wOutputNode);
+ }
+
+ // ==========================================
+ // Crossfeed creation
+ // ==========================================
+
+ _createCrossfeedNodes() {
+ const preset = CROSSFEED_PRESETS[this.crossfeedLevel] || CROSSFEED_PRESETS.medium;
+ const crossGain = Math.pow(10, preset.crossGainDb / 20);
+ const directGain = 1.0 - crossGain * 0.5; // Slightly reduce direct to compensate
+
+ this._cfSplitter = this.ctx.createChannelSplitter(2);
+ this._cfMerger = this.ctx.createChannelMerger(2);
+
+ // Direct paths
+ this._cfDirectL = this.ctx.createGain();
+ this._cfDirectL.gain.value = directGain;
+ this._cfDirectL.channelCount = 1;
+ this._cfDirectL.channelCountMode = 'explicit';
+
+ this._cfDirectR = this.ctx.createGain();
+ this._cfDirectR.gain.value = directGain;
+ this._cfDirectR.channelCount = 1;
+ this._cfDirectR.channelCountMode = 'explicit';
+
+ // Cross paths: L → R
+ this._cfFilterLR = this.ctx.createBiquadFilter();
+ this._cfFilterLR.type = 'lowpass';
+ this._cfFilterLR.frequency.value = preset.cutoff;
+ this._cfFilterLR.Q.value = 0.707;
+ this._cfFilterLR.channelCount = 1;
+ this._cfFilterLR.channelCountMode = 'explicit';
+
+ this._cfDelayLR = this.ctx.createDelay(0.01);
+ this._cfDelayLR.delayTime.value = preset.delayMs / 1000;
+
+ this._cfCrossLR = this.ctx.createGain();
+ this._cfCrossLR.gain.value = crossGain;
+ this._cfCrossLR.channelCount = 1;
+ this._cfCrossLR.channelCountMode = 'explicit';
+
+ // Cross paths: R → L
+ this._cfFilterRL = this.ctx.createBiquadFilter();
+ this._cfFilterRL.type = 'lowpass';
+ this._cfFilterRL.frequency.value = preset.cutoff;
+ this._cfFilterRL.Q.value = 0.707;
+ this._cfFilterRL.channelCount = 1;
+ this._cfFilterRL.channelCountMode = 'explicit';
+
+ this._cfDelayRL = this.ctx.createDelay(0.01);
+ this._cfDelayRL.delayTime.value = preset.delayMs / 1000;
+
+ this._cfCrossRL = this.ctx.createGain();
+ this._cfCrossRL.gain.value = crossGain;
+ this._cfCrossRL.channelCount = 1;
+ this._cfCrossRL.channelCountMode = 'explicit';
+
+ this._cfOutputNode = this.ctx.createGain();
+ }
+
+ _destroyCrossfeedNodes() {
+ const nodes = [
+ this._cfSplitter,
+ this._cfMerger,
+ this._cfDirectL,
+ this._cfDirectR,
+ this._cfCrossLR,
+ this._cfCrossRL,
+ this._cfFilterLR,
+ this._cfFilterRL,
+ this._cfDelayLR,
+ this._cfDelayRL,
+ this._cfOutputNode,
+ ];
+ nodes.forEach((n) => {
+ try {
+ n?.disconnect();
+ } catch {
+ /* */
+ }
+ });
+ this._cfSplitter = null;
+ this._cfMerger = null;
+ this._cfDirectL = null;
+ this._cfDirectR = null;
+ this._cfCrossLR = null;
+ this._cfCrossRL = null;
+ this._cfFilterLR = null;
+ this._cfFilterRL = null;
+ this._cfDelayLR = null;
+ this._cfDelayRL = null;
+ this._cfOutputNode = null;
+ }
+
+ // ==========================================
+ // Multichannel HRTF creation
+ // ==========================================
+
+ async _createMultichannelNodes() {
+ const numChannels = Math.min(this.channelCount, CHANNEL_ANGLES_51.length);
+
+ this._mcSplitter = this.ctx.createChannelSplitter(numChannels);
+ this._mcMerger = this.ctx.createChannelMerger(2); // binaural output
+
+ this._mcLfeGain = this.ctx.createGain();
+ this._mcLfeGain.gain.value = 0.5;
+ this._mcLfeGain.channelCount = 1;
+ this._mcLfeGain.channelCountMode = 'explicit';
+
+ // Generate HRTF impulse responses
+ if (!this._hrtfBuffers || this._hrtfBuffers._preset !== this.hrtfPreset) {
+ this._hrtfBuffers = await generateHRTFSet(this.ctx, this.hrtfPreset);
+ this._hrtfBuffers._preset = this.hrtfPreset;
+ }
+
+ this._mcConvolversL = [];
+ this._mcConvolversR = [];
+
+ for (let i = 0; i < numChannels; i++) {
+ const chInfo = CHANNEL_ANGLES_51[i];
+ if (chInfo.isLFE) {
+ // Placeholder - LFE uses gain node instead
+ this._mcConvolversL.push(null);
+ this._mcConvolversR.push(null);
+ continue;
+ }
+
+ const hrtf = this._hrtfBuffers.get(i);
+
+ const convL = this.ctx.createConvolver();
+ convL.normalize = false;
+ convL.buffer = hrtf.left;
+ convL.channelCount = 1;
+ convL.channelCountMode = 'explicit';
+
+ const convR = this.ctx.createConvolver();
+ convR.normalize = false;
+ convR.buffer = hrtf.right;
+ convR.channelCount = 1;
+ convR.channelCountMode = 'explicit';
+
+ this._mcConvolversL.push(convL);
+ this._mcConvolversR.push(convR);
+ }
+
+ this._mcOutputNode = this.ctx.createGain();
+ }
+
+ _destroyMultichannelNodes() {
+ const sd = (n) => {
+ try {
+ n?.disconnect();
+ } catch {
+ /* */
+ }
+ };
+ sd(this._mcSplitter);
+ sd(this._mcMerger);
+ sd(this._mcLfeGain);
+ this._mcConvolversL.forEach(sd);
+ this._mcConvolversR.forEach(sd);
+ sd(this._mcOutputNode);
+
+ this._mcSplitter = null;
+ this._mcMerger = null;
+ this._mcLfeGain = null;
+ this._mcConvolversL = [];
+ this._mcConvolversR = [];
+ this._mcOutputNode = null;
+ }
+
+ // ==========================================
+ // Stereo widener creation
+ // ==========================================
+
+ _createWidenerNodes() {
+ this._wSplitter = this.ctx.createChannelSplitter(2);
+ this._wOutputMerger = this.ctx.createChannelMerger(2);
+
+ // M/S encoder gains
+ this._wMidL = this.ctx.createGain();
+ this._wMidL.gain.value = 0.5;
+ this._wMidL.channelCount = 1;
+ this._wMidL.channelCountMode = 'explicit';
+
+ this._wMidR = this.ctx.createGain();
+ this._wMidR.gain.value = 0.5;
+ this._wMidR.channelCount = 1;
+ this._wMidR.channelCountMode = 'explicit';
+
+ this._wSideL = this.ctx.createGain();
+ this._wSideL.gain.value = 0.5;
+ this._wSideL.channelCount = 1;
+ this._wSideL.channelCountMode = 'explicit';
+
+ this._wSideR = this.ctx.createGain();
+ this._wSideR.gain.value = -0.5;
+ this._wSideR.channelCount = 1;
+ this._wSideR.channelCountMode = 'explicit';
+
+ // Mono mix points
+ this._wMidMix = this.ctx.createGain();
+ this._wMidMix.channelCount = 1;
+ this._wMidMix.channelCountMode = 'explicit';
+
+ this._wSideMix = this.ctx.createGain();
+ this._wSideMix.channelCount = 1;
+ this._wSideMix.channelCountMode = 'explicit';
+
+ // Width control: mid and side gains
+ this._wMidGain = this.ctx.createGain();
+ this._wMidGain.gain.value = this._calcMidGain();
+ this._wSideGain = this.ctx.createGain();
+ this._wSideGain.gain.value = this._calcSideGain();
+
+ // M/S decoder
+ this._wDecoderMidToL = this.ctx.createGain();
+ this._wDecoderMidToL.gain.value = 1.0;
+ this._wDecoderSideToL = this.ctx.createGain();
+ this._wDecoderSideToL.gain.value = 1.0;
+ this._wDecoderMidToR = this.ctx.createGain();
+ this._wDecoderMidToR.gain.value = 1.0;
+ this._wDecoderSideToR = this.ctx.createGain();
+ this._wDecoderSideToR.gain.value = -1.0;
+
+ // L/R recombination
+ this._wLMix = this.ctx.createGain();
+ this._wLMix.channelCount = 1;
+ this._wLMix.channelCountMode = 'explicit';
+ this._wRMix = this.ctx.createGain();
+ this._wRMix.channelCount = 1;
+ this._wRMix.channelCountMode = 'explicit';
+
+ this._wOutputNode = this.ctx.createGain();
+ }
+
+ _destroyWidenerNodes() {
+ const nodes = [
+ this._wSplitter,
+ this._wOutputMerger,
+ this._wMidL,
+ this._wMidR,
+ this._wSideL,
+ this._wSideR,
+ this._wMidGain,
+ this._wSideGain,
+ this._wMidMix,
+ this._wSideMix,
+ this._wDecoderMidToL,
+ this._wDecoderSideToL,
+ this._wDecoderMidToR,
+ this._wDecoderSideToR,
+ this._wLMix,
+ this._wRMix,
+ this._wOutputNode,
+ ];
+ nodes.forEach((n) => {
+ try {
+ n?.disconnect();
+ } catch {
+ /* */
+ }
+ });
+ this._wSplitter = null;
+ this._wOutputMerger = null;
+ this._wMidL = null;
+ this._wMidR = null;
+ this._wSideL = null;
+ this._wSideR = null;
+ this._wMidGain = null;
+ this._wSideGain = null;
+ this._wMidMix = null;
+ this._wSideMix = null;
+ this._wDecoderMidToL = null;
+ this._wDecoderSideToL = null;
+ this._wDecoderMidToR = null;
+ this._wDecoderSideToR = null;
+ this._wLMix = null;
+ this._wRMix = null;
+ this._wOutputNode = null;
+ }
+
+ _calcMidGain() {
+ // At amount=1.0, mid=1.0; at amount=2.0, mid~0.6; at amount=0, mid=2.0
+ return 2.0 - this.wideningAmount;
+ }
+
+ _calcSideGain() {
+ return this.wideningAmount;
+ }
+
+ // ==========================================
+ // Public API
+ // ==========================================
+
+ /**
+ * Enable/disable the entire binaural DSP block.
+ */
+ async setEnabled(enabled) {
+ this.enabled = enabled;
+ if (enabled) {
+ await this._ensureNodesCreated();
+ }
+ this._connectInternal();
+ }
+
+ /**
+ * Detect channel count and configure mode accordingly.
+ * Call this when source changes or track starts playing.
+ * @param {number} channelCount - Number of channels in the source
+ */
+ async detectAndConfigure(channelCount) {
+ const prevMode = this.mode;
+ const prevChannels = this.channelCount;
+ this.channelCount = channelCount;
+
+ if (channelCount > 2) {
+ this.mode = 'multichannel';
+ } else {
+ this.mode = 'stereo';
+ }
+
+ if (this.enabled && (this.mode !== prevMode || channelCount !== prevChannels)) {
+ await this._ensureNodesCreated();
+ this._connectInternal();
+
+ window.dispatchEvent(
+ new CustomEvent('binaural-mode-changed', {
+ detail: { mode: this.mode, channels: this.channelCount },
+ })
+ );
+ }
+ }
+
+ /**
+ * Set crossfeed level.
+ * @param {'low'|'medium'|'high'} level
+ */
+ setCrossfeedLevel(level) {
+ if (!CROSSFEED_PRESETS[level]) return;
+ this.crossfeedLevel = level;
+
+ // Update existing crossfeed nodes if they exist
+ if (this._cfFilterLR) {
+ const preset = CROSSFEED_PRESETS[level];
+ const crossGain = Math.pow(10, preset.crossGainDb / 20);
+ const directGain = 1.0 - crossGain * 0.5;
+ const now = this.ctx.currentTime;
+
+ this._cfFilterLR.frequency.setTargetAtTime(preset.cutoff, now, 0.005);
+ this._cfFilterRL.frequency.setTargetAtTime(preset.cutoff, now, 0.005);
+ this._cfDelayLR.delayTime.setTargetAtTime(preset.delayMs / 1000, now, 0.005);
+ this._cfDelayRL.delayTime.setTargetAtTime(preset.delayMs / 1000, now, 0.005);
+ this._cfCrossLR.gain.setTargetAtTime(crossGain, now, 0.005);
+ this._cfCrossRL.gain.setTargetAtTime(crossGain, now, 0.005);
+ this._cfDirectL.gain.setTargetAtTime(directGain, now, 0.005);
+ this._cfDirectR.gain.setTargetAtTime(directGain, now, 0.005);
+ }
+ }
+
+ /**
+ * Enable/disable crossfeed sub-feature.
+ */
+ async setCrossfeedEnabled(enabled) {
+ this.crossfeedEnabled = enabled;
+ if (this.enabled) {
+ await this._ensureNodesCreated();
+ this._connectInternal();
+ }
+ }
+
+ /**
+ * Set HRTF preset (changes virtual speaker angles).
+ * @param {'intimate'|'studio'|'wide'} preset
+ */
+ async setHrtfPreset(preset) {
+ if (!HRTF_PRESETS[preset]) return;
+ this.hrtfPreset = preset;
+
+ if (this.enabled && this.mode === 'multichannel') {
+ // Regenerate HRTF buffers with new angles
+ this._destroyMultichannelNodes();
+ await this._createMultichannelNodes();
+ this._connectInternal();
+ }
+ }
+
+ /**
+ * Set stereo widening amount.
+ * @param {number} amount - 0.0 (mono) to 2.0 (extra wide), 1.0 = neutral
+ */
+ setWideningAmount(amount) {
+ this.wideningAmount = Math.max(0, Math.min(2, amount));
+
+ if (this._wMidGain && this._wSideGain) {
+ const now = this.ctx.currentTime;
+ this._wMidGain.gain.setTargetAtTime(this._calcMidGain(), now, 0.005);
+ this._wSideGain.gain.setTargetAtTime(this._calcSideGain(), now, 0.005);
+ }
+ }
+
+ /**
+ * Enable/disable stereo widening sub-feature.
+ */
+ async setWideningEnabled(enabled) {
+ this.wideningEnabled = enabled;
+ if (this.enabled) {
+ await this._ensureNodesCreated();
+ this._connectInternal();
+ }
+ }
+
+ /**
+ * Ensure all required nodes are created for the current mode.
+ */
+ async _ensureNodesCreated() {
+ // Always create widener and crossfeed nodes
+ if (!this._cfOutputNode && this.crossfeedEnabled) {
+ this._createCrossfeedNodes();
+ }
+ if (!this._wOutputNode && this.wideningEnabled) {
+ this._createWidenerNodes();
+ }
+ if (this.mode === 'multichannel' && !this._mcOutputNode) {
+ await this._createMultichannelNodes();
+ }
+ }
+
+ /**
+ * Get current processing mode info.
+ */
+ getStatus() {
+ return {
+ enabled: this.enabled,
+ mode: this.mode,
+ channels: this.channelCount,
+ crossfeed: { enabled: this.crossfeedEnabled, level: this.crossfeedLevel },
+ hrtfPreset: this.hrtfPreset,
+ widening: { enabled: this.wideningEnabled, amount: this.wideningAmount },
+ };
+ }
+
+ /**
+ * Destroy all nodes and clean up.
+ */
+ destroy() {
+ this._disconnectAll();
+ this._destroyCrossfeedNodes();
+ this._destroyMultichannelNodes();
+ this._destroyWidenerNodes();
+ this._hrtfBuffers = null;
+ }
+}
+
+export { CROSSFEED_PRESETS, HRTF_PRESETS };
diff --git a/js/db.js b/js/db.js
index 95c306b..acd548e 100644
--- a/js/db.js
+++ b/js/db.js
@@ -102,8 +102,6 @@ export class MusicDatabase {
async addToHistory(track) {
const storeName = 'history_tracks';
const minified = this._minifyItem(track.type || 'track', track);
- const timestamp = Date.now();
- const entry = { ...minified, timestamp };
const db = await this.open();
@@ -112,25 +110,34 @@ export class MusicDatabase {
const store = transaction.objectStore(storeName);
const index = store.index('timestamp');
- const cursorReq = index.openCursor(null, 'prev');
+ const lastReq = index.openCursor(null, 'prev');
+ let lastTimestamp = 0;
- cursorReq.onsuccess = (e) => {
+ lastReq.onsuccess = (e) => {
const cursor = e.target.result;
- if (cursor) {
- const lastTrack = cursor.value;
- if (lastTrack.id === track.id) {
- store.delete(cursor.primaryKey);
- }
+ if (cursor && lastTimestamp === 0) {
+ lastTimestamp = cursor.value.timestamp;
}
- store.put(entry);
+
+ const timestamp = Math.max(Date.now(), lastTimestamp + 1);
+ const entry = { ...minified, timestamp };
+
+ const dedupeReq = index.openCursor(null, 'prev');
+ dedupeReq.onsuccess = (e2) => {
+ const dedupeCursor = e2.target.result;
+ if (dedupeCursor) {
+ const trackInHistory = dedupeCursor.value;
+ if (trackInHistory.id === track.id) {
+ store.delete(dedupeCursor.primaryKey);
+ }
+ dedupeCursor.continue();
+ } else {
+ store.put(entry);
+ resolve(entry);
+ }
+ };
};
- cursorReq.onerror = (_e) => {
- // If cursor fails, just try to put (fallback)
- store.put(entry);
- };
-
- transaction.oncomplete = () => resolve(entry);
transaction.onerror = (e) => reject(e.target.error);
});
}
diff --git a/js/downloads.js b/js/downloads.js
index b8ec561..65476e4 100644
--- a/js/downloads.js
+++ b/js/downloads.js
@@ -499,7 +499,7 @@ async function bulkDownload({
* to the configured folder (Local Media Folder or saved Folder Picker handle),
* or `null` if the feature is not active / no folder is configured.
*
- * In contrast to {@link createBulkWriter}, this never prompts the user – it
+ * In contrast to {@link createBulkWriter}, this never prompts the user - it
* only succeeds when the folder is already known.
*/
async function createSingleTrackFolderWriter() {
@@ -533,7 +533,7 @@ async function createSingleTrackFolderWriter() {
// fall through to picker
}
}
- // No usable saved handle – open the picker so the user can choose a folder.
+ // No usable saved handle - open the picker so the user can choose a folder.
try {
const writer = await FolderPickerWriter.create();
if (rememberFolder) {
@@ -542,7 +542,7 @@ async function createSingleTrackFolderWriter() {
return writer;
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
- // User cancelled the picker – return null so we fall back to the
+ // User cancelled the picker - return null so we fall back to the
// normal browser download instead of erroring out.
return null;
}
@@ -578,7 +578,7 @@ async function createBulkWriter(folderName) {
// fall through to picker
}
}
- // No usable handle – prompt and persist
+ // No usable handle - prompt and persist
try {
const writer = await FolderPickerWriter.create();
await db.saveSetting('local_folder_handle', writer.getDirHandle());
@@ -590,7 +590,7 @@ async function createBulkWriter(folderName) {
return null;
}
}
- // Browser without File System Access API – fall through to ZIP
+ // Browser without File System Access API - fall through to ZIP
}
// ── Folder Picker method ─────────────────────────────────────────────────
diff --git a/js/equalizer.js b/js/equalizer.js
index 797934a..2125d3c 100644
--- a/js/equalizer.js
+++ b/js/equalizer.js
@@ -201,7 +201,7 @@ export class Equalizer {
this.frequencyLabels = generateFrequencyLabels(this.frequencies);
// Interpolate current gains to new band count
- const newGains = equalizerSettings._interpolateGains(this.currentGains, newCount);
+ const newGains = equalizerSettings.interpolateGains(this.currentGains, newCount);
this.currentGains = newGains;
equalizerSettings.setGains(newGains);
@@ -455,7 +455,7 @@ export class Equalizer {
// Ensure gains array matches current band count
let adjustedGains = gains;
if (gains.length !== this.bandCount) {
- adjustedGains = equalizerSettings._interpolateGains(gains, this.bandCount);
+ adjustedGains = equalizerSettings.interpolateGains(gains, this.bandCount);
}
const now = this.audioContext?.currentTime || 0;
@@ -621,9 +621,12 @@ export class Equalizer {
this.frequencies.forEach((freq, index) => {
const gain = this.currentGains[index] || 0;
- const q = this.filters[index] ? this.filters[index].Q.value : this._calculateQ(index);
+ const type = this.currentTypes[index] || 'peaking';
+ const typeMap = { peaking: 'PK', lowshelf: 'LSC', highshelf: 'HSC' };
+ const typeStr = typeMap[type] || 'PK';
+ const q = this.currentQs[index] || this._calculateQ(index);
const filterNum = index + 1;
- lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`);
+ lines.push(`Filter ${filterNum}: ON ${typeStr} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`);
});
return lines.join('\n');
@@ -653,13 +656,13 @@ export class Equalizer {
// Parse filter lines (handle "Filter:" and "Filter X:" formats)
const filterMatch = line.match(
- /^Filter\s*\d*:\s*ON\s+(\w+)\s+Fc\s+(\d+)\s+Hz\s+Gain\s*([+-]?\d+\.?\d*)\s*dB\s+Q\s+(\d+\.?\d*)/i
+ /^Filter\s*\d*:\s*ON\s+(\w+)\s+Fc\s+(\d+)\s+Hz\s+Gain\s*([+-]?\d+\.?\d*)\s*dB(?:\s+Q\s+(\d+\.?\d*))?/i
);
if (filterMatch) {
const type = filterMatch[1].toUpperCase();
const freq = parseInt(filterMatch[2], 10);
const gain = parseFloat(filterMatch[3]);
- const q = parseFloat(filterMatch[4]);
+ const q = filterMatch[4] ? parseFloat(filterMatch[4]) : Math.SQRT1_2;
filters.push({ type, freq, gain, q });
}
}
diff --git a/js/events.js b/js/events.js
index 54186cf..d173fa3 100644
--- a/js/events.js
+++ b/js/events.js
@@ -23,37 +23,6 @@ import { syncManager } from './accounts/pocketbase.js';
import { waveformGenerator } from './waveform.js';
import { audioContextManager } from './audio-context.js';
import { hapticLongPress, hapticMedium, hapticLight } from './haptics.js';
-import {
- trackPlayTrack,
- trackPauseTrack,
- trackSkipTrack,
- trackToggleShuffle,
- trackToggleRepeat,
- trackAddToQueue,
- trackPlayNext,
- trackLikeTrack,
- trackUnlikeTrack,
- trackLikeAlbum,
- trackUnlikeAlbum,
- trackLikeArtist,
- trackUnlikeArtist,
- trackLikePlaylist,
- trackUnlikePlaylist,
- trackDownloadTrack,
- trackContextMenuAction,
- trackBlockTrack,
- trackUnblockTrack,
- trackBlockAlbum,
- trackUnblockAlbum,
- trackBlockArtist,
- trackUnblockArtist,
- trackCopyLink,
- trackOpenInNewTab,
- trackSetSleepTimer,
- trackCancelSleepTimer,
- trackStartMix,
- trackEvent,
-} from './analytics.js';
import { SVG_BIN, SVG_MUTE, SVG_PAUSE, SVG_PLAY, SVG_VOLUME, SVG_CHECKBOX, SVG_CHECKBOX_CHECKED } from './icons.js';
import { partyManager } from './listening-party.js';
import { MusicAPI } from './music-api.js';
@@ -435,9 +404,6 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
await audioContextManager.resume();
if (player.currentTrack) {
- // Track play event
- trackPlayTrack(player.currentTrack);
-
// Scrobble
if (scrobbler.isAuthenticated()) {
scrobbler.updateNowPlaying(player.currentTrack);
@@ -460,9 +426,6 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
element.addEventListener('pause', () => {
if (player.activeElement !== element) return;
- if (player.currentTrack) {
- trackPauseTrack(player.currentTrack);
- }
playPauseBtn.innerHTML = SVG_PLAY(20);
player.updateMediaSessionPlaybackState();
player.updateMediaSessionPositionState();
@@ -545,8 +508,6 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
if (player.isFallbackInProgress || canFallback) {
return;
}
- console.warn('Skipping to next track due to playback error');
- setTimeout(() => player.playNext(), 1000);
}
});
@@ -568,27 +529,23 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
});
nextBtn.addEventListener('click', async () => {
await hapticMedium();
- trackSkipTrack(player.currentTrack, 'next');
player.playNext();
});
prevBtn.addEventListener('click', async () => {
await hapticMedium();
- trackSkipTrack(player.currentTrack, 'previous');
player.playPrev();
});
shuffleBtn.addEventListener('click', async () => {
await hapticLight();
player.toggleShuffle();
- trackToggleShuffle(player.shuffleActive);
shuffleBtn.classList.toggle('active', player.shuffleActive);
if (window.renderQueueFunction) await window.renderQueueFunction();
});
repeatBtn.addEventListener('click', async () => {
await hapticLight();
- const mode = player.toggleRepeat();
- trackToggleRepeat(mode === REPEAT_MODE.OFF ? 'off' : mode === REPEAT_MODE.ALL ? 'all' : 'one');
+ const mode = await player.toggleRepeat();
repeatBtn.classList.toggle('active', mode !== REPEAT_MODE.OFF);
repeatBtn.classList.toggle('repeat-one', mode === REPEAT_MODE.ONE);
repeatBtn.title =
@@ -606,7 +563,6 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
sleepTimerBtnDesktop.addEventListener('click', () => {
if (player.isSleepTimerActive()) {
player.clearSleepTimer();
- trackCancelSleepTimer();
showNotification('Sleep timer cancelled');
} else {
showSleepTimerModal(player);
@@ -619,7 +575,6 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
sleepTimerBtnMobile.addEventListener('click', () => {
if (player.isSleepTimerActive()) {
player.clearSleepTimer();
- trackCancelSleepTimer();
showNotification('Sleep timer cancelled');
} else {
showSleepTimerModal(player);
@@ -1351,12 +1306,10 @@ export async function handleTrackAction(
}
if (action === 'add-to-queue') {
- trackAddToQueue(item, 'end');
player.addToQueue(item);
if (window.renderQueueFunction) await window.renderQueueFunction();
showNotification(`Added to queue: ${item.title}`);
} else if (action === 'play-next') {
- trackPlayNext(item);
player.addNextToQueue(item);
if (window.renderQueueFunction) await window.renderQueueFunction();
showNotification(`Playing next: ${item.title}`);
@@ -1365,34 +1318,17 @@ export async function handleTrackAction(
player.playAtIndex(0);
showNotification(`Playing track: ${item.title}`);
} else if (action === 'start-mix') {
- trackStartMix(type, item);
if (item.mixes?.TRACK_MIX) {
navigate(`/mix/${item.mixes.TRACK_MIX}`);
} else {
showNotification('No mix available for this track');
}
} else if (action === 'download') {
- trackDownloadTrack(item, downloadQualitySettings.getQuality());
await downloadTrackWithMetadata(item, downloadQualitySettings.getQuality(), api, lyricsManager);
} else if (action === 'toggle-like') {
const added = await db.toggleFavorite(type, item);
await syncManager.syncLibraryItem(type, item, added);
- // Track like/unlike
- if (added) {
- if (type === 'track') trackLikeTrack(item);
- else if (type === 'video') trackEvent('Like Video', { title: item.title });
- else if (type === 'album') trackLikeAlbum(item);
- else if (type === 'artist') trackLikeArtist(item);
- else if (type === 'playlist' || type === 'user-playlist') trackLikePlaylist(item);
- } else {
- if (type === 'track') trackUnlikeTrack(item);
- else if (type === 'video') trackEvent('Unlike Video', { title: item.title });
- else if (type === 'album') trackUnlikeAlbum(item);
- else if (type === 'artist') trackUnlikeArtist(item);
- else if (type === 'playlist' || type === 'user-playlist') trackUnlikePlaylist(item);
- }
-
if (added && type === 'track' && scrobbler) {
if (lastFMStorage.isEnabled() && lastFMStorage.shouldLoveOnLike()) {
scrobbler.loveTrack(item);
@@ -1677,7 +1613,6 @@ export async function handleTrackAction(
const typeForUrl = type === 'user-playlist' ? 'userplaylist' : type;
const url = getShareUrl(storedHref ? storedHref : `/${typeForUrl}/${item.id || item.uuid}`);
- trackCopyLink(type, item.id || item.uuid);
await navigator.clipboard
.writeText(url)
.then(() => {
@@ -1692,7 +1627,6 @@ export async function handleTrackAction(
? `${window.location.origin}${storedHref}`
: `${window.location.origin}/${type}/${item.id || item.uuid}`;
- trackOpenInNewTab(type, item.id || item.uuid);
window.open(url, '_blank');
} else if (action === 'open-in-harmony') {
const albumId = item.id;
@@ -1870,11 +1804,9 @@ export async function handleTrackAction(
const { contentBlockingSettings } = await import('./storage.js');
if (contentBlockingSettings.isTrackBlocked(item.id)) {
contentBlockingSettings.unblockTrack(item.id);
- trackUnblockTrack(item);
showNotification(`Unblocked track: ${item.title}`);
} else {
contentBlockingSettings.blockTrack(item);
- trackBlockTrack(item);
showNotification(`Blocked track: ${item.title}`);
}
} else if (action === 'block-album') {
@@ -1893,11 +1825,9 @@ export async function handleTrackAction(
if (contentBlockingSettings.isAlbumBlocked(albumId)) {
contentBlockingSettings.unblockAlbum(albumId);
- trackUnblockAlbum(albumObj);
showNotification(`Unblocked album: ${albumTitle || 'Unknown Album'}`);
} else {
contentBlockingSettings.blockAlbum(albumObj);
- trackBlockAlbum(albumObj);
showNotification(`Blocked album: ${albumTitle || 'Unknown Album'}`);
}
} else if (action === 'block-artist') {
@@ -1914,11 +1844,9 @@ export async function handleTrackAction(
if (contentBlockingSettings.isArtistBlocked(artistId)) {
contentBlockingSettings.unblockArtist(artistId);
- trackUnblockArtist(artistObj);
showNotification(`Unblocked artist: ${artistName || 'Unknown Artist'}`);
} else {
contentBlockingSettings.blockArtist(artistObj);
- trackBlockArtist(artistObj);
showNotification(`Blocked artist: ${artistName || 'Unknown Artist'}`);
}
}
@@ -2496,7 +2424,6 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
switch (action) {
case 'play-next':
selectedTracks.forEach((t) => {
- trackPlayNext(t);
player.addNextToQueue(t);
});
if (window.renderQueueFunction) await window.renderQueueFunction();
@@ -2538,8 +2465,6 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
break;
}
} else {
- // Track context menu action
- trackContextMenuAction(action, type, track);
await handleTrackAction(action, track, player, api, lyricsManager, type, ui, scrobbler, target.dataset);
}
}
@@ -2702,7 +2627,6 @@ function showSleepTimerModal(player) {
if (minutes) {
player.setSleepTimer(minutes);
- trackSetSleepTimer(minutes);
showNotification(`Sleep timer set for ${minutes} minute${minutes === 1 ? '' : 's'}`);
closeModal();
}
diff --git a/js/hrtf-generator.js b/js/hrtf-generator.js
new file mode 100644
index 0000000..795f610
--- /dev/null
+++ b/js/hrtf-generator.js
@@ -0,0 +1,207 @@
+// js/hrtf-generator.js
+// Procedural HRTF impulse response generation for binaural rendering.
+// Synthesizes per-angle stereo IRs modeling ITD, ILD, and head shadow.
+
+const HEAD_RADIUS = 0.0875; // meters (average human head radius)
+const SPEED_OF_SOUND = 343; // m/s
+const IR_LENGTH = 256; // samples
+
+/**
+ * Calculate the interaural time difference (ITD) for a given azimuth.
+ * Uses Woodworth's spherical head model.
+ * @param {number} azimuthRad - Azimuth in radians (0 = front, positive = right)
+ * @returns {number} ITD in seconds (positive = right ear leads)
+ */
+function calculateITD(azimuthRad) {
+ const absAz = Math.abs(azimuthRad);
+ if (absAz <= Math.PI / 2) {
+ return (HEAD_RADIUS / SPEED_OF_SOUND) * (absAz + Math.sin(absAz));
+ }
+ // Behind the head
+ return (HEAD_RADIUS / SPEED_OF_SOUND) * (Math.PI - absAz + Math.sin(absAz));
+}
+
+/**
+ * Calculate frequency-dependent ILD (head shadow attenuation) for the far ear.
+ * Higher frequencies are attenuated more by the head.
+ * @param {number} frequency - Frequency in Hz
+ * @param {number} azimuthRad - Absolute azimuth in radians
+ * @returns {number} Attenuation factor (0-1) for the shadowed ear
+ */
+function calculateHeadShadow(frequency, azimuthRad) {
+ const absAz = Math.abs(azimuthRad);
+ if (absAz < 0.01) return 1.0; // Source in front, no shadow
+
+ // Head shadow increases with frequency and angle
+ // Based on simplified spherical head diffraction model
+ const ka = (2 * Math.PI * frequency * HEAD_RADIUS) / SPEED_OF_SOUND;
+ const shadowFactor = 1.0 / (1.0 + 0.5 * ka * Math.sin(absAz));
+ return Math.max(0.05, shadowFactor);
+}
+
+/**
+ * Generate a single HRTF impulse response for a given azimuth angle.
+ * Returns a stereo AudioBuffer: channel 0 = left ear, channel 1 = right ear.
+ *
+ * @param {AudioContext} audioContext
+ * @param {number} azimuthDeg - Azimuth in degrees (-180 to 180, 0 = front, positive = right)
+ * @param {number} [elevationDeg=0] - Elevation in degrees (currently simplified)
+ * @returns {AudioBuffer} Stereo AudioBuffer with HRTF IR
+ */
+export function generateHRTF(audioContext, azimuthDeg, elevationDeg = 0) {
+ const sampleRate = audioContext.sampleRate;
+ const buffer = audioContext.createBuffer(2, IR_LENGTH, sampleRate);
+
+ const leftData = buffer.getChannelData(0);
+ const rightData = buffer.getChannelData(1);
+
+ const azimuthRad = (azimuthDeg * Math.PI) / 180;
+ const itd = calculateITD(azimuthRad);
+ const itdSamples = Math.round(itd * sampleRate);
+
+ // Determine which ear is ipsilateral (closer to source) and contralateral (farther)
+ const sourceOnRight = azimuthDeg > 0;
+ const ipsiData = sourceOnRight ? rightData : leftData;
+ const contraData = sourceOnRight ? leftData : rightData;
+
+ // Generate ipsilateral (near ear) IR - mostly a delayed impulse with slight coloring
+ // Ipsilateral ear (near source) receives sound first; contralateral ear is delayed by ITD
+ const ipsiDelay = 0;
+ const contraDelay = Math.abs(itdSamples);
+
+ // Create frequency-domain representation for head shadow
+ const fftSize = IR_LENGTH;
+ const halfFFT = fftSize / 2;
+
+ // Ipsilateral ear: near-flat response with slight high-frequency boost at extreme angles
+ for (let i = 0; i < fftSize; i++) {
+ const t = i / sampleRate;
+ let sum = 0;
+ for (let k = 1; k <= halfFFT; k++) {
+ const freq = (k * sampleRate) / fftSize;
+ const absAz = Math.abs(azimuthRad);
+
+ // Ipsilateral ear gets a slight boost at high frequencies for angles > 30°
+ let ipsiGain = 1.0;
+ if (absAz > 0.5 && freq > 2000) {
+ ipsiGain = 1.0 + 0.15 * Math.min(1, (freq - 2000) / 8000) * Math.sin(absAz);
+ }
+
+ // Pinna notch around 8-10kHz (elevation dependent)
+ const elevRad = (elevationDeg * Math.PI) / 180;
+ const notchFreq = 8000 + elevationDeg * 50; // Shifts with elevation
+ const notchWidth = 2000;
+ const notchDepth = 0.15 * Math.abs(Math.sin(elevRad + 0.3));
+ const notchFactor = 1.0 - notchDepth * Math.exp(-Math.pow((freq - notchFreq) / notchWidth, 2));
+
+ const phase = 2 * Math.PI * freq * (t - ipsiDelay / sampleRate);
+ sum += ((ipsiGain * notchFactor) / halfFFT) * Math.cos(phase);
+ }
+ ipsiData[i] = sum;
+ }
+
+ // Contralateral ear: apply head shadow (frequency-dependent attenuation)
+ for (let i = 0; i < fftSize; i++) {
+ const t = i / sampleRate;
+ let sum = 0;
+ for (let k = 1; k <= halfFFT; k++) {
+ const freq = (k * sampleRate) / fftSize;
+ const shadowGain = calculateHeadShadow(freq, azimuthRad);
+
+ const phase = 2 * Math.PI * freq * (t - contraDelay / sampleRate);
+ sum += (shadowGain / halfFFT) * Math.cos(phase);
+ }
+ contraData[i] = sum;
+ }
+
+ // Normalize to prevent clipping
+ let maxVal = 0;
+ for (let i = 0; i < IR_LENGTH; i++) {
+ maxVal = Math.max(maxVal, Math.abs(leftData[i]), Math.abs(rightData[i]));
+ }
+ if (maxVal > 0) {
+ const normFactor = 0.9 / maxVal;
+ for (let i = 0; i < IR_LENGTH; i++) {
+ leftData[i] *= normFactor;
+ rightData[i] *= normFactor;
+ }
+ }
+
+ return buffer;
+}
+
+/**
+ * HRTF angle presets for virtual speaker configurations.
+ */
+export const HRTF_PRESETS = {
+ intimate: { label: 'Intimate', angleScale: 0.73 }, // ±22° front
+ studio: { label: 'Studio', angleScale: 1.0 }, // ±30° front (standard)
+ wide: { label: 'Wide', angleScale: 1.5 }, // ±45° front
+};
+
+/**
+ * Standard 5.1 channel angles (ITU-R BS.775)
+ */
+export const CHANNEL_ANGLES_51 = [
+ { index: 0, name: 'FL', azimuth: -30 },
+ { index: 1, name: 'FR', azimuth: 30 },
+ { index: 2, name: 'C', azimuth: 0 },
+ { index: 3, name: 'LFE', azimuth: 0, isLFE: true },
+ { index: 4, name: 'SL', azimuth: -110 },
+ { index: 5, name: 'SR', azimuth: 110 },
+];
+
+/**
+ * Generate a complete set of HRTF impulse responses for 5.1 surround.
+ * Each entry contains separate left-ear and right-ear mono AudioBuffers
+ * suitable for use with ConvolverNode.
+ *
+ * @param {AudioContext} audioContext
+ * @param {string} [preset='studio'] - HRTF preset name
+ * @returns {Promise