Pen Settings

HTML

CSS

CSS Base

Vendor Prefixing

Add External Stylesheets/Pens

Any URL's added here will be added as <link>s in order, and before the CSS in the editor. If you link to another Pen, it will include the CSS from that Pen. If the preprocessor matches, it will attempt to combine them before processing.

+ add another resource

JavaScript

Babel is required to process package imports. If you need a different preprocessor remove all packages first.

Add External Scripts/Pens

Any URL's added here will be added as <script>s in order, and run before the JavaScript in the editor. You can use the URL of any other Pen and it will include the JavaScript from that Pen.

+ add another resource

Behavior

Save Automatically?

If active, Pens will autosave every 30 seconds after being saved once.

Auto-Updating Preview

If enabled, the preview panel updates automatically as you code. If disabled, use the "Run" button to update.

Format on Save

If enabled, your code will be formatted when you actively save your Pen. Note: your code becomes un-folded during formatting.

Editor Settings

Code Indentation

Want to change your Syntax Highlighting theme, Fonts and more?

Visit your global Editor Settings.

HTML

              
                main#app.v-app(
  @dragenter.prevent="onDragEnter"
  @dragover.prevent="onDragOver"
  @dragleave.prevent="onDragLeave"
  @drop.prevent="onDrop"
)
  canvas.v-app__canvas(
    ref="canvas"
    @click="canvasClicked"
  )
  header.v-app__header
    .content-wrapper
      .v-app__loader
        v-loader(:loading="loadingTrack")
      .v-app__layer-menu
        v-layer-menu(ref="layerMenu" :layers="layers")
  v-audio-menu(ref="menu")
  .v-app__drag-overlay(:class="{ 'v-app__drag-overlay--dragging': dragging }")
    i.material-icons.v-app__drag-icon note_add
  v-app-intro(@skip-intro="introSkipped")
  a.github-link(href="https://github.com/SeanFree/musical-particles-v3" target="_blank")

template#v-audio-menu
  aside.v-audio-menu(:class="{ 'v-audio-menu--open': open }")
    header.v-audio-menu__header
      .content-wrapper
        .v-audio-menu__control.v-audio-menu__control--toggle(@click="toggleOpen")
          i.material-icons {{ open ? 'close' : 'queue_music' }}
        v-audio(ref="audio")
        .v-audio-menu__control.v-audio-menu__control--add
          v-file-upload(
            id="fileUpload"
            :accepted-file-types="['audio/*']"
            @upload="uploadFile"
          )
    section.v-audio-menu__body
      v-track-selector(ref="trackSelector")

template#v-file-upload
  .v-file-upload
    form.v-file-upload__form(
      :name="`form-${name || id}`"
      :id="`form-${id}`"
      @submit.prevent="uploadFile"
      @reset.prevent="cancelUpload"
    )
      input.v-file-upload__file-input(
        type="file"
        ref="fileInput"
        :name="`file-input-${name || id}`"
        :id="`file-input-${id}`"
        :accept="accept"
        @change="startUpload"
      )
      label.v-file-upload__label(:for="`file-input-${id}`")
        i.material-icons playlist_add
      transition(name="v-file-upload__info-input")
        fieldset.v-file-upload__info-input(v-if="uploadStarted")
          legend.v-file-upload__legend Enter a Title &amp; Artist
          input.v-input.v-input--text.v-file-upload__text-input(
            type="text"
            placeholder="Title"
            :id="`text-input-${id}-title`"
            :name="`text-input-${name || id}-title`"
            v-model="title"
          )
          input.v-input.v-input--text.v-file-upload__text-input(
            :id="`text-input-${id}-artist`"
            type="text"
            :name="`text-input-${name || id}-artist`"
            placeholder="Artist"
            v-model="artist"
          )
          .v-file-upload__controls
            button.v-button.v-button--primary.v-file-upload__submit(
              type="submit"
            ) Submit
            button.v-button.v-button--secondary.v-file-upload__submit(
              type="reset"
            ) Cancel

template#v-track-selector
  section.v-track-selector
    ol.v-track-selector__track-list
      li.v-track-selector__list-item(
        v-for="(track, index) in trackList"
        key="`track-${index}`"
        :class="{ 'v-track-selector__list-item--selected': track.fileName === selectedTrack.fileName }"
        @click="selectTrack(track)"
      )
        .content-wrapper
          span.v-track-selector__artist {{ track.artist }}
          span.v-track-selector__title {{ track.title }}
          i.material-icons music_note

template#v-audio
  aside.v-audio
    .v-audio__container(:class="{ 'v-audio--disabled': !userInitialized || loading }")
      audio.audio(
        @canplaythrough="setTotalTime"
        @timeupdate="updateProgress"
        @ended="audioEnded"
      )
      h5.v-audio__track-title {{ trackTitle }}
      ul.v-audio__controls
        li.v-audio__control
          i.material-icons(@click="cyclePlaythroughType") {{ playthroughType }}
        li.v-audio__control
          i.material-icons(@click="skipPrevious") skip_previous
        li.v-audio__control.v-audio__control--play-pause
          i.material-icons(@click="playPause") {{ playing ? 'pause_circle_outline' : 'play_circle_outline' }}
        li.v-audio__control
          i.material-icons(@click="skipNext") skip_next
        li.v-audio__control.v-audio__control--volume
          i.material-icons(@click="toggleMute") {{ volumeType }}
          input.v-input.v-input--range.v-audio__input--volume(
            type="range"
            min="0"
            max="1"
            step="0.05"
            v-model="volume"
            @input="setVolume($event.target.value)"
          )
      .v-audio__progress
        span.v-audio__current-time {{ currentTime }}
        progress(
          value="0"
          max="1"
          ref="progressBar"
          @click="setTime"
        )
        span.v-audio__total-time {{ totalTime }}

template#v-layer-menu
  section.v-layer-menu
    .v-layer-menu__container(
      :class="{ 'v-layer-menu__container--open': layer.open }"
      :key="name"
      v-for="(layer, name) in layers"
    )
      span.v-layer-menu__toggle(@click="toggleOpen(name)")
        i.material-icons {{ layer.icon }}
      ul.v-layer-menu__list
        li.v-layer-menu__item(v-for="(item, key) in layer.options" :key="key")
          v-dropdown(
            v-if="item.type === 'string'"
            :id="name + key"
            :label="key"
            :options="item.options"
            :value="item.value"
            @change="onChange(name, key, item, $event)"
          )
          v-checkbox(
            v-if="item.type === 'boolean'"
            :id="name + key"
            :label="key"
            :checked="item.value"
            :value="item.value"
            @change="onChange(name, key, item, $event)"
          )
          v-range(
            type="range"
            v-model="item.value"
            v-if="item.type === 'number'"
            :id="name + key"
            :label="key"
            :value="item.value"
            :min="item.min"
            :max="item.max"
            :step="item.step"
            @change="onChange(name, key, item, $event)"
          )

template#v-dropdown
  .v-input__container
    label.v-input__label.v-input__label--select(
      :for="id"
    ) {{ label }}
    select.v-input.v-input--select(
      v-model="currentValue"
      @change="onChange"
    )
      option(
        v-for="(option, index) in options"
        :key="index"
        :value="option"
        :selected="currentValue === option"
      ) {{option}}

template#v-checkbox
  .v-input__container
    label.v-input__label.v-input__label--checkbox(
      :for="id"
    ) {{ label }}
      input.v-input.v-input--checkbox(
        type="checkbox"
        v-model="currentValue"
        :id="id"
        :name="name || id"
        :checked="checked"
        @change="onChange"
      )
      .v-input--checkbox__el(@click="onChange")
        i.material-icons check

template#v-range
  .v-input__container
    label.v-input__label.v-input__label--range(
      :for="id"
    ) {{ label }}
    input.v-input.v-input--range.v-layer-menu__input.v-layer-menu__input--range(
      type="range"
      v-model="currentValue"
      :id="id"
      :name="name || id"
      :value="value"
      :min="min"
      :max="max"
      :step="step"
      @input="onChange"
    )
    span.v-input__value {{ currentValue }}

template#v-loader
  transition(name="v-loader" tag="div")
    .v-loader__container(v-if="loading")
      span.v-loader__circle.v-loader__circle--parent
        span.v-loader__circle.v-loader__circle--child
          span.v-loader__circle.v-loader__circle--child
            span.v-loader__circle.v-loader__circle--child
              span.v-loader__circle.v-loader__circle--child

template#v-app-intro
  .v-app-intro(v-if="!skip")
    .content-wrapper
      .v-app-intro__step.v-app-intro__step--1(v-if="showStep1")
        p.v-app-intro__text Click here to open track selection
        i.material-icons.v-app-intro__indicator expand_more
      .v-app-intro__step.v-app-intro__step--2(v-if="showStep2")
        p.v-app-intro__text Select a track from the list
        i.material-icons.v-app-intro__indicator expand_more
      .v-app-intro__step.v-app-intro__step--3(v-if="showStep3")
        p.v-app-intro__text Click here to add an audio file
        p.v-app-intro__text You can also drag and drop a file anywhere on the screen
        i.material-icons.v-app-intro__indicator expand_more
      .v-app-intro__step.v-app-intro__step--4(v-if="showStep4")
        p.v-app-intro__text Visual controls are located here
        i.material-icons.v-app-intro__indicator expand_less
      .v-app-intro__step.v-app-intro__skip(v-if="showSkip" @click="skipIntro")
        p.v-app-intro__text Skip intro

              
            
!

CSS

              
                @import url('https://fonts.googleapis.com/css?family=Open+Sans:300&display=swap');

// Spacing / Padding & Margin

$space-2xs: 2px;
$space-xs: 4px;
$space-s: 8px;
$space-m: 16px;
$space-l: 24px;
$space-xl: 32px;
$space-2xl: 40px;
$space-3xl: 48px;
$space-4xl: 56px;
$space-5xl: 64px;

$content-padding: $space-m;
$content-padding-tablet: $space-m $space-l;

// Font & Icon Sizing

$size-xs: 12px;
$size-s: 14px;
$size-m: 16px;
$size-l: 18px;
$size-xl: 24px;
$size-2xl: 32px;
$size-3xl: 42px;
$size-4xl: 54px;

// (Bluish) Grayscale colors

$white: #ffffff;
$gray-1: #fafaff;
$gray-2: #f5f5f8;
$gray-3: #eeeefe;
$gray-4: #e0e0f4;
$gray-5: #bdbdce;
$gray-6: #9e9eac;
$gray-7: #75759d;
$gray-8: #616172;
$gray-9: #424252;
$gray-10: #21212a;
$black: #0c0c0c;
$blacker: #010101;

// Primary Colors

$teal-1: #b2dfdb;
$teal-2: #4db6ac;
$teal-3: #009688;
$teal-4: #00796b;

$amber-1: #ffecb3;
$amber-2: #ffd54f;
$amber-3: #ffc107;
$amber-4: #ffa000;

$cyan-1: #80deea;
$cyan-2: #26c6da;
$cyan-3: #00acc1;
$cyan-4: #00838f;

// Animation / Transition

$transition-duration-xs: 0.1s;
$transition-duration-s: 0.2s;
$transition-duration-m: 0.4s;
$transition-duration-l: 0.6s;

$animation-duration-xs: 1s;
$animation-duration-s: 2s;
$animation-duration-m: 4s;
$animation-duration-l: 6s;

// Breakpoints

$breakpoint-1: 512px;
$breakpoint-2: 640px;
$breakpoint-3: 768px;
$breakpoint-4: 1024px;

// Misc

$box-shadow-default: 0 0 $space-xs $black;

@mixin tablet {
	@media all and (min-width: $breakpoint-2) {
		@content;
	}
}

@mixin desktop {
	@media all and (min-width: $breakpoint-4) {
		@content;
	}
}

$layers: (
	background: 0,
	content: 1,
	interface: 2,
	overlay: 3
);

@function z($name){
	@return map-get($layers, $name);
}

html,
body {
	background: $black;
	font-family: "Open Sans", sans-serif;
	color: $white;
	overflow: hidden;
}

#app {
	position: fixed;
	top: 0;
	left: 0;
	width: 100vw;
	height: 100vh;
}

.material-icons {
	font-size: $size-xl;
	color: $white;

	&::selection {
		background-color: transparent;
	}
}

.content-wrapper {
	max-width: $breakpoint-4;
	padding: 0 $space-m;
	margin: $space-m auto 0;
	box-sizing: border-box;
}

.github-link {
	display: block;
	position: absolute;
	top: $space-s;
	left: $space-s;
	z-index: z("overlay");
	width: $space-l;
	height: $space-l;
	background: url("https://s3-us-west-2.amazonaws.com/s.cdpn.io/544318/GitHub-Mark-Light-32px.png");
	background-size: cover;
	opacity: 0.3;
	transition: opacity $transition-duration-s;

	&:hover {
		opacity: 0.9;
	}
}

.v-app {
	&__canvas {
		position: absolute;
		top: 0;
		left: 0;
		z-index: z("background");
		width: 100vw;
		height: 100vh;
		background-color: $blacker;
	}

	&__header {
		position: relative;
		z-index: z("interface");

		.content-wrapper {
			display: flex;
			align-items: flex-start;
			justify-content: space-between;
		}
	}

	&__drag-overlay {
		position: fixed;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    background: $gray-10;
    opacity: 0;
		z-index: z("overlay");
		pointer-events: none;
		transition: opacity $transition-duration-s;

		&--dragging {
			opacity: 0.8;
		}
	}

	&__drag-icon {
		font-size: $size-4xl;
    color: $cyan-2;
    position: relative;
    top: 50%;
    left: 50%;
    transform: translatex(-50%) translatey(-50%);
    border: 4px solid;
    border-radius: 50%;
    padding: $space-m;
	}
}

.v-app-intro {
	position: fixed;
	top: 0;
	left: 0;
	width: 100vw;
	height: 100vh;
	z-index: z("overlay");
	pointer-events: none;

	.content-wrapper {
		position: relative;
		width: 100%;
		height: 100%;
		margin-top: 0;
	}

	&__text:not(:first-child) {
		margin-top: $space-s;
	}

	&__step {
		position: absolute;
		max-width: 132px;
		padding: $space-s;
		border-radius: 4px;
		background: $gray-10;
		box-shadow: $box-shadow-default;
		pointer-events: auto;

		&--1 {
			bottom: 55px;
			left: $space-m;

			.v-app-intro__indicator {
				left: 0;
				animation: down-up $animation-duration-s infinite;
			}
		}

		&--2 {
			bottom: 337px;
			right: $space-m;

			.v-app-intro__indicator {
				right: 0;
				animation: down-up $animation-duration-s infinite;
			}
		}

		&--3 {
			bottom: 55px;
			right: $space-m;

			.v-app-intro__indicator {
				right: 0;
				animation: down-up $animation-duration-s infinite;
			}
		}

		&--4 {
			top: 55px;
			right: $space-m;

			.v-app-intro__indicator {
				top: -$space-m;
				right: $space-2xs;
				animation: up-down $animation-duration-s infinite;
			}
		}
	}

	&__skip {
		top: $space-m;
		cursor: pointer;

		&:hover {
			text-decoration: underline;
		}
	}

	&__indicator {
		position: absolute;
	}
}

@keyframes down-up {
	50% {
		transform: translatey($space-xs);
	}
}

@keyframes up-down {
	50% {
		transform: translatey(-$space-xs);
	}
}

@keyframes right-left {
	50% {
		transform: translatex($space-xs);
	}
}

@keyframes left-right {
	50% {
		transform: translatex(-$space-xs);
	}
}

////////////////////////
//  Input Components  //
////////////////////////

.v-input {
	&__container {
		padding: $space-s $space-m;
	}

	&__container,
	&__label--checkbox {
		width: 100%;
		display: flex;
		align-items: center;
		justify-content: space-between;
		box-sizing: border-box;
	}

	&__label {
		flex: 1;

		&--checkbox {
			cursor: pointer;
		}

		&--select,
		&--range {
			margin-right: $space-m;
		}
	}

	&__value {
		width: $space-xl;
		padding: $space-2xs;
		border-radius: 2px;
		background-color: $gray-9;
		text-align: right;
		cursor: default;
	}

	&--checkbox {
		display: none;

		&:checked + &__el {
			.material-icons {
				opacity: 1;
				transform: scale(1);
			}
		}

		&__el {
			width: $size-s;
			height: $size-s;
			border-radius: 2px;
			background-color: $gray-9;
			cursor: pointer;

			.material-icons {
				font-size: $size-s;
				color: $cyan-2;
				opacity: 0;
				transform: scale(0.8);
				transition: opacity $transition-duration-s, transform $transition-duration-s;
			}
		}
	}

	&--text {
		padding: $space-xs $space-s;
		border: 0;
		border-radius: 2px;
		margin: 0 0 $space-s;
		background: $gray-9;
		font-family: "Open Sans", sans-serif;
		color: $white;
		outline: none;

		&:focus {
			outline: none;
		}

		&::-webkit-input-placeholder {
			color: $gray-5;
		}

		&::-moz-placeholder {
			color: $gray-5;
		}

		&:-ms-input-placeholder {
			color: $gray-5;
		}

		&:-moz-placeholder {
			color: $gray-5;
			opacity: 1;
		}
	}

	&--select {
		cursor: pointer;
		padding: $space-2xs;
		border: 0;
		border-radius: 2px;
		background-color: $gray-9;
		font-family: "Open Sans", sans-serif;
		color: $white;
		outline: none;
	}
}

$input-range-thumb-radius: 14px;

.v-input--range {
	height: $space-m;
	margin-right: $space-s;
	background-color: transparent;
	color: transparent;
	cursor: pointer;
	-webkit-appearance: none;
	transition: opacity $transition-duration-s;

	&:focus {
		outline: none;
	}

	&::-webkit-slider-runnable-track {
		width: 100%;
		height: $space-2xs;
		-webkit-appearance: none;
		background: $gray-8;
	}

	&::-webkit-slider-thumb {
		position: relative;
		margin-top: -0.4 * $size-xs;
		height: $size-xs;
		width: $size-xs;
		border-radius: 50%;
		background: $cyan-2;
		-webkit-appearance: none;
	}

	&::-moz-slider-runnable-track {
		width: 100%;
		height: 2px;
		background: $gray-8;
	}

	&::-moz-slider-thumb {
		height: $input-range-thumb-radius;
		width: $input-range-thumb-radius;
		border-radius: 50%;
		background: $cyan-2;
		cursor: pointer;
		-webkit-appearance: none;
		margin-top: -6px;
	}

	&::-moz-range-track {
		width: 100%;
		height: 2px;
		cursor: pointer;
		background: $gray-8;
	}

	&::-moz-range-thumb {
		height: $input-range-thumb-radius;
		width: $input-range-thumb-radius;
		border-radius: 50%;
		border: 0;
		background: $cyan-2;
		cursor: ew-resize;
		-webkit-appearance: none;
		margin-top: -7px;
	}
}

///////////////
//  Buttons  //
///////////////

.v-button {
	box-sizing: border-box;
	padding: $space-xs $space-s;
	border-radius: 2px;
	font-family: "Open Sans", sans-serif;
	outline: none;
	color: $white;
	cursor: pointer;

	&--primary {
		background-color: $cyan-4;
		border: 0;
	}

	&--secondary {
		border: 1px solid;
		background-color: transparent;
		color: $cyan-4;
	}
}

///////////////////////
//  Audio Component  //
///////////////////////

.v-audio {
	width: 100%;
	z-index: z("interface");

	&__track-title {
		margin: $space-s 0;
		color: $white;
		font-size: $size-s;
		font-weight: bold;
		text-align: center;
		text-shadow: 0 0 4px $black;

		@include tablet {
			font-size: $size-m;
		}

		@include desktop {
			font-size: $size-l;
		}
	}
	
	&__container {
		position: relative;
		padding: $space-s $space-m;
		transition: opacity $transition-duration-m;
	}
	
	&__controls {
		display: flex;
		align-items: center;
		justify-content: center;
		width: 100%;
		box-sizing: border-box;
		margin-bottom: $space-s;
	}
	
	&__control {
		position: relative;

		.material-icons {
			font-size: $size-l;
			padding: $space-xs;
			cursor: pointer;
		}
		
		&--play-pause .material-icons {
			font-size: $size-2xl;
		}

		&:not(:last-child) {
			margin-right: $space-m;
		}

		.material-icons:active {
			transform: scale(0.85);
		}

		&--volume {
			.v-input {
				display: flex;
				align-items: center;
				position: absolute;
				top: calc(50% - #{0.65 * $space-m});
				left: $space-l;
				width: 72px;
				padding-left: $space-s;
				opacity: 0;
			}

			&:hover > .v-input {
				opacity: 1;
			}
		}
	}

	&__current-time,
	&__total-time {
		display: inline-block;
		font-size: $size-xs;
	}
	
	&__progress {
		display: flex;
		align-items: center;
		justify-content: center;
		margin-bottom: $space-s;

		progress[value] {
			width: 100%;
			max-width: 440px;
			height: $space-xs;
			display: block;
			border-radius: 0.5 * $space-xs;
			margin: 0 $space-m;
			cursor: pointer;
			appearance: none;
			overflow: hidden;

			&::-webkit-progress-bar {
				background-color: $gray-8;
				border-radius: 0.5 * $space-xs;
			}

			&::-webkit-progress-value {
				background-color: $cyan-2;
				border-radius: 0.5 * $space-xs;
			}

			&::-moz-progress-bar {
				background-color: $cyan-2;
				border-radius: 0.5 * $space-xs;
			}
		}
	}

	&--disabled {
		pointer-events: none;
		opacity: 0.5;

		.material-icons {
			color: $gray-6;
		}
	}
}

////////////////////////
//  Loader Component  //
////////////////////////

.v-loader {
	display: flex;
	height: $space-m;
	pointer-events: none;
	z-index: z("interface");

	&-enter-active,
	&-leave-active {
		transition: opacity $transition-duration-l;
	}

	&-enter,
	&-leave-to {
		opacity: 0;
	}

	&__container {
		position: relative;
		width: $space-2xl;
		height: $space-2xl;
	}

	&__circle {
		display: block;
		position: absolute;
		top: 50%;
		left: 50%;
		box-sizing: border-box;
		border: 2px solid;
		border-color: transparent $cyan-3 $cyan-3 transparent;
		border-radius: 50%;
		transform: translatex(-50%) translatey(-50%);
		animation: rotate $animation-duration-l infinite;

		&--parent {
			width: 100%;
			height: 100%;
		}

		&--child {
			width: calc(100% - 2px);
			height: calc(100% - 2px);
		}
	}
}

@keyframes rotate {
	100% {
		transform: translatex(-50%) translatey(-50%) rotate(360deg);
	}
}

//////////////////
//  Audio Menu  //
//////////////////

$audio-menu-body-height: 320px;

.v-audio-menu {
	position: absolute;
	top: 100%;
	width: 100%;
	height: 50vh;
	z-index: z("interface");
	opacity: 0.3;
	transition: transform $transition-duration-m, opacity $transition-duration-m;

	&:hover {
		opacity: 0.9;
	}

	&--open {
		opacity: 0.9;
		transform: translatey(-$audio-menu-body-height);

		.v-audio-menu__header {
			background-color: $gray-10;
			opacity: 0.9;
		}
	}

	&__header {
		position: absolute;
		transform: translatey(-100%);
		width: 100%;
		z-index: z("interface");
		transition: background-color $transition-duration-m;

		.content-wrapper {
			display: flex;
			align-items: flex-end;

			.v-audio {
				flex: 1;
			}
		}
	}

	&__body {
		height: $audio-menu-body-height;
		background-color: $gray-9;
		opacity: 0.9;
		overflow-y: scroll;
		z-index: z("content");

		&::-webkit-scrollbar {
			display: none;
		}
	}

	&__controls {
		transform: translatey(-143%);
		z-index: z("overlay");
	}

	&__control {
		position: relative;
		top: -$space-m;
		width: $space-l;
		height: $space-l;
		cursor: pointer;
	}
}

///////////////////
//  File Upload  //
///////////////////

.v-file-upload {
	position: relative;
	z-index: z("overlay");

	&__form {
		cursor: default;
	}

	&__file-input {
		display: none;
	}

	&__label {
		cursor: pointer;
	}

	&__legend {
		float: left;
		margin-bottom: $space-m;
	}

	&__info-input {
		position: absolute;
		right: 0;
		bottom: $space-xl;
		padding: $space-m;
		border-radius: 4px;
		background: $gray-10;
		box-shadow: $box-shadow-default;

		&-enter-active,
		&-leave-active {
			transition: transform $transition-duration-s, opacity $transition-duration-s;
		}
	
		&-enter,
		&-leave-to {
			opacity: 0;
			transform: translatey($space-xs);
		}
	}

	&__controls {
		display: flex;
		padding-top: $space-s;

		.v-button {
			display: inline-block;
			flex: 1;

			&:first-child {
				margin-right: $space-s;
			}
		}
	}
}

////////////////////////////////
//  Track Selector Component  //
////////////////////////////////

.v-track-selector {
	background-color: $gray-9;

	.content-wrapper {
		margin-top: 0;
	}

	&__track-list {
		font-size: $size-s;
	}

	&__list-item {
		border-bottom: 1px solid transparentize($gray-10, 0.5);
		cursor: pointer;

		.content-wrapper {
			position: relative;
			display: flex;
			flex-direction: column;
			align-items: flex-start;
			justify-content: space-between;
			padding: $space-s $space-m;

			@include tablet {
				flex-direction: row;
			}
		}
		
		.material-icons {
			position: absolute;
			top: 50%;
			right: $space-m;
			display: none;
			font-size: $size-l;
			color: $cyan-2;
			transform: translatey(-50%);
		}

		&--selected {
			.content-wrapper {
				padding-right: $space-2xl;
			}

			.material-icons {
				display: inline;
			}
		}

		&:hover {
			background: $gray-8;
		}
	}

	&__artist {
		text-align: left;
		margin-top: $space-s;

		&:before {
			display: inline-block;
		}

		@include tablet {
			margin-top: 0;
			text-align: right;
		}
	}
}

//////////////////
//  Layer Menu  //
//////////////////

.v-layer-menu {
	display: flex;
	align-items: center;
	z-index: z("interface");

	&__container {
		position: relative;
	}

	&__container--open &__list {
		opacity: 1;
		transform: translatex(0);
		pointer-events: auto;
	}

	&__container--open &__toggle {
		opacity: 0.9;
	}

	&__toggle {
		opacity: 0.3;
		transition: opacity $transition-duration-s;
		cursor: pointer;
		margin-left: $space-s;

		.material-icons:active {
			transform: scale(0.85);
		}

		&:hover {
			opacity: 0.9;
		}
	}

	&__list {
		position: absolute;
		right: 0;
		max-height: 50vh;
		border-radius: 4px;
		font-size: $size-s;
		opacity: 0;
		transform: translatey($space-s);
		pointer-events: none;
		overflow-y: scroll;
		transition: opacity $transition-duration-s, transform $transition-duration-s;
		
		&::-webkit-scrollbar {
			display: none;
		}
	}

	&__item {
		background-color: $gray-10;
		opacity: 0.7;

		&:hover {
			opacity: 0.9;
		}

		&:not(:last-child) {
			border-bottom: 1px solid $gray-9;
		}
	}
}

              
            
!

JS

              
                /////////////////////
//  App Constants  //
/////////////////////


const HOST_URL = "https://mp-vapp.s3.amazonaws.com/";
const ST_CONST = 0.9;
const MIN_DB = -140;
const MAX_DB = -10;
const FFT_SIZE = 1024; 

// increase FFT_SIZE for more particles / spectrum bars
// * Must be a power of 2, e.g. 1024, 2048

/////////////
//  Utils  //
/////////////

const pad = (n, len = 2, char = '0') => {
	let result = `${n}`;

	while (result.length < len) {
		result = `${char}${result}`;
	}

	return result;
};

const sToTime = s => {
	return `${pad(parseInt(s / 60))}:${pad(parseInt(s % 60))}`;
};

const clone = o => JSON.parse(JSON.stringify(o));

/////////////
//  Store  //
/////////////

// Getters

const LOADING_TRACK = "loadingTrack";
const SELECTED_TRACK = "selectedTrack";
const TRACK_LIST = "trackList";

const getters = {
	[LOADING_TRACK](state) {
		return state[LOADING_TRACK];
	},
	[TRACK_LIST](state) {
		return state[TRACK_LIST];
	},
	[SELECTED_TRACK](state) {
		return state[SELECTED_TRACK];
	}
};

// Actions

const FETCH_TRACK_DATA = "FETCH_TRACK_DATA";
const FETCH_FILE_FROM_HOST = "FETCH_FILE_FROM_HOST";
const SELECT_TRACK = "SELECT_TRACK";

const actions = {
	async [FETCH_FILE_FROM_HOST]({ commit }, track) {
		const { fileName } = track;

		commit(SET_TRACK_LOADING, true);

		try {
			const { status, statusText, data } = await axios.get(
				`${HOST_URL}${fileName}`,
				{
					responseType: "blob"
				}
			);

			if (status === 200) {
				track.data = data;
			} else {
				console.error({ status, statusText });
			}
		} catch (err) {
			console.error(err);
		} finally {
			commit(SET_TRACK_LOADING, false);
		}
	},
	async [SELECT_TRACK]({ commit, dispatch }, track) {
		if (!track.data) {
			await dispatch(FETCH_FILE_FROM_HOST, track);
		}

		commit(SET_SELECTED_TRACK, track);
	}
};

// Mutations

const SET_TRACK_LOADING = "SET_TRACK_LOADING";
const SET_SELECTED_TRACK = "SET_SELECTED_TRACK";
const SET_TRACK_LIST = "SET_TRACK_LIST";
const ADD_TRACK_TO_LIST = "ADD_TRACK_TO_LIST";

const mutations = {
	[SET_TRACK_LOADING](state, payload) {
		state[LOADING_TRACK] = payload;
	},
	[SET_SELECTED_TRACK](state, payload) {
		state[SELECTED_TRACK] = payload;
	},
	[SET_TRACK_LIST](state, payload) {
		state[TRACK_LIST] = payload;
	},
	[ADD_TRACK_TO_LIST](state, payload) {
		state[TRACK_LIST].push(payload);
	}
};

// State

const state = {
	[LOADING_TRACK]: false,
	[TRACK_LIST]: [],
	[SELECTED_TRACK]: {}
};

const store = new Vuex.Store({
  state,
  getters,
  actions,
  mutations
});

Vue.use(Vuex);

// Event Hub

const E_SELECT_TRACK = "track-select";
const E_TOGGLE_VISUAL_CONTROL = "toggle-visual-control";
const E_FILE_UPLOAD = "file-upload";
const E_CLOSE_MENUS = "close-menus";
const E_FILE_DROPPED = "file-dropped";

const eventHub = new Vue();
const vEvents = Vue.mixin({
	data() {
		return {
			eventHub
		};
	}
});

//////////////////
//  Components  //
//////////////////

const { mapGetters, mapActions, mapMutations } = Vuex;

const vAudio = Vue.component("v-audio", {
	template: document.querySelector("#v-audio"),
	mixins: [vEvents],
	data() {
		this.volume = 0.8;
		this.previousVolume = 0.8;
		this.maxVolume = 1;
		this.muted = false;
		this.element = null;
		this.source = null;
		this.ctx = null;
		this.gainNode = null;
		this.analyser = null;
		this.fileData = null;
		this.skipAmount = 10;
		this.playthroughTypes = ["repeat", "repeat_one", "shuffle"];
		this.volumeTypes = ["volume_off", "volume_down", "volume_up"];

		return {
			userInitialized: false,
			playing: false,
			loading: false,
			currentIndex: 0,
			currentTime: "0:00",
			totalTime: "0:00",
			playthroughType: "repeat",
			volumeType: "volume_down"
		};
	},
	computed: {
		trackTitle() {
			return this[SELECTED_TRACK].artist
				? `${this[SELECTED_TRACK].artist} - ${this[SELECTED_TRACK].title}`
				: ''
		},
		...mapGetters([SELECTED_TRACK, TRACK_LIST])
	},
	methods: {
		async changeTrack() {
			this.playing = true;
			this.loading = false;
			this.element.src = URL.createObjectURL(this[SELECTED_TRACK].data);
			this.element.play();
		},
		playPause() {
			this.element[this.playing ? "pause" : "play"]();

			this.playing = !this.playing;
		},
		toggleMute() {
			this.setVolume(this.muted ? this.previousVolume : 0);

			this.muted = !this.muted;
		},
		setVolume(newVolume) {
			this.previousVolume = this.volume;
			this.gainNode.gain.value = this.volume = newVolume;
			this.volumeType = this.volumeTypes[
				ceil(this.volume * (this.volumeTypes.length - 1))
			];
		},
		async skipPrevious() {
			if (this.playthroughType === "shuffle") {
				await this.randomizeSelection();
			} else {
				this.currentIndex =
					this.currentIndex > 0
						? this.currentIndex - 1
						: this[TRACK_LIST].length - 1;
			}

			this.continuePlaylist();
		},
		async skipNext() {
			if (this.playthroughType === "shuffle") {
				await this.randomizeSelection();
			} else {
				this.currentIndex =
					this.currentIndex < this[TRACK_LIST].length - 1
						? this.currentIndex + 1
						: 0;
			}

			this.continuePlaylist();
		},
		async continuePlaylist() {
			await this[SELECT_TRACK](this[TRACK_LIST][this.currentIndex]);
			await this.changeTrack();
		},
		async randomizeSelection() {
			let selection = round(rand(this[TRACK_LIST].length - 1));

			while (selection === this.currentIndex) {
				selection = round(rand(this[TRACK_LIST].length - 1));
			}

			this.currentIndex = selection;

			this.continuePlaylist();
		},
		async audioEnded() {
			this.element.currentTime = 0;
			this.element.pause();

			if (this.playthroughType === "repeat") {
				await this.skipNext();
			} else if (this.playthroughType === "shuffle") {
				await this.randomizeSelection();
			} else {
				await this.changeTrack();
			}
		},
		async userSelection(track) {
			this.ctx.resume();
			this.element.pause();
			this.playing = false;
			this.loading = true;

			await this[SELECT_TRACK](track);

			this.currentIndex = this[TRACK_LIST].indexOf(track);

			await this.changeTrack();

			this.userInitialized = true;
		},
		async fileUploaded() {
			this.currentIndex = this[TRACK_LIST].length - 1;

			await this.changeTrack();

			this.userInitialized = true;
		},
		cyclePlaythroughType() {
			const index = this.playthroughTypes.indexOf(this.playthroughType);

			this.playthroughType = this.playthroughTypes[
				index < this.playthroughTypes.length - 1 ? index + 1 : 0
			];
		},
		setTime($event) {
			this.element.currentTime =
				this.element.duration * ($event.offsetX / $event.target.offsetWidth);
		},
		setTotalTime() {
			this.totalTime = sToTime(this.element.duration);
		},
		updateProgress() {
			const value = this.element.currentTime / this.element.duration || 0;

			this.$refs.progressBar.value = value;
			this.currentTime = sToTime(this.element.currentTime);
		},
		...mapActions([SELECT_TRACK])
	},
	mounted() {
		this.eventHub.$on(E_SELECT_TRACK, this.userSelection.bind(this));
		this.eventHub.$on(E_FILE_UPLOAD, this.userSelection.bind(this));

		this.element = this.$el.querySelector("audio");
		this.ctx = new AudioContext();
		this.source = this.ctx.createMediaElementSource(this.element);

		this.gainNode = this.ctx.createGain();
		this.setVolume(this.volume);

		this.analyser = this.ctx.createAnalyser();
		this.analyser.smoothingTimeConstant = ST_CONST;
		this.analyser.minDecibels = MIN_DB;
		this.analyser.maxDecibels = MAX_DB;
		this.analyser.fftSize = FFT_SIZE;

		this.source.connect(this.gainNode);
		this.gainNode.connect(this.analyser);
		this.analyser.connect(this.ctx.destination);
	}
});

const vAudioMenu = Vue.component("v-audio-menu", {
	template: document.querySelector("#v-audio-menu"),
	mixins: [vEvents],
	data() {
		this.upload = null;

		return {
			open: false
		}
	},
	methods: {
		toggleOpen() {
			this.open = !this.open;
		},
		uploadFile(file) {
			this[ADD_TRACK_TO_LIST](file);

			this.eventHub.$emit(E_FILE_UPLOAD, file);

			this.open = false;
		},
		...mapMutations([ADD_TRACK_TO_LIST])
	},
	created() {
		this.eventHub.$on(E_CLOSE_MENUS, () => {
			this.open = false
		});
		this.eventHub.$on(E_SELECT_TRACK, this.toggleOpen.bind(this));
	}
});

const vFileUpload = Vue.component("v-file-upload", {
	template: document.querySelector("#v-file-upload"),
	mixins: [vEvents],
	props: {
		id: {
			type: String,
			required: true
		},
		name: {
			type: String,
			required: false
		},
		acceptedFileTypes: {
			type: Array,
			required: true
		}
	},
	data() {
		return {
			title: null,
			artist: null,
			uploadStarted: false
		}
	},
	computed: {
		accept() {
			return this.acceptedFileTypes.join()
		}
	},
	methods: {
		startUpload() {
			const self = this;

			jsmediatags.read(this.$refs.fileInput.files[0], {
				onSuccess(tags) {
					const {
						tags: {
							artist,
							title
						}
					} = tags;

					self.artist = artist;
					self.title = title;
					self.uploadStarted = true;
				},
				onError(err) {
					console.error(err);
				}
			});
		},
		cancelUpload() {
			this.uploadStarted = false;
		},
		uploadFile() {
			this.$emit("upload", {
				fileName: this.$refs.fileInput.files[0].name,
				data: this.$refs.fileInput.files[0],
				title: this.title,
				artist: this.artist
			});
			this.title = null;
			this.artist = null;
			this.$refs.fileInput.files = null;
			this.uploadStarted = false;
		},
		fileDropped($data) {
			this.$refs.fileInput.files = $data;

			this.startUpload();
		}
	},
	created() {
		this.fileReader = new FileReader();
	},
	mounted() {
		this.eventHub.$on(E_FILE_DROPPED, this.fileDropped.bind(this));
	}
});

const vInput = Vue.mixin({
	data() {
		return {
			currentValue: null
		}
	},
	props: {
		id: {
			type: String,
			required: true
		},
		label: {
			type: String,
			required: true
		},
		name: {
			type: String,
			required: false
		},
		value: {
			type: String,
			required: false
		}
	},
	methods: {
		onChange() {
			this.$emit("change", this.currentValue);
		}
	},
	created() {
		this.currentValue = this.value;
	}
});

const vDropdown = Vue.component("v-dropdown", {
	template: document.querySelector("#v-dropdown"),
	mixins: [vInput],
	props: {
		options: {
			type: Array,
			required: true
		}
	}
});

const vCheckbox = Vue.component("v-checkbox", {
	template: document.querySelector("#v-checkbox"),
	mixins: [vInput],
	props: {
		checked: {
			type: Boolean,
			required: false
		}
	}
});

const vRange = Vue.component("v-range", {
	template: document.querySelector("#v-range"),
	props: {
		min: {
			type: Number,
			required: true
		},
		max: {
			type: Number,
			required: true
		},
		step: {
			type: Number,
			required: true
		}
	},
	methods: {
		onChange() {
			this.$emit("change", +this.currentValue);
		}
	},
});

const vLayerMenu = Vue.component("v-layer-menu", {
	template: document.querySelector("#v-layer-menu"),
	mixins: [vEvents],
	props: {
		icon: {
			type: String,
			required: true
		},
		layers: {
			type: Object,
			required: true
		}
	},
	data() {
		return {
			open: false
		};
	},
	methods: {
		toggleOpen(layerName) {
			Object.keys(this.layers).forEach(key => {
				this.layers[key].open = this.layers[key].open
					? !this.layers[key].open
					: this.layers[key].open = key === layerName;
			});
			this.$forceUpdate();
		},
		closeAll() {
			Object.keys(this.layers).forEach(key => {
				this.layers[key].open = false;
			});
			this.$forceUpdate();
		},
		onChange(name, key, item, value) {
			this.eventHub.$emit("layerUpdate", {
				...item,
				name,
				key,
				value
			});
		}
	},
	created() {
		this.eventHub.$on(E_CLOSE_MENUS, this.closeAll.bind(this));
	}
});

const vLoader = Vue.component("v-loader", {
	template: document.querySelector("#v-loader"),
	mixins: [vEvents],
	props: {
		loading: {
			type: Boolean,
			default: false
		}
	}
});

const vTrackSelector = Vue.component("v-track-selector", {
	template: document.querySelector("#v-track-selector"),
	mixins: [vEvents],
	computed: {
		...mapGetters([SELECTED_TRACK, TRACK_LIST])
	},
	methods: {
		selectTrack(track) {
			this[SET_SELECTED_TRACK](track);
			this.eventHub.$emit(E_SELECT_TRACK, track);
		},
		...mapMutations([SET_SELECTED_TRACK])
	}
});

const vAppIntro = Vue.component("v-app-intro", {
  template: document.querySelector("#v-app-intro"),
  data() {
    return {
      skip: false,
      classNames: [
        ".v-audio-menu__control--toggle",
        ".v-track-selector__track-list",
        ".v-file-upload__label",
        ".v-app__layer-menu"
      ],
      steps: {
        0: true,
        1: true,
        2: true,
        3: true
      }
    }
  },
  computed: {
    showStep1() {
      return this.steps[0];
    },
    showStep2() {
      return !this.showStep1 && this.steps[1];
    },
    showStep3() {
      return !this.showStep1 && !this.showStep2 && this.steps[2];
    },
    showStep4() {
      return !this.showStep1 && !this.showStep2 && !this.showStep3 && this.steps[3];
    },
    showSkip() {
      return this.showStep1 || this.showStep2 || this.showStep3 || this.showStep4;
    }
  },
  methods: {
    skipIntro() {
      this.skip = true;
      localStorage.setItem("mpapp_skip_intro", true);
      this.$emit("skip-intro");
    },
    closeStep(index) {
      this.steps[index] = false;
      document.querySelector(this.classNames[index]).onclick = null;
    }
  },
  mounted() {
    if (localStorage["mpapp_skip_intro"]) {
      this.skip = true;
    } else {
      this.classNames.forEach((className, index) => {
        document.querySelector(className).onclick = this.closeStep.bind(this, index);
      });
    }
  }
});

////////////
//  Main  //
////////////

const vApp = new Vue({
	el: "#app",
	store,
	mixins: [vEvents],
	computed: {
		...mapGetters([LOADING_TRACK])
	},
	data() {
		return {
			dragging: false,
			layers: {},
			tracks: [
				{
					fileName: "verdidiesirae.mp3",
					title: "Messa da Reqiuem: Dies Irae",
					artist: "Giuseppi Verdi"
				},
				{
					fileName: "lisztcampanella.mp3",
					title: "La Campanella",
					artist: "Franz Liszt"
				},
				{
					fileName: "saintsaensdansemacabre.mp3",
					title: "Danse Macabre",
					artist: "Camille Saint-Saëns"
				},
				{
					fileName: "satiegnossienne1.mp3",
					title: "Gnoissienne No. 1",
					artist: "Erik Satie"
				},
				{
					fileName: "holstmars.mp3",
					title: "Mars, Bringer of War",
					artist: "Gustav Holst"
				}
			]
		};
	},
	methods: {
		addLayerControl(name, icon, options) {
			this.$set(this.layers, name, {
				icon,
				options
			});
		},
		onDragOver() {
			this.dragging = true;
		},
		onDragEnter() {
			this.dragging = true;
		},
		onDragLeave() {
			this.dragging = false;
		},
		onDrop($event) {
			const { dataTransfer } = $event;

			this.eventHub.$emit(E_FILE_DROPPED, dataTransfer.files);

			this.dragging = false;
		},
		canvasClicked() {
			this.eventHub.$emit(E_CLOSE_MENUS, true);
		},
		introSkipped() {
			this.eventHub.$emit(E_SELECT_TRACK, this.tracks[0]);
			this.$refs.menu.open = false;
		},
		...mapMutations([SET_TRACK_LIST])
	},
	created() {
		this[SET_TRACK_LIST](this.tracks);
	}
});

const {
	canvas, 
	menu: {
		$refs: {
			audio: {
				analyser
			}
		}
	}
} = vApp.$refs;

///////////////////
//  Canvas Setup //
///////////////////

class AudioFrequencyReader {
	constructor(analyser, type = "float", minDb = 0, maxDb = 0) {
		if (!(analyser instanceof AnalyserNode)) {
			throw new TypeError("Provided value is not of type AnaylserNode");
		}
		this.analyser = analyser;
		this.frequencyCount = analyser.frequencyBinCount;
		this.type = type;
		this.minDb = minDb;
		this.maxDb = maxDb;
		this.frequencyData =
			type === "float"
				? new Float32Array(this.frequencyCount)
				: new Uint8Array(this.frequencyCount);
		this.normalizedFrequencyData = new Float32Array(this.frequencyCount);
		this.min = type === "float" ? minDb : 0;
		this.max = type === "float" ? maxDb : 256;
		this.updateFn =
			type === "float" ? "getFloatFrequencyData" : "getByteFrequencyData";
	}
	update() {
		this.analyser[this.updateFn](this.frequencyData);
		this.averageFrequency =
			this.frequencyData.reduce((a, b) => a + b, 0) / this.frequencyCount;
		this.normalizedAverageFrequency = norm(
			this.averageFrequency,
			this.min,
			this.max
		);
	}
	async *readValues() {
		let i = 0;
		for (; i < this.frequencyCount; i++) {
			yield this.frequencyData[i];
		}
	}
	async *readNormalizedValues() {
		let i = 0;

		for (; i < this.frequencyCount; i++) {
			yield this.normalizedFrequencyData[i];
		}
	}
	async normalize() {
		let f, i = 0;

		for await (f of this.readValues()) {
			this.normalizedFrequencyData[i] = clamp(norm(f, this.min, this.max), 0, 1);

			i++;
		}
	}
}

class CompositeLayer {
	constructor(w, h) {
		this.frame = document.createElement("canvas");
		this.buffer = this.frame.getContext("2d");
		this.dimensions = [w, h];
		this.center = [0.5 * w, 0.5 * h];
		this.frame.width = w;
		this.frame.height = h;
	}
	get width() {
		return this.dimensions[0];
	}
	get height() {
		return this.dimensions[1];
	}
	set width(w) {
		this.dimensions[0] = w;
		this.frame.width = w;
	}
	set height(h) {
		this.dimensions[1] = h;
		this.frame.height = h;
	}
	get centerX() {
		return this.center[0];
	}
	get centerY() {
		return this.center[1];
	}
	resizeFrame(w, h) {
		this.width = this.frame.width = w;
		this.height = this.frame.height = h;

		this.center[0] = 0.5 * w;
		this.center[1] = 0.5 * h;
	}
}

class CanvasCompositer extends CompositeLayer {
	constructor(el, x, y, w, h) {
		super(w, h);
		this.el = el;
		this.ctx = this.el.getContext("2d");
		this.el.width = w;
		this.el.height = h;
		this.position = [x, y];
		this.compositeLayers = [];
	}
	addLayer(layer) {
		this.compositeLayers.push(layer);
	}
	resize(w, h) {
		this.el.width = w;
		this.el.height = h;
		this.resizeFrame(w, h);
		this.resizeLayers(w, h);
	}
	resizeLayers(w, h) {
		this.compositeLayers.forEach(layer => layer.resizeFrame(w, h));
	}
	drawGlow(frame) {
		this.buffer.save();
		this.buffer.filter = "blur(6px) brightness(150%)";
		this.buffer.globalCompositeOperation = "lighter";
		this.buffer.drawImage(frame, 0, 0);
		this.buffer.restore();
	}
	async render() {
		this.buffer.clearRect(0, 0, this.width, this.height);
		this.ctx.clearRect(0, 0, this.width, this.height);

		await Promise.all(
			this.compositeLayers.map(async layer => {
				layer.options.glow && this.drawGlow(layer.frame);

				this.buffer.drawImage(layer.frame, 0, 0, this.width, this.height);
			})
		);

		this.ctx.drawImage(this.frame, 0, 0);
	}
}

const PARTICLE_DEFAULTS = {
	baseTTL: 100,
	rangeTTL: 100,
	baseSize: 1,
	rangeSize: 10,
	baseHue: 0,
	rangeHue: 180,
	spawnRadius: 180,
	spawnSector: 1,
	spawnStartAngle: 0.5,
	originX: 0.5,
	originY: 0.5,
	baseSpeed: 1,
	rangeSpeed: 30,
	glow: true,
	frequencyCount: 0
};

const PARTICLE_CONFIG = {
	baseTTL: {
		type: "number",
		step: 1,
		min: 0,
		max: 200,
		value: PARTICLE_DEFAULTS.baseTTL
	},
	rangeTTL: {
		type: "number",
		step: 1,
		min: 0,
		max: 800,
		value: PARTICLE_DEFAULTS.rangeTTL
	},
	baseSize: {
		type: "number",
		step: 0.5,
		min: 1,
		max: 10,
		value: PARTICLE_DEFAULTS.baseSize
	},
	rangeSize: {
		type: "number",
		step: 0.5,
		min: 1,
		max: 50,
		value: PARTICLE_DEFAULTS.rangeSize
	},
	baseHue: {
		type: "number",
		step: 1,
		min: 0,
		max: 360,
		value: PARTICLE_DEFAULTS.baseHue
	},
	rangeHue: {
		type: "number",
		step: 1,
		min: 0,
		max: 360,
		value: PARTICLE_DEFAULTS.rangeHue
	},
	spawnRadius: {
		type: "number",
		step: 1,
		min: 50,
		max: 300,
		value: PARTICLE_DEFAULTS.spawnRadius
	},
	spawnSector: {
		type: "number",
		step: 0.05,
		min: 0,
		max: 1,
		value: PARTICLE_DEFAULTS.spawnSector
	},
	spawnStartAngle: {
		type: "number",
		step: 0.05,
		min: 0,
		max: 1,
		value: PARTICLE_DEFAULTS.spawnStartAngle
	},
	originX: {
		type: "number",
		step: 0.05,
		min: 0,
		max: 1,
		value: PARTICLE_DEFAULTS.originX
	},
	originY: {
		type: "number",
		step: 0.05,
		min: 0,
		max: 1,
		value: PARTICLE_DEFAULTS.originY
	},
	baseSpeed: {
		type: "number",
		step: 0.5,
		min: 0,
		max: 10,
		value: PARTICLE_DEFAULTS.baseSpeed
	},
	rangeSpeed: {
		type: "number",
		step: 0.5,
		min: 0,
		max: 50,
		value: PARTICLE_DEFAULTS.rangeSpeed
	},
	glow: {
		type: "boolean",
		value: PARTICLE_DEFAULTS.glow
	}
};

class ParticleVisualizer extends CompositeLayer {
	constructor(w, h, options) {
		super(w, h);
		this.options = Object.assign({}, PARTICLE_DEFAULTS, options);
		this.init();
	}
	init() {
		this.normalizedAverageFrequency = 0;
		this.particles = new PropsArray(this.options.frequencyCount, [
			"x",
			"y",
			"t",
			"vx",
			"vy",
			"r",
			"l",
			"ttl"
		]);
		this.particles.forEach(this.initParticle.bind(this));
	}
	initParticle(v, i) {
		let bx, by, x, y, r, t, p, d, nd, vx, vy, l, ttl;

		bx = this.options.originX * this.width;
		by = this.options.originY * this.height;
		p = (this.options.spawnStartAngle * TAU) + rand(this.options.spawnSector * TAU);
		d = rand(this.options.spawnRadius);
		x = bx + d * cos(p);
		y = by + d * sin(p);
		r = this.options.baseSize;
		t = angle(bx, by, x, y);
		nd = norm(dist(bx, by, x, y), 0, this.options.spawnRadius);
		vx = cos(t) * nd;
		vy = sin(t) * nd;
		l = 0;
		ttl = this.options.baseTTL + rand(this.options.rangeTTL);

		this.particles.set([x, y, t, vx, vy, r, l, ttl], i);
	}
	setFrequencyCount(frequencyCount) {
		this.frequencyCount = frequencyCount;
	}
	setNormalizedAverageFrequency(normalizedAverageFrequency) {
		this.normalizedAverageFrequency = normalizedAverageFrequency;
	}
	update() {
		this.buffer.clearRect(0, 0, this.width, this.height);
	}
	render(index, data) {
		let [x, y, t, vx, vy, r, l, ttl] = this.particles.get(index * this.particles.props.length);
		let bx, by, h, a, nd, sv;

		bx = this.options.originX * this.width;
		by = this.options.originY * this.height;
		nd = norm(dist(x, y, bx, by), 0, this.options.spawnRadius);
		h =
			this.options.baseHue +
			this.options.rangeHue * (index / this.options.frequencyCount + (1 - data));
		a = data * fadeInOut(l, ttl);
		r =
			this.options.baseSize +
			pow(data + this.normalizedAverageFrequency, 2) * this.options.rangeSize;
		sv =
			this.options.baseSpeed +
			pow(data + this.normalizedAverageFrequency, 2) *
			this.options.rangeSpeed * nd;
		vx = cos(t) * sv;
		vy = sin(t) * sv;
		x = lerp(x, x + vx, 0.15);
		y = lerp(y, y + vy, 0.15);
		l++;

		this.buffer.fillStyle = `hsla(${h}, 85%, 50%, ${a})`;
		this.buffer.beginPath();
		this.buffer.arc(x, y, r, 0, TAU);
		this.buffer.fill();
		this.buffer.closePath();
		this.particles.set(
			[x, y, t, vx, vy, r, l, ttl],
			index * this.particles.props.length
		);

		(l > ttl || this.checkBounds(x, y, r)) &&
			this.initParticle(null, index * this.particles.props.length);
	}
	checkBounds(x, y, r) {
		return x - r > this.width || x + r < 0 || y - r > this.height || y + r < 0;
	}
}

const SPECTRUM_DEFAULTS = {
	spectrumType: "radial",
	backlight: true,
	backlightRadius: 220,
	startAngle: 0.75,
	spectrumRadius: 160,
	segmentLengthMin: 0,
	segmentLengthMax: 100,
	reflect: false,
	baseHue: 160,
	rangeHue: 120,
	originX: 0.5,
	originY: 0.5,
	glow: true,
	frequencyCount: 0
};

const SPECTRUM_CONFIG = {
	spectrumType: {
		type: "string",
		options: [
			"linear",
			"radial"
		],
		init: true,
		value: SPECTRUM_DEFAULTS.spectrumType
	},
	backlight: {
		type: "boolean",
		value: SPECTRUM_DEFAULTS.backlight
	},
	backlightRadius: {
		type: "number",
		step: 1,
		min: 0,
		max: 800,
		value: SPECTRUM_DEFAULTS.backlightRadius
	},
	startAngle: {
		type: "number",
		step: 0.05,
		min: 0,
		max: 1,
		value: SPECTRUM_DEFAULTS.startAngle
	},
	spectrumRadius: {
		type: "number",
		step: 1,
		min: 0,
		max: 300,
		init: true,
		value: SPECTRUM_DEFAULTS.spectrumRadius
	},
	segmentLengthMin: {
		type: "number",
		step: 1,
		min: 0,
		max: 100,
		value: SPECTRUM_DEFAULTS.segmentLengthMin
	},
	segmentLengthMax: {
		type: "number",
		step: 1,
		min: 0,
		max: 300,
		value: SPECTRUM_DEFAULTS.segmentLengthMax
	},
	reflect: {
		type: "boolean",
		init: true,
		value: SPECTRUM_DEFAULTS.reflect
	},
	baseHue: {
		type: "number",
		step: 1,
		min: 0,
		max: 360,
		value: SPECTRUM_DEFAULTS.baseHue
	},
	rangeHue:  {
		type: "number",
		step: 1,
		min: 0,
		max: 360,
		value: SPECTRUM_DEFAULTS.rangeHue
	},
	originX: {
		type: "number",
		step: 0.05,
		min: 0,
		max: 1,
		value: SPECTRUM_DEFAULTS.originX
	},
	originY: {
		type: "number",
		step: 0.05,
		min: 0,
		max: 1,
		value: SPECTRUM_DEFAULTS.originY
	},
	glow: {
		type: "boolean",
		value: SPECTRUM_DEFAULTS.glow
	}
};

class SpectrumVisualizer extends CompositeLayer {
	constructor(w, h, options) {
		super(w, h);
		this.options = Object.assign({}, SPECTRUM_DEFAULTS, options);
		this.init();
	}
	init() {
		this.spectrumWidth = this.options.frequencyCount;
		this.spread = this.spectrumWidth * (this.options.reflect ? 2 : 1);
		this.segmentWidth =
			this.options.spectrumRadius /
			(this.spectrumWidth * (this.options.reflect ? 0.5 : 0.25));
		this.baseRadius = this.options.spectrumRadius;
		this.normalizedAverageFrequency = 0;
		this.drawSegment =
			this.options.spectrumType === "linear"
				? this.drawLinearSegment
				: this.drawRadialSegment;
	}
	setNormalizedAverageFrequency(normalizedAverageFrequency) {
		this.normalizedAverageFrequency = normalizedAverageFrequency;
	}
	update() {
		this.buffer.clearRect(0, 0, this.width, this.height);
		this.scaledRadius =
			this.options.spectrumRadius * (1 + this.normalizedAverageFrequency);
		this.options.backlight && this.drawBacklight();
	}
	render(index, data) {
		if (this.options.reflect) {
			this.drawSegment(index, data);
			this.drawSegment(-index - 1, data);
		} else {
			this.drawSegment(index, data);
		}
	}
	drawBacklight() {
		let bx, by, hue, gradient;

		bx = this.options.originX * this.width;
		by = this.options.originY * this.height;
		hue =
			this.options.baseHue +
			this.options.rangeHue * (1 - this.normalizedAverageFrequency);
		gradient = this.buffer.createRadialGradient(
			bx,
			by,
			0,
			bx,
			by,
			this.options.backlightRadius * (1 + this.normalizedAverageFrequency)
		);
		gradient.addColorStop(
			0,
			`hsla(${hue}, 75%, 50%, ${this.normalizedAverageFrequency})`
		);
		gradient.addColorStop(1, `hsla(${hue}, 75%, 40%, 0)`);
		this.buffer.fillStyle = gradient;
		this.buffer.fillRect(0, 0, this.width, this.height);
	}
	drawRadialSegment(index, data) {
		let t, cosT, sinT, x1, y1, x2, y2, hue, scale;

		t = (TAU * this.options.startAngle) + index / this.spread * TAU;
		cosT = cos(t);
		sinT = sin(t);
		x1 = this.options.originX * this.width + this.scaledRadius * cosT;
		y1 = this.options.originY * this.height + this.scaledRadius * sinT;
		hue = this.options.baseHue + this.options.rangeHue * (1 - data);
		scale = this.options.segmentLengthMin + (data * this.options.segmentLengthMax);
		x2 = x1 + scale * cosT;
		y2 = y1 + scale * sinT;

		this.buffer.lineWidth = this.segmentWidth;
		this.buffer.strokeStyle = `hsla(${hue}, 75%, 50%, 0.8)`;
		this.buffer.beginPath();
		this.buffer.moveTo(x1, y1);
		this.buffer.lineTo(x2, y2);
		this.buffer.stroke();
		this.buffer.closePath();
	}
	drawLinearSegment(index, data) {
		let x, y, lineWidth, hue, scale;

		scale = this.options.segmentLengthMin + (data * this.options.segmentLengthMax);
		x =
			index /
				this.options.frequencyCount *
				(this.width * (this.options.reflect ? 0.5 : 1)) +
			(this.options.reflect ? 0.5 * this.width : 0);
		y =
			this.height * this.options.originY +
			(scale - scale * this.options.originY);
		lineWidth = this.segmentWidth;
		hue = this.options.baseHue + this.options.rangeHue * (1 - data);

		this.buffer.lineWidth = lineWidth;
		this.buffer.strokeStyle = `hsla(${hue}, 75%, 50%, 0.8)`;
		this.buffer.beginPath();
		this.buffer.moveTo(x + lineWidth, y);
		this.buffer.lineTo(x + lineWidth, y - scale);
		this.buffer.stroke();
		this.buffer.closePath();
	}
}

const VISUALIZER_DEFAULTS = {
	minDb: 0,
	maxDb: 0,
	analyserType: "int"
};

class VisualizerController extends CanvasCompositer {
	constructor(el, x, y, w, h, analyser, options) {
		super(el, x, y, w, h);

		this.options = Object.defineProperties(
			VISUALIZER_DEFAULTS,
			Object.getOwnPropertyDescriptors(options)
		);

		this.frequencyReader = new AudioFrequencyReader(
			analyser,
			this.options.analyserType,
			this.options.minDb,
			this.options.maxDb
		);
	}
	async updateCompositeLayers() {
		let index, layer, data;
		
		for await (layer of this.compositeLayers) {
			index = 0;

			layer.update();

			for await (data of this.frequencyReader.readNormalizedValues()) {
				layer.setNormalizedAverageFrequency(
					this.frequencyReader.normalizedAverageFrequency
				);
				layer.render(index, data);
				index++;
			}
		}
	}
	async update() {
		this.frequencyReader.update();
		await this.frequencyReader.normalize();
		await this.updateCompositeLayers();
		await this.render();
	}
}

class VisualizerApp {
	constructor(app, canvas, analyser) {
		this.controller = new VisualizerController(
			canvas,
			0,
			0,
			window.innerWidth,
			window.innerHeight,
			analyser,
			{
				minDb: MIN_DB,
				maxDb: MAX_DB
			}
		);

		this.layerControls = {};

		this.layerControls.spectrum = new SpectrumVisualizer(window.innerWidth, window.innerHeight, {
			frequencyCount: analyser.frequencyBinCount
		});
		this.controller.addLayer(this.layerControls.spectrum);
		app.addLayerControl("spectrum", "bar_chart", clone(SPECTRUM_CONFIG));

		this.layerControls.particles = new ParticleVisualizer(window.innerWidth, window.innerHeight, {
			frequencyCount: analyser.frequencyBinCount
		});
		this.controller.addLayer(this.layerControls.particles);
		app.addLayerControl("particles", "bubble_chart", clone(PARTICLE_CONFIG));
		
		app.eventHub.$on("layerUpdate", data => {
			this.layerControls[data.name].options[data.key] = data.value;
			data.init && this.layerControls[data.name].init();
		});
	}
	init() {
		this.resize();
		window.addEventListener("resize", this.resize.bind(this));
		this.run();
	}
	resize() {
		const { innerWidth, innerHeight } = window;

		this.controller.resize(innerWidth, innerHeight);
	}
	async run() {
		await this.controller.update();
		window.requestAnimationFrame(this.run.bind(this));
	}
}

const visApp = new VisualizerApp(vApp, canvas, analyser);

visApp.init();

// ----------------- //

              
            
!
999px

Console