diff --git a/packages/visualizer-web-test/index.html b/packages/visualizer-web-test/index.html
index dc30f3f..35fc4db 100644
--- a/packages/visualizer-web-test/index.html
+++ b/packages/visualizer-web-test/index.html
@@ -15,6 +15,16 @@
diff --git a/packages/visualizer-web-test/src/main.ts b/packages/visualizer-web-test/src/main.ts
index fb7df7f..076297a 100644
--- a/packages/visualizer-web-test/src/main.ts
+++ b/packages/visualizer-web-test/src/main.ts
@@ -1,12 +1,17 @@
import { MusicPlayerBuilder } from "@euterpe.js/player";
-import { AudioVisualBuilder, SmoothingAlgorythm, ShapeType } from "@euterpe.js/visualizer"
+import { fft_data } from "./waveform_data";
+import { AudioVisualBuilder, SmoothingAlgorythm, ShapeType, WaveformOrientation, WaveformShape } from "@euterpe.js/visualizer"
const audio_el = document.querySelector("#audio") as HTMLAudioElement
const music_player_builder = new MusicPlayerBuilder(audio_el)
const trapnation_analyser_node = music_player_builder.add_analyser()
const bar_analyser_node = music_player_builder.add_analyser()
const music_player = music_player_builder.build()
-music_player.change_volume(1)
+music_player.change_volume(.5)
+const waveform_canvas = document.querySelector("#waveform-canvas") as SVGSVGElement
+const seek_element = document.querySelector("#seek") as HTMLInputElement
+const duration_element = document.querySelector("#duration") as HTMLElement
+const current_time_element = document.querySelector("#current") as HTMLElement
/**
* Create the Audio Visualizer
*/
@@ -18,23 +23,84 @@ const trapnation_visual_builder = new AudioVisualBuilder(trapnation_analyser_nod
.set_fft_time_smoothing(0.6)
//If not using typescript enums, CatmullRom = number 2
.set_smoothing_algorythm(SmoothingAlgorythm.CatmullRom)
-const trapnation_visual = trapnation_visual_builder.build(ShapeType.Circle)
+const trapnation_visual = trapnation_visual_builder.build(ShapeType.Circle, false)
const bar_visual_builder = new AudioVisualBuilder(bar_analyser_node, document.querySelector("#bar-canvas") as SVGSVGElement)
- .set_fft_data_tresholds({ point_count_i: 50, fft_multiplier_i: 3, fft_offset_i: -30 })
+ .set_fft_data_tresholds({ point_count_i: 50, fft_multiplier_i: 2, fft_offset_i: -100 })
.set_fft_time_smoothing(0.8)
.set_smoothing_algorythm(SmoothingAlgorythm.BezierPerpendicular)
-const bar_visual = bar_visual_builder.build(ShapeType.Line)
+const bar_visual = bar_visual_builder.build(ShapeType.Line, false)
+
+const waveform_visual_builder = new AudioVisualBuilder(bar_analyser_node, waveform_canvas)
+ .set_fft_data_tresholds({ point_count_i: 100, fft_multiplier_i: 1, fft_offset_i: -80 })
+ .set_fft_time_smoothing(0.8)
+ .set_smoothing_algorythm(SmoothingAlgorythm.CatmullRom)
+const waveform_visual = waveform_visual_builder.build(ShapeType.Waveform, true, { fft_data: new Float32Array(fft_data.fft_data), orientation: WaveformOrientation.Horizontal, shape_type: WaveformShape.LineLike })
+
trapnation_visual.draw()
bar_visual.draw()
+waveform_visual.draw_once()
+//Here I create 2 duplicate elements of the waveform, set their opacity to 1/2, map one to current song time, other to seeking on hover
+const waveform_path_seek = waveform_canvas.children[0].cloneNode()
+const waveform_path_time = waveform_canvas.children[0].cloneNode()
+waveform_path_seek.id = "waveform-seek"
+waveform_path_time.id = "waveform-time"
+waveform_path_seek.classList.add("waveform-seek")
+waveform_path_time.classList.add("waveform-time")
+waveform_canvas.appendChild(waveform_path_time)
+waveform_canvas.appendChild(waveform_path_seek)
+/*
+const time_clip_path = document.createElement("clipPath")
+const seek_clip_path = document.createElement("clipPath")
+const seek_clip_rect = document.createElement("rect")
+const time_clip_rect = document.createElement("rect")
+const time_clip_use = document.createElement("use")
+const seek_clip_use = document.createElement("use")
+time_clip_path.id = "clip-time"
+seek_clip_path.id = "clip-seek"
+time_clip_rect.id = "clip-time-rect"
+time_clip_rect.setAttribute("width", "0")
+time_clip_rect.setAttribute("height", "500")
+seek_clip_rect.id = "clip-seek-rect"
+seek_clip_rect.setAttribute("width", "200")
+seek_clip_rect.setAttribute("height", "500")
+time_clip_use.setAttribute("clip-path", "url(#clip-time)")
+time_clip_use.setAttribute("href", "#waveform-time")
+time_clip_use.classList.add("clipping-waveform")
+
+seek_clip_use.classList.add("clipping-waveform")
+seek_clip_use.setAttribute("clip-path", "url(#clip-seek)")
+seek_clip_use.setAttribute("href", "#waveform-seek")
+
+waveform_canvas.appendChild(time_clip_path)
+waveform_canvas.appendChild(seek_clip_path)
+waveform_canvas.appendChild(time_clip_use)
+waveform_canvas.appendChild(seek_clip_use)
+seek_clip_path.appendChild(seek_clip_rect)
+time_clip_path.appendChild(time_clip_rect)
+*/
+function convert_range(value: number, r1: number[], r2: number[]) {
+ return ((value - r1[0]) * (r2[1] - r2[0])) / (r1[1] - r1[0]) + r2[0]
+}
+waveform_canvas.addEventListener("mousemove", (e) => {
+ const rect = e.target.getBoundingClientRect()
+ const x = e.clientX - rect.left
+ const resX = convert_range(x, [0, rect.width], [0, waveform_canvas.viewBox.baseVal.width + 40])
+ const polygon = `polygon(0 0, ${resX}px 0, ${resX}px 100%, 0 100%)`
+ document.documentElement.style.setProperty("--clip-seek-path", polygon)
+})
+waveform_canvas.addEventListener("mouseleave", (e) => {
+ const polygon = `polygon(0 0, 0 0, 0 100%, 0 100%)`
+ document.documentElement.style.setProperty("--clip-seek-path", polygon)
+})
/*
* The player part
*/
-music_player.try_new_song_async(encodeURI("http://127.0.0.1:4200/nuphory - NVISION (EXTENDED MIX).ogg"))
+music_player.try_new_song_async(encodeURI("http://localhost:4200/nuphory - NVISION (EXTENDED MIX).ogg"))
.then(() => {
let is_seeking = false
document.querySelector("#play")?.addEventListener("click", () => {
@@ -70,15 +136,18 @@ music_player.try_new_song_async(encodeURI("http://127.0.0.1:4200/nuphory - NVISI
})
// Subscriptions to AudioContext changes, eg. time..
music_player.on_duration_formatted((time) => {
- document.querySelector("#duration").innerHTML = time
- document.querySelector("#seek").max = "" + music_player.current_song_duration
+ duration_element.innerHTML = time
+ seek_element.max = "" + music_player.current_song_duration
})
music_player.on_time_tick_formatted((time) => {
- document.querySelector("#current").innerHTML = time
+ current_time_element.innerHTML = time
})
music_player.on_time_tick((time) => {
if (is_seeking) return
- document.querySelector("#seek").value = "" + time
+ seek_element.value = "" + time
+ const x = `${time / music_player.current_song_duration * 100}%`
+ const polygon = `polygon(0 0, ${x} 0, ${x} 100%, 0 100%)`
+ document.documentElement.style.setProperty("--clip-time-path", polygon)
})
}, (e) => console.log(e))
\ No newline at end of file
diff --git a/packages/visualizer-web-test/src/styles.css b/packages/visualizer-web-test/src/styles.css
index 302751e..d2e7b7a 100644
--- a/packages/visualizer-web-test/src/styles.css
+++ b/packages/visualizer-web-test/src/styles.css
@@ -1,22 +1,23 @@
#volume{
- transform: rotate(270deg);
+ transform: rotate(270deg);
+}
+:root {
+ --clip-time-path: polygon("0 0, 0 0, 0 100%, 0 100%");
+ --clip-seek-path: polygon("0 0, 0 0, 0 100%, 0 100%");
}
body {
- width: 100vw;
- height:100vh;
+ height:100%;
+ padding: 10vh;
margin: 0;
- padding: 0;
- display: flex;
- justify-content: center;
- align-items: center;
}
.player-wrapper{
- width:50vw;
+ width:100%;
display: flex;
justify-content: space-between;
+ margin-top:10vh;
}
.viz-wrapper{
- width:50vw;
+ width:100%;
display: flex;
justify-content: space-between;
}
@@ -27,4 +28,12 @@ body {
}
.wrapper div{
width:100%;
+}
+.waveform-time {
+ clip-path: var(--clip-time-path);
+ fill: rgba(50,145,200,0.3);
+}
+.waveform-seek {
+ clip-path: var(--clip-seek-path);
+ fill: rgba(50,145,200,0.3);
}
\ No newline at end of file
diff --git a/packages/visualizer-web-test/src/waveform_data.ts b/packages/visualizer-web-test/src/waveform_data.ts
new file mode 100644
index 0000000..7f7bb2f
--- /dev/null
+++ b/packages/visualizer-web-test/src/waveform_data.ts
@@ -0,0 +1,14 @@
+export const fft_data = {
+ "fft_data": [
+ -106.24, -99.73, -100.98, -101.34, -107.01, -92.38, -84.85, -90.28, -93.68, -95.02, -97.16,
+ -96.32, -99.23, -103.13, -85.57, -98.17, -103.27, -107.5, -83.62, -95.23, -97.12, -94.78,
+ -95.93, -101.42, -97.83, -102.42, -111.74, -101.38, -106.8, -111.05, -88.04, -90.88, -97.67,
+ -96.31, -96.69, -102.15, -102.03, -100.51, -107.14, -101.48, -101.6, -106.62, -73.94,
+ -79.53, -92.74, -96.08, -96.26, -100.35, -99.13, -102.03, -107.4, -93.57, -102.31, -102.3,
+ -109.04, -81.85, -92.79, -100.06, -95.79, -96.49, -99.89, -100.27, -102.69, -107.35,
+ -103.94, -104.64, -104.3, -78.82, -84.2, -95.29, -92.57, -93.47, -98.08, -98.9, -101.56,
+ -109.38, -102.01, -102.51, -104.83, -72.18, -76.52, -91.69, -99.97, -96.63, -98.61, -76.97,
+ -90.41, -100.38, -106.77, -102.83, -104.46, -108.59, -80.97, -88.05, -100.77, -79.64, -72.3,
+ -87.96, -92.89, -93.03
+ ],
+}
\ No newline at end of file
diff --git a/packages/visualizer/package.json b/packages/visualizer/package.json
index 350d0b4..c873c9b 100644
--- a/packages/visualizer/package.json
+++ b/packages/visualizer/package.json
@@ -1,6 +1,6 @@
{
"name": "@euterpe.js/visualizer",
- "version": "1.0.1",
+ "version": "1.0.2",
"type": "module",
"description": "Music visualizer based on SVG and AudioContext",
"main": "./src/index.js",
diff --git a/packages/visualizer/src/index.ts b/packages/visualizer/src/index.ts
index 5fd6676..bad15e9 100644
--- a/packages/visualizer/src/index.ts
+++ b/packages/visualizer/src/index.ts
@@ -7,12 +7,25 @@ export enum SmoothingAlgorythm {
export enum ShapeType {
Circle,
Line,
+ Waveform,
/*To be Implmeneted
Custom,
- Waveform,
FullSongWaveForm
*/
}
+export enum WaveformOrientation {
+ Vertical,
+ Horizontal,
+}
+export enum WaveformShape {
+ LineLike,
+ Striped,
+}
+export type WaveformOptions = {
+ fft_data: Float32Array,
+ shape_type: WaveformShape,
+ orientation: WaveformOrientation
+}
type Point = {
x: number,
y: number,
@@ -22,6 +35,8 @@ type Shape = {
//Algo-rythm, because this is about music. Get it? xd
smoothing_algorythm: SmoothingAlgorythm
points: Point[]
+ waveform_options?: WaveformOptions
+ symmetry?: boolean
}
export class AudioVisual {
@@ -59,14 +74,20 @@ export class AudioVisual {
this.#to_fft_range = to_fft_range
this.#point_count = point_count
- this.#fft_data = new Float32Array()
+ this.#fft_data = new Float32Array(this.#analyzer_node.frequencyBinCount)
this.#canvas_width = svg_injecting_element.viewBox.baseVal.width
this.#canvas_height = svg_injecting_element.viewBox.baseVal.height
}
- #get_cured_frequency_data() {
- this.#fft_data = new Float32Array(this.#buffer_length)
- this.#analyzer_node.getFloatFrequencyData(this.#fft_data)
+ #get_cured_frequency_data(): Array
{
+ let buffer_length_cache
+ if (this.#shape.shape_type == ShapeType.Waveform) {
+ this.#fft_data = this.#shape.waveform_options!.fft_data
+ buffer_length_cache = this.#buffer_length
+ this.#buffer_length = this.#fft_data.length
+ } else {
+ this.#analyzer_node.getFloatFrequencyData(this.#fft_data)
+ }
const from = Math.round((this.#point_count / 100) * this.#from_fft_range)
const to = Math.round(this.#buffer_length - (this.#buffer_length / 100) * this.#to_fft_range)
const squeeze_factor = Math.round((this.#buffer_length - to) / this.#point_count)
@@ -127,8 +148,7 @@ export class AudioVisual {
#catmull_rom_smooth(arr: { x: number, y: number }[], k: number) {
if (k == null) k = 1
const last = arr.length - 2
-
- let path = "M" + [arr[0].x, arr[0].y]
+ let path = ""
for (let i = 0; i < arr.length - 1; i++) {
@@ -159,7 +179,6 @@ export class AudioVisual {
path += "C" + [cp1x, cp1y, cp2x, cp2y, x2, y2]
}
- path += " Z"
return path
}
@@ -171,9 +190,10 @@ export class AudioVisual {
switch (this.#shape.shape_type) {
case ShapeType.Line: {
for (let i = 0; i < frequency_data.length - 1; i++) {
+ const mutator = isFinite(frequency_data[i]) ? this.#convert_range(frequency_data[i] * this.#fft_multiplier + this.#fft_offset, in_range, out_range) : -1 * this.#canvas_height
mutated_points.push({
x: this.#shape.points[i].x /** ((Math.max(FFTDataArray[i] + 100)) * 4)*/,
- y: this.#shape.points[i].y - this.#convert_range(frequency_data[i] * this.#fft_multiplier + this.#fft_offset, in_range, out_range),
+ y: this.#shape.points[i].y - mutator,
})
}
break
@@ -192,6 +212,50 @@ export class AudioVisual {
break
}
+ case ShapeType.Waveform: {
+ if (this.#shape.waveform_options!.shape_type == WaveformShape.LineLike) {
+ if (this.#shape.symmetry) {
+ for (let i = 0; i < this.#shape.points.length - 1; i += 2) {
+ const mutator = this.#convert_range(frequency_data[i / 2] * this.#fft_multiplier + this.#fft_offset, in_range, out_range)
+ if (this.#shape.waveform_options!.orientation == WaveformOrientation.Horizontal) {
+ mutated_points.push({
+ x: this.#shape.points[i].x,
+ y: this.#shape.points[i].y - mutator
+ })
+ mutated_points.push({
+ x: this.#shape.points[i + 1].x,
+ y: this.#shape.points[i + 1].y + mutator
+ })
+ } else {
+ mutated_points.push({
+ x: this.#shape.points[i].x + mutator,
+ y: this.#shape.points[i].y
+ })
+ mutated_points.push({
+ x: this.#shape.points[i + 1].x - mutator,
+ y: this.#shape.points[i + 1].y
+ })
+ }
+ }
+ } else {
+ for (let i = 0; i < frequency_data.length - 1; i++) {
+ const mutator = this.#convert_range(frequency_data[i] * this.#fft_multiplier + this.#fft_offset, in_range, out_range)
+ if (this.#shape.waveform_options!.orientation == WaveformOrientation.Horizontal) {
+ mutated_points.push({
+ x: this.#shape.points[i].x,
+ y: this.#shape.points[i].y - mutator
+ })
+ } else {
+ mutated_points.push({
+ x: this.#shape.points[i].x - mutator,
+ y: this.#shape.points[i].y
+ })
+ }
+ }
+ }
+ }
+ break
+ }
}
return mutated_points
}
@@ -211,16 +275,40 @@ export class AudioVisual {
}
case ShapeType.Circle: {
path = `M ${arr[0].x} ${arr[0].y} `
+ break
+ }
+ case ShapeType.Waveform: {
+ path = `M ${0} ${this.#canvas_height / 2}`
+ break
}
}
switch (this.#shape.smoothing_algorythm) {
case SmoothingAlgorythm.Linear: {
- for (let i = 0; i < arr.length; i++) {
- path += `L ${arr[i].x},${arr[i].y} `
- }
- if (this.#shape.shape_type == ShapeType.Line) {
- path += `L ${this.#canvas_width} ${this.#canvas_height / 2} `
- //path += `L ${canvas_width} ${canvas_height} `
+ switch (this.#shape.shape_type) {
+ case ShapeType.Line: {
+ for (let i = 0; i < arr.length; i++) {
+ path += `L ${arr[i].x},${arr[i].y} `
+ }
+ if (this.#shape.shape_type == ShapeType.Line) {
+ path += `L ${this.#canvas_width} ${this.#canvas_height} `
+ //path += `L ${canvas_width} ${canvas_height} `
+ }
+ break
+ }
+ case ShapeType.Circle: {
+ for (let i = 0; i < arr.length; i++) {
+ path += `L ${arr[i].x},${arr[i].y} `
+ }
+ break
+ }
+ case ShapeType.Waveform: {
+ for (let i = 0; i < arr.length; i += 2) {
+ path += `L ${arr[i].x},${arr[i].y} `
+ }
+ for (let i = arr.length - 1; i >= 0; i -= 2) {
+ path += `L ${arr[i].x},${arr[i].y} `
+ }
+ }
}
path += `Z `
break
@@ -250,7 +338,28 @@ export class AudioVisual {
break
}
case SmoothingAlgorythm.CatmullRom: {
- path = this.#catmull_rom_smooth(arr, 1)
+ if (this.#shape.shape_type == ShapeType.Waveform && this.#shape.symmetry == true) {
+ //adding points so both halfs ends and start at the same center point
+ const first_half = [{ x: 0, y: this.#canvas_height / 2 }]
+ const second_half = [{ x: 0, y: this.#canvas_height / 2 }]
+ for (let i = 0; i < arr.length - 1; i += 2) {
+ first_half.push(arr[i])
+ second_half.push(arr[i + 1])
+ }
+ first_half.push({ x: this.#canvas_width, y: this.#canvas_height / 2 })
+ second_half.push({ x: this.#canvas_width, y: this.#canvas_height / 2 })
+ // path += `M ${this.#canvas_width},${this.#canvas_height / 2}`
+ second_half.reverse()
+ //path += ` L 0 ${this.#canvas_height / 2}`
+ path += this.#catmull_rom_smooth(first_half, 1)
+ //path += ` L ${this.#canvas_width} ${this.#canvas_height / 2}`
+ path += this.#catmull_rom_smooth(second_half, 1)
+ //path += `L 0 ${this.#canvas_height / 2}`
+ }
+ else {
+ path += this.#catmull_rom_smooth(arr, 1)
+ }
+ path += `Z`
break
}
}
@@ -260,9 +369,15 @@ export class AudioVisual {
on_data(fn: ((data: Float32Array) => void)) {
this.#subscriber_fns.push(fn)
}
+ /**
+ * Useful for waveforms or shapes that don't need to redraw every frame
+ */
+ draw_once() {
+ this.#svg_injecting_element.innerHTML = this.#create_svg_element()
+ this.#subscriber_fns.forEach((fn) => fn(this.#fft_data))
+ }
draw() {
- this.#analyzer_node.getFloatFrequencyData(this.#fft_data)
this.#svg_injecting_element.innerHTML = this.#create_svg_element()
this.#subscriber_fns.forEach((fn) => fn(this.#fft_data))
requestAnimationFrame(this.draw.bind(this))
@@ -381,11 +496,11 @@ export class AudioVisualBuilder {
* @param shape_type Circle = 0; Line = 1;
* @returns `new AudioVisual`
*/
- build(shape_type: ShapeType) {
- const shape = this.#create_shape(shape_type)
+ build(shape_type: ShapeType, symmetry: boolean, waveform_options?: WaveformOptions) {
+ const shape = this.#create_shape(shape_type, symmetry, waveform_options)
return new AudioVisual(this.#analyzer_node, this.#svg_injecting_element, shape, this.#buffer_length, this.#fft_multipier, this.#fft_offset, this.#from_fft_range, this.#to_fft_range, this.#point_count)
}
- #create_shape(shape_type: ShapeType): Shape {
+ #create_shape(shape_type: ShapeType, symmetry: boolean, waveform_options?: WaveformOptions): Shape {
const point_amount = this.#get_cured_frequency_data().length
let new_shape: Shape
switch (shape_type) {
@@ -394,7 +509,7 @@ export class AudioVisualBuilder {
for (let i = 0; i < point_amount; i++) {
points.push({
x: (this.#canvas_width / point_amount) * i,
- y: this.#canvas_height / 2 - (0 / point_amount) * i,
+ y: 0,
})
}
new_shape = { shape_type, points, smoothing_algorythm: this.#smoothing_algorythm }
@@ -413,20 +528,49 @@ export class AudioVisualBuilder {
new_shape = { shape_type, points, smoothing_algorythm: this.#smoothing_algorythm }
break
}
+ case ShapeType.Waveform: {
+ if (waveform_options === undefined) {
+ console.error("Waveform options undefined at shapetype.waveform, please define!")
+ throw Error("Waveform options undefined at shapetype.waveform, please define!")
+ }
+ const fft_length = this.#get_cured_frequency_data(waveform_options.fft_data).length
+ const points = []
+ for (let i = 0; i < fft_length; i++) {
+ let x, y
+ if (waveform_options.shape_type == WaveformShape.LineLike) {
+ x = (this.#canvas_width / point_amount) * i
+ y = this.#canvas_height / 2
+ } else {
+ throw Error("WaveformShape.Striped not implemented yet")
+ }
+ waveform_options.orientation == WaveformOrientation.Horizontal ?
+ points.push({ x: x, y: y }) :
+ points.push({ x: y, y: x })
+ //Douple the points needed for symmetry
+ if (symmetry) {
+ waveform_options.orientation == WaveformOrientation.Horizontal ?
+ points.push({ x: x, y: y }) :
+ points.push({ x: y, y: x })
+ }
+ }
+ new_shape = { shape_type, points, smoothing_algorythm: this.#smoothing_algorythm, symmetry: symmetry, waveform_options: waveform_options }
+ }
}
-
return new_shape
}
- #get_cured_frequency_data() {
- const fft_data_array = new Float32Array(this.#buffer_length)
- this.#analyzer_node.getFloatFrequencyData(fft_data_array)
+
+ #get_cured_frequency_data(fft_data?: Float32Array) {
+ if (!fft_data) {
+ fft_data = new Float32Array(this.#buffer_length)
+ this.#analyzer_node.getFloatFrequencyData(fft_data)
+ }
const from = Math.round((this.#point_count / 100) * this.#from_fft_range)
const to = Math.round(this.#buffer_length - (this.#buffer_length / 100) * this.#to_fft_range)
const squeezeFactor = Math.round((this.#buffer_length - to) / this.#point_count)
const return_array = new Array(this.#point_count)
for (let i = 0; i < this.#point_count; i++) {
- return_array[i] = fft_data_array[from + i * squeezeFactor]
+ return_array[i] = fft_data[from + i * squeezeFactor]
}
return return_array
}