├── .vscode └── settings.json ├── .gitignore ├── src ├── types │ ├── spotify-credentials.d.ts │ └── spotify-track-segment.d.ts ├── lib │ ├── functions │ │ ├── assert-fader.ts │ │ ├── calculate-pitches.ts │ │ └── fetch-color-palette-from-remote-image.ts │ ├── spotify-fetchers │ │ ├── fetch-spotify-track-segments.ts │ │ ├── fetch-from-spotify.ts │ │ ├── fetch-spotify-access-token.ts │ │ └── fetch-spotify-playback-state.ts │ ├── spotify-hue-worker.ts │ ├── spotify-hue-server.ts │ └── spotify-hue.ts ├── constants │ └── faders.ts └── app.ts ├── tsconfig.json ├── package.json └── README.md /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | node_modules/ 3 | credentials.json 4 | web-demo.html -------------------------------------------------------------------------------- /src/types/spotify-credentials.d.ts: -------------------------------------------------------------------------------- 1 | interface SpotifyCredentials { 2 | clientId: string 3 | clientSecret: string 4 | refreshToken: string 5 | } 6 | 7 | export default SpotifyCredentials 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "moduleResolution": "node", 5 | "allowSyntheticDefaultImports": true, 6 | "module": "CommonJS", 7 | "outDir": "build", 8 | "baseUrl": "src" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/types/spotify-track-segment.d.ts: -------------------------------------------------------------------------------- 1 | interface SpotifyTrackSegment { 2 | start: number 3 | duration: number 4 | confidence: number 5 | loudness_start: number 6 | loudness_max_time: number 7 | loudness_max: number 8 | loudness_end: number 9 | pitches: number[] 10 | timbre: number[] 11 | } 12 | 13 | export default SpotifyTrackSegment 14 | -------------------------------------------------------------------------------- /src/lib/functions/assert-fader.ts: -------------------------------------------------------------------------------- 1 | import faders from '../../constants/faders' 2 | 3 | const assertFader: (str: any) => asserts str is keyof typeof faders = (str) => { 4 | if (typeof str !== 'string') 5 | throw new TypeError('Expected input to be a string') 6 | if (!Object.keys(faders).includes(str)) 7 | throw new TypeError(`'${str}' is not keyof typeof faders`) 8 | } 9 | 10 | export default assertFader 11 | -------------------------------------------------------------------------------- /src/lib/spotify-fetchers/fetch-spotify-track-segments.ts: -------------------------------------------------------------------------------- 1 | import SpotifyTrackSegment from '../../types/spotify-track-segment' 2 | import fetchFromSpotify from './fetch-from-spotify' 3 | 4 | const fetchSpotifyTrackSegments = async (accessToken: string, id: string) => { 5 | const result = await fetchFromSpotify(accessToken, `/audio-analysis/${id}`) 6 | 7 | if (result) return result.segments as SpotifyTrackSegment[] 8 | return [] as SpotifyTrackSegment[] 9 | } 10 | 11 | export default fetchSpotifyTrackSegments 12 | -------------------------------------------------------------------------------- /src/lib/spotify-fetchers/fetch-from-spotify.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | 3 | const fetchFromSpotify = (accessToken, endpoint) => 4 | fetch(`https://api.spotify.com/v1${endpoint}`, { 5 | headers: { 6 | Accept: 'application/json', 7 | 'Content-Type': 'application/json', 8 | Authorization: `Bearer ${accessToken}`, 9 | }, 10 | }).then((res) => { 11 | if (res.status === 204) { 12 | return null 13 | } 14 | return res.json() 15 | }) 16 | 17 | export default fetchFromSpotify 18 | -------------------------------------------------------------------------------- /src/constants/faders.ts: -------------------------------------------------------------------------------- 1 | const faders = { 2 | default: (x: number) => 1 - x, 3 | linearSoft: (x: number) => (x > 0.5 ? x : 1 - x), 4 | cubicSoft: (x: number) => 5 | Math.max(Math.min(1, 0.5 - (1.4141 * x - 0.707) ** 2), 0), 6 | cubicAlwaysOn: (x: number) => Math.max(Math.min(0.5 - (x - 0.5), 1), 0), 7 | defaultSofter: (x: number) => Math.max(Math.min(1 - x ** 2, 1), 0), 8 | defaultSoftest: (x: number) => Math.max(Math.min(0.5 - (0.5 * x) ** 2, 1), 0), 9 | cubicAlwaysOnSofter: (x: number) => 10 | Math.max(Math.min(0.5 - (0.5 * x - 0.25), 1), 0), 11 | } 12 | 13 | export default faders 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spotify-hue", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "build/app.js", 6 | "scripts": { 7 | "start": "node build/app.js", 8 | "build": "tsc -p .", 9 | "dev": "ts-node-dev src/app.ts" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "@types/express": "^4.17.11", 16 | "@types/node-fetch": "^2.5.8", 17 | "@types/socket.io": "^2.1.13", 18 | "express": "^4.17.1", 19 | "firebase-admin": "^9.5.0", 20 | "get-image-colors": "^4.0.0", 21 | "node-fetch": "^2.6.1", 22 | "phea": "^1.0.6", 23 | "readline": "^1.3.0", 24 | "socket.io": "^3.1.2" 25 | }, 26 | "devDependencies": { 27 | "@types/node": "^14.14.31", 28 | "ts-node-dev": "^1.1.6", 29 | "typescript": "^4.2.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/spotify-fetchers/fetch-spotify-access-token.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | import SpotifyCredentials from '../../types/spotify-credentials' 3 | 4 | const fetchSpotifyAccessToken = async ({ 5 | clientId, 6 | clientSecret, 7 | refreshToken, 8 | }: SpotifyCredentials) => { 9 | const body = new URLSearchParams() 10 | body.append('grant_type', 'refresh_token') 11 | body.append('refresh_token', refreshToken) 12 | 13 | let { access_token: accessToken } = await fetch( 14 | 'https://accounts.spotify.com/api/token', 15 | { 16 | method: 'POST', 17 | headers: { 18 | Authorization: `Basic ${Buffer.from( 19 | `${clientId}:${clientSecret}` 20 | ).toString('base64')}`, 21 | }, 22 | body, 23 | } 24 | ).then((res) => res.json()) 25 | 26 | return accessToken as string 27 | } 28 | 29 | export default fetchSpotifyAccessToken 30 | -------------------------------------------------------------------------------- /src/lib/functions/calculate-pitches.ts: -------------------------------------------------------------------------------- 1 | import faders from '../../constants/faders' 2 | import SpotifyTrackSegment from '../../types/spotify-track-segment' 3 | 4 | const calculatePitches = ( 5 | progress: number, 6 | segments: SpotifyTrackSegment[], 7 | fader: typeof faders[keyof typeof faders] 8 | ) => { 9 | const currentSegment = segments.find(({ start, duration }) => { 10 | const min = start * 1000 11 | const max = start * 1000 + duration * 1000 12 | 13 | return progress >= min && progress < max 14 | }) 15 | 16 | if (!currentSegment) return Array(12).fill(0) 17 | 18 | const { start, duration, pitches } = currentSegment 19 | 20 | const segmentProgress = (progress - start * 1000) / (duration * 1000) 21 | 22 | const segmentFadedProgress = fader(segmentProgress) 23 | 24 | return pitches.map((pitch) => pitch * segmentFadedProgress) 25 | } 26 | 27 | export default calculatePitches 28 | -------------------------------------------------------------------------------- /src/lib/spotify-hue-worker.ts: -------------------------------------------------------------------------------- 1 | import createSpotifyHueSync, { SpotifyHueSyncArgs } from './spotify-hue' 2 | import { workerData, parentPort } from 'worker_threads' 3 | import assertFader from './functions/assert-fader' 4 | 5 | const start = async () => { 6 | const spotifyHueSync = await createSpotifyHueSync( 7 | ...(workerData as SpotifyHueSyncArgs) 8 | ) 9 | 10 | parentPort.on('close', async () => { 11 | await spotifyHueSync.stop() 12 | process.exit(0) 13 | }) 14 | 15 | parentPort.on('message', (data) => { 16 | if (data.latency) { 17 | spotifyHueSync.changeLatency(data.latency) 18 | } 19 | 20 | if (data.fader) { 21 | try { 22 | assertFader(data.fader) 23 | } catch { 24 | throw new Error('Invalid fader input') 25 | } 26 | spotifyHueSync.changeSegmentFader(data.fader) 27 | } 28 | }) 29 | 30 | try { 31 | await spotifyHueSync.start() 32 | parentPort.postMessage('started') 33 | } catch (e) { 34 | process.exit(1) 35 | } 36 | } 37 | 38 | start() 39 | -------------------------------------------------------------------------------- /src/lib/spotify-fetchers/fetch-spotify-playback-state.ts: -------------------------------------------------------------------------------- 1 | import { performance } from 'perf_hooks' 2 | import fetchFromSpotify from './fetch-from-spotify' 3 | 4 | const fetchSpotifyPlaybackState = async ( 5 | accessToken: string 6 | ): Promise< 7 | [ 8 | { 9 | trackId: string 10 | progress: number 11 | isPlaying: boolean 12 | albumCover: string 13 | } | null, 14 | number 15 | ] 16 | > => { 17 | const before = performance.now() 18 | let { 19 | item: { 20 | id, 21 | album: { images: [{ url: albumCover } = { url: '' }] = [] } = {}, 22 | }, 23 | progress_ms, 24 | is_playing, 25 | } = ((await fetchFromSpotify(accessToken, '/me/player')) || { item: {} }) as { 26 | item?: { id?: string; album: { images: [{ url: string }] } } 27 | progress_ms?: number 28 | is_playing?: boolean 29 | } 30 | const after = performance.now() 31 | 32 | return [ 33 | (id as string | undefined) 34 | ? { 35 | trackId: id, 36 | progress: progress_ms, 37 | isPlaying: is_playing, 38 | albumCover, 39 | } 40 | : null, 41 | after - before, 42 | ] 43 | } 44 | 45 | export default fetchSpotifyPlaybackState 46 | -------------------------------------------------------------------------------- /src/lib/functions/fetch-color-palette-from-remote-image.ts: -------------------------------------------------------------------------------- 1 | const getImageColors = require('get-image-colors') 2 | 3 | const minBrightness = 160 4 | 5 | const getPercievedBrightness = ([r, g, b]: [number, number, number]) => 6 | Math.ceil(0.21 * r + 0.72 * g + 0.07 * b) 7 | 8 | const checkColor = (rgb) => { 9 | const percievedBrightness = getPercievedBrightness(rgb) 10 | 11 | if (percievedBrightness >= minBrightness) return rgb 12 | 13 | const currentMaxValue = Math.max(...rgb) 14 | const factor = Math.min( 15 | minBrightness / percievedBrightness, 16 | 255 / currentMaxValue 17 | ) 18 | 19 | return rgb.map((val) => 20 | factor === Infinity ? minBrightness : Math.ceil(val * factor) 21 | ) 22 | } 23 | 24 | const fetchColorsFromRemoteImage = async (url: string, count = 12) => { 25 | const imageColors = await getImageColors(url, { 26 | count: count + 1, 27 | }) 28 | 29 | return imageColors.map((color) => color.rgb()) 30 | } 31 | 32 | const fetchColorPaletteFromRemoteImage = async (url: string, count = 12) => { 33 | const imageColors = await fetchColorsFromRemoteImage(url) 34 | 35 | const checkedColors = imageColors.map((color) => checkColor(color)) 36 | return checkedColors 37 | } 38 | 39 | export default fetchColorPaletteFromRemoteImage 40 | -------------------------------------------------------------------------------- /src/lib/spotify-hue-server.ts: -------------------------------------------------------------------------------- 1 | import { Worker } from 'worker_threads' 2 | import faders from '../constants/faders' 3 | import { SpotifyHueSyncArgs } from './spotify-hue' 4 | import { join } from 'path' 5 | 6 | export type HueSyncWorker = { 7 | stop: () => Promise 8 | changeLatency: (latency: number) => void 9 | changeSegmentFader: (segmentFader: keyof typeof faders) => void 10 | onError: (cb: () => void) => void 11 | } 12 | 13 | const startWorker = async (...args: SpotifyHueSyncArgs) => 14 | new Promise((resolve, reject) => { 15 | const worker = new Worker(join(__dirname, 'spotify-hue-worker.js'), { 16 | workerData: args, 17 | }) 18 | 19 | let started = false 20 | let errorCallback = null 21 | 22 | worker.on('exit', (exitCode) => { 23 | if (exitCode !== 0) { 24 | if (!started) { 25 | reject(new Error('Failed to start')) 26 | return 27 | } 28 | 29 | if (errorCallback) errorCallback() 30 | } 31 | }) 32 | 33 | const onError = (cb: () => void) => { 34 | errorCallback = cb 35 | } 36 | 37 | worker.on('message', (msg) => { 38 | if (msg === 'started') { 39 | started = true 40 | const result = { 41 | stop: () => worker.terminate(), 42 | changeLatency: (latency: number) => worker.postMessage({ latency }), 43 | changeSegmentFader: (segmentFader: string) => 44 | worker.postMessage({ segmentFader }), 45 | onError, 46 | } 47 | resolve(result) 48 | } 49 | }) 50 | }) 51 | 52 | export default startWorker 53 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import startWorker, { HueSyncWorker } from './lib/spotify-hue-server' 2 | import express = require('express') 3 | import faders from './constants/faders' 4 | import assertFader from './lib/functions/assert-fader' 5 | 6 | const credentials = require('../credentials.json') 7 | 8 | const app = express() 9 | 10 | let activeSync: HueSyncWorker | null = null 11 | 12 | app.get('/start', async (req, res) => { 13 | if (activeSync) return res.status(400).send('Already running') 14 | 15 | try { 16 | activeSync = await startWorker( 17 | credentials.zone, 18 | credentials.hue, 19 | credentials.spotify 20 | ) 21 | 22 | activeSync.onError = () => { 23 | activeSync = null 24 | } 25 | 26 | res.send('OK') 27 | } catch (e) { 28 | res.status(500).send('Failed to start') 29 | } 30 | }) 31 | 32 | app.get('/stop', async (req, res) => { 33 | if (!activeSync) return res.status(400).send('Not running') 34 | 35 | try { 36 | await activeSync.stop() 37 | 38 | activeSync = null 39 | 40 | res.send('OK') 41 | } catch (e) { 42 | res.status(500).send(e?.message) 43 | } 44 | }) 45 | 46 | app.get('/running', (req, res) => { 47 | res.send(!!activeSync) 48 | }) 49 | 50 | app.get('/faders', (req, res) => { 51 | res.send(Object.keys(faders)) 52 | }) 53 | 54 | app.get('/faders/:fader', async (req, res) => { 55 | if (!activeSync) return res.status(400).send('Not running') 56 | 57 | const { fader } = req.params 58 | 59 | try { 60 | assertFader(fader) 61 | } catch { 62 | return res.status(400).send('Invalid fader') 63 | } 64 | 65 | await activeSync.changeSegmentFader(fader) 66 | 67 | res.send('OK') 68 | }) 69 | 70 | app.listen(process.env.PORT || 7070) 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spotify-hue-sync 2 | 3 | Sync your Hue Lights with your Spotify playback. 4 | 5 | ## What is it? 6 | 7 | This project controls your Hue lights based on the Spotify Audio Analysis of your current playback in realtime. Each light represents a pitch. The colors are extracted from the album cover of your playback. Playback is rendered at roughly 30fps with 0ms latency. If you experience any latency, you can configure this project accordingly. 8 | 9 | ## How-to 10 | 11 | For the best experience, at least 8 bulbs are recommended, as there are 12 pitches. With fewer lamps, not every pitch is synced and it can therefore look out of sync. 12 | 13 | This project uses the Entertainment API of your Hue Bridge. Therefore, you need to set up an entertainment area via the Hue App. 14 | 15 | To get started you need to provide your credentials for both your Spotify Account as well as your Hue Bridge. Put them in a file called credentials.json at the project's root. The file should be structured like this: 16 | 17 | ``` 18 | { 19 | "zone": "", 23 | "psk": "" 24 | }, 25 | "spotify": { 26 | "clientId": "", 27 | "clientSecret": "", 28 | "refreshToken": "" 29 | } 30 | } 31 | ``` 32 | 33 | Please refer to the documentation of the node-phea package on how to get your bridge credentials. 34 | 35 | ## Usage 36 | 37 | Just run `npm start` (after you installed all dependencies via `npm i`) and your server is ready. Go to `localhost:7070/start` to start syncing and `localhost:7070/stop` to stop it. 38 | -------------------------------------------------------------------------------- /src/lib/spotify-hue.ts: -------------------------------------------------------------------------------- 1 | import { bridge as pheaBridge } from 'phea' 2 | import { performance } from 'perf_hooks' 3 | import { HueBridge } from 'phea/build/hue-bridge' 4 | import faders from '../constants/faders' 5 | import calculatePitches from '../lib/functions/calculate-pitches' 6 | import fetchSpotifyAccessToken from '../lib/spotify-fetchers/fetch-spotify-access-token' 7 | import fetchSpotifyPlaybackState from '../lib/spotify-fetchers/fetch-spotify-playback-state' 8 | import fetchSpotifyTrackSegments from '../lib/spotify-fetchers/fetch-spotify-track-segments' 9 | import SpotifyCredentials from '../types/spotify-credentials' 10 | import SpotifyTrackSegment from '../types/spotify-track-segment' 11 | import fetchColorPaletteFromRemoteImage from '../lib/functions/fetch-color-palette-from-remote-image' 12 | 13 | export type SpotifyHueSyncArgs = [ 14 | groupName: string, 15 | hueCredentials: { 16 | address: string 17 | username: string 18 | psk: string 19 | }, 20 | spotifyCredentials: SpotifyCredentials, 21 | syncOptions?: { 22 | latency?: number 23 | segmentFader?: keyof typeof faders 24 | spotifyPollingInterval?: number 25 | } 26 | ] 27 | 28 | type CreateSpotifyHueSync = ( 29 | ...args: SpotifyHueSyncArgs 30 | ) => Promise<{ 31 | stop: () => Promise 32 | start: () => Promise 33 | changeLatency: (newLatency: number) => void 34 | isRunning: () => boolean 35 | changeSegmentFader: (newSegmentFader: keyof typeof faders) => void 36 | }> 37 | 38 | const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) 39 | 40 | const startBridge = (bridge: HueBridge, id: string, timeout = 5000) => 41 | new Promise(async (resolve, reject) => { 42 | const errorHandler = (e) => { 43 | if (e?.message !== 'Error: The DTLS handshake timed out') { 44 | return 45 | } 46 | 47 | reject('Failed to start: DTLS handshake timed out') 48 | } 49 | process.on('uncaughtException', errorHandler) 50 | 51 | await bridge.start(id) 52 | await wait(timeout) 53 | 54 | process.removeListener('uncaughtException', errorHandler) 55 | 56 | resolve() 57 | }) 58 | 59 | const createSpotifyHueSync: CreateSpotifyHueSync = async ( 60 | groupName, 61 | hueCredentials, 62 | spotifyCredentials, 63 | { spotifyPollingInterval = 2000, latency = 0, segmentFader = 'default' } = {} 64 | ) => { 65 | let running = false 66 | // @ts-ignore 67 | const bridge = await pheaBridge({ ...hueCredentials }) 68 | 69 | const groups = await bridge.getGroup(0) 70 | 71 | const [id] = 72 | Object.entries(groups).find( 73 | // @ts-ignore 74 | ([, { name }]) => name === groupName 75 | ) || [] 76 | 77 | if (!id) { 78 | throw new Error('Group not found') 79 | } 80 | 81 | let coverPalette = [[255, 255, 255]] 82 | 83 | let spotifyAccessToken = await fetchSpotifyAccessToken(spotifyCredentials) 84 | setInterval(async () => { 85 | spotifyAccessToken = await fetchSpotifyAccessToken(spotifyCredentials) 86 | }, 1000 * 60 * 30) 87 | 88 | let [spotifyPlaybackState, fetchLatency] = await fetchSpotifyPlaybackState( 89 | spotifyAccessToken 90 | ) 91 | setInterval(async () => { 92 | ;[spotifyPlaybackState, fetchLatency] = await fetchSpotifyPlaybackState( 93 | spotifyAccessToken 94 | ) 95 | 96 | const albumCoverUrl = spotifyPlaybackState?.albumCover 97 | if (!albumCoverUrl) { 98 | coverPalette = [[255, 255, 255]] 99 | return 100 | } 101 | coverPalette = await fetchColorPaletteFromRemoteImage(albumCoverUrl) 102 | }, spotifyPollingInterval - 100) 103 | 104 | let segments = [] as SpotifyTrackSegment[] 105 | setInterval(async () => { 106 | if (!running) { 107 | segments = [] as SpotifyTrackSegment[] 108 | return 109 | } 110 | segments = spotifyPlaybackState?.isPlaying 111 | ? await fetchSpotifyTrackSegments( 112 | spotifyAccessToken, 113 | spotifyPlaybackState.trackId 114 | ) 115 | : ([] as SpotifyTrackSegment[]) 116 | }, spotifyPollingInterval + 100) 117 | 118 | const start = async () => { 119 | if (running) throw new Error('Already running') 120 | await startBridge(bridge, id) 121 | running = true 122 | loop() 123 | } 124 | 125 | const stop = async () => { 126 | if (!running) throw new Error('Not running') 127 | await wait(100) 128 | await bridge.transition([0], [255, 255, 255], 0) 129 | await wait(100) 130 | await bridge.stop() 131 | running = false 132 | } 133 | 134 | const changeLatency = (newLatency: number) => { 135 | latency = newLatency 136 | } 137 | 138 | const { lights } = await bridge.getGroup(id) 139 | 140 | let acknowledgedProgressOverwrite: number = 0 141 | 142 | const loop = ( 143 | progress: number = spotifyPlaybackState?.progress || 0, 144 | timestamp: number = performance.now() 145 | ) => { 146 | if (!running) return 147 | 148 | const progressOverwrite = spotifyPlaybackState?.progress || 0 149 | let actualProgress = progress 150 | if ( 151 | acknowledgedProgressOverwrite !== progressOverwrite || 152 | !spotifyPlaybackState?.isPlaying 153 | ) { 154 | actualProgress = progressOverwrite 155 | acknowledgedProgressOverwrite = progressOverwrite 156 | } 157 | 158 | const currentTimestamp = performance.now() 159 | const timeDifference = currentTimestamp - timestamp 160 | 161 | const pitches = calculatePitches( 162 | actualProgress + latency, 163 | segments, 164 | faders[segmentFader] 165 | ) 166 | 167 | for (const i of Object.keys(lights)) { 168 | if (bridge.pheaEngine.running) 169 | bridge.transition( 170 | [i], 171 | (coverPalette[i] || coverPalette[0]).map((val) => 172 | Math.floor(val * pitches[i]) 173 | ) 174 | ) 175 | } 176 | 177 | setTimeout( 178 | () => 179 | loop( 180 | segments.length ? actualProgress + timeDifference : 0, 181 | currentTimestamp 182 | ), 183 | 25 184 | ) 185 | } 186 | 187 | const changeSegmentFader = (newSegmentFader: keyof typeof faders) => { 188 | segmentFader = newSegmentFader 189 | } 190 | 191 | const isRunning = () => running 192 | 193 | return { 194 | stop, 195 | start, 196 | changeLatency, 197 | isRunning, 198 | changeSegmentFader, 199 | } 200 | } 201 | 202 | export default createSpotifyHueSync 203 | --------------------------------------------------------------------------------