Add euterpe, many fixes.

This commit is contained in:
Djkato 2023-06-12 17:50:33 +02:00
parent d269605f55
commit 081cb68b3d
34 changed files with 1347 additions and 276 deletions

View file

@ -5,7 +5,8 @@
"scripts": { "scripts": {
"publish-player": "nx build player && cd dist/packages/player && npm publish --access=public", "publish-player": "nx build player && cd dist/packages/player && npm publish --access=public",
"publish-visualizer": "nx build visualizer && cd dist/packages/visualizer && npm publish --access=public", "publish-visualizer": "nx build visualizer && cd dist/packages/visualizer && npm publish --access=public",
"publish-library": "nx build music-library && cd dist/packages/music-library && npm publish --access=public" "publish-library": "nx build music-library && cd dist/packages/music-library && npm publish --access=public",
"publish-euterpe": "nx build euterpe && cd dist/packages/euterpe && npm publish --access=public"
}, },
"private": false, "private": false,
"devDependencies": { "devDependencies": {

View file

@ -0,0 +1,5 @@
{
"presets": [
"@nx/js/babel"
]
}

View file

@ -0,0 +1,33 @@
{
"extends": [
"../../.eslintrc.json"
],
"ignorePatterns": [
"!**/*"
],
"overrides": [
{
"files": [
"*.ts",
"*.tsx",
"*.js",
"*.jsx"
],
"rules": {}
},
{
"files": [
"*.ts",
"*.tsx"
],
"rules": {}
},
{
"files": [
"*.js",
"*.jsx"
],
"rules": {}
}
]
}

View file

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>EuterpeTest</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
<div class="library-wrapper">
</div>
<div class="name-wrapper">
<p>playing:&nbsp;&nbsp;
<p id="text-playing"> ID - ID</p>
</p>
</div>
<div class="wrapper">
<audio src="" id="audio"></audio>
<button id="previous">Previous Song</button>
<button id="play">Play</button>
<button id="pause">Pause</button>
<button id="toggle-play">Toggle Pause/Play</button>
<button id="next">Next Song</button>
<p id="current">-:--</p>
<input type="range" min="0" max="10" value="0" id="seek" step="0.01">
<p id="duration">-:--</p>
<span>
<input type="range" min="0" max="1" value="1" id="volume" step="0.01">
</span>
<button id="mute">Mute</button>
<button id="unmute">Unmute</button>
<button id="toggle-mute">Toggle Mute</button>
</div>
<div class="dev-wrapper">
queue:<p id="queue-info"></p>
history:<p id="history-info"></p>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -0,0 +1,70 @@
{
"name": "euterpe-web-test",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "packages/euterpe-web-test/src",
"tags": [],
"targets": {
"build": {
"executor": "@nx/vite:build",
"outputs": [
"{options.outputPath}"
],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/packages/euterpe-web-test"
},
"configurations": {
"development": {
"mode": "development"
},
"production": {
"mode": "production"
}
}
},
"serve": {
"executor": "@nx/vite:dev-server",
"defaultConfiguration": "development",
"options": {
"buildTarget": "euterpe-web-test:build"
},
"configurations": {
"development": {
"buildTarget": "euterpe-web-test:build:development",
"hmr": true
},
"production": {
"buildTarget": "euterpe-web-test:build:production",
"hmr": false
}
}
},
"preview": {
"executor": "@nx/vite:preview-server",
"defaultConfiguration": "development",
"options": {
"buildTarget": "euterpe-web-test:build"
},
"configurations": {
"development": {
"buildTarget": "euterpe-web-test:build:development"
},
"production": {
"buildTarget": "euterpe-web-test:build:production"
}
}
},
"lint": {
"executor": "@nx/linter:eslint",
"outputs": [
"{options.outputFile}"
],
"options": {
"lintFilePatterns": [
"packages/euterpe-web-test/**/*.ts"
]
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

View file

@ -0,0 +1,63 @@
import { EuterpeBuilder, Library, Player } from "@euterpe.js/euterpe";
//export const euterpe = new
export const db = new Library.DB
db.add([
//The IDs are added incrementally & are 0 based., so first artists ID added is 0, next 1 etc...
//You can specify the ID manually if you want
new Library.Artist({
name: "Jamie xx",
}),
new Library.Artist({
name: "janz",
}),
new Library.Artist({
name: "Machinedrum",
}),
new Library.Artist({
name: "Tanerélle",
}),
new Library.Artist({
name: "Mono/Poly",
}),
new Library.Artist({
name: "IMANU",
links: [
[Library.Platforms.Spotify, new URL("https://open.spotify.com/artist/5Y7rFm0tiJTVDzGLMzz0W1?si=DRaZyugTTIqlBHDkMGKVqA&nd=1")]
]
}),
new Library.Artist({
name: "toe",
id: 10
}),
])
db.add([
new Library.Song({
//Refrences are constructed as such. This allows to get to the artist from either collection or song
artists: [new Library.Ref(Library.RefTo.Artists, 2), new Library.Ref(Library.RefTo.Artists, 3), new Library.Ref(Library.RefTo.Artists, 4)],
duration: 252,
name: "Star",
remix_artists: [new Library.Ref(Library.RefTo.Artists, 5)],
url: new URL("http://127.0.0.1:4200/Machinedrum, Tanerelle & Mono Poly - Star (IMANU Remix) final.mp3")
}),
new Library.Song({
//If you don't like guessing the IDs, then this is also a way to do it
artists: [new Library.Ref(Library.RefTo.Artists, db.artists.find((a) => a.name == "Jamie xx")!.id!)],
duration: 331,
name: "Sleep Sound",
url: new URL("http://127.0.0.1:4200/Jamie xx - Sleep Sound.mp3")
}),
new Library.Song({
artists: [new Library.Ref(Library.RefTo.Artists, 1)],
duration: 75,
name: "wish",
url: new URL("http://127.0.0.1:4200/janz - wish.mp3")
}),
new Library.Song({
artists: [new Library.Ref(Library.RefTo.Artists, 10)],
duration: 4 * 60 + 5,
name: "サニーボーイ・ラプソディ",
url: new URL("http://127.0.0.1:4200/16.サニーボーイ・ラプソディ.ogg")
})
])

View file

@ -0,0 +1,142 @@
import { db } from "./db";
import { EuterpeBuilder, Euterpe } from "@euterpe.js/euterpe";
let is_seeking = false
// document.addEventListener("click", start, { once: true })
let euterpe: Euterpe
maybe_start()
function maybe_start() {
if (euterpe) return
euterpe = new EuterpeBuilder(document.querySelector("#audio")!, db)
.build()
add_library_to_dom()
euterpe.preload_song_async(0).then(() => {
document.querySelector("#text-playing")!.innerHTML = euterpe.format_current_song()
}, (e) => console.log(e + " Failed to preload"))
document.querySelector("#seek")?.addEventListener("mouseup", (e) => {
euterpe.try_seek_async(e.target?.valueAsNumber).then(() => { console.log("seeked to " + e.target?.valueAsNumber) }, () => {
alert("Failed seeking! " + e)
})
is_seeking = false
})
// Subscriptions to AudioContext changes, eg. time..
euterpe.on_duration_formatted((time) => {
document.querySelector("#duration")!.innerHTML = time
document.querySelector("#seek")!.max = "" + euterpe.current_song_duration
})
euterpe.on_time_tick_formatted((time) => {
document.querySelector("#current")!.innerHTML = time
})
euterpe.on_time_tick((time) => {
if (is_seeking) return
document.querySelector("#seek")!.value = "" + time
dev_queue_update()
dev_history_update()
})
}
document.querySelector("#previous")?.addEventListener("click", () => {
maybe_start()
euterpe.previous_song_async().then(() => {
document.querySelector("#text-playing")!.innerHTML = euterpe.format_current_song()
}, (e) => alert(e + "Failed to change song"))
})
document.querySelector("#next")?.addEventListener("click", () => {
maybe_start()
euterpe.next_song_async().then(() => {
document.querySelector("#text-playing")!.innerHTML = euterpe.format_current_song()
}, (e) => alert(e + "Failed to change song"))
})
document.querySelector("#play")?.addEventListener("click", () => {
maybe_start()
euterpe.play_async().catch((e) => alert("Failed to play, " + e))
})
document.querySelector("#pause")?.addEventListener("click", () => {
maybe_start()
euterpe.pause()
})
document.querySelector("#mute")?.addEventListener("click", () => {
maybe_start()
euterpe.mute()
})
document.querySelector("#unmute")?.addEventListener("click", () => {
maybe_start()
euterpe.unmute()
})
document.querySelector("#toggle-mute")?.addEventListener("click", () => {
maybe_start()
euterpe.mute_toggle()
})
document.querySelector("#toggle-play")?.addEventListener("click", () => {
maybe_start()
euterpe.play_toggle_async().catch((e) => alert("failed to toggle pause/play!" + e))
})
document.querySelector("#volume")?.addEventListener("input", (e) => {
maybe_start()
euterpe.change_volume(e.target?.valueAsNumber)
})
//disables time updates so the time slider doesn't slip away from user
document.querySelector("#seek")?.addEventListener("mousedown", () => {
is_seeking = true;
})
function add_library_to_dom() {
const lib_dom = document.querySelector(".library-wrapper") as HTMLDivElement
for (const song of euterpe.db.songs) {
const div = document.createElement("div")
const p = document.createElement("p")
const button_play = document.createElement("button")
const button_queue = document.createElement("button")
const span = document.createElement("span")
p.innerHTML = `${euterpe.format_current_song(song.id)}`
button_play.innerHTML = "play"
button_play.dataset["id"] = `${song.id}`
button_play.onclick = library_play
button_queue.innerHTML = "queue"
button_queue.dataset["id"] = `${song.id}`
button_queue.onclick = library_queue
div.appendChild(p)
span.appendChild(button_play)
span.appendChild(button_queue)
div.appendChild(span)
lib_dom.appendChild(div)
}
}
function library_play(e: MouseEvent) {
const b = e.currentTarget as HTMLButtonElement
euterpe.try_specific_song_async(parseInt(b.dataset["id"]!)).then(
() => document.querySelector("#text-playing")!.innerHTML = euterpe.format_current_song(),
(e) => alert(e)
)
}
function library_queue(e: MouseEvent) {
const b = e.currentTarget as HTMLButtonElement
euterpe.queue_append(parseInt(b.dataset["id"]!))
}
function dev_queue_update() {
const p = document.querySelector("#queue-info") as HTMLParagraphElement
const dev_arr = []
for (const song of euterpe.queue) {
dev_arr.push(`Name: ${song.name}, ID: ${song.id} |`)
}
p.innerHTML = dev_arr.toString()
}
function dev_history_update() {
const p = document.querySelector("#history-info") as HTMLParagraphElement
const dev_arr = []
for (const song of euterpe.played_history) {
dev_arr.push(`Name: ${song.name}, ID: ${song.id} |`)
}
p.innerHTML = dev_arr.toString()
}

View file

@ -0,0 +1,38 @@
#volume{
transform: rotate(270deg);
}
body {
width: 100vw;
height:100vh;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.wrapper{
width:80vw;
display: flex;
justify-content: space-between;
}
.name-wrapper{
width:80vw;
display: flex;
justify-content: center;
}
.library-wrapper > div {
display: flex;
margin: 0 5rem;
justify-content: space-between;
}
.library-wrapper {
width:60vw;
}
.library-wrapper button {
height: 100%;
}
.dev-wrapper{
width: 80vw;
margin: 6rem 0;
}

View file

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["node"]
},
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"],
"include": ["src/**/*.ts"]
}

View file

@ -0,0 +1,27 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM"],
"moduleResolution": "Node",
"strict": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src"],
"references": [
{
"path": "./tsconfig.app.json"
}
]
}

View file

@ -0,0 +1,33 @@
/// <reference types="vitest" />
import { defineConfig } from "vite"
import viteTsConfigPaths from "vite-tsconfig-paths"
export default defineConfig({
cacheDir: "../../node_modules/.vite/euterpe-web-test",
server: {
port: 4200,
host: "localhost"
},
preview: {
port: 4300,
host: "localhost"
},
plugins: [
viteTsConfigPaths({
root: "../../"
})
]
// Uncomment this if you are using workers.
// worker: {
// plugins: [
// viteTsConfigPaths({
// root: '../../',
// }),
// ],
// },
})

View file

@ -0,0 +1,33 @@
{
"extends": [
"../../.eslintrc.json"
],
"ignorePatterns": [
"!**/*"
],
"overrides": [
{
"files": [
"*.ts",
"*.tsx",
"*.js",
"*.jsx"
],
"rules": {}
},
{
"files": [
"*.ts",
"*.tsx"
],
"rules": {}
},
{
"files": [
"*.js",
"*.jsx"
],
"rules": {}
}
]
}

View file

@ -0,0 +1,22 @@
# Euterpe
Fully featured AudioContext music player for the web.
Features:
- "Local" library/Database for songs, collections etc.
- Queue
- History
- Easy way to create Vector based audio visuals
- Safe. Provides wrappers for all functions that are either unsafe or don't give a success return. (very Rust inspired, yes.)
- Async / Await or simple funcions.
## How to use:
#### Simple demo [here](https://github.com/euterpe-js/euterpe-source/tree/master/packages/euterpe-web-test)
Since this package is just a compilation of our smaller modules, you can read individual modules' tutorials on their respective npm page:
- [Euterpe Player](https://www.npmjs.com/package/@euterpe.js/player)
- [Euterpe Visualizer](https://www.npmjs.com/package/@euterpe.js/visualizer)
- [Euterpe Music Library](https://www.npmjs.com/package/@euterpe.js/music-library)
This module builds on those, and further adds functions for playing backwards, forwards and managing the queue.

View file

@ -0,0 +1,33 @@
{
"name": "@euterpe.js/euterpe",
"version": "1.0.0",
"type": "module",
"description": "Fully featured solution for playing music on the web. Support for local library, audio visuals and more!",
"main": "./src/index.js",
"author": {
"name": "Djkáťo",
"email": "djkatovfx@gmail.com"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/euterpe-js/euterpe-source.git"
},
"homepage": "https://github.com/euterpe-js/euterpe-source/tree/master/packages/euterpe#readme",
"keywords": [
"audio",
"library",
"music-database",
"audio-player",
"webaudio",
"database",
"db"
],
"exports": {
".": {
"types": "./src/index.d.ts",
"import": "./src/index.js",
"require": "./src/lib/euterpe.js"
}
}
}

View file

@ -0,0 +1,40 @@
{
"name": "euterpe",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/euterpe/src",
"projectType": "library",
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": [
"{options.outputPath}"
],
"options": {
"outputPath": "dist/packages/euterpe",
"main": "packages/euterpe/src/index.ts",
"tsConfig": "packages/euterpe/tsconfig.lib.json",
"assets": [
"packages/euterpe/*.md"
]
}
},
"publish": {
"command": "node tools/scripts/publish.mjs euterpe {args.ver} {args.tag}",
"dependsOn": [
"build"
]
},
"lint": {
"executor": "@nx/linter:eslint",
"outputs": [
"{options.outputFile}"
],
"options": {
"lintFilePatterns": [
"packages/euterpe/**/*.ts"
]
}
}
},
"tags": []
}

View file

@ -0,0 +1,484 @@
import * as Player from "@euterpe.js/player"
import * as Library from "@euterpe.js/music-library"
import * as Visualizer from "@euterpe.js/visualizer"
export { Player, Library, Visualizer, Euterpe, EuterpeBuilder }
/**
* Avoid Writing directly to any fields in this class!
*/
class Euterpe extends Player.MusicPlayer {
current_song: Library.Song | undefined
current_song_id = 0
queue: Library.Song[] = []
played_history: Library.Song[] = []
constructor(
public db: Library.DB,
audio_context: AudioContext,
audio_element: HTMLAudioElement,
track: MediaElementAudioSourceNode,
gain: GainNode,
volume: number,
current_song_path?: string) {
super(audio_context, audio_element, track, gain, volume, current_song_path,)
}
/**
* Use to load song on page load.
*/
preload_song_async(id: number) {
return new Promise((resolve, reject) => {
const next = this.db.songs.find((song) => song!.id == id)
if (!next) reject(new Error(`Song with id ${id} doesn't exist`))
else {
this.try_new_song_async(next.url.pathname).then((s) => {
this.current_song = next
resolve(s)
}, (e) => reject(e))
}
})
}
/**
* Won't loop back to first song if already on the last.
* If queue present, uses that, if not, relies on Song ID directly from DB
*/
try_next_song_async() {
return new Promise((resolve, reject) => {
let new_song: Library.Song
if (this.queue.length > 0) {
new_song = this.queue.shift()!
} else {
let id_i = this.db.songs.length;
this.db.songs.sort((a, b) => a.id! - b.id!)
while (this.db.songs[--id_i].id! > this.current_song_id);
const next_id = ++id_i;
if (next_id == this.db.songs.length) reject(new Error("Won't go past the last song"))
new_song = this.db.songs.find((song) => song.id == next_id)!
}
this.try_new_song_async(new_song.url.href).then(
() => {
this.try_play_async().then((s) => {
if (this.current_song) this.played_history.push(this.current_song)
this.current_song = new_song
this.current_song_id = new_song.id!
resolve(s)
}, (e) => reject(e))
},
(e) => reject(e)
)
})
}
/**
* Will loop back to first song if already on last song,
* If queue present, uses that, if not, relies on Song ID directly from DB
*/
next_song_async() {
return new Promise((resolve, reject) => {
let new_song: Library.Song
if (this.queue.length > 0) {
new_song = this.queue.shift()!
} else {
let id_i = this.db.songs.length;
this.db.songs.sort((a, b) => a.id! - b.id!)
while (this.db.songs[--id_i].id! > this.current_song_id);
let next_id = ++id_i
if (next_id == this.db.songs.length) next_id = this.db.songs[0].id!
new_song = this.db.songs.find((song) => song.id == next_id)!
}
this.try_new_song_async(new_song.url.href).then(
() => {
this.try_play_async().then((s) => {
if (this.current_song) this.played_history.push(this.current_song)
this.current_song = new_song
this.current_song_id = new_song.id!
resolve(s)
}, (e) => reject(e))
},
(e) => reject(e)
)
})
}
/**
* Won't tell you if the playback was successsful & wil loop back if already on last song. Best use try_next_song_async()
* If queue present, uses that, if not, relies on Song ID directly from DB
*/
next_song() {
let new_song: Library.Song
if (this.queue.length > 0) {
new_song = this.queue.shift()!
} else {
let id_i = this.db.songs.length;
this.db.songs.sort((a, b) => a.id! - b.id!)
while (this.db.songs[--id_i].id! > this.current_song_id);
let next_id = ++id_i;
if (next_id == this.db.songs.length) next_id = this.db.songs[0].id!
new_song = this.db.songs.find((song) => song.id == next_id)!
}
this.new_song(new_song.url.href)
this.play()
if (this.current_song) this.played_history.push(this.current_song)
this.current_song = new_song
this.current_song_id = new_song.id!
}
/**
* Won't tell you if the playback was successsful, won't loop back if already on last song and will throw error if attempted. Best use next_song_async()
* If queue present, uses that, if not, relies on Song ID directly from DB
*/
try_next_song() {
let new_song: Library.Song
if (this.queue.length > 0) {
new_song = this.queue.shift()!
} else {
let id_i = this.db.songs.length;
this.db.songs.sort((a, b) => a.id! - b.id!)
while (this.db.songs[--id_i].id! > this.current_song_id);
const next_id = ++id_i;
if (next_id == this.db.songs.length) throw new Error("Won't go past the last song")
new_song = this.db.songs.find((song) => song.id == next_id)!
}
this.new_song(new_song.url.href)
this.play()
if (this.current_song) this.played_history.push(this.current_song)
this.current_song = new_song
this.current_song_id = new_song.id!
}
/**
* Uses safer try_play_async. Normal play / play_async will try to start the player even if the track hasn't started yet, or was previously suspended/closed
*/
try_specific_song_async(new_song_id: number) {
return new Promise((resolve, reject) => {
const new_song = this.db.songs.find((song) => song.id! == new_song_id)
if (!new_song) reject(new Error(`No song with id "${new_song_id}" found`))
else {
this.try_new_song_async(new_song.url.href).then(
() => {
this.try_play_async().then((s) => {
if (this.current_song) this.played_history.push(this.current_song)
this.current_song = new_song
this.current_song_id = new_song.id!
resolve(s)
}, (e) => reject(e))
},
(e) => reject(e)
)
}
})
}
/**
* uses play_async. Will try to play even if the audio context was suspended or closed.
*/
specific_song_async(new_song_id: number) {
return new Promise((resolve, reject) => {
const new_song = this.db.songs.find((song) => song.id! == new_song_id)
if (!new_song) reject(new Error(`No song with id "${new_song_id}" found`))
else {
this.try_new_song_async(new_song.url.href).then(
() => {
this.play_async().then((s) => {
if (this.current_song) this.played_history.push(this.current_song)
this.current_song = new_song
this.current_song_id = new_song.id!
resolve(s)
}, (e) => reject(e))
},
(e) => reject(e)
)
}
})
}
/**
* Will throw an error if new ID not found. Won't tell you if the play was successful, best use specific_song_async() or try_specific_song_async()
*/
specific_song(new_song_id: number) {
const new_song = this.db.songs.find((song) => song.id! == new_song_id)
if (!new_song) throw new Error(`No song with id "${new_song_id}" found`)
else {
this.new_song(new_song.url.href)
this.play()
if (this.current_song) this.played_history.push(this.current_song)
this.current_song = new_song
this.current_song_id = new_song.id!
}
}
/**
* Won't loop back to first song if already on the last.
* If played_history is present, uses that, if not, relies on Song ID directly from DB
*/
try_previous_song_async() {
return new Promise((resolve, reject) => {
let new_song: Library.Song
if (this.played_history.length > 0) {
new_song = this.played_history.pop()!
} else {
let id_i = 0;
this.db.songs.sort((a, b) => a.id! - b.id!)
while (this.db.songs[++id_i].id! < this.current_song_id);
const next_id = --id_i;
if (next_id == this.db.songs.length) reject(new Error("Won't roll backwards to last song"))
new_song = this.db.songs.find((song) => song.id == next_id)!
}
this.try_new_song_async(new_song.url.href).then(
() => {
this.try_play_async().then((s) => {
//if (this.current_song) this.played_history.push(this.current_song)
this.current_song = new_song
this.current_song_id = new_song.id!
resolve(s)
}, (e) => reject(e))
},
(e) => reject(e)
)
})
}
/**
* Will loop back to first song if already on the last.
* If history present, uses that, if not, relies on Song ID directly from DB
*/
previous_song_async() {
return new Promise((resolve, reject) => {
let new_song: Library.Song
if (this.played_history.length > 0) {
new_song = this.played_history.pop()!
} else {
let id_i = -1;
this.db.songs.sort((a, b) => a.id! - b.id!)
while (this.db.songs[++id_i].id! < this.current_song_id);
let next_id = --id_i;
if (next_id == -1) next_id = this.db.songs[this.db.songs.length - 1].id!
new_song = this.db.songs.find((song) => song.id == next_id)!
}
this.try_new_song_async(new_song.url.href).then(
() => {
this.try_play_async().then((s) => {
//if (this.current_song) this.played_history.push(this.current_song)
this.current_song = new_song
this.current_song_id = new_song.id!
resolve(s)
}, (e) => reject(e))
},
(e) => reject(e)
)
})
}
/**
* won't tell you if the play was successful, won't loop back to last song if already on the first and will throw error if attempted.
* If history present, uses that, if not, relies on Song ID directly from DB
*/
try_previous_song() {
let new_song: Library.Song
if (this.played_history.length > 0) {
new_song = this.played_history.pop()!
} else {
let id_i = 0;
this.db.songs.sort((a, b) => a.id! - b.id!)
while (this.db.songs[++id_i].id! < this.current_song_id);
const next_id = -id_i;
if (next_id == this.db.songs.length) throw new Error("Won't go past the last song")
new_song = this.db.songs.find((song) => song.id == next_id)!
}
this.new_song(new_song.url.href)
this.play()
//if (this.current_song) this.played_history.push(this.current_song)
this.current_song_id = new_song.id!
this.current_song = new_song
}
/**
* won't tell you if the play was successful & will loop back to last song if already on the first.
* If queue present, uses that, if not, relies on Song ID directly from DB
*/
previous_song() {
let new_song: Library.Song
if (this.played_history.length > 0) {
new_song = this.played_history.pop()!
} else {
let id_i = 0;
this.db.songs.sort((a, b) => a.id! - b.id!)
while (this.db.songs[++id_i].id! < this.current_song_id);
let next_id = -id_i;
if (next_id == this.db.songs.length) next_id = this.db.songs[this.db.songs.length].id!
new_song = this.db.songs.find((song) => song.id == next_id)!
}
this.new_song(new_song.url.href)
this.play()
//if (this.current_song) this.played_history.push(this.current_song)
this.current_song_id = new_song.id!
this.current_song = new_song
}
/**
* Takes the song data from current song if no song ID is specified. Will return "ID - ID" if ID and current song doesn't exist
* @returns {ARTIST}, {ARTIST2}... - {SONG NAME} ({REMIX ARTIST}, {REMIX ARTIST2}... remix)
*/
format_current_song(id = this.current_song?.id) {
const curr_song = this.db.songs.find((song) => song.id == id)
if (!curr_song) {
return "ID - ID"
}
let final_text = ""
for (const artist of curr_song.artists) {
const curr_artist = artist.get(this.db) as Library.Artist
final_text += curr_artist.name + ", "
}
final_text = final_text.slice(0, final_text.length - 2) // remove trailing ", "
final_text += " - " + curr_song.name
if (curr_song.remix_artists.length > 0) {
final_text += " ("
for (const artist of curr_song.remix_artists) {
const curr_artist = artist.get(this.db) as Library.Artist
if (curr_artist.links && curr_artist.links.length > 0) {
final_text += curr_artist.name
} else {
final_text += curr_artist.name + ", "
}
}
final_text = final_text.slice(0, final_text.length - 2) // remove trailing ", "
final_text += " Remix)"
}
return final_text
}
/**
* Will add to queue, if ID is undefined nothing will happen. If ID already is in queue, nothing will happen. For more control use `try_queue_add()`
*/
queue_add(id: number) {
const curr_song = this.db.songs.find((song) => song.id == id)
if (!curr_song) return
if (this.queue.find((song) => song.id == id)) return
this.queue.push(curr_song)
}
/**
* Will add to queue. If ID is undefined throws error. if ID is already in queue, throws error.
*/
try_queue_add(id: number) {
const curr_song = this.db.songs.find((song) => song.id == id)
if (!curr_song) throw new Error(`Song of id "${id}" doesn't exist`)
if (this.queue.find((song) => song.id == id)) throw new Error(`Song of id "${id}" already queued`)
this.queue.push(curr_song)
}
/**
* Will add to queue. Unlike queue_add, if given ID is already in queue, it will move it to the end of the queue. Throws error if ID doesn't exist.
*/
try_queue_append(id: number) {
const curr_song = this.db.songs.find((song) => song.id == id)
if (!curr_song) throw new Error(`Song of id "${id}" doesn't exist`)
const i = this.queue.findIndex((song) => song.id == id)
if (i != -1) this.queue.push(this.queue.splice(i, 1)[0])
else this.queue.push(curr_song)
}
/**
* Will add to queue. Unlike queue_add, if given ID is already in queue, it will move it to the end of the queue. If ID Doesn't exist, does nothing. For more control use try_queue_append()
*/
queue_append(id: number) {
const curr_song = this.db.songs.find((song) => song.id == id)
if (!curr_song) return
const i = this.queue.findIndex((song) => song.id == id)
if (i != -1) this.queue.push(this.queue.splice(i, 1)[0])
else this.queue.push(curr_song)
}
/**
* Removes song of ID from queue and returns it. Does and returns nothing if song already not found.
*/
queue_remove(id: number) {
const i = this.queue.findIndex((song) => song.id == id)
if (i == -1) return
return this.queue.splice(i, 1)
}
}
class EuterpeBuilder {
#audio_context: AudioContext
#gain: GainNode
#track: MediaElementAudioSourceNode
#volume = 1
#prev_node: any;
#is_gain_connected = false
/**
* Creates a context and #gain( Gets connected at the end )
* will throw if audio_element is undefined (stupid vue setup amirite?)
* will throw if user has not interacted with the page yet (Can't initiate AudioContext)
*/
constructor(private audio_element: HTMLAudioElement, private db: Library.DB) {
if (audio_element === undefined) throw Error("audio_element was undefined")
// ↓ For old browsers
const AudioContext = window.AudioContext || window.webkitAudioContext;
this.#audio_context = new AudioContext()
this.#track = this.#audio_context.createMediaElementSource(audio_element)
this.#gain = this.#audio_context.createGain()
}
/**
* For external use, not kept inside player after connection.
* @returns {AnalyserNode}
*/
add_analyser() {
const analyser = this.#audio_context.createAnalyser()
!this.#prev_node ? this.#track.connect(analyser) : this.#prev_node.connect(analyser)
this.#prev_node = analyser
return analyser
}
/**
* For external use, not kept inside player after connection.
* @returns {StereoPannerNode}
*/
add_stereo_panner_node() {
const panner = this.#audio_context.createStereoPanner()
!this.#prev_node ? this.#track.connect(panner) : this.#prev_node.connect(panner)
this.#prev_node = panner
return panner
}
/**
* For external use, not kept inside player after connection.
* @returns {StereoPannerNode}
*/
add_wave_shaper_node() {
const shaper = this.#audio_context.createWaveShaper()
!this.#prev_node ? this.#track.connect(shaper) : this.#prev_node.connect(shaper)
this.#prev_node = shaper
return shaper
}
/**
* For additional trickery, you can connect your own node.
*/
connect_custom_node(node: AudioNode) {
!this.#prev_node ? this.#track.connect(node) : this.#prev_node.connect(node)
this.#prev_node = node
}
/**
* Only use if you need to connect the #gain before another node,
* eg. if you want the analyser nodes output to be affected by user #gain
*/
connect_gain() {
!this.#prev_node ? this.#track.connect(this.#gain) : this.#prev_node.connect(this.#gain)
this.#prev_node = this.#gain
this.#is_gain_connected = true
}
/**
* Finishes the build
* @returns {Euterpe}
*/
build() {
if (!this.#is_gain_connected) {
!this.#prev_node ? this.#track.connect(this.#gain) : this.#prev_node.connect(this.#gain)
this.#prev_node = this.#gain
}
this.#prev_node.connect(this.#audio_context.destination)
this.audio_element.preload = "metadata"
return new Euterpe(this.db, this.#audio_context, this.audio_element, this.#track, this.#gain, this.#volume)
}
}

View file

@ -0,0 +1,3 @@
export function euterpe(): string {
return "euterpe"
}

View file

@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}

View file

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}

View file

@ -1,4 +1,4 @@
import { DB, Artist, Song, RefTo, Ref, Platforms } from "@euterpe/music-library"; import { DB, Artist, Song, RefTo, Ref, Platforms } from "@euterpe.js/music-library";
export const db = new DB export const db = new DB
db.add([ db.add([

View file

@ -1,10 +1,9 @@
import { MusicPlayerBuilder } from "@euterpe/player"; import { MusicPlayerBuilder } from "@euterpe.js/player";
import { db } from "./db"; import { db } from "./db";
import { Artist } from "@euterpe/music-library"; import { Artist } from "@euterpe.js/music-library";
import { DB, Platforms } from "@euterpe/music-library"; import { DB, Platforms } from "@euterpe.js/music-library";
const audio_el = document.querySelector("#audio") as HTMLAudioElement const audio_el = document.querySelector("#audio") as HTMLAudioElement
const music_player_builder = MusicPlayerBuilder(audio_el) const music_player_builder = new MusicPlayerBuilder(audio_el)
music_player_builder.start()
const music_player = music_player_builder.build() const music_player = music_player_builder.build()
music_player.change_volume(1) music_player.change_volume(1)

View file

@ -1,7 +1,7 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"module": "commonjs", "module": "ESNext",
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strict": true, "strict": true,
"noImplicitOverride": true, "noImplicitOverride": true,

View file

@ -1,7 +1,6 @@
import { MusicPlayerBuilder } from "@euterpe/player"; import { MusicPlayerBuilder } from "@euterpe.js/player";
const audio_el = document.querySelector("#audio") as HTMLAudioElement const audio_el = document.querySelector("#audio") as HTMLAudioElement
const music_player_builder = MusicPlayerBuilder(audio_el) const music_player_builder = new MusicPlayerBuilder(audio_el)
music_player_builder.start()
const music_player = music_player_builder.build() const music_player = music_player_builder.build()
music_player.change_volume(1) music_player.change_volume(1)

View file

@ -55,19 +55,19 @@ document.querySelector("#volume")?.addEventListener("input", (e) => {
Euterpe Player also provides functions to easily track the status of playback. It does this via Subscription/Publisher pattern which publishes every frame ( Using `requestAnimationFrame()`). This allows for always up todate values reflecting on the UI. Euterpe Player also provides functions to easily track the status of playback. It does this via Subscription/Publisher pattern which publishes every frame ( Using `requestAnimationFrame()`). This allows for always up todate values reflecting on the UI.
```js ```js
// Subscriptions to AudioContext changes, eg. time.. // Subscriptions to AudioContext changes, eg. time..
music_player.subscribe_to_formatted_duration_time((time) => { music_player.on_duration_formatted((time) => {
//time == "4:53, "15:59", "1756:15:59"... //time == "4:53, "15:59", "1756:15:59"...
document.querySelector("#duration-text").innerHTML = time document.querySelector("#duration-text").innerHTML = time
//duration but in "0","1.2", "1223.21668181"... format //duration but in "0","1.2", "1223.21668181"... format
document.querySelector("#input-seek-range").max = "" + music_player.get_current_duration() document.querySelector("#input-seek-range").max = "" + music_player.get_current_duration()
}) })
//Keep the current time uptodate but formatted. //Keep the current time uptodate but formatted.
music_player.subscribe_to_formatted_current_time_tick((time) => { music_player.on_time_tick_formatted((time) => {
//time == "2:52", "10:59:59"... //time == "2:52", "10:59:59"...
document.querySelector("#current-text").innerHTML = time document.querySelector("#current-text").innerHTML = time
}) })
//Keep <input type="range"..> slider uptodate //Keep <input type="range"..> slider uptodate
music_player.subscribe_to_time_tick((time) => { music_player.on_time_tick((time) => {
//time == "0","1.2", "1223.21668181"... //time == "0","1.2", "1223.21668181"...
document.querySelector("#input-seek-range").value = "" + time document.querySelector("#input-seek-range").value = "" + time
}) })

View file

@ -3,80 +3,72 @@ export enum SubscribeEvents {
FormattedDurationTick, FormattedDurationTick,
FormattedCurrentTimeTick, FormattedCurrentTimeTick,
} }
const PubSub = () => { class PubSub {
//el = event listener //el = event listener
const el_current_time_tick: Array<(data: any) => void> = [] el_current_time_tick: Array<(data: any) => void> = []
const el_formatted_duration_tick: Array<(data: any) => void> = [] el_formatted_duration_tick: Array<(data: any) => void> = []
const el_formatted_current_time_tick: Array<(data: any) => void> = [] el_formatted_current_time_tick: Array<(data: any) => void> = []
function subscribe(event_name: SubscribeEvents, func: (data: any) => void) { subscribe(event_name: SubscribeEvents, func: (data: any) => void) {
switch (event_name) { switch (event_name) {
case SubscribeEvents.CurrentTimeTick: { case SubscribeEvents.CurrentTimeTick: {
el_current_time_tick.push(func) this.el_current_time_tick.push(func)
break break
} }
case SubscribeEvents.FormattedDurationTick: { case SubscribeEvents.FormattedDurationTick: {
el_formatted_duration_tick.push(func) this.el_formatted_duration_tick.push(func)
break break
} }
case SubscribeEvents.FormattedCurrentTimeTick: { case SubscribeEvents.FormattedCurrentTimeTick: {
el_formatted_current_time_tick.push(func) this.el_formatted_current_time_tick.push(func)
break break
} }
} }
} }
function unsubscribe(event_name: SubscribeEvents, func: (data: any) => void) { unsubscribe(event_name: SubscribeEvents, func: (data: any) => void) {
switch (event_name) { switch (event_name) {
case SubscribeEvents.CurrentTimeTick: { case SubscribeEvents.CurrentTimeTick: {
if (el_current_time_tick.includes(func)) { if (this.el_current_time_tick.includes(func)) {
el_current_time_tick.splice(el_current_time_tick.indexOf(func), 1) this.el_current_time_tick.splice(this.el_current_time_tick.indexOf(func), 1)
} }
break break
} }
case SubscribeEvents.FormattedDurationTick: { case SubscribeEvents.FormattedDurationTick: {
if (el_formatted_duration_tick.includes(func)) { if (this.el_formatted_duration_tick.includes(func)) {
el_formatted_duration_tick.splice(el_formatted_duration_tick.indexOf(func), 1) this.el_formatted_duration_tick.splice(this.el_formatted_duration_tick.indexOf(func), 1)
} }
break break
} }
case SubscribeEvents.FormattedCurrentTimeTick: { case SubscribeEvents.FormattedCurrentTimeTick: {
if (el_formatted_duration_tick.includes(func)) { if (this.el_formatted_duration_tick.includes(func)) {
el_formatted_duration_tick.splice(el_formatted_duration_tick.indexOf(func), 1) this.el_formatted_duration_tick.splice(this.el_formatted_duration_tick.indexOf(func), 1)
} }
break break
} }
} }
} }
function emit(event_name: SubscribeEvents, data: any) { emit(event_name: SubscribeEvents, data: any) {
switch (event_name) { switch (event_name) {
case SubscribeEvents.CurrentTimeTick: { case SubscribeEvents.CurrentTimeTick: {
el_current_time_tick.forEach((func) => { this.el_current_time_tick.forEach((func) => {
func(data) func(data)
}) })
break break
} }
case SubscribeEvents.FormattedDurationTick: { case SubscribeEvents.FormattedDurationTick: {
el_formatted_duration_tick.forEach((func) => { this.el_formatted_duration_tick.forEach((func) => {
func(data) func(data)
}) })
break break
} }
case SubscribeEvents.FormattedCurrentTimeTick: { case SubscribeEvents.FormattedCurrentTimeTick: {
el_formatted_current_time_tick.forEach((func) => { this.el_formatted_current_time_tick.forEach((func) => {
func(data) func(data)
}) })
break break
} }
} }
} }
return {
el_current_time_tick,
el_formatted_duration_tick,
el_formatted_current_time_tick,
subscribe,
unsubscribe,
emit
}
} }
/* For old browsers */ /* For old browsers */
@ -87,51 +79,54 @@ declare global {
} }
export const MusicPlayer = (audio_context_i: AudioContext, audio_element_i: HTMLAudioElement, track_i: MediaElementAudioSourceNode, gain_i: GainNode, volume_i: number, current_song_path_i?: string) => { export class MusicPlayer {
const audio_element: HTMLAudioElement = audio_element_i current_song_duration = 0
const audio_context: AudioContext = audio_context_i #volume_cache: number
const track: MediaElementAudioSourceNode = track_i is_playing = false
const gain: GainNode = gain_i time = 0
let current_song_path: string | undefined = current_song_path_i #pub_sub = new PubSub
let current_song_duration: number constructor(
let volume_cache: number = volume_i private audio_context: AudioContext,
let volume: number = volume_i private audio_element: HTMLAudioElement,
let is_playing = false public track: MediaElementAudioSourceNode,
let time = 0 private gain: GainNode,
const pub_sub = PubSub() public volume: number,
private current_song_path?: string) {
this.#volume_cache = volume
}
function mute_toggle() { mute_toggle() {
if (gain.gain.value == 0) { if (this.gain.gain.value == 0) {
unmute() this.unmute()
} else { } else {
mute() this.mute()
} }
} }
function mute() { mute() {
volume_cache = gain.gain.value this.#volume_cache = this.gain.gain.value
/* Gentler mute, doesn't pop /* Gentler mute, doesn't pop
gain.gain.linearRampToValueAtTime( gain.gain.linearRampToValueAtTime(
0, 0,
audio_context.currentTime + 0.1 audio_context.currentTime + 0.1
);*/ );*/
volume = gain.gain.value = 0 this.volume = this.gain.gain.value = 0
} }
function unmute() { unmute() {
volume = gain.gain.value = volume_cache this.volume = this.gain.gain.value = this.#volume_cache
} }
function change_volume(volume_i: number) { change_volume(volume_i: number) {
volume = gain.gain.value = volume_i this.volume = this.gain.gain.value = volume_i
} }
/** /**
* Safer seek_async. Normal seek will try to start the player even if the track hasn't started yet, or was previously suspended/closed * Safer seek_async. Normal seek will try to start the player even if the track hasn't started yet, or was previously suspended/closed
*/ */
function try_seek_async(new_time: number) { try_seek_async(new_time: number) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (track.context.state == "closed" || track.context.state == "suspended") { if (this.track.context.state == "closed" || this.track.context.state == "suspended") {
is_playing = false this.is_playing = false
reject("Can't seek - track not playing") reject("Can't seek - track not playing")
} }
audio_element.currentTime = new_time this.audio_element.currentTime = new_time
resolve(null) resolve(null)
/*audio_element.play().then((s) => resolve(s), (r) => { /*audio_element.play().then((s) => resolve(s), (r) => {
is_playing = false is_playing = false
@ -139,45 +134,47 @@ export const MusicPlayer = (audio_context_i: AudioContext, audio_element_i: HTML
})*/ })*/
}) })
} }
/** // THIS MIGHT BE UNNECESSARY? CUZ SEEKING DOESN'T REQUIRE PLAY
* Can try to seek even if the audio context was suspended or closed. Best to use try_seek_async() // /**
*/ // * Can try to seek even if the audio context was suspended or closed. Best to use try_seek_async()
function seek_async(new_time: number) { // */
return new Promise((resolve, reject) => { // seek_async(new_time: number) {
audio_element.currentTime = new_time // return new Promise((resolve, reject) => {
resolve(null) // this.audio_element.currentTime = new_time
/* audio_element.play().then((s) => resolve(s), (r) => { // resolve(null)
is_playing = false // /* audio_element.play().then((s) => resolve(s), (r) => {
reject(r) // is_playing = false
})*/ // reject(r)
}) // })*/
} // })
/** // // }
* Unsafe, throws error if failed. Use try_seek_async or seek_async unless you don't care about the result. // /**
*/ // * Unsafe, throws error if failed. Use try_seek_async or seek_async unless you don't care about the result.
function seek(new_time: number) { // */
audio_element.currentTime = new_time
audio_element.play().catch((e) => { throw e }) seek(new_time: number) {
this.audio_element.currentTime = new_time
//this.audio_element.play().catch((e) => { throw e })
} }
/** /**
* Safer play_toggle_async. Normal play_toggle will try to start the player even if the track hasn't started yet, or was previously suspended/closed * Safer play_toggle_async. Normal play_toggle will try to start the player even if the track hasn't started yet, or was previously suspended/closed
*/ */
function try_play_toggle_async() { try_play_toggle_async() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (audio_context.state === "suspended" || audio_context.state === "closed") { if (this.audio_context.state === "suspended" || this.audio_context.state === "closed") {
reject("Context closed or suspended") reject("Context closed or suspended")
} }
if (audio_element.paused) { if (this.audio_element.paused) {
audio_element.play().then((s) => { this.audio_element.play().then((s) => {
is_playing = true this.is_playing = true
resolve(s) resolve(s)
}, (r) => { }, (r) => {
is_playing = false this.is_playing = false
reject(r) reject(r)
}) })
} else { } else {
audio_element.pause() this.audio_element.pause()
is_playing = false this.is_playing = false
resolve(null) resolve(null)
} }
}) })
@ -185,22 +182,22 @@ export const MusicPlayer = (audio_context_i: AudioContext, audio_element_i: HTML
/** /**
* Can try to play even if the audio context was suspended or closed. Best to use try_play_toggle_async() * Can try to play even if the audio context was suspended or closed. Best to use try_play_toggle_async()
*/ */
function play_toggle_async() { play_toggle_async() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (audio_context.state === "suspended" || audio_context.state === "closed") { if (this.audio_context.state === "suspended" || this.audio_context.state === "closed") {
audio_context.resume() this.audio_context.resume()
} }
if (audio_element.paused) { if (this.audio_element.paused) {
audio_element.play().then((s) => { this.audio_element.play().then((s) => {
is_playing = true this.is_playing = true
resolve(s) resolve(s)
}, (r) => { }, (r) => {
is_playing = false this.is_playing = false
reject(r) reject(r)
}) })
} else { } else {
audio_element.pause() this.audio_element.pause()
is_playing = false this.is_playing = false
resolve(null) resolve(null)
} }
}) })
@ -208,32 +205,32 @@ export const MusicPlayer = (audio_context_i: AudioContext, audio_element_i: HTML
/** /**
* Unsafe, throws error if failed. Use play_toggle_async or try_play_toggle_async unless you don't care about the result. * Unsafe, throws error if failed. Use play_toggle_async or try_play_toggle_async unless you don't care about the result.
*/ */
function play_toggle() { play_toggle() {
if (audio_element.paused) { if (this.audio_element.paused) {
is_playing = true this.is_playing = true
audio_element.play().catch((r) => { this.audio_element.play().catch((r) => {
is_playing = false this.is_playing = false
throw r throw r
}) })
} else { } else {
is_playing = false this.is_playing = false
audio_element.pause() this.audio_element.pause()
} }
} }
/** /**
* Safer play_async. Normal play will try to start the player even if the track hasn't started yet, or was previously suspended/closed * Safer play_async. Normal play will try to start the player even if the track hasn't started yet, or was previously suspended/closed
*/ */
function try_play_async() { try_play_async() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (is_playing) reject(Error("Already playing")) if (this.is_playing) reject(Error("Already playing"))
if (audio_context.state === "suspended" || audio_context.state === "closed") { if (this.audio_context.state === "suspended" || this.audio_context.state === "closed") {
reject("Context closed or suspended") reject("Context closed or suspended")
} }
audio_element.play().then((s) => { this.audio_element.play().then((s) => {
is_playing = true this.is_playing = true
resolve(s) resolve(s)
}, (r) => { }, (r) => {
is_playing = false this.is_playing = false
reject(r) reject(r)
}) })
}) })
@ -241,17 +238,17 @@ export const MusicPlayer = (audio_context_i: AudioContext, audio_element_i: HTML
/** /**
* Will try to play even if the audio context was suspended or closed. Best to use try_play_async() * Will try to play even if the audio context was suspended or closed. Best to use try_play_async()
*/ */
function play_async() { play_async() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (is_playing) resolve(null) if (this.is_playing) resolve(null)
if (audio_context.state === "suspended" || audio_context.state === "closed") { if (this.audio_context.state === "suspended" || this.audio_context.state === "closed") {
audio_context.resume() this.audio_context.resume()
} }
audio_element.play().then((s) => { this.audio_element.play().then((s) => {
is_playing = true this.is_playing = true
resolve(s) resolve(s)
}, (r) => { }, (r) => {
is_playing = false this.is_playing = false
reject(r) reject(r)
}) })
}) })
@ -259,67 +256,61 @@ export const MusicPlayer = (audio_context_i: AudioContext, audio_element_i: HTML
/** /**
* Unsafe, throws error if failed. Use play_async or try_play_async unless you don't care about the result. * Unsafe, throws error if failed. Use play_async or try_play_async unless you don't care about the result.
*/ */
function play() { play() {
if (is_playing) return if (this.is_playing) return
audio_element.play().catch((r) => { this.audio_element.play().catch((r) => {
is_playing = false this.is_playing = false
throw r throw r
}) })
} }
/** /**
* Safe technically. Even if audioContext is suspended or closed it will pretend that it paused. * Safe technically. Even if audioContext is suspended or closed it will pretend that it paused.
*/ */
function pause() { pause() {
audio_element.pause() this.audio_element.pause()
is_playing = false this.is_playing = false
} }
/** /**
* Will only load metadata of the upcoming song. Need to call try_play_async() afterwards to start the playback * Will only load metadata of the upcoming song. Need to call try_play_async() afterwards to start the playback
*/ */
function try_new_song_async(path: string) { try_new_song_async(path: string) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
audio_element.src = current_song_path = path this.audio_element.src = this.current_song_path = path
//Found out today about this. Such a nice new way to mass remove event listeners! //Found out today about this. Such a nice new way to mass remove event listeners!
const controller = new AbortController(); const controller = new AbortController();
audio_element.addEventListener("canplaythrough", function canplay_listener(s) { this.audio_element.addEventListener("canplaythrough", function canplay_listener(s) {
//current_song_duration = audio_element.duration
controller.abort() controller.abort()
resolve(s) resolve(s)
}, { signal: controller.signal }) }, { signal: controller.signal })
audio_element.addEventListener("error", function error_listener(e) { this.audio_element.addEventListener("error", function error_listener(e) {
controller.abort()
reject(e)
}, { signal: controller.signal })
/*
audio_element.addEventListener("abort", function abort_listener(e) {
controller.abort() controller.abort()
reject(e) reject(e)
}, { signal: controller.signal }) }, { signal: controller.signal })
audio_element.addEventListener("stalled", function stalled_listener(e) { this.audio_element.addEventListener("stalled", function stalled_listener(e) {
controller.abort() controller.abort()
reject(e) reject(e)
}, { signal: controller.signal }) }, { signal: controller.signal })
*/
is_playing = false this.is_playing = false
}) })
} }
/** /**
* Won't tell if you if the song actually got loaded or if it failed. For a safer version use try_new_song_async() unless you don't care about the result * Won't tell if you if the song actually got loaded or if it failed. For a safer version use try_new_song_async() unless you don't care about the result
*/ */
function new_song(path: string) { new_song(path: string) {
audio_element.src = current_song_path = path this.audio_element.src = this.current_song_path = path
//current_song_duration = audio_element.duration this.current_song_duration = this.audio_element.duration
} }
/** /**
* Will parse the duration of the song to make it easy to display in UI * Will parse the duration of the song to make it easy to display in UI
* If somethings undefined it returns "0:00" * If somethings undefined it returns "0:00"
*/ */
function get_formatted_duration() { get_formatted_duration() {
const dur = audio_element.duration const dur = this.audio_element.duration
current_song_duration = audio_element.duration this.current_song_duration = this.audio_element.duration
if (dur == 0 || !dur) return "0:00" if (dur == 0 || !dur) return "0:00"
@ -341,8 +332,8 @@ export const MusicPlayer = (audio_context_i: AudioContext, audio_element_i: HTML
* Will parse the current time of the song to make it easy to display in UI * Will parse the current time of the song to make it easy to display in UI
* If somethings undefined it returns "0:00" * If somethings undefined it returns "0:00"
*/ */
function get_formatted_current_time() { get_formatted_current_time() {
const curr = audio_element.currentTime const curr = this.audio_element.currentTime
if (curr == 0 || !curr) return "0:00" if (curr == 0 || !curr) return "0:00"
// ~~ is Bitwise OR, equivalent to Math.floor() // ~~ is Bitwise OR, equivalent to Math.floor()
@ -359,170 +350,133 @@ export const MusicPlayer = (audio_context_i: AudioContext, audio_element_i: HTML
ret += "" + secs; ret += "" + secs;
return ret; return ret;
} }
#emit_time() {
const request_id = requestAnimationFrame(this.#emit_time.bind(this))
if (this.audio_element.ended) this.is_playing = false
if (this.audio_element.paused) this.is_playing == false
// if use reactively changes volume directly
this.gain.gain.value = this.volume
this.time = this.audio_element.currentTime
if (this.#pub_sub.el_current_time_tick.length == 0) cancelAnimationFrame(request_id)
this.#pub_sub.emit(SubscribeEvents.CurrentTimeTick, this.time)
}
#emit_duration_fmt() {
const request_id = requestAnimationFrame(this.#emit_duration_fmt.bind(this))
const time = this.get_formatted_duration()
if (this.#pub_sub.el_formatted_duration_tick.length == 0) cancelAnimationFrame(request_id)
this.#pub_sub.emit(SubscribeEvents.FormattedDurationTick, time)
}
#emit_time_fmt() {
const request_id = requestAnimationFrame(this.#emit_time_fmt.bind(this))
const time = this.get_formatted_current_time()
if (this.#pub_sub.el_formatted_current_time_tick.length == 0) cancelAnimationFrame(request_id)
this.#pub_sub.emit(SubscribeEvents.FormattedCurrentTimeTick, time)
}
/** /**
* Will give current time every animation frame * Will give current time every animation frame
*/ */
function subscribe_to_time_tick(callback: (data: any) => void) { on_time_tick(callback: (data: any) => void) {
pub_sub.subscribe(SubscribeEvents.CurrentTimeTick, callback) this.#pub_sub.subscribe(SubscribeEvents.CurrentTimeTick, callback)
emit_current_time() this.#emit_time()
} }
function emit_current_time() {
const request_id = requestAnimationFrame(emit_current_time.bind(MusicPlayer))
if (audio_element.ended) is_playing = false
if (audio_element.paused) is_playing == false
// if use reactively changes volume directly
gain.gain.value = volume
time = audio_element.currentTime
if (pub_sub.el_current_time_tick.length == 0) cancelAnimationFrame(request_id)
pub_sub.emit(SubscribeEvents.CurrentTimeTick, time)
}
/** /**
* Will give formatted current time via get_formatted_current_time() every animation frame * Will give formatted current time via get_formatted_current_time() every animation frame
*/ */
function subscribe_to_formatted_current_time_tick(callback: (data: any) => void) { on_time_tick_formatted(callback: (data: any) => void) {
pub_sub.subscribe(SubscribeEvents.FormattedCurrentTimeTick, callback) this.#pub_sub.subscribe(SubscribeEvents.FormattedCurrentTimeTick, callback)
emit_formatted_current_time() this.#emit_time_fmt()
}
function emit_formatted_current_time() {
const request_id = requestAnimationFrame(emit_formatted_current_time.bind(MusicPlayer))
const time = get_formatted_current_time()
//if (pub_sub.el_formatted_current_time_tick.length == 0) cancelAnimationFrame(request_id)
pub_sub.emit(SubscribeEvents.FormattedCurrentTimeTick, time)
} }
/** /**
* Will give formatted duration time via get_formatted_duration() every animation frame * Will give formatted duration time via get_formatted_duration() every animation frame
*/ */
function subscribe_to_formatted_duration_time(callback: (data: any) => void) { on_duration_formatted(callback: (data: any) => void) {
pub_sub.subscribe(SubscribeEvents.FormattedDurationTick, callback) this.#pub_sub.subscribe(SubscribeEvents.FormattedDurationTick, callback)
emit_formatted_duration_time() this.#emit_duration_fmt()
}
function emit_formatted_duration_time() {
const request_id = requestAnimationFrame(emit_formatted_duration_time.bind(MusicPlayer))
const time = get_formatted_duration()
//if (pub_sub.el_formatted_duration_tick.length == 0) cancelAnimationFrame(request_id)
pub_sub.emit(SubscribeEvents.FormattedDurationTick, time)
}
return {
track,
get_is_playing: () => is_playing,
get_current_path: () => current_song_path,
get_current_duration: () => current_song_duration,
volume,
time,
mute,
unmute,
mute_toggle,
change_volume,
try_seek_async,
seek_async,
seek,
play,
pause,
play_toggle,
play_toggle_async,
try_play_toggle_async,
try_new_song_async,
new_song,
get_formatted_duration,
get_formatted_current_time,
subscribe_to_formatted_current_time_tick,
subscribe_to_formatted_duration_time,
subscribe_to_time_tick,
try_play_async,
play_async,
} }
} }
export function MusicPlayerBuilder(audio_element: HTMLAudioElement) { export class MusicPlayerBuilder {
let audio_context: AudioContext #audio_context: AudioContext
let gain: GainNode #gain: GainNode
let track: MediaElementAudioSourceNode #track: MediaElementAudioSourceNode
const volume = 1 #volume = 1
let prev_node: any; #prev_node: any;
let is_gain_connected = false #is_gain_connected = false
/** /**
* Creates a context and gain( Gets connected at the end ) * Creates a context and #gain( Gets connected at the end )
* will throw if audio_element is undefined (stupid vue setup amirite?) * will throw if audio_element is undefined (stupid vue setup amirite?)
* will throw if user has not interacted with the page yet (Can't initiate AudioContext) * will throw if user has not interacted with the page yet (Can't initiate AudioContext)
*/ */
function start() { constructor(private audio_element: HTMLAudioElement) {
if (audio_element === undefined) throw Error("audio_element was undefined") if (audio_element === undefined) throw Error("audio_element was undefined")
// ↓ For old browsers // ↓ For old browsers
const AudioContext = window.AudioContext || window.webkitAudioContext; const AudioContext = window.AudioContext || window.webkitAudioContext;
audio_context = new AudioContext() this.#audio_context = new AudioContext()
track = audio_context.createMediaElementSource(audio_element) this.#track = this.#audio_context.createMediaElementSource(audio_element)
gain = audio_context.createGain() this.#gain = this.#audio_context.createGain()
} }
/** /**
* For external use, not kept inside player after connection. * For external use, not kept inside player after connection.
* @returns {AnalyserNode} * @returns {AnalyserNode}
*/ */
function add_analyser() { add_analyser() {
const analyser = audio_context.createAnalyser() const analyser = this.#audio_context.createAnalyser()
!prev_node ? track.connect(analyser) : prev_node.connect(analyser) !this.#prev_node ? this.#track.connect(analyser) : this.#prev_node.connect(analyser)
prev_node = analyser this.#prev_node = analyser
return analyser return analyser
} }
/** /**
* For external use, not kept inside player after connection. * For external use, not kept inside player after connection.
* @returns {StereoPannerNode} * @returns {StereoPannerNode}
*/ */
function add_stereo_panner_node() { add_stereo_panner_node() {
const panner = audio_context.createStereoPanner() const panner = this.#audio_context.createStereoPanner()
!prev_node ? track.connect(panner) : prev_node.connect(panner) !this.#prev_node ? this.#track.connect(panner) : this.#prev_node.connect(panner)
prev_node = panner this.#prev_node = panner
return panner return panner
} }
/** /**
* For external use, not kept inside player after connection. * For external use, not kept inside player after connection.
* @returns {StereoPannerNode} * @returns {StereoPannerNode}
*/ */
function add_wave_shaper_node() { add_wave_shaper_node() {
const shaper = audio_context.createWaveShaper() const shaper = this.#audio_context.createWaveShaper()
!prev_node ? track.connect(shaper) : prev_node.connect(shaper) !this.#prev_node ? this.#track.connect(shaper) : this.#prev_node.connect(shaper)
prev_node = shaper this.#prev_node = shaper
return shaper return shaper
} }
/** /**
* For additional trickery, you can connect your own node. * For additional trickery, you can connect your own node.
*/ */
function connect_custom_node(node: AudioNode) { connect_custom_node(node: AudioNode) {
!prev_node ? track.connect(node) : prev_node.connect(node) !this.#prev_node ? this.#track.connect(node) : this.#prev_node.connect(node)
prev_node = node this.#prev_node = node
} }
/** /**
* Only use if you need to connect the gain before another node, * Only use if you need to connect the #gain before another node,
* eg. if you want the analyser nodes output to be affected by user gain * eg. if you want the analyser nodes output to be affected by user #gain
*/ */
function connect_gain() { connect_gain() {
!prev_node ? track.connect(gain) : prev_node.connect(gain) !this.#prev_node ? this.#track.connect(this.#gain) : this.#prev_node.connect(this.#gain)
prev_node = gain this.#prev_node = this.#gain
is_gain_connected = true this.#is_gain_connected = true
} }
/** /**
* Finishes the build * Finishes the build
* @returns {MusicPlayer: () => void} * @returns {Euterpe}
*/ */
function build() { build() {
if (!is_gain_connected) { if (!this.#is_gain_connected) {
!prev_node ? track.connect(gain) : prev_node.connect(gain) !this.#prev_node ? this.#track.connect(this.#gain) : this.#prev_node.connect(this.#gain)
prev_node = gain this.#prev_node = this.#gain
} }
prev_node.connect(audio_context.destination) this.#prev_node.connect(this.#audio_context.destination)
audio_element.preload = "metadata" this.audio_element.preload = "metadata"
return MusicPlayer(audio_context, audio_element, track, gain, volume) return new MusicPlayer(this.#audio_context, this.audio_element, this.#track, this.#gain, this.#volume)
}
return {
start,
add_analyser,
add_stereo_panner_node,
add_wave_shaper_node,
connect_gain,
connect_custom_node,
build
} }
} }

View file

@ -1,8 +1,7 @@
import { MusicPlayerBuilder } from "@euterpe/player"; import { MusicPlayerBuilder } from "@euterpe.js/player";
import { AudioVisualBuilder, SmoothingAlgorythm, ShapeType } from "@euterpe/visualizer" import { AudioVisualBuilder, SmoothingAlgorythm, ShapeType } from "@euterpe.js/visualizer"
const audio_el = document.querySelector("#audio") as HTMLAudioElement const audio_el = document.querySelector("#audio") as HTMLAudioElement
const music_player_builder = MusicPlayerBuilder(audio_el) const music_player_builder = new MusicPlayerBuilder(audio_el)
music_player_builder.start()
const trapnation_analyser_node = music_player_builder.add_analyser() const trapnation_analyser_node = music_player_builder.add_analyser()
const bar_analyser_node = music_player_builder.add_analyser() const bar_analyser_node = music_player_builder.add_analyser()
const music_player = music_player_builder.build() const music_player = music_player_builder.build()
@ -70,14 +69,14 @@ music_player.try_new_song_async(encodeURI("http://127.0.0.1:4200/nuphory - NVISI
is_seeking = false is_seeking = false
}) })
// Subscriptions to AudioContext changes, eg. time.. // Subscriptions to AudioContext changes, eg. time..
music_player.subscribe_to_formatted_duration_time((time) => { music_player.on_duration_formatted((time) => {
document.querySelector("#duration").innerHTML = time document.querySelector("#duration").innerHTML = time
document.querySelector("#seek").max = "" + music_player.get_current_duration() document.querySelector("#seek").max = "" + music_player.current_song_duration
}) })
music_player.subscribe_to_formatted_current_time_tick((time) => { music_player.on_time_tick_formatted((time) => {
document.querySelector("#current").innerHTML = time document.querySelector("#current").innerHTML = time
}) })
music_player.subscribe_to_time_tick((time) => { music_player.on_time_tick((time) => {
if (is_seeking) return if (is_seeking) return
document.querySelector("#seek").value = "" + time document.querySelector("#seek").value = "" + time
}) })

View file

@ -37,6 +37,7 @@ export class AudioVisual {
#canvas_height #canvas_height
#canvas_width #canvas_width
#fft_data #fft_data
#subscriber_fns = new Array<(data: Float32Array) => void>()
constructor( constructor(
analyzer_node: AnalyserNode, analyzer_node: AnalyserNode,
svg_injecting_element: SVGSVGElement, svg_injecting_element: SVGSVGElement,
@ -256,9 +257,14 @@ export class AudioVisual {
return `<path width="100%" height="100%" d="${path}"/>` return `<path width="100%" height="100%" d="${path}"/>`
} }
on_data(fn: ((data: Float32Array) => void)) {
this.#subscriber_fns.push(fn)
}
draw() { draw() {
this.#analyzer_node.getFloatFrequencyData(this.#fft_data) this.#analyzer_node.getFloatFrequencyData(this.#fft_data)
this.#svg_injecting_element.innerHTML = this.#create_svg_element() this.#svg_injecting_element.innerHTML = this.#create_svg_element()
this.#subscriber_fns.forEach((fn) => fn(this.#fft_data))
requestAnimationFrame(this.draw.bind(this)) requestAnimationFrame(this.draw.bind(this))
} }
} }

View file

@ -18,13 +18,16 @@
"skipDefaultLibCheck": true, "skipDefaultLibCheck": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@euterpe/music-library": [ "@euterpe.js/euterpe": [
"packages/euterpe/src/index.ts"
],
"@euterpe.js/music-library": [
"packages/music-library/src/index.ts" "packages/music-library/src/index.ts"
], ],
"@euterpe/player": [ "@euterpe.js/player": [
"packages/player/src/index.ts" "packages/player/src/index.ts"
], ],
"@euterpe/visualizer": [ "@euterpe.js/visualizer": [
"packages/visualizer/src/index.ts" "packages/visualizer/src/index.ts"
] ]
} }