Skip to content

Instantly share code, notes, and snippets.

@harunpehlivan
Created May 30, 2021 17:51
Show Gist options
  • Save harunpehlivan/285fb3e02ea25b009c839bafcfc3d41e to your computer and use it in GitHub Desktop.
Save harunpehlivan/285fb3e02ea25b009c839bafcfc3d41e to your computer and use it in GitHub Desktop.
Soundcloud Audio Visualizer
<div id="app">
<nav class="navbar fixed-top navbar-light bg-light dense">
<div class="form-inline">
<a class="btn-icon mr-3" href="http://harunpehlivan.fm.tc/" target="_blank">
<i class="fab fa-soundcloud fa-lg"></i>
</a>
<button @click="onTogglePlay" type="button" class="btn-icon mr-2">
<i v-if="playing" class="fas fa-pause"></i>
<i v-else class="fas fa-play"></i>
</button>
<progress-input v-model="currentTimeInput" :total="duration" style="width:150px;"></progress-input>
<button @click="onToggleVolume" type="button" class="btn-icon ml-4 mr-2">
<i v-if="volume>0" class="fas fa-volume-up"></i>
<i v-else class="fas fa-volume-mute"></i>
</button>
<progress-input v-model="volume" :total="100"></progress-input>
</div>
<div v-if="track.title" class="d-none d-lg-block">
<a :href="track.permalink_url" :title="track.title" class="navbar-brand" target="_blank">
<img v-if="track.artwork_url" :src="track.artwork_url">
</a>
<a :href="track.user.permalink_url" :title="track.user.username" @click.prevent="onSearchUserTracks(track.user)">{{ truncate(track.user.username, 4) }}</a>
- <a :href="track.permalink_url" :title="track.title" target="_blank">{{ truncate(track.title, 4) }}</a>
</div>
<form class="form-inline d-none d-sm-block">
<input v-model="q" @keyup.enter="onSearch" class="form-control form-control-sm" type="search" placeholder="Search" aria-label="Search">
<button @click.prevent="onSearch" class="btn btn-outline-secondary btn-sm ml-2" type="submit"><i class="fas fa-search"></i></button>
</form>
</nav>
<transition mode="out-in" enter-active-class="fadeIn" leave-active-class="fadeOut">
<div v-if="showSearchModal" class="modal-container">
<div @click.self="showSearchModal=false" class="modal show" style="display: block;" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg modal-dialog-scrollable modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<form class="form-inline">
<input v-model="q" @keyup.enter="onSearch" class="form-control form-control-sm" type="search" placeholder="Search" aria-label="Search">
<button @click.prevent="onSearch" class="btn btn-outline-secondary btn-sm ml-2" type="submit"><i class="fas fa-search"></i></button>
</form>
<button @click="showSearchModal=false" type="button" class="close" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div v-if="searching" class="loading fa-3x"><i class="fas fa-circle-notch fa-spin"></i></div>
<div v-else class="tracks">
<p v-if="tracks.length==0">No results match your search criteria.</p>
<ul v-else class="list-unstyled">
<li v-for="(t, i) in tracks" class="media mb-2">
<div class="cover mr-2">
<img :src="t.artwork_url" @click="onToggleTrack(t)">
<button @click="onToggleTrack(t)" type="button" class="btn-icon">
<i v-if="isPlaying(t.id)" class="fas fa-pause"></i>
<i v-else class="fas fa-play"></i>
</button>
</div>
<div class="media-body">
<h5 class="my-0">
<a :href="t.user.permalink_url" :title="t.user.username" @click.prevent="onSearchUserTracks(t.user)">{{ t.user.username }}</a>
- <a :href="t.permalink_url" :title="t.title" @click.prevent="onToggleTrack(t)">{{ t.title }}</a>
</h5>
<div>{{ truncate(t.description, 15) }}</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div v-if="showSearchModal" class="modal-backdrop show"></div>
</div>
</transition>
<div v-if="showStart" id="start">
<button @click="onStart" type="button" class="btn-icon"><i class="fas fa-play"></i></button>
<div class="modal-backdrop show"></div>
</div>
<canvas id="canvas"></canvas>
</div>
<script type="text/x-template" id="progress-input-template">
<div class="progress progress-input" @click="onClick">
<div class="progress-bar progress-current" :style="{ width: `${progressPct}%` }"></div>
</div>
</script>
const { cos, sin, PI, min, max, floor, random } = Math;
const { clamp } = THREE.Math;
const simplex = new SimplexNoise();
/**
* Audio Vis
*/
function AudioVis(conf) {
conf = {
el: 'canvas',
fov: 75,
cameraZ: 130,
background: 0x000000,
fftSize: 32,
fftIgnore: 3,
torusRadius: 6,
...conf,
};
const fftDSize = conf.fftSize / 2;
const numTorus = fftDSize - conf.fftIgnore;
let renderer, scene, camera, cameraCtrl;
let width, height, cx, cy;
let cscale;
let trings;
let composer;
let analyser;
let aFrequencies = [], aFAvg = 0, aFMax = 0;
let frequencies, fAvg = 0;
init();
function init() {
renderer = new THREE.WebGLRenderer({ canvas: document.getElementById(conf.el) });
camera = new THREE.PerspectiveCamera(conf.fov);
camera.position.z = conf.cameraZ;
updateSize();
window.addEventListener('resize', updateSize, false);
initScene();
initPostProcessing();
animate();
}
function initScene() {
scene = new THREE.Scene();
if (conf.background) scene.background = new THREE.Color(conf.background);
scene.add(new THREE.AmbientLight(0xcccccc));
// cscale = chroma.scale([0x0, 0x4040ff, 0xff4040, 0xffffff]);
cscale = chroma.scale([0x0, 0x00b9e0, 0xff880a, 0x5f1b90, 0x7ec08d]);
trings = createTRings();
trings.o3d.lookAt(new THREE.Vector3(0, 100, 60));
scene.add(trings.o3d);
const mouse = new THREE.Vector2();
const mousePlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), -20);
const mousePosition = new THREE.Vector3();
const raycaster = new THREE.Raycaster();
renderer.domElement.addEventListener('mousemove', e => {
mouse.x = (e.clientX / width) * 2 - 1;
mouse.y = -(e.clientY / height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
raycaster.ray.intersectPlane(mousePlane, mousePosition);
trings.o3d.lookAt(mousePosition);
});
renderer.domElement.addEventListener('click', e => {
cscale = chroma.scale([0x0, chroma.random(), chroma.random(), chroma.random()]);
});
}
function initPostProcessing() {
const { EffectComposer, RenderPass, BloomEffect, EffectPass } = POSTPROCESSING;
composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
renderPass.renderToScreen = false;
composer.addPass(renderPass);
const bloomEffect = new BloomEffect();
bloomEffect.blendMode.opacity.value = 4;
bloomEffect.distinction = 1;
const effectPass = new EffectPass(camera, bloomEffect);
effectPass.renderToScreen = true;
composer.addPass(effectPass);
}
this.setAudio = (audio) => {
const tAudio = new THREE.Audio(new THREE.AudioListener());
tAudio.setMediaElementSource(audio);
analyser = new THREE.AudioAnalyser(tAudio, conf.fftSize);
}
function analyseFrequencyData() {
if (analyser) {
frequencies = analyser.getFrequencyData();
fAvg = analyser.getAverageFrequency();
} else {
// fake frequencies
frequencies = [];
const time = Date.now() * 0.0005;
for (let i = 0; i < fftDSize; i++)
frequencies[i] = (simplex.noise2D(i * 0.05, time) + 1) * (fftDSize - i) * (128 / fftDSize);
fAvg = frequencies.reduce((a, b) => a + b) / frequencies.length;
}
aFrequencies.push(fAvg);
if (aFrequencies.length > 120) aFrequencies.shift();
aFAvg = aFrequencies.reduce((a, b) => a + b) / aFrequencies.length;
aFMax = aFrequencies.reduce((prev, current) => (prev > current) ? prev : current);
}
function animate() {
requestAnimationFrame(animate);
analyseFrequencyData();
animateTrings();
composer.render();
}
function animateTrings() {
for (let i = 0; i < numTorus; i++) {
animateTorus(trings.objects[numTorus - i - 1], { aFAvg, aFMax, fAvg, f: frequencies[i] });
}
}
function animateTorus(torus, { aFAvg, aFMax, fAvg, f }) {
const aFMaxCoef = aFMax > 0 ? 1 / aFMax : 1 / 0xff;
const fCoef = aFMaxCoef * f;
const fCoef1 = aFMaxCoef * (f - fAvg);
// const fCoef2 = aFMaxCoef * (f - aFAvg);
// const fCoef3 = aFMaxCoef * (fAvg - aFAvg);
const z = torus.position.z + ((fCoef * (5 + aFAvg * 0.3)) - torus.position.z) / 2;
torus.position.z = z;
const s = torus.scale.x + ((1 + fCoef1 * 0.05) - torus.scale.x) / 2;
torus.scale.set(s, s, s);
torus.material.color = new THREE.Color(cscale(clamp(0.01 + f / 300, 0, 1)).hex());
}
function createTRings() {
const trings = new THREE.Object3D();
const objects = [];
let r, tr, geo, mat, mesh;
for (let i = 0; i < numTorus; i++) {
r = conf.torusRadius / 2 + i * conf.torusRadius;
tr = conf.torusRadius / 2;
mat = new THREE.MeshStandardMaterial({ color: cscale(0.01).hex(), roughness: 0.5, metalness: 0.9 });
geo = new THREE.TorusBufferGeometry(r, tr, 8, (i + 1) * 16);
mesh = new THREE.Mesh(geo, mat);
objects.push(mesh);
trings.add(mesh);
}
const o3d = new THREE.Object3D();
trings.position.z = -50;
o3d.add(trings);
const pointLight = new THREE.PointLight(0xffffff);
pointLight.position.z = 200;
o3d.add(pointLight);
return { o3d, objects };
}
function updateSize() {
width = window.innerWidth; cx = width / 2;
height = window.innerHeight; cy = height / 2;
renderer.setSize(width, height);
if (composer) composer.setSize(width, height);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
}
/**
* SoundCloud helper
*/
class SoundCloud {
constructor(client_id) {
this.client_id = client_id;
this.api = axios.create({
baseURL: 'https://api.soundcloud.com/',
timeout: 5000,
});
this.api.defaults.params = { 'client_id': this.client_id };
}
search(q) {
return new Promise((resolve, reject) => {
this.api.get('tracks', { params: { q } })
.then(response => {
resolve(response);
})
.catch(error => {
reject(error);
});
});
}
searchUserTracks(user_id) {
return new Promise((resolve, reject) => {
this.api.get(`users/${user_id}/tracks`)
.then(response => {
resolve(response);
})
.catch(error => {
reject(error);
});
});
}
getTrack(trackId) {
return new Promise((resolve, reject) => {
this.api.get(`tracks/${trackId}`)
.then(response => {
resolve(response);
})
.catch(error => {
reject(error);
});
});
}
getStreamUrl(track) {
return `${track.stream_url}?client_id=${this.client_id}`;
}
}
/**
* Progress input component
*/
Vue.component('progress-input', {
template: '#progress-input-template',
props: {
'value': Number,
'total': { type: Number, default: 0 },
},
computed: {
progressPct() {
return 100 * this.value / this.total;
},
},
methods: {
onClick(e) {
const r = this.$el.getBoundingClientRect();
const pct = min(max(0, 100 * (e.clientX - r.x) / r.width), 100);
this.$emit('input', pct);
},
},
});
/**
* Vue App
*/
new Vue({
el: '#app',
data: {
duration: 0,
currentTime: 0,
volume: 50,
track: {},
tracks: [],
q: '',
playing: false,
searching: false,
showStart: true,
showSearchModal: false,
},
computed: {
currentTimeInput: {
get() {
return this.currentTime;
},
set(v) {
this.getAudio().currentTime = v * this.duration / 100;
}
},
},
watch: {
volume() {
this.getAudio().volume = this.volume / 100;
},
},
mounted() {
const cliendIds = ['xIa292zocJP1G1huxplgJKVnK0V3Ni9D', 'o6wsvSiFtQ4JE7mUaWl2qX71BHUy5Zgv', 'b8RLIe5KjFHhkmmbPLN0znpdHKEwLdFF', 'zcIaffYH3Kmirg4eYPYL3jtdqbIIuctK', 'FQ9LDhAVSqOIHJ3QvP6QSvclL0MFDVyx', 'xHUOEQdFkG0xugRO3lIHWfbVds0wnC3J'];
const clientId = cliendIds[floor(random() * cliendIds.length)];
console.log(clientId);
this.sc = new SoundCloud(clientId);
this.audioVis = new AudioVis();
},
methods: {
onStart() {
this.showStart = false;
this.onEmptySearch();
},
onEmptySearch() {
this.tracks = [
{
'id': 314116820,
'title': ' Duydum Ki Bensiz Yaralı Gibisin ',
'artwork_url': 'https://res.cloudinary.com/tercuman-b-l-m-merkez/image/upload/v1606079516/logo_transparent_hpplu2.png',
'permalink_url': 'https://soundcloud.com/harun-pehl-van/duydum-ki-bensiz-yarali-gibisin',
'stream_url': 'https://api.soundcloud.com/tracks/314116820/stream',
'user': { 'id': 121602784, 'username': 'harun-pehl-van', 'permalink_url': 'http://soundcloud.com/harun-pehl-van' },
},
{
'id': 312752664,
'title': ' Iyi Ki Dogdun Harun ',
'artwork_url': 'https://res.cloudinary.com/tercuman-b-l-m-merkez/image/upload/v1606079516/logo_transparent_hpplu2.png',
'permalink_url': 'https://soundcloud.com/harun-pehl-van/iyi-ki-dogdun-harun',
'stream_url': 'https://api.soundcloud.com/tracks/312752664/stream',
'user': { 'id': 121602784, 'username': 'harun-pehl-van', 'permalink_url': 'http://soundcloud.com/harun-pehl-van' },
},
{
'id': 167675625,
'title': 'harun-pehl-van -HARUN PEHLİVANIN DOĞUM GÜNÜ MARŞI ',
'artwork_url': 'https://res.cloudinary.com/tercuman-b-l-m-merkez/image/upload/v1606079516/logo_transparent_hpplu2.png',
'permalink_url': 'https://soundcloud.com/harun-pehl-van/dogumgunumarsi',
'stream_url': 'https://api.soundcloud.com/tracks/167675625/stream',
'user': { 'id': 28593673, 'username': 'harun-pehl-van', 'permalink_url': 'http://soundcloud.com/harun-pehl-van' },
},
{
'id': 45710769,
'title': ' Sami YUSUF Sallou ',
'artwork_url': 'https://res.cloudinary.com/tercuman-b-l-m-merkez/image/upload/v1606079516/logo_transparent_hpplu2.png',
'permalink_url': 'https://soundcloud.com/harun-pehl-van/mp3indirdur-sami-yusuf-sallou',
'stream_url': 'https://api.soundcloud.com/tracks/45710769/stream',
'user': { 'id': 856062, 'username': 'harun-pehl-van', 'permalink_url': 'http://soundcloud.com/harun-pehl-van' },
},
];
this.showSearchModal = true;
},
getAudio() {
// https://developers.google.com/web/updates/2017/09/autoplay-policy-changes#webaudio
if (!this.audio) {
this.audio = new Audio();
this.audio.crossOrigin = 'anonymous';
this.audio.addEventListener('loadeddata', e => { this.duration = this.audio.duration; });
this.audio.addEventListener('timeupdate', e => { this.currentTime = this.audio.currentTime; });
this.audio.addEventListener('ended', e => { this.playing = false; this.audio.currentTime = 0; });
this.volume = 20;
this.audioVis.setAudio(this.audio);
}
return this.audio;
},
reset() {
this.track = {};
this.getAudio().pause();
},
onSearch() {
if (this.q) {
this.search(this.q);
this.showSearchModal = true;
} else {
this.onEmptySearch();
}
},
search(q) {
this.tracks = [];
this.searching = true;
this.sc.search(q)
.then(response => {
this.tracks = response.data;
})
.then(() => {
this.searching = false;
});
},
onSearchUserTracks(user) {
this.searchUserTracks(user);
this.showSearchModal = true;
},
searchUserTracks(user) {
this.tracks = [];
this.searching = true;
this.sc.searchUserTracks(user.id)
.then(response => {
this.tracks = response.data;
})
.then(() => {
this.searching = false;
});
},
setTrack(track) {
this.track = track;
this.getAudio().src = this.sc.getStreamUrl(this.track);
},
onToggleTrack(track) {
if (this.track.id == track.id) {
this.onTogglePlay();
return;
}
this.showSearchModal = false;
this.reset();
this.setTrack(track);
this.play();
},
onToggleVolume() {
if (this.volume > 0) {
this.oldVolume = this.volume;
this.volume = 0;
} else {
this.volume = this.oldVolume || 0;
}
},
isPlaying(id) {
return (this.track.id == id && this.playing);
},
play() {
this.getAudio().play();
this.playing = true;
},
pause() {
this.getAudio().pause();
this.playing = false;
},
onTogglePlay() {
if (!this.getAudio().src) return;
if (this.playing) this.pause();
else this.play();
},
truncate(s, limit) {
if (!s) return;
const words = s.split(' ');
let ts = words.slice(0, limit);
return ts.join(' ') + (words.length > limit ? '...' : '');
},
}
});
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.18.0/axios.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/103/three.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/chroma-js/2.0.3/chroma.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/postprocessing.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/simplex-noise/2.4.0/simplex-noise.min.js"></script>

Soundcloud Audio Visualizer

First test with Soundcloud API, search your favorite songs, click to change colors. Post a comment if you have problem with API, otherwise post your favorite song :)

A Pen by HARUN PEHLİVAN on CodePen.

License.

html, body {
margin: 0;
width: 100%;
height: 100%;
}
a {
color: #555;
transition: color 0.4s;
}
a:hover {
color: #000;
}
.navbar {
height: 60px;
}
.navbar-brand img {
max-height: 30px;
}
.btn-icon {
border: none;
padding: 0;
background-color: transparent;
}
.progress-input {
height: 8px;
min-width: 50px;
border-radius: 0;
cursor: pointer;
}
.progress-input .progress-current {
background-color: #555;
}
.cover {
position: relative;
width: 64px;
height: 64px;
cursor: pointer;
}
.cover img {
width: 100%;
height: 100%;
}
.cover button {
position: absolute;
left: 12px;
top: 12px;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.5);
transition: background-color 0.3s;
}
.cover:hover button {
background-color: rgba(255, 255, 255, 1);
}
.tracks .media-body {
font-size: 0.8rem;
}
.tracks .media-body h5 {
font-size: 1rem;
}
.loading {
text-align: center;
margin: 3rem auto;
}
#start .btn-icon {
position: fixed;
z-index: 1050;
margin-top: 50vh;
margin-left: 50vw;
transform: translateX(-50%) translateY(-50%);
font-size: 60px;
width: 150px;
height: 150px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.9);
color: #555;
transition: all 0.4s;
}
#start .btn-icon:hover {
background-color: rgba(255, 255, 255, 0.6);
color: #000;
}
canvas {
position: fixed;
top: 0;
z-index: 0;
width: 100%;
height: 100%;
/* cursor: none; */
}
.modal-container {
position: fixed;
z-index: 1040;
animation-duration: 0.5s;
}
.modal-container.fadeIn { animation-name: fadeIn; }
.modal-container.fadeOut { animation-name: fadeOut; }
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://use.fontawesome.com/releases/v5.8.1/css/all.css" rel="stylesheet" />
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment