// js/autoeq-importer.js // Headphone Database Browser - Fetches from AutoEq GitHub repository // Provides access to 4000+ headphone measurement profiles import { parseRawData } from './autoeq-data.js'; import { db } from './db.js'; const CACHE_KEY = 'autoeq_index_v4'; const OLD_LS_CACHE_KEY = 'monochrome_autoeq_index_v4'; const CACHE_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours // 5 most popular headphones - pre-loaded as defaults and shown in the headphone select // All measured on Rtings B&K 5128 rig for consistency const POPULAR_HEADPHONES = [ { name: 'Sony WH-1000XM5 (Rtings)', type: 'over-ear', path: 'Rtings/Bruel & Kjaer 5128 over-ear/Sony WH-1000XM5', fileName: 'Sony WH-1000XM5.csv', }, { name: 'Apple AirPods Pro2 (Rtings)', type: 'in-ear', path: 'Rtings/Bruel & Kjaer 5128 in-ear/Apple AirPods Pro2', fileName: 'Apple AirPods Pro2.csv', }, { name: 'Sony WF-1000XM5 (Rtings)', type: 'in-ear', path: 'Rtings/Bruel & Kjaer 5128 in-ear/Sony WF-1000XM5', fileName: 'Sony WF-1000XM5.csv', }, { name: 'Samsung Galaxy Buds3 Pro (Rtings)', type: 'in-ear', path: 'Rtings/Bruel & Kjaer 5128 in-ear/Samsung Galaxy Buds3 Pro', fileName: 'Samsung Galaxy Buds3 Pro.csv', }, { name: 'Sennheiser HD 600 (Rtings)', type: 'over-ear', path: 'Rtings/Bruel & Kjaer 5128 over-ear/Sennheiser HD 600', fileName: 'Sennheiser HD 600.csv', }, ]; // Static fallback list in case GitHub API fails - popular picks + additional well-known models const FALLBACK_INDEX = [ ...POPULAR_HEADPHONES, { name: 'Sennheiser HD 600 (Filk)', type: 'over-ear', path: 'Filk/over-ear/Sennheiser HD 600', fileName: 'Sennheiser HD 600.csv', }, { name: 'Sennheiser HD 600 (Innerfidelity)', type: 'over-ear', path: 'Innerfidelity/over-ear/Sennheiser HD 600', fileName: 'Sennheiser HD 600.csv', }, { name: 'Samsung Galaxy Buds2 Pro (Rtings)', type: 'in-ear', path: 'Rtings/Bruel & Kjaer 5128 in-ear/Samsung Galaxy Buds2 Pro', fileName: 'Samsung Galaxy Buds2 Pro.csv', }, { name: 'Sony WF-1000XM5 (Kazi)', type: 'in-ear', path: 'Kazi/in-ear/Sony WF-1000XM5', fileName: 'Sony WF-1000XM5.csv', }, { name: 'Samsung Galaxy Buds3 Pro (DHRME)', type: 'in-ear', path: 'DHRME/in-ear/Samsung Galaxy Buds3 Pro', fileName: 'Samsung Galaxy Buds3 Pro.csv', }, { name: 'Apple AirPods Pro (Super Review)', type: 'in-ear', path: 'Super Review/in-ear/Apple AirPods Pro', fileName: 'Apple AirPods Pro.csv', }, { name: 'Sennheiser HD 600 (2020) (Kuulokenurkka)', type: 'over-ear', path: 'Kuulokenurkka/over-ear/Sennheiser HD 600 (2020)', fileName: 'Sennheiser HD 600 (2020).csv', }, ]; /** * Fetch the full AutoEq headphone index from GitHub * Uses GitHub API to get the repository tree, then parses it for measurement files * Caches results in localStorage for 24 hours * @returns {Promise>} */ async function fetchAutoEqIndex() { // Migrate: remove old localStorage cache to free quota try { localStorage.removeItem(OLD_LS_CACHE_KEY); } catch { /* ignore */ } // 1. Try loading from IndexedDB cache try { const cached = await db.getSetting(CACHE_KEY); if (cached && cached.timestamp && cached.data) { if (Date.now() - cached.timestamp < CACHE_EXPIRY) { console.log('[AutoEQ] Loaded index from cache'); return cached.data; } } } catch (e) { console.warn('[AutoEQ] Failed to read cache:', e); } // 2. Fetch from GitHub API try { console.log('[AutoEQ] Fetching index from GitHub...'); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 8000); let response; try { response = await fetch('https://api.github.com/repos/jaakkopasanen/AutoEq/git/trees/master?recursive=1', { signal: controller.signal, }); } finally { clearTimeout(timeoutId); } if (!response.ok) { try { const cached = await db.getSetting(CACHE_KEY); if (cached?.data) { console.warn('[AutoEQ] GitHub API limit reached. Using stale cache.'); return cached.data; } } catch { /* ignore */ } console.warn('[AutoEQ] GitHub API error. Using fallback.'); return FALLBACK_INDEX; } const data = await response.json(); const entries = []; for (const item of data.tree) { if (!item.path.startsWith('results/')) continue; if (!item.path.endsWith('.csv') && !item.path.endsWith('.txt')) continue; const parts = item.path.split('/'); if (parts.length < 4) continue; const fileName = parts.pop(); const fileNameLower = fileName.toLowerCase(); // Skip non-measurement files (EQ presets, not raw frequency response) if ( fileNameLower.includes('parametriceq') || fileNameLower.includes('fixedbandeq') || fileNameLower.includes('graphiceq') || fileNameLower.includes('convolution') || fileNameLower.includes('fixed band eq') || fileNameLower.includes('parametric eq') || fileNameLower.includes('graphic eq') ) { continue; } const headphoneName = parts[parts.length - 1]; const folderPath = parts.slice(1).join('/'); const source = parts[1]; let type = 'over-ear'; const lowerPath = item.path.toLowerCase(); if (lowerPath.includes('in-ear') || lowerPath.includes('iem')) { type = 'in-ear'; } else if (lowerPath.includes('earbud')) { type = 'in-ear'; } entries.push({ name: `${headphoneName} (${source})`, type, path: folderPath, fileName, }); } if (entries.length === 0) return FALLBACK_INDEX; const sortedEntries = entries.sort((a, b) => a.name.localeCompare(b.name)); // 3. Save to IndexedDB cache try { await db.saveSetting(CACHE_KEY, { timestamp: Date.now(), data: sortedEntries, }); console.log(`[AutoEQ] Cached ${sortedEntries.length} entries`); } catch (e) { console.warn('[AutoEQ] Failed to save cache:', e); } return sortedEntries; } catch (err) { if (err.name === 'AbortError') { console.warn('[AutoEQ] GitHub API request timed out. Falling back to cache or fallback index.'); } else { console.error('[AutoEQ] Failed to fetch index:', err); } try { const cached = await db.getSetting(CACHE_KEY); if (cached?.data) return cached.data; } catch { /* ignore */ } return FALLBACK_INDEX; } } /** * Fetch the frequency response measurement data for a specific headphone * Tries raw GitHub first, falls back to jsDelivr CDN * @param {object} entry - AutoEq entry {name, type, path, fileName} * @returns {Promise>} */ async function fetchHeadphoneData(entry) { const encodedPath = entry.path.split('/').map(encodeURIComponent).join('/'); const encodedFileName = encodeURIComponent(entry.fileName); const urls = [ `https://raw.githubusercontent.com/jaakkopasanen/AutoEq/master/results/${encodedPath}/${encodedFileName}`, `https://cdn.jsdelivr.net/gh/jaakkopasanen/AutoEq@master/results/${encodedPath}/${encodedFileName}`, ]; for (const url of urls) { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 8000); let response; try { response = await fetch(url, { signal: controller.signal }); } finally { clearTimeout(timeoutId); } if (!response.ok) continue; const text = await response.text(); // Validate it's not an HTML error page if (text.trim().startsWith(' 0) return points; } catch (e) { console.warn(`[AutoEQ] Fetch failed for ${url}:`, e); } } throw new Error(`Failed to fetch data for ${entry.name}`); } /** * Search/filter headphone entries by query and optional type filter * @param {string} query - Search query * @param {Array} entries - Full list of entries * @param {string} typeFilter - Optional type filter ('all', 'over-ear', 'in-ear') * @param {number} limit - Maximum results to return * @returns {Array} */ function searchHeadphones(query, entries, typeFilter = 'all', limit = 100) { let filtered = entries; if (typeFilter !== 'all') { filtered = filtered.filter((e) => e.type === typeFilter); } if (query && query.trim()) { const lower = query.toLowerCase().trim(); filtered = filtered.filter((e) => e.name.toLowerCase().includes(lower)); } return filtered.slice(0, limit); } export { fetchAutoEqIndex, fetchHeadphoneData, searchHeadphones, POPULAR_HEADPHONES };