[READ-ONLY] Mirror of https://github.com/probablykasper/ferrum. Music library app for Mac, Linux and Windows ferrum.kasper.space
electron linux macos music music-library music-player napi windows
0

Configure Feed

Select the types of activity you want to include in your feed.

WIP visualizer prepare

Kasper (Oct 2, 2025, 11:46 PM +0200) 45c28e79 5eba883d

+239 -18
+18 -18
eslint.config.mjs
··· 39 39 }, 40 40 { 41 41 rules: { 42 - '@typescript-eslint/naming-convention': [ 43 - 'error', 44 - { 45 - selector: 'variableLike', 46 - format: ['snake_case', 'UPPER_CASE', 'PascalCase'], 47 - leadingUnderscore: 'allow', 48 - }, 49 - { 50 - selector: 'parameter', 51 - modifiers: ['destructured'], 52 - format: null, 53 - }, 54 - { 55 - selector: 'variable', 56 - modifiers: ['destructured'], 57 - format: null, 58 - }, 59 - ], 42 + // '@typescript-eslint/naming-convention': [ 43 + // 'error', 44 + // { 45 + // selector: 'variableLike', 46 + // format: ['snake_case', 'UPPER_CASE', 'PascalCase'], 47 + // leadingUnderscore: 'allow', 48 + // }, 49 + // { 50 + // selector: 'parameter', 51 + // modifiers: ['destructured'], 52 + // format: null, 53 + // }, 54 + // { 55 + // selector: 'variable', 56 + // modifiers: ['destructured'], 57 + // format: null, 58 + // }, 59 + // ], 60 60 '@typescript-eslint/no-unused-expressions': 'off', 61 61 '@typescript-eslint/no-unused-vars': [ 62 62 'error',
+118
package-lock.json
··· 14 14 "@sveltejs/vite-plugin-svelte": "^5.1.0", 15 15 "@tailwindcss/vite": "^4.1.8", 16 16 "@tsconfig/svelte": "^5.0.4", 17 + "@types/d3-scale": "^4.0.9", 18 + "d3-scale": "^4.0.2", 17 19 "electron": "^30.5.1", 18 20 "electron-builder": "^26.0.12", 19 21 "eslint": "^9.28.0", ··· 4410 4412 "@types/responselike": "^1.0.0" 4411 4413 } 4412 4414 }, 4415 + "node_modules/@types/d3-scale": { 4416 + "version": "4.0.9", 4417 + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", 4418 + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", 4419 + "dev": true, 4420 + "license": "MIT", 4421 + "dependencies": { 4422 + "@types/d3-time": "*" 4423 + } 4424 + }, 4425 + "node_modules/@types/d3-time": { 4426 + "version": "3.0.4", 4427 + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", 4428 + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", 4429 + "dev": true, 4430 + "license": "MIT" 4431 + }, 4413 4432 "node_modules/@types/debug": { 4414 4433 "version": "4.1.12", 4415 4434 "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", ··· 5915 5934 }, 5916 5935 "engines": { 5917 5936 "node": ">=4" 5937 + } 5938 + }, 5939 + "node_modules/d3-array": { 5940 + "version": "3.2.4", 5941 + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", 5942 + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", 5943 + "dev": true, 5944 + "license": "ISC", 5945 + "dependencies": { 5946 + "internmap": "1 - 2" 5947 + }, 5948 + "engines": { 5949 + "node": ">=12" 5950 + } 5951 + }, 5952 + "node_modules/d3-color": { 5953 + "version": "3.1.0", 5954 + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", 5955 + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", 5956 + "dev": true, 5957 + "license": "ISC", 5958 + "engines": { 5959 + "node": ">=12" 5960 + } 5961 + }, 5962 + "node_modules/d3-format": { 5963 + "version": "3.1.0", 5964 + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", 5965 + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", 5966 + "dev": true, 5967 + "license": "ISC", 5968 + "engines": { 5969 + "node": ">=12" 5970 + } 5971 + }, 5972 + "node_modules/d3-interpolate": { 5973 + "version": "3.0.1", 5974 + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", 5975 + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", 5976 + "dev": true, 5977 + "license": "ISC", 5978 + "dependencies": { 5979 + "d3-color": "1 - 3" 5980 + }, 5981 + "engines": { 5982 + "node": ">=12" 5983 + } 5984 + }, 5985 + "node_modules/d3-scale": { 5986 + "version": "4.0.2", 5987 + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", 5988 + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", 5989 + "dev": true, 5990 + "license": "ISC", 5991 + "dependencies": { 5992 + "d3-array": "2.10.0 - 3", 5993 + "d3-format": "1 - 3", 5994 + "d3-interpolate": "1.2.0 - 3", 5995 + "d3-time": "2.1.1 - 3", 5996 + "d3-time-format": "2 - 4" 5997 + }, 5998 + "engines": { 5999 + "node": ">=12" 6000 + } 6001 + }, 6002 + "node_modules/d3-time": { 6003 + "version": "3.1.0", 6004 + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", 6005 + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", 6006 + "dev": true, 6007 + "license": "ISC", 6008 + "dependencies": { 6009 + "d3-array": "2 - 3" 6010 + }, 6011 + "engines": { 6012 + "node": ">=12" 6013 + } 6014 + }, 6015 + "node_modules/d3-time-format": { 6016 + "version": "4.1.0", 6017 + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", 6018 + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", 6019 + "dev": true, 6020 + "license": "ISC", 6021 + "dependencies": { 6022 + "d3-time": "1 - 3" 6023 + }, 6024 + "engines": { 6025 + "node": ">=12" 5918 6026 } 5919 6027 }, 5920 6028 "node_modules/debug": { ··· 7757 7865 "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", 7758 7866 "dev": true, 7759 7867 "license": "ISC" 7868 + }, 7869 + "node_modules/internmap": { 7870 + "version": "2.0.3", 7871 + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", 7872 + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", 7873 + "dev": true, 7874 + "license": "ISC", 7875 + "engines": { 7876 + "node": ">=12" 7877 + } 7760 7878 }, 7761 7879 "node_modules/ip-address": { 7762 7880 "version": "9.0.5",
+2
package.json
··· 74 74 "@sveltejs/vite-plugin-svelte": "^5.1.0", 75 75 "@tailwindcss/vite": "^4.1.8", 76 76 "@tsconfig/svelte": "^5.0.4", 77 + "@types/d3-scale": "^4.0.9", 78 + "d3-scale": "^4.0.2", 77 79 "electron": "^30.5.1", 78 80 "electron-builder": "^26.0.12", 79 81 "eslint": "^9.28.0",
+2
src/lib/player.ts
··· 16 16 import { ipc_renderer } from './window' 17 17 import { queue, set_new_queue, next as queueNext, prev as queuePrev } from './queue' 18 18 import { tracks_page_item_ids } from '$components/TrackList.svelte' 19 + // import { start_visualizer } from './vis' 19 20 20 21 const audio = new Audio() 22 + 21 23 let is_stopped = true 22 24 export const stopped = (() => { 23 25 const { subscribe, set } = writable(true)
+99
src/lib/vis.ts
··· 1 + import { scaleLinear } from 'd3-scale' 2 + 3 + const visualizer_settings = { 4 + disableFlashing: false, 5 + def: [2.5, 0.09], 6 + } 7 + 8 + const BIT_DEPTH = Math.pow(2, 10) 9 + const FILTER_TYPE = 'lowpass' 10 + const FILTER_FREQUENCY = 7500 11 + const FILTER_Q = 0.75 12 + 13 + export function start_visualizer(element: HTMLAudioElement) { 14 + const audioContext = new AudioContext() 15 + const mediaElementSource = audioContext.createMediaElementSource(element) 16 + 17 + const analyser = audioContext.createAnalyser() 18 + const filter = audioContext.createBiquadFilter() 19 + const analyserBuffer = new Uint8Array(BIT_DEPTH / 2) 20 + 21 + mediaElementSource.connect(filter) 22 + filter.connect(analyser) 23 + mediaElementSource.connect(audioContext.destination) 24 + analyser.smoothingTimeConstant = 0 25 + analyser.fftSize = BIT_DEPTH 26 + filter.type = FILTER_TYPE 27 + filter.frequency.value = FILTER_FREQUENCY 28 + filter.Q.value = FILTER_Q 29 + 30 + let stream = 0 31 + let volume = 1 32 + 33 + const def = visualizer_settings?.def || [2.5, 0.09] 34 + const volumeBuffer: number[] = [] 35 + 36 + function sampleVolume(totalSamples: number) { 37 + let value = 0 38 + const start = Math.max(volumeBuffer.length - 1, 0) 39 + const end = Math.max(start - totalSamples, 0) 40 + let min = Infinity 41 + for (let i = start; i >= end; i--) { 42 + value += volumeBuffer[i] 43 + if (volumeBuffer[i] < min) min = volumeBuffer[i] 44 + } 45 + return [value / totalSamples, min] 46 + } 47 + 48 + function measure_volume(frameRate: number) { 49 + const rawVolume = getRawVolume() 50 + volumeBuffer.push(rawVolume) 51 + const [ref, min] = sampleVolume((def[0] * 1000) / (1000 / frameRate)) 52 + const [sample] = sampleVolume((def[1] * 1000) / (1000 / frameRate)) 53 + const scaled = scaleLinear([min, ref], [0, 1])(sample) 54 + const raw = Number(Math.pow(scaled, 1.5).toFixed(3)) 55 + return isNaN(raw) ? 1 : raw / 2 56 + } 57 + 58 + function getRawVolume() { 59 + analyser.getByteFrequencyData(analyserBuffer) 60 + const len = BIT_DEPTH / 2 61 + let val = 0 62 + for (let i = 0; i < len; i++) val += analyserBuffer[i] 63 + return val / len 64 + } 65 + 66 + let destroyed = false 67 + 68 + function measure() { 69 + // TODO: avoid hardcoded framerate 70 + const vol = measure_volume(60) 71 + 72 + if (visualizer_settings.disableFlashing) { 73 + volume = 1 74 + } else { 75 + volume = vol 76 + } 77 + 78 + const playing = audioContext.state === 'running' 79 + 80 + stream = stream + Math.pow(vol, 0.75) / (playing ? 10 : 100) 81 + 82 + if (!destroyed) { 83 + raf_id = requestAnimationFrame(measure) 84 + } 85 + } 86 + 87 + let raf_id = requestAnimationFrame(measure) 88 + 89 + return { 90 + destroy() { 91 + destroyed = true 92 + if (raf_id) { 93 + cancelAnimationFrame(raf_id) 94 + } 95 + analyser.disconnect() 96 + filter.disconnect() 97 + }, 98 + } 99 + }