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 }